Compare commits

...

122 Commits

Author SHA1 Message Date
benex
f3f4be7410 chore: update lock files 2024-11-21 16:52:30 +03:00
benex
3915ef0fb6 chore: bump version (v2.8.3) 2024-11-21 16:52:23 +03:00
benex
20d26166dd docs: update readme 2024-11-21 16:50:07 +03:00
benex
ddca724bd8 chore: update deps 2024-11-21 16:40:21 +03:00
benex
b86c1a0479 feat: add fastanime anilist download beta 2024-11-21 16:40:09 +03:00
benex
1fa7830ddf Merge branch 'master' into fastanime-anilist-download 2024-11-21 15:35:36 +03:00
Benex
59abafbe16 Update README.md 2024-11-21 13:16:56 +03:00
Benex
b6eebb9736 Update README.md 2024-11-21 10:43:19 +03:00
benex
7797053102 chore: update lock files 2024-11-21 10:30:06 +03:00
benex
d763445f72 chore: bump version (v2.8.2) 2024-11-21 10:30:03 +03:00
benex
7bc6b14b5f docs: update config file docs 2024-11-21 10:29:39 +03:00
benex
f70d2ac8af feat(make_release): update to include nix files 2024-11-21 10:29:39 +03:00
benex
defdfc5a47 refactor: update todo 2024-11-21 10:29:39 +03:00
benex
e67eeda492 feat(updater): add ability to update on nix 2024-11-21 10:29:39 +03:00
benex
a17588d02c fix(interfaces): episode previews 2024-11-21 10:29:39 +03:00
Benex
67b59305c4 Update README.md 2024-11-21 07:43:53 +03:00
Benex
4f0768a060 Merge pull request #24 from gand0rf/chafa_call_update
fix(anilist_interface):  Updated chafa call
2024-11-19 22:14:29 +03:00
Gand0rf
21704cbbea Updated chafa call 2024-11-19 14:00:06 -05:00
Benex
886bc4d011 Update README.md 2024-11-19 19:23:51 +03:00
Benex
e3437e066a Update README.md 2024-11-19 19:18:49 +03:00
Benex
8f2795843a Update README.md 2024-11-19 19:03:53 +03:00
Benex
c6290592e8 Merge pull request #23 from she11sh0cked/master
fix(cli): prevent update check during completions
2024-11-19 19:01:57 +03:00
she11sh0cked
050ba740b8 fix(cli): prevent update check during completions 2024-11-19 15:24:55 +01:00
benex
0b1a27b223 chore: bump version (v2.8.1) 2024-11-19 14:08:42 +03:00
benex
bafd04b788 chore: update lock file 2024-11-19 14:08:08 +03:00
benex
fb5f51eea5 feat(config): add customization options 2024-11-19 14:08:08 +03:00
benex
799e1f0681 fix(updater): handle no internet 2024-11-19 14:08:08 +03:00
benex
53a2d953f8 feat: enable customization of the preview window 2024-11-19 14:08:08 +03:00
Benex
9ce5bc3c76 Update README.md 2024-11-19 11:38:33 +03:00
benex
dc58fc8536 chore: bump version (v2.8.0) 2024-11-19 10:06:57 +03:00
benex
1d5c3016fc chore: update lock file 2024-11-19 10:06:38 +03:00
benex
8737aea746 fix(player): set character encoding for compatibility with windows 2024-11-19 10:06:38 +03:00
Benedict Xavier
bd03866f5e Update README.md 2024-11-19 08:40:09 +03:00
Benedict Xavier
81690a8015 Update README.md 2024-11-19 08:37:26 +03:00
Benedict Xavier
933112a52b Update README.md 2024-11-19 08:34:23 +03:00
Benedict Xavier
eb513dfe0e Update README.md 2024-11-19 08:23:45 +03:00
Benedict Xavier
3928b77506 Update README.md 2024-11-19 08:17:44 +03:00
Benedict Xavier
95cb2bd78c Update README.md 2024-11-19 08:03:33 +03:00
Benedict Xavier
4fa1c45eb2 Update DISCLAIMER.md 2024-11-19 08:02:41 +03:00
Benedict Xavier
b9051bc792 Create DISCLAIMER.md 2024-11-19 08:01:23 +03:00
Benedict Xavier
a590024f1c Merge pull request #15 from piradata/patch-1
Update README.md typo
2024-11-19 07:42:10 +03:00
Benedict Xavier
2f51936679 Merge pull request #16 from AtticusHelvig/uvREADME
Update README.md for uv Instructions
2024-11-19 07:37:26 +03:00
Atticus Helvig
327c50d290 Update README.md 2024-11-18 20:32:38 -07:00
Piradata
031dfbb9b5 Update README.md typo 2024-11-19 00:15:47 -03:00
benex
050365302a chore: bump flake.nix 2024-11-18 20:10:21 +03:00
benex
0f248b1119 chore: update flake.nix to include external deps and all python deps 2024-11-18 20:04:57 +03:00
benex
871d5cf758 chore: add result to .gitignore 2024-11-18 20:04:22 +03:00
benex
320376d2e8 chore: bump version (v2.7.9) 2024-11-18 18:19:21 +03:00
benex
02e7fdff6f chore: update lock file 2024-11-18 18:17:03 +03:00
benex
2c5c28f295 refactor(config): remove useless functions 2024-11-18 18:17:03 +03:00
benex
2d3509ccc1 chore: bump version (v2.7.8) 2024-11-18 17:10:26 +03:00
benex
30babf2d69 feat(cli): alert the user that the cli is checking for updates 2024-11-18 17:10:10 +03:00
benex
cfbbabf898 chore: update uv.lock 2024-11-18 17:10:10 +03:00
benex
5ac6c45fdf fix(allanime_provider): give all the servers the referer header 2024-11-18 17:10:10 +03:00
Benedict Xavier
a14645b563 Update README.md 2024-11-18 15:17:32 +03:00
Benedict Xavier
90dbc26c46 Merge pull request #9 from KaylorBen/master
fix: for nix flake
2024-11-18 13:31:31 +03:00
Benjamin Kaylor
54cc830c35 sed flake preBuild 2024-11-18 03:23:32 -07:00
Benjamin Kaylor
4928ff5b74 undo pyproject.toml edit 2024-11-18 03:20:55 -07:00
Benjamin Kaylor
bb481fe21a fix for nix flake 2024-11-17 16:21:56 -07:00
benex
0d27b8f652 chore: bump version (v2.7.7) 2024-11-17 20:08:37 +03:00
benex
bdd3aae399 chore: update lockfile 2024-11-17 20:08:08 +03:00
benex
af94cd7eb5 fix: recent anime 2024-11-17 20:07:46 +03:00
benex
54044f9527 chore: bump version (v2.7.6) 2024-11-17 18:46:42 +03:00
benex
1e5c039ece chore: update flake.nix 2024-11-17 18:45:32 +03:00
benex
15555759dc feat: ask user if they want to update on new release 2024-11-17 18:45:17 +03:00
benex
0ed51e05cc chore: fix error is in flake.nix 2024-11-17 17:23:41 +03:00
benex
634ef6febf chore: update lock file 2024-11-17 14:28:02 +03:00
benex
bda4b2dbe1 chore: add flake.nix 2024-11-17 14:27:41 +03:00
benex
f015305e7c chore: add shell.nix 2024-11-17 14:27:32 +03:00
benex
d32b7e917f chore: bump version (v2.7.5) 2024-11-16 22:47:31 +03:00
benex
3b35e80199 chore: update lock file 2024-11-16 22:47:19 +03:00
benex
c65a1a2815 feat: by default check for updates when any command is ran 2024-11-16 22:47:04 +03:00
benex
0b3615c9f5 feat: add default rofi themes 2024-11-16 22:46:19 +03:00
benex
3ac4e1ac71 chore: bump version (v2.7.4) 2024-11-16 22:10:15 +03:00
benex
d62f580d7a docs: update readme 2024-11-16 22:09:41 +03:00
benex
02e35b66cb fix: implement another way to get timestamps from mpv due to issues on nixos 2024-11-16 22:09:41 +03:00
benex
7b11e0a301 feat: add rofi-theme-preview option 2024-11-16 22:09:41 +03:00
benex
aa8b91aed3 chore: remove unnecessary yt-dlp extras 2024-11-16 22:09:41 +03:00
benex
fe0fa97576 fix(player): workaround weird problem with mpv in nixos 2024-11-16 22:09:41 +03:00
Benedict Xavier
92059cd5ed Update README.md 2024-11-16 12:01:55 +03:00
benex
ed3064e3b1 feat(anime_provider): add yugen provider 2024-11-13 20:53:18 +03:00
Benex254
441d1e5e6c chore: bump version (v2.7.3) 2024-11-11 12:57:50 +03:00
Benex254
653b2cf4eb chore: add pyinstaller as dev dep 2024-11-11 12:57:29 +03:00
Benex254
8d4b71e0c8 feat: add entry point for pyinstaller executable 2024-11-11 12:57:29 +03:00
Benex254
29cc6cad09 build: pyinstaller spec 2024-11-11 12:57:28 +03:00
Benex254
8119eef263 docs(readme): update table of contents 2024-11-11 12:57:28 +03:00
Benex254
912c8674cf refactor(player): remove unnecessary orint statement 2024-11-11 12:57:28 +03:00
Benex254
6b3ca236dd fix: auto next episode 2024-11-11 12:57:28 +03:00
benex
f1c352d4ff chore: bump version (v2.7.2) 2024-11-10 12:29:46 +03:00
benex
714533d845 refactor(cli): update config options and set fastanime config environs 2024-11-10 12:06:44 +03:00
benex
56dd25df8d refactor(cli): update config options
This commit updates the configuration options in the CLI module. Specifically, it modifies the "image_previews" option to be platform-dependent, setting it to "True" for non-Windows platforms and "False" for Windows. Additionally, it sets the "normalize_titles" option to "True". These changes improve the behavior and user experience of the CLI.
2024-11-10 12:06:17 +03:00
benex
8248dc53df fix: text preview not showing on windows 2024-11-10 12:05:32 +03:00
Benex254
1a8a187de6 docs: update readme 2024-11-09 00:27:49 +03:00
Benex254
bc86be8c93 chore: bump version 2024-11-09 00:11:20 +03:00
Benex254
75026d4fc5 feat(api): add watch endpoint 2024-11-09 00:11:20 +03:00
Benedict Xavier
f8a5ccb8d2 Update README.md 2024-11-08 22:33:16 +03:00
Benex254
719d1bd187 chore: update deps 2024-11-08 16:26:43 +03:00
Benex254
0dd83463c6 docs: update readme 2024-10-20 11:28:39 +03:00
Benex254
966301bce8 feat: register fastanime anilist download(s) 2024-10-20 10:39:53 +03:00
Benex254
d776880306 feat: init fastanime anilist download(s) 2024-10-20 10:14:03 +03:00
Benex254
1ee50e8a55 chore: bump version (v2.6.9) 2024-10-20 10:06:02 +03:00
Benex254
ae95c5ea3d docs: update readme 2024-10-20 10:04:58 +03:00
Benex254
d64ad5e11d fix: move quality to stream section in config 2024-10-20 10:03:49 +03:00
Benex254
d1a47c6d44 chore: bump version (v2.6.8) 2024-10-18 22:59:18 +03:00
Benex254
51a834a62f chore: update deps 2024-10-18 22:53:39 +03:00
Benex254
3a030bf6f7 feat: add ability to update fastanime uv installations 2024-10-18 22:53:26 +03:00
Benex254
eb6a6fc82c chore: use uv in fa script 2024-10-18 22:46:44 +03:00
Benex254
437ccd94e4 ci: update to use uv 2024-10-18 22:37:14 +03:00
Benex254
d65868cc30 chore: update workflows to work with uv 2024-10-18 21:50:20 +03:00
Benex254
8678aa6544 Merge branch 'master' into uv 2024-10-18 20:26:55 +03:00
Benex254
00e5141152 chore: bump version (v2.6.7) 2024-10-12 01:08:14 +03:00
Benex254
90e757dfe1 feat: init switch to uv 2024-10-11 11:57:29 +03:00
Benex254
8b471b08e8 chore: init switch to uv 2024-10-11 10:52:18 +03:00
Benex254
158bc5710f docs: update readme 2024-10-11 10:49:53 +03:00
Benex254
a0b946a13d feat: add recent menu 2024-10-11 10:22:23 +03:00
Benex254
b547b75f03 feat: add environment variable that force updating of the cache db 2024-10-11 09:34:40 +03:00
Benex254
58c7427a47 feat(cli:serve): use the full executable path to python 2024-10-06 01:25:22 +03:00
Benex254
6220b9c55d chore: bump version (v2.6.6) 2024-10-06 01:15:15 +03:00
Benex254
6b9b5c131c fix(cli): use str instead of ints in serve 2024-10-06 01:15:05 +03:00
Benex254
212f2af39c chore: bump version (v2.6.5) 2024-10-06 01:05:28 +03:00
Benex254
f7b2b4e0c9 feat: add serve command 2024-10-06 01:04:20 +03:00
Benedict Xavier
a747529279 Update README.md 2024-10-05 19:37:19 +03:00
51 changed files with 4908 additions and 2277 deletions

View File

@@ -8,31 +8,24 @@ jobs:
debug_build: debug_build:
if: ${{ github.event.workflow_run.conclusion == 'success' }} if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Python
- name: "Set up Python"
uses: actions/setup-python@v5 uses: actions/setup-python@v5
- name: Install poetry
uses: abatilo/actions-poetry@v2 - name: Install uv
- name: Setup a local virtual environment (if no poetry.toml file) uses: astral-sh/setup-uv@v3
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v3
name: Define a cache for the virtual environment based on the dependencies lock file
with: with:
path: ./.venv enable-cache: true
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies - name: Build fastanime
run: poetry install --all-extras run: uv build
- name: build app
run: poetry build
- name: Archive production artifacts - name: Archive production artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: fastanime_debug_build name: fastanime_debug_build
path: | path: |
dist dist
!dist/*.whl
# - name: Run the automated tests (for example)
# run: poetry run pytest -v

View File

@@ -27,11 +27,13 @@ jobs:
with: with:
python-version: "3.10" python-version: "3.10"
- name: Build release distributions - name: Install uv
run: | uses: astral-sh/setup-uv@v3
# NOTE: put your own distribution build steps here. with:
python -m pip install build enable-cache: true
python -m build
- name: Build fastanime
run: uv build
- name: Upload distributions - name: Upload distributions
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -6,37 +6,35 @@ on:
pull_request: pull_request:
branches: branches:
- master - master
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: ["3.10", "3.11"] # List the Python versions you want to test python-version: ["3.10", "3.11"] # List the Python versions you want to test
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Python ${{ matrix.python-version }} - name: Install Python ${{ matrix.python-version }}
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
- name: Install poetry
uses: abatilo/actions-poetry@v2 - name: Install uv
- name: Setup a local virtual environment (if no poetry.toml file) uses: astral-sh/setup-uv@v3
run: |
poetry config virtualenvs.create true --local
poetry config virtualenvs.in-project true --local
- uses: actions/cache@v3
name: Define a cache for the virtual environment based on the dependencies lock file
with: with:
path: ./.venv enable-cache: true
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies - name: Install the project
run: poetry install --all-extras run: uv sync --all-extras --dev
- name: run linter, formatters and sort imports
run: | - name: Run linter and formater
poetry run black . run: uv run ruff check --output-format=github
poetry run ruff check --output-format=github . --fix
poetry run isort . --profile black - name: Run type checking
- name: run type checking run: uv run pyright
run: poetry run pyright
- name: run tests - name: Run tests
run: poetry run pytest run: uv run pytest tests

1
.gitignore vendored
View File

@@ -176,3 +176,4 @@ app/View/SearchScreen/.search_screen.py.un~
app/View/SearchScreen/search_screen.py~ app/View/SearchScreen/search_screen.py~
app/user_data.json app/user_data.json
.buildozer .buildozer
result

40
DISCLAIMER.md Normal file
View File

@@ -0,0 +1,40 @@
<h1 align="center">Disclaimer</h1>
<div align="center">
<h2>This project: fastanime</h2>
<br>
The core aim of this project is to co-relate automation and efficiency to extract what is provided to a user on the internet. All content available through the project is hosted by external non-affiliated sources.
<br>
<b>All content served through this project is publicly accessible. If your site is listed in this project, the code is pretty much public. Take necessary measures to counter the exploits used to extract content in your site.</b>
Think of this project as your normal browser, but a bit more straight-forward and specific. While an average browser makes hundreds of requests to get everything from a site, this project goes on to only make requests associated with getting the content served by the sites.
<b>
This project is to be used at the user's own risk, based on their government and laws.
This project has no control on the content it is serving, using copyrighted content from the providers is not going to be accounted for by the developer. It is the user's own risk.
</b>
<br>
<h2>DMCA and Copyright Infrigements</h3>
<br>
<b>
A browser is a tool, and the maliciousness of the tool is directly based on the user.
</b>
This project uses client-side content access mechanisms. Hence, the copyright infrigements or DMCA in this project's regards are to be forwarded to the associated site by the associated notifier of any such claims. This is one of the main reasons the sites are listed in this project.
<b>Do not harass the developer. Any personal information about the developer is intentionally not made public. Exploiting such information without consent in regards to this topic will lead to legal actions by the developer themselves.</b>

View File

@@ -1,10 +1,7 @@
FROM ubuntu FROM python:3.12-slim-bookworm
RUN apt-get update COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN apt-get -y install python3
RUN apt-get update
RUN apt-get -y install pipx
RUN pipx ensurepath
COPY . /fastanime COPY . /fastanime
ENV PATH=/root/.local/bin:$PATH
WORKDIR /fastanime WORKDIR /fastanime
RUN pipx install . RUN uv tool install .
CMD ["bash"] CMD ["bash"]

798
README.md
View File

@@ -1,19 +1,47 @@
# **FastAnime** <p align="center">
<h1 align="center">FastAnime</h1>
![PyPI - Downloads](https://img.shields.io/pypi/dm/fastanime) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Benex254/FastAnime/test.yml?label=Tests) </p>
<p align="center">
<sup>
Browse anime from the terminal
</sup>
</p>
<div align="center">
![PyPI - Downloads](https://img.shields.io/pypi/dm/fastanime) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/FastAnime/FastAnime/test.yml?label=Tests)
![Discord](https://img.shields.io/discord/1250887070906323096?label=Discord) ![Discord](https://img.shields.io/discord/1250887070906323096?label=Discord)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/Benex254/FastAnime) ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/FastAnime/FastAnime)
![GitHub deployments](https://img.shields.io/github/deployments/Benex254/fastanime/pypi?label=PyPi%20Publish) ![GitHub deployments](https://img.shields.io/github/deployments/FastAnime/fastanime/pypi?label=PyPi%20Publish)
![PyPI - License](https://img.shields.io/pypi/l/fastanime) ![PyPI - License](https://img.shields.io/pypi/l/fastanime)
![Static Badge](https://img.shields.io/badge/lines%20of%20code-13k%2B-green)
</div>
Welcome to **FastAnime**, anime site experience from the terminal. <p align="center">
<a href="https://discord.gg/HBEmAwvbHV">
<img src="https://invidget.switchblade.xyz/C4rhMA4mmK">
</a>
</p>
![fastanime-demo](https://github.com/user-attachments/assets/16e29f54-e9fa-48c7-b944-bfacb31ae1b5) ![fastanime](https://github.com/user-attachments/assets/9ab09f26-e4a8-4b70-a315-7def998cec63)
<details>
<summary>
<b>My Rice</b>
</summary>
![image](https://github.com/user-attachments/assets/240023a7-7e4e-47dd-80ff-017d65081ee1)
![image](https://github.com/user-attachments/assets/580f86ef-326f-4ab3-9bd8-c1cb312fbfa6)
**Without preview images enabled:**
![image](https://github.com/user-attachments/assets/e1248a85-438f-4758-ae34-b0e0b224addd)
</details>
<details> <details>
<summary><b>fzf mode</b></summary> <summary><b>fzf mode</b></summary>
[fa_fzf_demo.webm](https://github.com/user-attachments/assets/b1fecf25-e358-4e8b-a144-bcb7947210cf) [fastanime-fzf.webm](https://github.com/user-attachments/assets/90875a57-198b-4c78-98d5-10a459001edd)
</details> </details>
@@ -31,13 +59,12 @@ Welcome to **FastAnime**, anime site experience from the terminal.
</details> </details>
Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [jerry](https://github.com/justchokingaround/jerry/tree/main),[magic-tape](https://gitlab.com/christosangel/magic-tape/-/tree/main?ref_type=heads) and [ani-cli](https://github.com/pystardust/ani-cli).
<!--toc:start--> <!--toc:start-->
- [**FastAnime**](#fastanime) - [**FastAnime**](#fastanime)
- [Installation](#installation) - [Installation](#installation)
- [Installation using your favourite package manager](#installation-using-your-favourite-package-manager) - [Installation using your favourite package manager](#installation-using-your-favourite-package-manager)
- [Using uv](#using-uv)
- [Using pipx](#using-pipx) - [Using pipx](#using-pipx)
- [Using pip](#using-pip) - [Using pip](#using-pip)
- [Installing the bleeding edge version](#installing-the-bleeding-edge-version) - [Installing the bleeding edge version](#installing-the-bleeding-edge-version)
@@ -56,6 +83,7 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [jerr
- [cache subcommand](#cache-subcommand) - [cache subcommand](#cache-subcommand)
- [update subcommand](#update-subcommand) - [update subcommand](#update-subcommand)
- [completions subcommand](#completions-subcommand) - [completions subcommand](#completions-subcommand)
- [fastanime serve](#fastanime-serve)
- [MPV specific commands](#mpv-specific-commands) - [MPV specific commands](#mpv-specific-commands)
- [Key Bindings](#key-bindings) - [Key Bindings](#key-bindings)
- [Script Messages](#script-messages) - [Script Messages](#script-messages)
@@ -66,10 +94,6 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [jerr
- [Supporting the Project](#supporting-the-project) - [Supporting the Project](#supporting-the-project)
<!--toc:end--> <!--toc:end-->
> [!IMPORTANT]
>
> This project currently scrapes allanime, hianime and animepahe, nyaa. The site is in the public domain and can be accessed by any one with a browser.
## Installation ## Installation
![Windows](https://img.shields.io/badge/-Windows_x64-blue.svg?style=for-the-badge&logo=windows) ![Windows](https://img.shields.io/badge/-Windows_x64-blue.svg?style=for-the-badge&logo=windows)
@@ -82,14 +106,42 @@ The app can run wherever python can run. So all you need to have is python insta
On android you can use [termux](https://github.com/termux/termux-app). On android you can use [termux](https://github.com/termux/termux-app).
If you have any difficulty consult for help on the [discord channel](https://discord.gg/HBEmAwvbHV) If you have any difficulty consult for help on the [discord channel](https://discord.gg/HBEmAwvbHV)
### Installation on nixos
![Static Badge](https://img.shields.io/badge/NixOs-black?style=flat&logo=nixos)
```bash
nix profile install github:Benex254/fastanime
```
### Installation using your favourite package manager ### Installation using your favourite package manager
Currently the app is only published on [pypi](https://pypi.org/project/fastanime/). Currently the app is only published on [pypi](https://pypi.org/project/fastanime/).
With the following extras available:
- standard -which installs all dependencies
- api - which installs dependencies required to use `fastanime serve`
- mpv - which installs python mpv
- notifications - which installs plyer required for desktop notifications
#### Using uv
Recommended method of installation is using [uv](https://docs.astral.sh/uv/).
```bash
# generally:
uv tool install "fastanime[standard]"
# or stripped down installations:
uv tool install fastanime
uv tool install "fastanime[api]"
uv tool install "fastanime[mpv]"
uv tool install "fastanime[notifications]"
```
#### Using pipx #### Using pipx
Preferred method of installation since [Pipx](https://github.com/pypa/pipx) creates an isolated environment for each app it installs.
```bash ```bash
pipx install fastanime pipx install fastanime
@@ -112,7 +164,7 @@ pip install 'fastanime==<latest-pre-release-tag>.dev1'
### Installing the bleeding edge version ### Installing the bleeding edge version
To install the latest build which are created on every push by GitHub actions, download the [fastanime_debug_build](https://github.com/Benex254/FastAnime/actions) of your choosing from the GitHub actions page. To install the latest build which are created on every push by GitHub actions, download the [fastanime_debug_build](https://github.com/FastAnime/FastAnime/actions) of your choosing from the GitHub actions page.
Then: Then:
```bash ```bash
@@ -133,24 +185,17 @@ Requirements:
- [git](https://git-scm.com/) - [git](https://git-scm.com/)
- [python 3.10 and above](https://www.python.org/) - [python 3.10 and above](https://www.python.org/)
- [poetry](https://python-poetry.org/docs/#installation) - [uv](https://astral.sh/blog/uv)
To build from the source, follow these steps: To build from the source, follow these steps:
1. Clone the repository: `git clone https://github.com/Benex254/FastAnime.git --depth 1` 1. Clone the repository: `git clone https://github.com/FastAnime/FastAnime.git --depth 1`
2. Navigate into the folder: `cd FastAnime` 2. Navigate into the folder: `cd FastAnime`
3. Then build and Install the app: 3. Then build and Install the app:
```bash ```bash
# Normal Installation # build and install fastanime with uv
poetry build uv tool install .
cd dist
pip install fastanime<version>.whl
# Editable installation (easiest for updates)
# just do a git pull in the Project dir
# the latter will require rebuilding the app
pip install -e .
``` ```
4. Enjoy! Verify installation with: 4. Enjoy! Verify installation with:
@@ -161,12 +206,13 @@ fastanime --version
> [!Tip] > [!Tip]
> >
> Download the completions from [here](https://github.com/Benex254/FastAnime/tree/master/completions) for your shell. > Download the completions from [here](https://github.com/FastAnime/FastAnime/tree/master/completions) for your shell.
> To add completions: > To add completions:
> >
> - Fish Users: `cp $FASTANIME_PATH/completions/fastanime.fish ~/.config/fish/completions/` > - Fish Users: `cp $FASTANIME_PATH/completions/fastanime.fish ~/.config/fish/completions/`
> - Bash Users: Add `source $FASTANIME_PATH/completions/fastanime.bash` to your `.bashrc` > - Bash Users: Add `source $FASTANIME_PATH/completions/fastanime.bash` to your `.bashrc`
> - Zsh Users: Add `source $FASTANIME_PATH/completions/fastanime.zsh` to your `.zshrc` > - Zsh Users: Add `source $FASTANIME_PATH/completions/fastanime.zsh` to your `.zshrc`
> or using the built in command `fastanime completions`
### External Dependencies ### External Dependencies
@@ -185,7 +231,7 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
- [webtorrent-cli](https://github.com/webtorrent/webtorrent-cli) used when the provider is nyaa - [webtorrent-cli](https://github.com/webtorrent/webtorrent-cli) used when the provider is nyaa
- [ffmpeg](https://www.ffmpeg.org/) is required to be in your path environment variables to properly download [hls](https://www.cloudflare.com/en-gb/learning/video/what-is-http-live-streaming/) streams. - [ffmpeg](https://www.ffmpeg.org/) is required to be in your path environment variables to properly download [hls](https://www.cloudflare.com/en-gb/learning/video/what-is-http-live-streaming/) streams.
- [fzf](https://github.com/junegunn/fzf) 🔥 which is used as a better alternative to the ui. - [fzf](https://github.com/junegunn/fzf) 🔥 which is used as a better alternative to the ui.
- [rofi](https://github.com/davatorium/rofi) 🔥 which is used as another alternative ui + the the desktop entry ui - [rofi](https://github.com/davatorium/rofi) 🔥 which is used as another alternative ui + the desktop entry ui
- [chafa](https://github.com/hpjansson/chafa) currently the best cross platform and cross terminal image viewer for the terminal. - [chafa](https://github.com/hpjansson/chafa) currently the best cross platform and cross terminal image viewer for the terminal.
- [icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) an image viewer that only works in [kitty terminal](https://sw.kovidgoyal.net/kitty/), which is currently the best terminal in my opinion, and by far the best image renderer for the terminal thanks to kitty's terminal graphics protocol. Its terminal graphics is so op that you can [run a browser on it](https://github.com/chase/awrit?tab=readme-ov-file)!! - [icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) an image viewer that only works in [kitty terminal](https://sw.kovidgoyal.net/kitty/), which is currently the best terminal in my opinion, and by far the best image renderer for the terminal thanks to kitty's terminal graphics protocol. Its terminal graphics is so op that you can [run a browser on it](https://github.com/chase/awrit?tab=readme-ov-file)!!
- [bash](https://www.gnu.org/software/bash/) is used as the preview script language. - [bash](https://www.gnu.org/software/bash/) is used as the preview script language.
@@ -344,7 +390,34 @@ fastanime anilist search -f MOVIE -s FAVOURITES_DESC
For more details visit the anilist docs or just get the completions which will improve the experience. For more details visit the anilist docs or just get the completions which will improve the experience.
Like seriously **[get the completions](https://github.com/Benex254/FastAnime#completions-subcommand)** and the experience will be a 💯 💯 better. Like seriously **[get the completions](https://github.com/FastAnime/FastAnime#completions-subcommand)** and the experience will be a 💯 💯 better.
**Fastanime anilist download:**
Supports all the options for search except its used for downloading.
it also supports all options for `fastanime download`
Example:
```bash
# get anime with the tag of isekai
fastanime anilist download -T isekai
# get anime of 2024 and sort by popularity
# that has already finished airing or is releasing
# and is not in your anime lists
fastanime anilist download -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
# get anime of 2024 season WINTER
fastanime anilist download -y 2024 --season WINTER
# get anime genre action and tag isekai,magic
fastanime anilist download -g Action -T Isekai -T Magic
# get anime of 2024 thats finished airing
fastanime anilist download -y 2024 -S FINISHED
# get the most favourite anime movies
fastanime anilist download -f MOVIE -s FAVOURITES_DESC
```
The following are commands you can only run if you are signed in to your AniList account: The following are commands you can only run if you are signed in to your AniList account:
@@ -584,7 +657,7 @@ fastanime config --view
> [!Note] > [!Note]
> >
> If it opens [vim](https://www.vim.org/download.php) you can exit by typing `:q` . > If it opens [vim](https://www.vim.org/download.php) you can exit by typing `:q` 😉.
#### cache subcommand #### cache subcommand
@@ -637,6 +710,645 @@ fastanime completions --bash
fastanime completions --zsh fastanime completions --zsh
``` ```
#### fastanime serve
Helper command that starts a rest server.
This requires you to install fastanime with the api extra or standard extra.
```bash
# default options
fastanime serve
# specify host and port
fastanime serve --host <host> --port <port>
```
An example instance is hosted by [render](https://fastanime.onrender.com/)
Examples:
**search for anime by title:**
```bash
curl 'https://fastanime.onrender.com/search?title=dragon&translation_type=sub'
```
<details>
<summary>
Result
</summary>
```json
{
"pageInfo": {
"total": 22839
},
"results": [
{
"id": "ju2pgynxn9o9DZvse",
"title": "Dragon Ball Daima",
"type": "Show",
"availableEpisodes": {
"sub": 5,
"dub": 0,
"raw": 0
}
},
{
"id": "qpnhxfarTHfP7kjgR",
"title": "My WeChat connects to the Dragon Palace",
"type": "Show",
"availableEpisodes": {
"sub": 26,
"dub": 0,
"raw": 0
}
},
{
"id": "8aM5BBoEGLvjG3MZm",
"title": "Sayounara Ryuusei, Konnichiwa Jinsei",
"type": "Show",
"availableEpisodes": {
"sub": 6,
"dub": 0,
"raw": 0
}
},
{
"id": "Sg9Q9FyqBnJ9qtv5n",
"title": "Yarinaoshi Reijou wa Ryuutei Heika wo Kouryakuchuu",
"type": "Show",
"availableEpisodes": {
"sub": 5,
"dub": 0,
"raw": 0
}
},
{
"id": "gF2mKbWBatQudcF6A",
"title": "Throne of the Dragon King",
"type": "Show",
"availableEpisodes": {
"sub": 3,
"dub": 0,
"raw": 0
}
},
{
"id": "SXLNNoorPifT5ZStw",
"title": "Shi Cao Lao Long Bei Guan Yi E Long Zhi Ming Season 2",
"type": "Show",
"availableEpisodes": {
"sub": 7,
"dub": 0,
"raw": 0
}
},
{
"id": "v4ZkjtyftscNzYF2A",
"title": "I Have a Dragon in My Body Episode122-133",
"type": "Show",
"availableEpisodes": {
"sub": 77,
"dub": 0,
"raw": 0
}
},
{
"id": "9RSQCRJ3d554sBzoz",
"title": "City Immortal Emperor: Dragon King Temple",
"type": "Show",
"availableEpisodes": {
"sub": 20,
"dub": 0,
"raw": 0
}
},
{
"id": "t8C6zvsdJE5JJKDLE",
"title": "It Turns Out I Am the Peerless Dragon God",
"type": "Show",
"availableEpisodes": {
"sub": 2,
"dub": 0,
"raw": 0
}
},
{
"id": "xyDt3mJieZkD76P7S",
"title": "Urban Hidden Dragon",
"type": "Show",
"availableEpisodes": {
"sub": 13,
"dub": 0,
"raw": 0
}
},
{
"id": "8PoJiTEDAswkw8b3u",
"title": "The Collected Animations of ICAF (2001-2006)",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 0,
"raw": 0
}
},
{
"id": "KZeMmRSsyJgz37EmH",
"title": "Dragon Master",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 0,
"raw": 0
}
},
{
"id": "7a33i9m26poonyNLg",
"title": "I Have a Dragon in My Body",
"type": "Show",
"availableEpisodes": {
"sub": 79,
"dub": 0,
"raw": 0
}
},
{
"id": "uwwvBujGRsjCQ8kKM",
"title": "Cong Gu Huo Niao Kaishi: Long Cheng Fengyun",
"type": "Show",
"availableEpisodes": {
"sub": 16,
"dub": 0,
"raw": 0
}
},
{
"id": "RoexdZwHSTDwyzEzd",
"title": "Super Dragon Ball Heroes Meteor Mission",
"type": "Show",
"availableEpisodes": {
"sub": 6,
"dub": 0,
"raw": 0
}
},
{
"id": "gAcGCcMENjbWhBnR9",
"title": "Dungeon Meshi",
"type": "Show",
"availableEpisodes": {
"sub": 24,
"dub": 24,
"raw": 0
}
},
{
"id": "ZGh2QHiaCY5T5Mhi4",
"title": "Long Shidai",
"type": "Show",
"availableEpisodes": {
"sub": 9,
"dub": 0,
"raw": 1
}
},
{
"id": "gZSHt98fQpHRfJJXw",
"title": "Xanadu Dragonslayer Densetsu",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 0,
"raw": 0
}
},
{
"id": "wo8pX4Sba97mFCAkc",
"title": "Vanguard Dragon God",
"type": "Show",
"availableEpisodes": {
"sub": 86,
"dub": 0,
"raw": 0
}
},
{
"id": "rrbCftmca3Y2TEiBX",
"title": "Super Dragon Ball Heroes Ultra God Mission",
"type": "Show",
"availableEpisodes": {
"sub": 10,
"dub": 0,
"raw": 0
}
},
{
"id": "JzSeXC2WtBBhn3guN",
"title": "Dragon King's Son-In-Law",
"type": "Show",
"availableEpisodes": {
"sub": 11,
"dub": 0,
"raw": 0
}
},
{
"id": "eE3txJGGk9atw7k2v",
"title": "Majutsushi Orphen Hagure Tabi: Seiiki-hen",
"type": "Show",
"availableEpisodes": {
"sub": 12,
"dub": 0,
"raw": 0
}
},
{
"id": "4X2JbZgiQrb2PTzex",
"title": "Yowai 5000-nen no Soushoku Dragon, Iwarenaki Jaryuu Nintei (Japanese Dub)",
"type": "Show",
"availableEpisodes": {
"sub": 12,
"dub": 0,
"raw": 0
}
},
{
"id": "SHp5NFDakKjPT5nJE",
"title": "Starting from Gu Huoniao: Dragon City Hegemony",
"type": "Show",
"availableEpisodes": {
"sub": 22,
"dub": 0,
"raw": 0
}
},
{
"id": "8LgaCGrz7Gz35LRpk",
"title": "Yuan Zun",
"type": "Show",
"availableEpisodes": {
"sub": 5,
"dub": 0,
"raw": 0
}
},
{
"id": "4GKHyjFC7Dyc7fBpT",
"title": "Shen Ji Long Wei",
"type": "Show",
"availableEpisodes": {
"sub": 26,
"dub": 0,
"raw": 0
}
},
{
"id": "2PQiuXiuJoTQTdgy4",
"title": "Long Zu",
"type": "Show",
"availableEpisodes": {
"sub": 15,
"dub": 0,
"raw": 0
}
},
{
"id": "rE47AepmBFRvZ6cne",
"title": "Jidao Long Shen",
"type": "Show",
"availableEpisodes": {
"sub": 40,
"dub": 0,
"raw": 0
}
},
{
"id": "c4JcjPbRfiuoJPB4F",
"title": "Dragon Quest: Dai no Daibouken (2020)",
"type": "Show",
"availableEpisodes": {
"sub": 101,
"dub": 100,
"raw": 0
}
},
{
"id": "nGRTwG7kj5rCPiAX4",
"title": "Dragon Quest: Dai no Daibouken Tachiagare!! Aban no Shito",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 0,
"raw": 0
}
},
{
"id": "6LJBjT4RzJaucdmX3",
"title": "Dragon Slayer Eiyuu Densetsu: Ouji no Tabidachi",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 1,
"raw": 0
}
},
{
"id": "JKbtxdw2cRqqmZgnS",
"title": "Dragon Quest: Dai no Daibouken Buchiyabure!! Shinsei 6 Daishougun",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 0,
"raw": 0
}
},
{
"id": "pn32RijEHPfuTYt4h",
"title": "Dragon Quest Retsuden: Roto no Monshou",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 0,
"raw": 0
}
},
{
"id": "xHwk6oo7jaDrMG9to",
"title": "Dragon Fist",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 0,
"raw": 0
}
},
{
"id": "ugFXPFQW8kvLocZgx",
"title": "Yowai 5000-nen no Soushoku Dragon, Iwarenaki Jaryuu Nintei",
"type": "Show",
"availableEpisodes": {
"sub": 12,
"dub": 0,
"raw": 0
}
},
{
"id": "qSFMEcT4SufEhLZnq",
"title": "Doraemon Movie 8: Nobita to Ryuu no Kishi",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 0,
"raw": 0
}
},
{
"id": "LTzXFSmQR878MdJaS",
"title": "Dragon Ball Specials",
"type": "Show",
"availableEpisodes": {
"sub": 2,
"dub": 0,
"raw": 0
}
},
{
"id": "XuTNNzF7DfapLFMFJ",
"title": "Dragon Ball Super: Super Hero",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 1,
"raw": 0
}
},
{
"id": "n4S2spjyTHXHNAMDW",
"title": "Shin Ikkitousen",
"type": "Show",
"availableEpisodes": {
"sub": 3,
"dub": 3,
"raw": 0
}
},
{
"id": "srMRCkMEJA9Rmt7do",
"title": "Dragon Ball Z: Atsumare! Goku World",
"type": "Show",
"availableEpisodes": {
"sub": 1,
"dub": 0,
"raw": 0
}
}
]
}
```
</details>
**Get anime by id:**
```bash
curl 'https://fastanime.onrender.com/anime/8aM5BBoEGLvjG3MZm'
```
<details>
<summary>
Result
</summary>
```json
{
"id": "8aM5BBoEGLvjG3MZm",
"title": "Sayounara Ryuusei, Konnichiwa Jinsei",
"availableEpisodesDetail": {
"sub": ["6", "5", "4", "3", "2", "1"],
"dub": [],
"raw": []
},
"type": null
}
```
</details>
**Get episode streams by translation_type:**
```bash
curl 'https://fastanime.onrender.com/anime/8aM5BBoEGLvjG3MZm/watch?episode=3&translation_type=sub'
```
<details>
<summary>
Result
</summary>
```json
[
{
"server": "Yt",
"episode_title": "Sayounara Ryuusei, Konnichiwa Jinsei; Episode 3",
"headers": {
"Referer": "https://allanime.day/"
},
"subtitles": [],
"links": [
{
"link": "",
"quality": "1080"
}
]
},
{
"server": "sharepoint",
"headers": {},
"subtitles": [],
"episode_title": "Sayounara Ryuusei, Konnichiwa Jinsei; Episode 3",
"links": [
{
"link": "",
"mp4": true,
"resolutionStr": "Mp4",
"src": "",
"quality": "1080"
}
]
},
{
"server": "gogoanime",
"headers": {},
"subtitles": [],
"episode_title": "Sayounara Ryuusei, Konnichiwa Jinsei; Episode 3",
"links": [
{
"link": "",
"hls": true,
"mp4": false,
"resolutionStr": "hls P",
"priority": 3,
"quality": "1080"
},
{
"link": "",
"hls": true,
"mp4": false,
"resolutionStr": "HLS1",
"priority": 2,
"quality": "720"
},
{
"link": "",
"hls": true,
"resolutionStr": "Alt",
"src": "",
"priority": 1,
"quality": "480"
}
]
}
]
```
</details>
**Get Episode Streams by AniList Id:**
```bash
curl 'https://fastanime.onrender.com/watch/269?episode=1&translation_type=dub'
```
<details>
<summary>
Results
</summary>
```json
[
{
"server": "gogoanime",
"headers": {},
"subtitles": [],
"episode_title": "Bleach; Episode 1",
"links": [
{
"link": "",
"hls": true,
"mp4": false,
"resolutionStr": "hls P",
"priority": 3,
"quality": "1080"
},
{
"link": "",
"hls": true,
"mp4": false,
"resolutionStr": "HLS1",
"priority": 2,
"quality": "720"
},
{
"link": "",
"hls": true,
"resolutionStr": "Alt",
"src": "",
"priority": 1,
"quality": "480"
}
]
},
{
"server": "Yt",
"episode_title": "Bleach; Episode 1",
"headers": {
"Referer": "https://allanime.day/"
},
"subtitles": [],
"links": [
{
"link": "",
"quality": "1080"
}
]
},
{
"server": "wixmp",
"headers": {},
"subtitles": [],
"episode_title": "Bleach; Episode 1",
"links": [
{
"link": "",
"hls": true,
"resolutionStr": "Hls",
"quality": "1080"
}
]
},
{
"server": "sharepoint",
"headers": {},
"subtitles": [],
"episode_title": "Bleach; Episode 1",
"links": [
{
"link": "",
"mp4": true,
"resolutionStr": "Mp4",
"src": "",
"quality": "1080"
}
]
}
]
```
</details>
### MPV specific commands ### MPV specific commands
The project now allows on the fly media controls directly from mpv. This means you can go to the next or previous episode without the window ever closing thus offering a seamless experience. The project now allows on the fly media controls directly from mpv. This means you can go to the next or previous episode without the window ever closing thus offering a seamless experience.
@@ -728,6 +1440,8 @@ cache_requests = True
use_persistent_provider_store = False use_persistent_provider_store = False
recent = 50
[stream] [stream]
continue_from_history = True continue_from_history = True
@@ -757,11 +1471,10 @@ player = mpv
## Contributing ## Contributing
We welcome your issues and feature requests. However, due to time constraints, we currently do not plan to add another provider. We welcome your issues and feature requests. However, due to time constraints, I currently do not plan to add another provider.
But if you are willing to add one yourself pr's are welcome.
If you wish to contribute directly, please first open an issue describing your proposed changes so it can be discussed or if you are in a rush for the feature to be merged just open a pr. If you find an anime title that does not correspond with a provider or is just weird just [edit the data file](https://github.com/FastAnime/FastAnime/blob/master/fastanime/Utility/data.py) and open a pr, i will ignore issues 😝.
If you find an anime title that does not correspond with a provider or is just weird just [edit the data file](https://github.com/Benex254/FastAnime/blob/master/fastanime/Utility/data.py) and open a pr or if you don't want to do that open an issue.
## Receiving Support ## Receiving Support
@@ -775,4 +1488,15 @@ For inquiries, join our [Discord Server](https://discord.gg/HBEmAwvbHV).
## Supporting the Project ## Supporting the Project
Show your support by starring our GitHub repository or [buying us a coffee](https://ko-fi.com/benex254). More pr's less issues 🙃
Those who contribute at least five times will be able to make changes to the repo without my review.
Show your support by starring the GitHub repository or [buying me a coffee](https://ko-fi.com/benex254).
## Disclaimer
> [!IMPORTANT]
>
> This project currently scrapes allanime, hianime, nyaa, yugen and animepahe.
> The developer(s) of this application does not have any affiliation with the content providers available, and this application hosts zero content.
> [DISCLAIMER](https://github.com/Benex254/FastAnime/blob/master/DISCLAIMER.md)

2
fa
View File

@@ -1,3 +1,3 @@
#!/usr/bin/env sh #!/usr/bin/env sh
CLI_DIR="$(dirname "$(realpath "$0")")" CLI_DIR="$(dirname "$(realpath "$0")")"
exec python -m "$CLI_DIR/fastanime" "$@" exec uv run --directory "$CLI_DIR/../" fastanime "$@"

View File

@@ -15,7 +15,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# TODO: improve performance of this class and add cool features like auto retry # TODO: add cool features like auto retry
class AnimeProvider: class AnimeProvider:
"""Class that manages all anime sources adding some extra functionality to them. """Class that manages all anime sources adding some extra functionality to them.
Attributes: Attributes:

View File

@@ -14,6 +14,7 @@ anime_normalizer_raw = {
"hianime": {"My Star": "Oshi no Ko"}, "hianime": {"My Star": "Oshi no Ko"},
"animepahe": {"Azumanga Daiou The Animation": "Azumanga Daioh"}, "animepahe": {"Azumanga Daiou The Animation": "Azumanga Daioh"},
"nyaa": {}, "nyaa": {},
"yugen": {},
} }

View File

@@ -6,7 +6,7 @@ if sys.version_info < (3, 10):
) # noqa: F541 ) # noqa: F541
__version__ = "v2.6.4" __version__ = "v2.8.3"
APP_NAME = "FastAnime" APP_NAME = "FastAnime"
AUTHOR = "Benex254" AUTHOR = "Benex254"

93
fastanime/api/__init__.py Normal file
View File

@@ -0,0 +1,93 @@
from typing import Literal
from fastapi import FastAPI
from requests import post
from thefuzz import fuzz
from ..AnimeProvider import AnimeProvider
from ..Utility.data import anime_normalizer
app = FastAPI()
anime_provider = AnimeProvider("allanime", "true", "true")
ANILIST_ENDPOINT = "https://graphql.anilist.co"
@app.get("/search")
def search_for_anime(title: str, translation_type: Literal["dub", "sub"] = "sub"):
return anime_provider.search_for_anime(title, translation_type)
@app.get("/anime/{anime_id}")
def get_anime(anime_id: str):
return anime_provider.get_anime(anime_id)
@app.get("/anime/{anime_id}/watch")
def get_episode_streams(
anime_id: str, episode: str, translation_type: Literal["sub", "dub"]
):
return anime_provider.get_episode_streams(anime_id, episode, translation_type)
def get_anime_by_anilist_id(anilist_id: int):
query = f"""
query {{
Media(id: {anilist_id}) {{
id
title {{
romaji
english
native
}}
synonyms
episodes
duration
}}
}}
"""
response = post(ANILIST_ENDPOINT, json={"query": query}).json()
return response["data"]["Media"]
@app.get("/watch/{anilist_id}")
def get_episode_streams_by_anilist_id(
anilist_id: int, episode: str, translation_type: Literal["sub", "dub"]
):
anime = get_anime_by_anilist_id(anilist_id)
if not anime:
return
if search_results := anime_provider.search_for_anime(
str(anime["title"]["romaji"] or anime["title"]["english"]), translation_type
):
if not search_results["results"]:
return
def match_title(possible_user_requested_anime_title):
possible_user_requested_anime_title = anime_normalizer.get(
possible_user_requested_anime_title, possible_user_requested_anime_title
)
title_a = str(anime["title"]["romaji"])
title_b = str(anime["title"]["english"])
percentage_ratio = max(
*[
fuzz.ratio(
title.lower(), possible_user_requested_anime_title.lower()
)
for title in anime["synonyms"]
],
fuzz.ratio(
title_a.lower(), possible_user_requested_anime_title.lower()
),
fuzz.ratio(
title_b.lower(), possible_user_requested_anime_title.lower()
),
)
return percentage_ratio
provider_anime = max(
search_results["results"], key=lambda x: match_title(x["title"])
)
anime_provider.get_anime(provider_anime["id"])
return anime_provider.get_episode_streams(
provider_anime["id"], episode, translation_type
)

View File

@@ -0,0 +1,84 @@
// https://github.com/Wraient/curd/blob/main/rofi/selectanime.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
configuration {
font: "Sans 12";
line-margin: 10;
display-drun: "";
}
* {
background: #000000; /* Black background for everything */
background-alt: #000000; /* Ensures no alternation */
foreground: #CCCCCC;
selected: #3584E4;
active: #2E7D32;
urgent: #C62828;
}
window {
fullscreen: false;
background-color: rgba(0, 0, 0, 1); /* Solid black background */
}
mainbox {
padding: 50px 100px;
background-color: rgba(0, 0, 0, 1); /* Ensures black background fills entire main area */
children: [inputbar, listview];
spacing: 20px;
}
inputbar {
background-color: #333333; /* Dark gray background for input bar */
padding: 8px;
border-radius: 8px;
children: [prompt, entry];
}
prompt {
enabled: true;
padding: 8px;
background-color: @selected;
text-color: #000000;
border-radius: 4px;
}
entry {
padding: 8px;
background-color: #444444; /* Slightly lighter gray for visibility */
text-color: #FFFFFF; /* White text to make typing visible */
placeholder: "Search...";
placeholder-color: rgba(255, 255, 255, 0.5);
border-radius: 6px;
}
listview {
layout: vertical;
spacing: 8px;
lines: 10;
background-color: @background; /* Consistent black background for list items */
}
element {
padding: 12px;
border-radius: 4px;
background-color: @background; /* Uniform color for each list item */
text-color: @foreground;
}
element normal.normal {
background-color: @background; /* Ensures no alternating color */
}
element selected.normal {
background-color: @selected;
text-color: #FFFFFF;
}
element-text {
background-color: transparent;
text-color: inherit;
vertical-align: 0.5;
}

View File

@@ -0,0 +1,55 @@
// https://github.com/Wraient/curd/blob/main/rofi/userinput.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
configuration {
font: "Sans 12";
}
* {
background-color: rgba(0, 0, 0, 0.7);
text-color: #FFFFFF;
}
window {
fullscreen: true;
transparency: "real";
background-color: @background-color;
}
mainbox {
children: [ message, listview, inputbar ];
padding: 40% 30%;
}
message {
border: 0;
padding: 10px;
margin: 0 0 20px 0;
font: "Sans Bold 24"; /* Increased font size and made it bold */
}
inputbar {
children: [ prompt, entry ];
background-color: rgba(255, 255, 255, 0.1);
padding: 8px;
border-radius: 4px;
}
prompt {
padding: 8px;
}
entry {
padding: 8px;
}
listview {
lines: 0;
}
/* Style for the message text specifically */
textbox {
horizontal-align: 0.5; /* Center the text */
font: "Sans Bold 24"; /* Match message font */
}

View File

@@ -0,0 +1,55 @@
// https://github.com/Wraient/curd/blob/main/rofi/userinput.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
configuration {
font: "Sans 12";
}
* {
background-color: rgba(0, 0, 0, 0.7);
text-color: #FFFFFF;
}
window {
fullscreen: true;
transparency: "real";
background-color: @background-color;
}
mainbox {
children: [ message, listview, inputbar ];
padding: 40% 30%;
}
message {
border: 0;
padding: 10px;
margin: 0 0 20px 0;
font: "Sans Bold 24"; /* Increased font size and made it bold */
}
inputbar {
children: [ prompt, entry ];
background-color: rgba(255, 255, 255, 0.1);
padding: 8px;
border-radius: 4px;
}
prompt {
padding: 8px;
}
entry {
padding: 8px;
}
listview {
lines: 0;
}
/* Style for the message text specifically */
textbox {
horizontal-align: 0.5; /* Center the text */
font: "Sans Bold 24"; /* Match message font */
}

View File

@@ -0,0 +1,122 @@
// Based on https://github.com/Wraient/curd/blob/main/rofi/selectanimepreview.rasi
// Go give there project a star!
// Was too lazy to make my own preview, so I just used theirs
// Colours
* {
background-color: transparent;
background: #1D2330;
background-transparent: #1D2330A0;
text-color: #BBBBBB;
text-color-selected: #FFFFFF;
primary: #BB77BB;
important: #BF616A;
}
configuration {
font: "Roboto 17";
show-icons: true;
}
window {
fullscreen: true;
height: 100%;
width: 100%;
transparency: "real";
background-color: @background-transparent;
border: 0px;
border-color: @primary;
}
mainbox {
children: [prompt, inputbar-box, listview];
padding: 0px;
}
prompt {
width: 100%;
margin: 10px 0px 0px 30px;
text-color: @important;
font: "Roboto Bold 27";
}
listview {
layout: vertical;
padding: 60px;
dynamic: true;
columns: 7;
spacing: 20px;
horizontal-align: center; /* Center the list items */
}
inputbar-box {
children: [dummy, inputbar, dummy];
orientation: horizontal;
expand: false;
}
inputbar {
children: [textbox-prompt, entry];
margin: 0px;
background-color: @primary;
border: 4px;
border-color: @primary;
border-radius: 8px;
}
textbox-prompt {
text-color: @background;
horizontal-align: 0.5;
vertical-align: 0.5;
expand: false;
}
entry {
expand: false;
padding: 8px;
margin: -6px;
horizontal-align: 0;
width: 300;
background-color: @background;
border: 6px;
border-color: @primary;
border-radius: 8px;
cursor: text;
}
element {
children: [dummy, element-box, dummy];
padding: 5px;
orientation: vertical;
border: 0px;
border-radius: 16px;
background-color: transparent; /* Default background */
}
element selected {
background-color: @primary; /* Solid color for selected item */
}
element-box {
children: [element-icon, element-text];
orientation: vertical;
expand: false;
cursor: pointer;
}
element-icon {
padding: 10px;
cursor: inherit;
size: 33%;
margin: 10px;
}
element-text {
horizontal-align: 0.5;
cursor: inherit;
text-color: @text-color;
}
element-text selected {
text-color: @text-color-selected;
}

View File

@@ -16,6 +16,7 @@ commands = {
"completions": "completions.completions", "completions": "completions.completions",
"update": "update.update", "update": "update.update",
"grab": "grab.grab", "grab": "grab.grab",
"serve": "serve.serve",
} }
@@ -157,6 +158,9 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option("--sub", help="Set the translation type to sub", is_flag=True) @click.option("--sub", help="Set the translation type to sub", is_flag=True)
@click.option("--rofi", help="Use rofi for the ui", is_flag=True) @click.option("--rofi", help="Use rofi for the ui", is_flag=True)
@click.option("--rofi-theme", help="Rofi theme to use", type=click.Path()) @click.option("--rofi-theme", help="Rofi theme to use", type=click.Path())
@click.option(
"--rofi-theme-preview", help="Rofi theme to use for previews", type=click.Path()
)
@click.option( @click.option(
"--rofi-theme-confirm", "--rofi-theme-confirm",
help="Rofi theme to use for the confirm prompt", help="Rofi theme to use for the confirm prompt",
@@ -177,6 +181,9 @@ signal.signal(signal.SIGINT, handle_exit)
help="the player to use when streaming", help="the player to use when streaming",
type=click.Choice(["mpv", "vlc"]), type=click.Choice(["mpv", "vlc"]),
) )
@click.option(
"--fresh-requests", is_flag=True, help="Force the requests cache to be updated"
)
@click.pass_context @click.pass_context
def run_cli( def run_cli(
ctx: click.Context, ctx: click.Context,
@@ -206,15 +213,53 @@ def run_cli(
sub, sub,
rofi, rofi,
rofi_theme, rofi_theme,
rofi_theme_preview,
rofi_theme_confirm, rofi_theme_confirm,
rofi_theme_input, rofi_theme_input,
use_python_mpv, use_python_mpv,
sync_play, sync_play,
player, player,
fresh_requests,
): ):
import os
from .config import Config from .config import Config
ctx.obj = Config() ctx.obj = Config()
if ctx.obj.check_for_updates and ctx.invoked_subcommand != "completions":
from .app_updater import check_for_updates
print("Checking for updates...")
print("So you can enjoy the latest features and bug fixes")
print(
"You can disable this by setting check_for_updates to False in the config"
)
is_latest, github_release_data = check_for_updates()
if not is_latest:
from rich.console import Console
from rich.markdown import Markdown
from .app_updater import update_app
from rich.prompt import Confirm
def _print_release(release_data):
console = Console()
body = Markdown(release_data["body"])
tag = github_release_data["tag_name"]
tag_title = release_data["name"]
github_page_url = release_data["html_url"]
console.print(f"Release Page: {github_page_url}")
console.print(f"Tag: {tag}")
console.print(f"Title: {tag_title}")
console.print(body)
if Confirm.ask(
"A new version of fastanime is available, would you like to update?"
):
_, release_json = update_app()
print("Successfully updated")
_print_release(release_json)
exit(0)
ctx.obj.manga = manga ctx.obj.manga = manga
if log: if log:
import logging import logging
@@ -250,6 +295,8 @@ def run_cli(
install() install()
if fresh_requests:
os.environ["FASTANIME_FRESH_REQUESTS"] = "1"
if sync_play: if sync_play:
ctx.obj.sync_play = sync_play ctx.obj.sync_play = sync_play
if provider: if provider:
@@ -316,6 +363,10 @@ def run_cli(
if rofi: if rofi:
from ..libs.rofi import Rofi from ..libs.rofi import Rofi
if rofi_theme_preview:
ctx.obj.rofi_theme_preview = rofi_theme_preview
Rofi.rofi_theme_preview = rofi_theme_preview
if rofi_theme: if rofi_theme:
ctx.obj.rofi_theme = rofi_theme ctx.obj.rofi_theme = rofi_theme
Rofi.rofi_theme = rofi_theme Rofi.rofi_theme = rofi_theme

View File

@@ -4,6 +4,7 @@ import shlex
import shutil import shutil
import subprocess import subprocess
import sys import sys
import os
import requests import requests
from rich import print from rich import print
@@ -15,14 +16,18 @@ API_URL = f"https://api.{GIT_REPO}/repos/{AUTHOR}/{APP_NAME}/releases/latest"
def check_for_updates(): def check_for_updates():
USER_AGENT = f"{APP_NAME} user" USER_AGENT = f"{APP_NAME} user"
request = requests.get( try:
API_URL, request = requests.get(
headers={ API_URL,
"User-Agent": USER_AGENT, headers={
"X-GitHub-Api-Version": "2022-11-28", "User-Agent": USER_AGENT,
"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28",
}, "Accept": "application/vnd.github+json",
) },
)
except Exception:
print("You are not connected to the internet")
return True, {}
if request.status_code == 200: if request.status_code == 200:
release_json = request.json() release_json = request.json()
@@ -45,8 +50,9 @@ def check_for_updates():
return (is_latest, release_json) return (is_latest, release_json)
else: else:
print("Failed to check for updates")
print(request.text) print(request.text)
return (False, {}) return (True, {})
def is_git_repo(author, repository): def is_git_repo(author, repository):
@@ -75,15 +81,22 @@ def is_git_repo(author, repository):
return bool(match) and match.group(1) == f"{author}/{repository}" return bool(match) and match.group(1) == f"{author}/{repository}"
def update_app(): def update_app(force=False):
is_latest, release_json = check_for_updates() is_latest, release_json = check_for_updates()
if is_latest: if is_latest and not force:
print("[green]App is up to date[/]") print("[green]App is up to date[/]")
return False, release_json return False, release_json
tag_name = release_json["tag_name"] tag_name = release_json["tag_name"]
print("[cyan]Updating app to version %s[/]" % tag_name) print("[cyan]Updating app to version %s[/]" % tag_name)
if is_git_repo(AUTHOR, APP_NAME): if os.path.exists("/nix/store") and os.path.exists("/run/current-system"):
NIX = shutil.which("nix")
if not NIX:
print("[red]Cannot find nix, it looks like your system is broken.[/]")
return False, release_json
process = subprocess.run([NIX, "profile", "upgrade", APP_NAME.lower()])
elif is_git_repo(AUTHOR, APP_NAME):
GIT_EXECUTABLE = shutil.which("git") GIT_EXECUTABLE = shutil.which("git")
args = [ args = [
GIT_EXECUTABLE, GIT_EXECUTABLE,
@@ -101,8 +114,10 @@ def update_app():
) )
else: else:
if PIPX_EXECUTABLE := shutil.which("pipx"): if UV := shutil.which("uv"):
process = subprocess.run([PIPX_EXECUTABLE, "upgrade", APP_NAME]) process = subprocess.run([UV, "tool", "upgrade", APP_NAME])
elif PIPX := shutil.which("pipx"):
process = subprocess.run([PIPX, "upgrade", APP_NAME])
else: else:
PYTHON_EXECUTABLE = sys.executable PYTHON_EXECUTABLE = sys.executable

View File

@@ -21,6 +21,8 @@ commands = {
"planning": "planning.planning", "planning": "planning.planning",
"notifier": "notifier.notifier", "notifier": "notifier.notifier",
"stats": "stats.stats", "stats": "stats.stats",
"download": "download.download",
"downloads": "downloads.downloads",
} }

View File

@@ -0,0 +1,476 @@
sorts_available = [
"ID",
"ID_DESC",
"TITLE_ROMAJI",
"TITLE_ROMAJI_DESC",
"TITLE_ENGLISH",
"TITLE_ENGLISH_DESC",
"TITLE_NATIVE",
"TITLE_NATIVE_DESC",
"TYPE",
"TYPE_DESC",
"FORMAT",
"FORMAT_DESC",
"START_DATE",
"START_DATE_DESC",
"END_DATE",
"END_DATE_DESC",
"SCORE",
"SCORE_DESC",
"POPULARITY",
"POPULARITY_DESC",
"TRENDING",
"TRENDING_DESC",
"EPISODES",
"EPISODES_DESC",
"DURATION",
"DURATION_DESC",
"STATUS",
"STATUS_DESC",
"CHAPTERS",
"CHAPTERS_DESC",
"VOLUMES",
"VOLUMES_DESC",
"UPDATED_AT",
"UPDATED_AT_DESC",
"SEARCH_MATCH",
"FAVOURITES",
"FAVOURITES_DESC",
]
media_statuses_available = [
"FINISHED",
"RELEASING",
"NOT_YET_RELEASED",
"CANCELLED",
"HIATUS",
]
seasons_available = ["WINTER", "SPRING", "SUMMER", "FALL"]
genres_available = [
"Action",
"Adventure",
"Comedy",
"Drama",
"Ecchi",
"Fantasy",
"Horror",
"Mahou Shoujo",
"Mecha",
"Music",
"Mystery",
"Psychological",
"Romance",
"Sci-Fi",
"Slice of Life",
"Sports",
"Supernatural",
"Thriller",
"Hentai",
]
media_formats_available = [
"TV",
"TV_SHORT",
"MOVIE",
"SPECIAL",
"OVA",
"MUSIC",
"NOVEL",
"ONE_SHOT",
]
years_available = [
"1900",
"1910",
"1920",
"1930",
"1940",
"1950",
"1960",
"1970",
"1980",
"1990",
"2000",
"2004",
"2005",
"2006",
"2007",
"2008",
"2009",
"2010",
"2011",
"2012",
"2013",
"2014",
"2015",
"2016",
"2017",
"2018",
"2019",
"2020",
"2021",
"2022",
"2023",
"2024",
]
tags_available = {
"Cast": ["Polyamorous"],
"Cast Main Cast": [
"Anti-Hero",
"Elderly Protagonist",
"Ensemble Cast",
"Estranged Family",
"Female Protagonist",
"Male Protagonist",
"Primarily Adult Cast",
"Primarily Animal Cast",
"Primarily Child Cast",
"Primarily Female Cast",
"Primarily Male Cast",
"Primarily Teen Cast",
],
"Cast Traits": [
"Age Regression",
"Agender",
"Aliens",
"Amnesia",
"Angels",
"Anthropomorphism",
"Aromantic",
"Arranged Marriage",
"Artificial Intelligence",
"Asexual",
"Butler",
"Centaur",
"Chimera",
"Chuunibyou",
"Clone",
"Cosplay",
"Cowboys",
"Crossdressing",
"Cyborg",
"Delinquents",
"Demons",
"Detective",
"Dinosaurs",
"Disability",
"Dissociative Identities",
"Dragons",
"Dullahan",
"Elf",
"Fairy",
"Femboy",
"Ghost",
"Goblin",
"Gods",
"Gyaru",
"Hikikomori",
"Homeless",
"Idol",
"Kemonomimi",
"Kuudere",
"Maids",
"Mermaid",
"Monster Boy",
"Monster Girl",
"Nekomimi",
"Ninja",
"Nudity",
"Nun",
"Office Lady",
"Oiran",
"Ojou-sama",
"Orphan",
"Pirates",
"Robots",
"Samurai",
"Shrine Maiden",
"Skeleton",
"Succubus",
"Tanned Skin",
"Teacher",
"Tomboy",
"Transgender",
"Tsundere",
"Twins",
"Vampire",
"Veterinarian",
"Vikings",
"Villainess",
"VTuber",
"Werewolf",
"Witch",
"Yandere",
"Zombie",
],
"Demographic": ["Josei", "Kids", "Seinen", "Shoujo", "Shounen"],
"Setting": ["Matriarchy"],
"Setting Scene": [
"Bar",
"Boarding School",
"Circus",
"Coastal",
"College",
"Desert",
"Dungeon",
"Foreign",
"Inn",
"Konbini",
"Natural Disaster",
"Office",
"Outdoor",
"Prison",
"Restaurant",
"Rural",
"School",
"School Club",
"Snowscape",
"Urban",
"Work",
],
"Setting Time": [
"Achronological Order",
"Anachronism",
"Ancient China",
"Dystopian",
"Historical",
"Time Skip",
],
"Setting Universe": [
"Afterlife",
"Alternate Universe",
"Augmented Reality",
"Omegaverse",
"Post-Apocalyptic",
"Space",
"Urban Fantasy",
"Virtual World",
],
"Technical": [
"4-koma",
"Achromatic",
"Advertisement",
"Anthology",
"CGI",
"Episodic",
"Flash",
"Full CGI",
"Full Color",
"No Dialogue",
"Non-fiction",
"POV",
"Puppetry",
"Rotoscoping",
"Stop Motion",
],
"Theme Action": [
"Archery",
"Battle Royale",
"Espionage",
"Fugitive",
"Guns",
"Martial Arts",
"Spearplay",
"Swordplay",
],
"Theme Arts": [
"Acting",
"Calligraphy",
"Classic Literature",
"Drawing",
"Fashion",
"Food",
"Makeup",
"Photography",
"Rakugo",
"Writing",
],
"Theme Arts-Music": [
"Band",
"Classical Music",
"Dancing",
"Hip-hop Music",
"Jazz Music",
"Metal Music",
"Musical Theater",
"Rock Music",
],
"Theme Comedy": ["Parody", "Satire", "Slapstick", "Surreal Comedy"],
"Theme Drama": [
"Bullying",
"Class Struggle",
"Coming of Age",
"Conspiracy",
"Eco-Horror",
"Fake Relationship",
"Kingdom Management",
"Rehabilitation",
"Revenge",
"Suicide",
"Tragedy",
],
"Theme Fantasy": [
"Alchemy",
"Body Swapping",
"Cultivation",
"Fairy Tale",
"Henshin",
"Isekai",
"Kaiju",
"Magic",
"Mythology",
"Necromancy",
"Shapeshifting",
"Steampunk",
"Super Power",
"Superhero",
"Wuxia",
"Youkai",
],
"Theme Game": ["Board Game", "E-Sports", "Video Games"],
"Theme Game-Card & Board Game": [
"Card Battle",
"Go",
"Karuta",
"Mahjong",
"Poker",
"Shogi",
],
"Theme Game-Sport": [
"Acrobatics",
"Airsoft",
"American Football",
"Athletics",
"Badminton",
"Baseball",
"Basketball",
"Bowling",
"Boxing",
"Cheerleading",
"Cycling",
"Fencing",
"Fishing",
"Fitness",
"Football",
"Golf",
"Handball",
"Ice Skating",
"Judo",
"Lacrosse",
"Parkour",
"Rugby",
"Scuba Diving",
"Skateboarding",
"Sumo",
"Surfing",
"Swimming",
"Table Tennis",
"Tennis",
"Volleyball",
"Wrestling",
],
"Theme Other": [
"Adoption",
"Animals",
"Astronomy",
"Autobiographical",
"Biographical",
"Body Horror",
"Cannibalism",
"Chibi",
"Cosmic Horror",
"Crime",
"Crossover",
"Death Game",
"Denpa",
"Drugs",
"Economics",
"Educational",
"Environmental",
"Ero Guro",
"Filmmaking",
"Found Family",
"Gambling",
"Gender Bending",
"Gore",
"Language Barrier",
"LGBTQ+ Themes",
"Lost Civilization",
"Marriage",
"Medicine",
"Memory Manipulation",
"Meta",
"Mountaineering",
"Noir",
"Otaku Culture",
"Pandemic",
"Philosophy",
"Politics",
"Proxy Battle",
"Psychosexual",
"Reincarnation",
"Religion",
"Royal Affairs",
"Slavery",
"Software Development",
"Survival",
"Terrorism",
"Torture",
"Travel",
"War",
],
"Theme Other-Organisations": [
"Assassins",
"Criminal Organization",
"Cult",
"Firefighters",
"Gangs",
"Mafia",
"Military",
"Police",
"Triads",
"Yakuza",
],
"Theme Other-Vehicle": [
"Aviation",
"Cars",
"Mopeds",
"Motorcycles",
"Ships",
"Tanks",
"Trains",
],
"Theme Romance": [
"Age Gap",
"Bisexual",
"Boys' Love",
"Female Harem",
"Heterosexual",
"Love Triangle",
"Male Harem",
"Matchmaking",
"Mixed Gender Harem",
"Teens' Love",
"Unrequited Love",
"Yuri",
],
"Theme Sci Fi": [
"Cyberpunk",
"Space Opera",
"Time Loop",
"Time Manipulation",
"Tokusatsu",
],
"Theme Sci Fi-Mecha": ["Real Robot", "Super Robot"],
"Theme Slice of Life": [
"Agriculture",
"Cute Boys Doing Cute Things",
"Cute Girls Doing Cute Things",
"Family Life",
"Horticulture",
"Iyashikei",
"Parenthood",
],
}
tags_available_list = []
for tag_category, tags_in_category in tags_available.items():
tags_available_list.extend(tags_in_category)

View File

@@ -0,0 +1,382 @@
import click
from ...completion_functions import anime_titles_shell_complete
from .data import (
tags_available_list,
sorts_available,
media_statuses_available,
seasons_available,
genres_available,
media_formats_available,
years_available,
)
@click.command(
help="download anime using anilists api to get the titles",
short_help="download anime with anilist intergration",
)
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
@click.option(
"--season",
help="The season the media was released",
type=click.Choice(seasons_available),
)
@click.option(
"--status",
"-S",
help="The media status of the anime",
multiple=True,
type=click.Choice(media_statuses_available),
)
@click.option(
"--sort",
"-s",
help="What to sort the search results on",
type=click.Choice(sorts_available),
)
@click.option(
"--genres",
"-g",
multiple=True,
help="the genres to filter by",
type=click.Choice(genres_available),
)
@click.option(
"--tags",
"-T",
multiple=True,
help="the tags to filter by",
type=click.Choice(tags_available_list),
)
@click.option(
"--media-format",
"-f",
multiple=True,
help="Media format",
type=click.Choice(media_formats_available),
)
@click.option(
"--year",
"-y",
type=click.Choice(years_available),
help="the year the media was released",
)
@click.option(
"--on-list/--not-on-list",
"-L/-no-L",
help="Whether the anime should be in your list or not",
type=bool,
)
@click.option(
"--episode-range",
"-r",
help="A range of episodes to download (start-end)",
)
@click.option(
"--force-unknown-ext",
"-F",
help="This option forces yt-dlp to download extensions its not aware of",
is_flag=True,
)
@click.option(
"--silent/--no-silent",
"-q/-V",
type=bool,
help="Download silently (during download)",
default=True,
)
@click.option("--verbose", "-v", is_flag=True, help="Download verbosely (everywhere)")
@click.option(
"--merge", "-m", is_flag=True, help="Merge the subfile with video using ffmpeg"
)
@click.option(
"--clean",
"-c",
is_flag=True,
help="After merging delete the original files",
)
@click.option(
"--wait-time",
"-w",
type=int,
help="The amount of time to wait after downloading is complete before the screen is completely cleared",
default=60,
)
@click.option(
"--prompt/--no-prompt",
help="Whether to prompt for anything instead just do the best thing",
default=True,
)
@click.option(
"--max-results", "-M", type=int, help="The maximum number of results to show"
)
@click.pass_obj
def download(
config,
title,
season,
status,
sort,
genres,
tags,
media_format,
year,
on_list,
episode_range,
force_unknown_ext,
silent,
verbose,
merge,
clean,
wait_time,
prompt,
max_results,
):
from ....anilist import AniList
from rich import print
success, anilist_search_results = AniList.search(
query=title,
sort=sort,
status_in=list(status),
genre_in=list(genres),
season=season,
tag_in=list(tags),
seasonYear=year,
format_in=list(media_format),
on_list=on_list,
max_results=max_results,
)
if success:
import time
from rich.progress import Progress
from thefuzz import fuzz
from ....AnimeProvider import AnimeProvider
from ....libs.anime_provider.types import Anime
from ....libs.fzf import fzf
from ....Utility.data import anime_normalizer
from ....Utility.downloader.downloader import downloader
from ...utils.tools import exit_app
from ...utils.utils import (
filter_by_quality,
fuzzy_inquirer,
move_preferred_subtitle_lang_to_top,
)
anime_provider = AnimeProvider(config.provider)
anilist_anime_info = None
translation_type = config.translation_type
download_dir = config.downloads_dir
anime_titles = [
(
anime["title"][config.preferred_language]
or anime["title"]["english"]
or anime["title"]["romaji"]
)
for anime in anilist_search_results["data"]["Page"]["media"]
]
print(f"[green bold]Queued:[/] {anime_titles}")
for i, anime_title in enumerate(anime_titles):
print(f"[green bold]Now Downloading: [/] {anime_title}")
# ---- search for anime ----
with Progress() as progress:
progress.add_task("Fetching Search Results...", total=None)
search_results = anime_provider.search_for_anime(
anime_title, translation_type=translation_type
)
if not search_results:
print(
"No search results found from provider for {}".format(anime_title)
)
continue
search_results = search_results["results"]
if not search_results:
print("Nothing muches your search term")
continue
search_results_ = {
search_result["title"]: search_result
for search_result in search_results
}
if config.auto_select:
selected_anime_title = max(
search_results_.keys(),
key=lambda title: fuzz.ratio(
anime_normalizer.get(title, title), anime_title
),
)
print("[cyan]Auto selecting:[/] ", selected_anime_title)
else:
choices = list(search_results_.keys())
if config.use_fzf:
selected_anime_title = fzf.run(
choices, "Please Select title", "FastAnime"
)
else:
selected_anime_title = fuzzy_inquirer(
choices,
"Please Select title",
)
# ---- fetch anime ----
with Progress() as progress:
progress.add_task("Fetching Anime...", total=None)
anime: Anime | None = anime_provider.get_anime(
search_results_[selected_anime_title]["id"]
)
if not anime:
print("Failed to fetch anime {}".format(selected_anime_title))
continue
episodes = sorted(
anime["availableEpisodesDetail"][config.translation_type], key=float
)
# where the magic happens
if episode_range:
if ":" in episode_range:
ep_range_tuple = episode_range.split(":")
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
episodes_start, episodes_end = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end)
]
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
episodes_start, episodes_end, step = ep_range_tuple
episodes_range = episodes[
int(episodes_start) : int(episodes_end) : int(step)
]
else:
episodes_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range = episodes[int(episodes_start) :]
elif episodes_end.strip():
episodes_range = episodes[: int(episodes_end)]
else:
episodes_range = episodes
else:
episodes_range = episodes[int(episode_range) :]
print(f"[green bold]Downloading: [/] {episodes_range}")
else:
episodes_range = sorted(episodes, key=float)
if config.normalize_titles:
anilist_anime_info = anilist_search_results["data"]["Page"]["media"][i]
# lets download em
for episode in episodes_range:
try:
episode = str(episode)
if episode not in episodes:
print(
f"[cyan]Warning[/]: Episode {episode} not found, skipping"
)
continue
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime["id"], episode, config.translation_type
)
if not streams:
print("No streams skipping")
continue
# ---- fetch servers ----
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server_name = next(streams, None)
if not server_name:
print("Sth went wrong when fetching the server")
continue
stream_link = filter_by_quality(
config.quality, server_name["links"]
)
if not stream_link:
print("[yellow bold]WARNING:[/] No streams found")
time.sleep(1)
print("Continuing...")
continue
link = stream_link["link"]
provider_headers = server_name["headers"]
episode_title = server_name["episode_title"]
subtitles = server_name["subtitles"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.server in servers_names:
server_name = config.server
else:
if config.use_fzf:
server_name = fzf.run(servers_names, "Select an link")
else:
server_name = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server_name]["links"]
)
if not stream_link:
print("[yellow bold]WARNING:[/] No streams found")
time.sleep(1)
print("Continuing...")
continue
link = stream_link["link"]
provider_headers = servers[server_name]["headers"]
subtitles = servers[server_name]["subtitles"]
episode_title = servers[server_name]["episode_title"]
if anilist_anime_info:
selected_anime_title = (
anilist_anime_info["title"][config.preferred_language]
or anilist_anime_info["title"]["romaji"]
or anilist_anime_info["title"]["english"]
)
import re
for episode_detail in anilist_anime_info["streamingEpisodes"]:
if re.match(
f".*Episode {episode} .*", episode_detail["title"]
):
episode_title = episode_detail["title"]
break
print(f"[purple]Now Downloading:[/] {episode_title}")
subtitles = move_preferred_subtitle_lang_to_top(
subtitles, config.sub_lang
)
downloader._download_file(
link,
selected_anime_title,
episode_title,
download_dir,
silent,
vid_format=config.format,
force_unknown_ext=force_unknown_ext,
verbose=verbose,
headers=provider_headers,
sub=subtitles[0]["url"] if subtitles else "",
merge=merge,
clean=clean,
prompt=prompt,
)
except Exception as e:
print(e)
time.sleep(1)
print("Continuing...")
print("Done Downloading")
time.sleep(wait_time)
exit_app()
else:
from sys import exit
print("Failed to search for anime", anilist_search_results)
exit(1)

View File

@@ -0,0 +1,358 @@
import logging
from typing import TYPE_CHECKING
import click
from ...completion_functions import downloaded_anime_titles
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ..config import Config
@click.command(
help="View and watch your downloads using mpv",
short_help="Watch downloads",
epilog="""
\b
\b\bExamples:
fastanime downloads
\b
# view individual episodes
fastanime downloads --view-episodes
# --- or ---
fastanime downloads -v
\b
# to set seek time when using ffmpegthumbnailer for local previews
# -1 means random and is the default
fastanime downloads --time-to-seek <intRange(-1,100)>
# --- or ---
fastanime downloads -t <intRange(-1,100)>
\b
# to watch a specific title
# be sure to get the completions for the best experience
fastanime downloads --title <title>
\b
# to get the path to the downloads folder set
fastanime downloads --path
# useful when you want to use the value for other programs
""",
)
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
@click.option(
"--title",
"-T",
shell_complete=downloaded_anime_titles,
help="watch a specific title",
)
@click.option("--view-episodes", "-v", help="View individual episodes", is_flag=True)
@click.option(
"--ffmpegthumbnailer-seek-time",
"--time-to-seek",
"-t",
type=click.IntRange(-1, 100),
help="ffmpegthumbnailer seek time",
)
@click.pass_obj
def downloads(
config: "Config", path: bool, title, view_episodes, ffmpegthumbnailer_seek_time
):
import os
from ....cli.utils.mpv import run_mpv
from ....libs.fzf import fzf
from ....libs.rofi import Rofi
from ....Utility.utils import sort_by_episode_number
from ...utils.tools import exit_app
from ...utils.utils import fuzzy_inquirer
if not ffmpegthumbnailer_seek_time:
ffmpegthumbnailer_seek_time = config.ffmpegthumbnailer_seek_time
USER_VIDEOS_DIR = config.downloads_dir
if path:
print(USER_VIDEOS_DIR)
return
if not os.path.exists(USER_VIDEOS_DIR):
print("Downloads directory specified does not exist")
return
anime_downloads = sorted(
os.listdir(USER_VIDEOS_DIR),
)
anime_downloads.append("Exit")
def create_thumbnails(video_path, anime_title, downloads_thumbnail_cache_dir):
import os
import shutil
import subprocess
FFMPEG_THUMBNAILER = shutil.which("ffmpegthumbnailer")
if not FFMPEG_THUMBNAILER:
return
out = os.path.join(downloads_thumbnail_cache_dir, anime_title)
if ffmpegthumbnailer_seek_time == -1:
import random
seektime = str(random.randrange(0, 100))
else:
seektime = str(ffmpegthumbnailer_seek_time)
_ = subprocess.run(
[
FFMPEG_THUMBNAILER,
"-i",
video_path,
"-o",
out,
"-s",
"0",
"-t",
seektime,
],
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
)
def get_previews_anime(workers=None, bg=True):
import concurrent.futures
import random
import shutil
from pathlib import Path
if not shutil.which("ffmpegthumbnailer"):
print("ffmpegthumbnailer not found")
logger.error("ffmpegthumbnailer not found")
return
from ....constants import APP_CACHE_DIR
from ...utils.scripts import fzf_preview
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
def _worker():
# use concurrency to download the images as fast as possible
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs
future_to_url = {}
for anime_title in anime_downloads:
anime_path = os.path.join(USER_VIDEOS_DIR, anime_title)
if not os.path.isdir(anime_path):
continue
playlist = [
anime
for anime in sorted(
os.listdir(anime_path),
)
if "mp4" in anime
]
if playlist:
# actual link to download image from
video_path = os.path.join(anime_path, random.choice(playlist))
future_to_url[
executor.submit(
create_thumbnails,
video_path,
anime_title,
downloads_thumbnail_cache_dir,
)
] = anime_title
# execute the jobs
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
future.result()
except Exception as e:
logger.error("%r generated an exception: %s" % (url, e))
if bg:
from threading import Thread
worker = Thread(target=_worker)
worker.daemon = True
worker.start()
else:
_worker()
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then
if ! fzf-preview %s/{} 2>/dev/null; then
echo Loading...
fi
else echo Loading...
fi
""" % (
fzf_preview,
downloads_thumbnail_cache_dir,
downloads_thumbnail_cache_dir,
)
return preview
def get_previews_episodes(anime_playlist_path, workers=None, bg=True):
import shutil
from pathlib import Path
from ....constants import APP_CACHE_DIR
from ...utils.scripts import fzf_preview
if not shutil.which("ffmpegthumbnailer"):
print("ffmpegthumbnailer not found")
logger.error("ffmpegthumbnailer not found")
return
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
def _worker():
import concurrent.futures
# use concurrency to download the images as fast as possible
# anime_playlist_path = os.path.join(USER_VIDEOS_DIR, anime_playlist_path)
if not os.path.isdir(anime_playlist_path):
return
anime_episodes = sorted(
os.listdir(anime_playlist_path), key=sort_by_episode_number
)
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs
future_to_url = {}
for episode_title in anime_episodes:
episode_path = os.path.join(anime_playlist_path, episode_title)
# actual link to download image from
future_to_url[
executor.submit(
create_thumbnails,
episode_path,
episode_title,
downloads_thumbnail_cache_dir,
)
] = episode_title
# execute the jobs
for future in concurrent.futures.as_completed(future_to_url):
url = future_to_url[future]
try:
future.result()
except Exception as e:
logger.error("%r generated an exception: %s" % (url, e))
if bg:
from threading import Thread
worker = Thread(target=_worker)
worker.daemon = True
worker.start()
else:
_worker()
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then
if ! fzf-preview %s/{} 2>/dev/null; then
echo Loading...
fi
else echo Loading...
fi
""" % (
fzf_preview,
downloads_thumbnail_cache_dir,
downloads_thumbnail_cache_dir,
)
return preview
def stream_episode(
anime_playlist_path,
):
if view_episodes:
if not os.path.isdir(anime_playlist_path):
print(anime_playlist_path, "is not dir")
exit_app(1)
return
episodes = sorted(
os.listdir(anime_playlist_path), key=sort_by_episode_number
)
downloaded_episodes = [*episodes, "Back"]
if config.use_fzf:
if not config.preview:
episode_title = fzf.run(
downloaded_episodes,
"Enter Episode ",
)
else:
preview = get_previews_episodes(anime_playlist_path)
episode_title = fzf.run(
downloaded_episodes,
"Enter Episode ",
preview=preview,
)
elif config.use_rofi:
episode_title = Rofi.run(downloaded_episodes, "Enter Episode")
else:
episode_title = fuzzy_inquirer(
downloaded_episodes,
"Enter Playlist Name",
)
if episode_title == "Back":
stream_anime()
return
episode_path = os.path.join(anime_playlist_path, episode_title)
if config.sync_play:
from ...utils.syncplay import SyncPlayer
SyncPlayer(episode_path)
else:
run_mpv(
episode_path,
player=config.player,
)
stream_episode(anime_playlist_path)
def stream_anime(title=None):
if title:
from thefuzz import fuzz
playlist_name = max(anime_downloads, key=lambda t: fuzz.ratio(title, t))
elif config.use_fzf:
if not config.preview:
playlist_name = fzf.run(
anime_downloads,
"Enter Playlist Name",
)
else:
preview = get_previews_anime()
playlist_name = fzf.run(
anime_downloads,
"Enter Playlist Name",
preview=preview,
)
elif config.use_rofi:
playlist_name = Rofi.run(anime_downloads, "Enter Playlist Name")
else:
playlist_name = fuzzy_inquirer(
anime_downloads,
"Enter Playlist Name",
)
if playlist_name == "Exit":
exit_app()
return
playlist = os.path.join(USER_VIDEOS_DIR, playlist_name)
if view_episodes:
stream_episode(
playlist,
)
else:
if config.sync_play:
from ...utils.syncplay import SyncPlayer
SyncPlayer(playlist)
else:
run_mpv(
playlist,
player=config.player,
)
stream_anime()
stream_anime(title)

View File

@@ -1,369 +1,15 @@
import click import click
from ...completion_functions import anime_titles_shell_complete from ...completion_functions import anime_titles_shell_complete
from .data import (
tags_available = { tags_available_list,
"Cast": ["Polyamorous"], sorts_available,
"Cast Main Cast": [ media_statuses_available,
"Anti-Hero", seasons_available,
"Elderly Protagonist", genres_available,
"Ensemble Cast", media_formats_available,
"Estranged Family", years_available,
"Female Protagonist", )
"Male Protagonist",
"Primarily Adult Cast",
"Primarily Animal Cast",
"Primarily Child Cast",
"Primarily Female Cast",
"Primarily Male Cast",
"Primarily Teen Cast",
],
"Cast Traits": [
"Age Regression",
"Agender",
"Aliens",
"Amnesia",
"Angels",
"Anthropomorphism",
"Aromantic",
"Arranged Marriage",
"Artificial Intelligence",
"Asexual",
"Butler",
"Centaur",
"Chimera",
"Chuunibyou",
"Clone",
"Cosplay",
"Cowboys",
"Crossdressing",
"Cyborg",
"Delinquents",
"Demons",
"Detective",
"Dinosaurs",
"Disability",
"Dissociative Identities",
"Dragons",
"Dullahan",
"Elf",
"Fairy",
"Femboy",
"Ghost",
"Goblin",
"Gods",
"Gyaru",
"Hikikomori",
"Homeless",
"Idol",
"Kemonomimi",
"Kuudere",
"Maids",
"Mermaid",
"Monster Boy",
"Monster Girl",
"Nekomimi",
"Ninja",
"Nudity",
"Nun",
"Office Lady",
"Oiran",
"Ojou-sama",
"Orphan",
"Pirates",
"Robots",
"Samurai",
"Shrine Maiden",
"Skeleton",
"Succubus",
"Tanned Skin",
"Teacher",
"Tomboy",
"Transgender",
"Tsundere",
"Twins",
"Vampire",
"Veterinarian",
"Vikings",
"Villainess",
"VTuber",
"Werewolf",
"Witch",
"Yandere",
"Zombie",
],
"Demographic": ["Josei", "Kids", "Seinen", "Shoujo", "Shounen"],
"Setting": ["Matriarchy"],
"Setting Scene": [
"Bar",
"Boarding School",
"Circus",
"Coastal",
"College",
"Desert",
"Dungeon",
"Foreign",
"Inn",
"Konbini",
"Natural Disaster",
"Office",
"Outdoor",
"Prison",
"Restaurant",
"Rural",
"School",
"School Club",
"Snowscape",
"Urban",
"Work",
],
"Setting Time": [
"Achronological Order",
"Anachronism",
"Ancient China",
"Dystopian",
"Historical",
"Time Skip",
],
"Setting Universe": [
"Afterlife",
"Alternate Universe",
"Augmented Reality",
"Omegaverse",
"Post-Apocalyptic",
"Space",
"Urban Fantasy",
"Virtual World",
],
"Technical": [
"4-koma",
"Achromatic",
"Advertisement",
"Anthology",
"CGI",
"Episodic",
"Flash",
"Full CGI",
"Full Color",
"No Dialogue",
"Non-fiction",
"POV",
"Puppetry",
"Rotoscoping",
"Stop Motion",
],
"Theme Action": [
"Archery",
"Battle Royale",
"Espionage",
"Fugitive",
"Guns",
"Martial Arts",
"Spearplay",
"Swordplay",
],
"Theme Arts": [
"Acting",
"Calligraphy",
"Classic Literature",
"Drawing",
"Fashion",
"Food",
"Makeup",
"Photography",
"Rakugo",
"Writing",
],
"Theme Arts-Music": [
"Band",
"Classical Music",
"Dancing",
"Hip-hop Music",
"Jazz Music",
"Metal Music",
"Musical Theater",
"Rock Music",
],
"Theme Comedy": ["Parody", "Satire", "Slapstick", "Surreal Comedy"],
"Theme Drama": [
"Bullying",
"Class Struggle",
"Coming of Age",
"Conspiracy",
"Eco-Horror",
"Fake Relationship",
"Kingdom Management",
"Rehabilitation",
"Revenge",
"Suicide",
"Tragedy",
],
"Theme Fantasy": [
"Alchemy",
"Body Swapping",
"Cultivation",
"Fairy Tale",
"Henshin",
"Isekai",
"Kaiju",
"Magic",
"Mythology",
"Necromancy",
"Shapeshifting",
"Steampunk",
"Super Power",
"Superhero",
"Wuxia",
"Youkai",
],
"Theme Game": ["Board Game", "E-Sports", "Video Games"],
"Theme Game-Card & Board Game": [
"Card Battle",
"Go",
"Karuta",
"Mahjong",
"Poker",
"Shogi",
],
"Theme Game-Sport": [
"Acrobatics",
"Airsoft",
"American Football",
"Athletics",
"Badminton",
"Baseball",
"Basketball",
"Bowling",
"Boxing",
"Cheerleading",
"Cycling",
"Fencing",
"Fishing",
"Fitness",
"Football",
"Golf",
"Handball",
"Ice Skating",
"Judo",
"Lacrosse",
"Parkour",
"Rugby",
"Scuba Diving",
"Skateboarding",
"Sumo",
"Surfing",
"Swimming",
"Table Tennis",
"Tennis",
"Volleyball",
"Wrestling",
],
"Theme Other": [
"Adoption",
"Animals",
"Astronomy",
"Autobiographical",
"Biographical",
"Body Horror",
"Cannibalism",
"Chibi",
"Cosmic Horror",
"Crime",
"Crossover",
"Death Game",
"Denpa",
"Drugs",
"Economics",
"Educational",
"Environmental",
"Ero Guro",
"Filmmaking",
"Found Family",
"Gambling",
"Gender Bending",
"Gore",
"Language Barrier",
"LGBTQ+ Themes",
"Lost Civilization",
"Marriage",
"Medicine",
"Memory Manipulation",
"Meta",
"Mountaineering",
"Noir",
"Otaku Culture",
"Pandemic",
"Philosophy",
"Politics",
"Proxy Battle",
"Psychosexual",
"Reincarnation",
"Religion",
"Royal Affairs",
"Slavery",
"Software Development",
"Survival",
"Terrorism",
"Torture",
"Travel",
"War",
],
"Theme Other-Organisations": [
"Assassins",
"Criminal Organization",
"Cult",
"Firefighters",
"Gangs",
"Mafia",
"Military",
"Police",
"Triads",
"Yakuza",
],
"Theme Other-Vehicle": [
"Aviation",
"Cars",
"Mopeds",
"Motorcycles",
"Ships",
"Tanks",
"Trains",
],
"Theme Romance": [
"Age Gap",
"Bisexual",
"Boys' Love",
"Female Harem",
"Heterosexual",
"Love Triangle",
"Male Harem",
"Matchmaking",
"Mixed Gender Harem",
"Teens' Love",
"Unrequited Love",
"Yuri",
],
"Theme Sci Fi": [
"Cyberpunk",
"Space Opera",
"Time Loop",
"Time Manipulation",
"Tokusatsu",
],
"Theme Sci Fi-Mecha": ["Real Robot", "Super Robot"],
"Theme Slice of Life": [
"Agriculture",
"Cute Boys Doing Cute Things",
"Cute Girls Doing Cute Things",
"Family Life",
"Horticulture",
"Iyashikei",
"Parenthood",
],
}
tags_available_list = []
for tag_category, tags_in_category in tags_available.items():
tags_available_list.extend(tags_in_category)
@click.command( @click.command(
@@ -380,91 +26,27 @@ for tag_category, tags_in_category in tags_available.items():
@click.option( @click.option(
"--season", "--season",
help="The season the media was released", help="The season the media was released",
type=click.Choice(["WINTER", "SPRING", "SUMMER", "FALL"]), type=click.Choice(seasons_available),
) )
@click.option( @click.option(
"--status", "--status",
"-S", "-S",
help="The media status of the anime", help="The media status of the anime",
multiple=True, multiple=True,
type=click.Choice( type=click.Choice(media_statuses_available),
["FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"]
),
) )
@click.option( @click.option(
"--sort", "--sort",
"-s", "-s",
help="What to sort the search results on", help="What to sort the search results on",
type=click.Choice( type=click.Choice(sorts_available),
[
"ID",
"ID_DESC",
"TITLE_ROMAJI",
"TITLE_ROMAJI_DESC",
"TITLE_ENGLISH",
"TITLE_ENGLISH_DESC",
"TITLE_NATIVE",
"TITLE_NATIVE_DESC",
"TYPE",
"TYPE_DESC",
"FORMAT",
"FORMAT_DESC",
"START_DATE",
"START_DATE_DESC",
"END_DATE",
"END_DATE_DESC",
"SCORE",
"SCORE_DESC",
"POPULARITY",
"POPULARITY_DESC",
"TRENDING",
"TRENDING_DESC",
"EPISODES",
"EPISODES_DESC",
"DURATION",
"DURATION_DESC",
"STATUS",
"STATUS_DESC",
"CHAPTERS",
"CHAPTERS_DESC",
"VOLUMES",
"VOLUMES_DESC",
"UPDATED_AT",
"UPDATED_AT_DESC",
"SEARCH_MATCH",
"FAVOURITES",
"FAVOURITES_DESC",
]
),
) )
@click.option( @click.option(
"--genres", "--genres",
"-g", "-g",
multiple=True, multiple=True,
help="the genres to filter by", help="the genres to filter by",
type=click.Choice( type=click.Choice(genres_available),
[
"Action",
"Adventure",
"Comedy",
"Drama",
"Ecchi",
"Fantasy",
"Horror",
"Mahou Shoujo",
"Mecha",
"Music",
"Mystery",
"Psychological",
"Romance",
"Sci-Fi",
"Slice of Life",
"Sports",
"Supernatural",
"Thriller",
"Hentai",
]
),
) )
@click.option( @click.option(
"--tags", "--tags",
@@ -478,49 +60,12 @@ for tag_category, tags_in_category in tags_available.items():
"-f", "-f",
multiple=True, multiple=True,
help="Media format", help="Media format",
type=click.Choice( type=click.Choice(media_formats_available),
["TV", "TV_SHORT", "MOVIE", "SPECIAL", "OVA", "MUSIC", "NOVEL", "ONE_SHOT"]
),
) )
@click.option( @click.option(
"--year", "--year",
"-y", "-y",
type=click.Choice( type=click.Choice(years_available),
[
"1900",
"1910",
"1920",
"1930",
"1940",
"1950",
"1960",
"1970",
"1980",
"1990",
"2000",
"2004",
"2005",
"2006",
"2007",
"2008",
"2009",
"2010",
"2011",
"2012",
"2013",
"2014",
"2015",
"2016",
"2017",
"2018",
"2019",
"2020",
"2021",
"2022",
"2023",
"2024",
]
),
help="the year the media was released", help="the year the media was released",
) )
@click.option( @click.option(

View File

@@ -0,0 +1,31 @@
import click
@click.command(
help="Command that automates the starting of the builtin fastanime server",
epilog="""
\b
\b\bExamples:
# default
fastanime serve
# specify host and port
fastanime serve --host 127.0.0.1 --port 8080
""",
)
@click.option("--host", "-H", help="Specify the host to run the server on")
@click.option("--port", "-p", help="Specify the port to run the server on")
def serve(host, port):
import os
import sys
from ...constants import APP_DIR
args = [sys.executable, "-m", "fastapi", "run"]
if host:
args.extend(["--host", host])
if port:
args.extend(["--port", port])
args.append(os.path.join(APP_DIR, "api"))
os.execv(sys.executable, args)

View File

@@ -11,12 +11,14 @@ import click
\b \b
# check for latest release # check for latest release
fastanime update --check fastanime update --check
# Force an update regardless of the current version
fastanime update --force
""", """,
) )
@click.option("--check", "-c", help="Check for the latest release", is_flag=True) @click.option("--check", "-c", help="Check for the latest release", is_flag=True)
def update( @click.option("--force", "-c", help="Force update", is_flag=True)
check, def update(check, force):
):
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
@@ -45,7 +47,7 @@ def update(
print(f"You are running the latest version ({__version__}) of fastanime") print(f"You are running the latest version ({__version__}) of fastanime")
_print_release(github_release_data) _print_release(github_release_data)
else: else:
success, github_release_data = update_app() success, github_release_data = update_app(force)
_print_release(github_release_data) _print_release(github_release_data)
if success: if success:
print("Successfully updated") print("Successfully updated")

View File

@@ -3,12 +3,15 @@ import logging
import os import os
from configparser import ConfigParser from configparser import ConfigParser
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..libs.fzf import FZF_DEFAULT_OPTS, HEADER
from ..constants import ( from ..constants import (
USER_CONFIG_PATH, USER_CONFIG_PATH,
USER_DATA_PATH, USER_DATA_PATH,
USER_VIDEOS_DIR, USER_VIDEOS_DIR,
ASSETS_DIR,
USER_WATCH_HISTORY_PATH, USER_WATCH_HISTORY_PATH,
S_PLATFORM,
) )
from ..libs.rofi import Rofi from ..libs.rofi import Rofi
@@ -26,32 +29,41 @@ class Config(object):
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token" "https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
) )
anime_provider: "AnimeProvider" anime_provider: "AnimeProvider"
user_data = {"watch_history": {}, "animelist": [], "user": {}} user_data = {"recent_anime": [], "animelist": [], "user": {}}
default_config = { default_config = {
"auto_next": "False", "auto_next": "False",
"auto_select": "True", "auto_select": "True",
"cache_requests": "true", "cache_requests": "true",
"check_for_updates": "True",
"continue_from_history": "True", "continue_from_history": "True",
"default_media_list_tracking": "None", "default_media_list_tracking": "None",
"downloads_dir": USER_VIDEOS_DIR, "downloads_dir": USER_VIDEOS_DIR,
"disable_mpv_popen": "True",
"episode_complete_at": "80", "episode_complete_at": "80",
"ffmpegthumbnailer_seek_time": "-1", "ffmpegthumbnailer_seek_time": "-1",
"force_forward_tracking": "true", "force_forward_tracking": "true",
"force_window": "immediate", "force_window": "immediate",
"fzf_opts": FZF_DEFAULT_OPTS,
"header_color": "95,135,175",
"header_ascii_art": HEADER,
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best", "format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
"icons": "false", "icons": "false",
"image_previews": "true", "image_previews": "True" if S_PLATFORM != "win32" else "False",
"normalize_titles": "true", "normalize_titles": "True",
"notification_duration": "2", "notification_duration": "2",
"player": "mpv", "player": "mpv",
"preferred_history": "local", "preferred_history": "local",
"preferred_language": "english", "preferred_language": "english",
"preview": "False", "preview": "False",
"preview_header_color": "215,0,95",
"preview_separator_color": "208,208,208",
"provider": "allanime", "provider": "allanime",
"quality": "1080", "quality": "1080",
"rofi_theme": "", "recent": "50",
"rofi_theme_confirm": "", "rofi_theme": os.path.join(ASSETS_DIR, "rofi_theme.rasi"),
"rofi_theme_input": "", "rofi_theme_preview": os.path.join(ASSETS_DIR, "rofi_theme_preview.rasi"),
"rofi_theme_confirm": os.path.join(ASSETS_DIR, "rofi_theme_confirm.rasi"),
"rofi_theme_input": os.path.join(ASSETS_DIR, "rofi_theme_input.rasi"),
"server": "top", "server": "top",
"skip": "false", "skip": "false",
"sort_by": "search match", "sort_by": "search match",
@@ -64,7 +76,7 @@ class Config(object):
} }
def __init__(self) -> None: def __init__(self) -> None:
self.initialize_user_data_and_watch_history() self.initialize_user_data_and_watch_history_recent_anime()
self.load_config() self.load_config()
def load_config(self): def load_config(self):
@@ -77,45 +89,80 @@ class Config(object):
if os.path.exists(USER_CONFIG_PATH): if os.path.exists(USER_CONFIG_PATH):
self.configparser.read(USER_CONFIG_PATH, encoding="utf-8") self.configparser.read(USER_CONFIG_PATH, encoding="utf-8")
self.auto_next = self.get_auto_next() # get the configuration
self.auto_select = self.get_auto_select() self.auto_next = self.configparser.getboolean("stream", "auto_next")
self.cache_requests = self.get_cache_requests() self.auto_select = self.configparser.getboolean("stream", "auto_select")
self.continue_from_history = self.get_continue_from_history() self.cache_requests = self.configparser.getboolean("general", "cache_requests")
self.default_media_list_tracking = self.get_default_media_list_tracking() self.check_for_updates = self.configparser.getboolean(
self.downloads_dir = self.get_downloads_dir() "general", "check_for_updates"
self.episode_complete_at = self.get_episode_complete_at() )
self.ffmpegthumbnailer_seek_time = self.get_ffmpegthumnailer_seek_time() self.continue_from_history = self.configparser.getboolean(
self.force_forward_tracking = self.get_force_forward_tracking() "stream", "continue_from_history"
self.force_window = self.get_force_window() )
self.format = self.get_format() self.default_media_list_tracking = self.configparser.get(
self.icons = self.get_icons() "general", "default_media_list_tracking"
self.image_previews = self.get_image_previews() )
self.normalize_titles = self.get_normalize_titles() self.disable_mpv_popen = self.configparser.getboolean(
self.notification_duration = self.get_notification_duration() "stream", "disable_mpv_popen"
self.player = self.get_player() )
self.preferred_history = self.get_preferred_history() self.downloads_dir = self.configparser.get("general", "downloads_dir")
self.preferred_language = self.get_preferred_language() self.episode_complete_at = self.configparser.getint(
self.preview = self.get_preview() "stream", "episode_complete_at"
self.provider = self.get_provider() )
self.quality = self.get_quality() self.ffmpegthumbnailer_seek_time = self.configparser.getint(
"general", "ffmpegthumbnailer_seek_time"
)
self.force_forward_tracking = self.configparser.getboolean(
"general", "force_forward_tracking"
)
self.force_window = self.configparser.get("stream", "force_window")
self.format = self.configparser.get("stream", "format")
self.fzf_opts = self.configparser.get("general", "fzf_opts")
self.header_color = self.configparser.get("general", "header_color")
self.header_ascii_art = self.configparser.get("general", "header_ascii_art")
self.icons = self.configparser.getboolean("general", "icons")
self.image_previews = self.configparser.getboolean("general", "image_previews")
self.normalize_titles = self.configparser.getboolean(
"general", "normalize_titles"
)
self.notification_duration = self.configparser.getint(
"general", "notification_duration"
)
self.player = self.configparser.get("stream", "player")
self.preferred_history = self.configparser.get("stream", "preferred_history")
self.preferred_language = self.configparser.get("general", "preferred_language")
self.preview = self.configparser.getboolean("general", "preview")
self.preview_separator_color = self.configparser.get(
"general", "preview_separator_color"
)
self.preview_header_color = self.configparser.get(
"general", "preview_header_color"
)
self.provider = self.configparser.get("general", "provider")
self.quality = self.configparser.get("stream", "quality")
self.recent = self.configparser.getint("general", "recent")
self.rofi_theme_confirm = self.configparser.get("general", "rofi_theme_confirm")
self.rofi_theme_input = self.configparser.get("general", "rofi_theme_input")
self.rofi_theme = self.configparser.get("general", "rofi_theme")
self.rofi_theme_preview = self.configparser.get("general", "rofi_theme_preview")
self.server = self.configparser.get("stream", "server")
self.skip = self.configparser.getboolean("stream", "skip")
self.sort_by = self.configparser.get("anilist", "sort_by")
self.sub_lang = self.configparser.get("general", "sub_lang")
self.translation_type = self.configparser.get("stream", "translation_type")
self.use_fzf = self.configparser.getboolean("general", "use_fzf")
self.use_python_mpv = self.configparser.getboolean("stream", "use_python_mpv")
self.use_rofi = self.configparser.getboolean("general", "use_rofi")
self.use_persistent_provider_store = self.configparser.getboolean(
"general", "use_persistent_provider_store"
)
self.rofi_theme_confirm = self.get_rofi_theme_confirm()
self.rofi_theme_input = self.get_rofi_theme_input()
self.rofi_theme = self.get_rofi_theme()
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
Rofi.rofi_theme_input = self.rofi_theme_input
Rofi.rofi_theme = self.rofi_theme Rofi.rofi_theme = self.rofi_theme
Rofi.rofi_theme_input = self.rofi_theme_input
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
Rofi.rofi_theme_preview = self.rofi_theme_preview
self.server = self.get_server() os.environ["FZF_DEFAULT_OPTS"] = self.fzf_opts
self.skip = self.get_skip()
self.sort_by = self.get_sort_by()
self.sub_lang = self.get_sub_lang()
self.translation_type = self.get_translation_type()
self.use_fzf = self.get_use_fzf()
self.use_python_mpv = self.get_use_mpv_mod()
self.use_rofi = self.get_use_rofi()
self.use_persistent_provider_store = self.get_use_persistent_provider_store()
# ---- setup user data ------ # ---- setup user data ------
self.anime_list: list = self.user_data.get("animelist", []) self.anime_list: list = self.user_data.get("animelist", [])
@@ -136,6 +183,20 @@ class Config(object):
self.user_data["user"] = user self.user_data["user"] = user
self._update_user_data() self._update_user_data()
def update_recent(self, recent_anime: list):
recent_anime_ids = []
_recent_anime = []
for anime in recent_anime:
if (
anime["id"] not in recent_anime_ids
and len(recent_anime_ids) <= self.recent
):
_recent_anime.append(anime)
recent_anime_ids.append(anime["id"])
self.user_data["recent_anime"] = _recent_anime
self._update_user_data()
def media_list_track( def media_list_track(
self, self,
anime_id: int, anime_id: int,
@@ -157,7 +218,7 @@ class Config(object):
with open(USER_WATCH_HISTORY_PATH, "w") as f: with open(USER_WATCH_HISTORY_PATH, "w") as f:
json.dump(self.watch_history, f) json.dump(self.watch_history, f)
def initialize_user_data_and_watch_history(self): def initialize_user_data_and_watch_history_recent_anime(self):
try: try:
if os.path.isfile(USER_DATA_PATH): if os.path.isfile(USER_DATA_PATH):
with open(USER_DATA_PATH, "r") as f: with open(USER_DATA_PATH, "r") as f:
@@ -178,116 +239,14 @@ class Config(object):
with open(USER_DATA_PATH, "w") as f: with open(USER_DATA_PATH, "w") as f:
json.dump(self.user_data, f) json.dump(self.user_data, f)
# getters for user configuration
# --- general section ---
def get_provider(self):
return self.configparser.get("general", "provider")
def get_ffmpegthumnailer_seek_time(self):
return self.configparser.getint("general", "ffmpegthumbnailer_seek_time")
def get_preferred_language(self):
return self.configparser.get("general", "preferred_language")
def get_sub_lang(self):
return self.configparser.get("general", "sub_lang")
def get_downloads_dir(self):
return self.configparser.get("general", "downloads_dir")
def get_icons(self):
return self.configparser.getboolean("general", "icons")
def get_image_previews(self):
return self.configparser.getboolean("general", "image_previews")
def get_preview(self):
return self.configparser.getboolean("general", "preview")
def get_use_fzf(self):
return self.configparser.getboolean("general", "use_fzf")
def get_use_persistent_provider_store(self):
return self.configparser.getboolean("general", "use_persistent_provider_store")
# rofi conifiguration
def get_use_rofi(self):
return self.configparser.getboolean("general", "use_rofi")
def get_rofi_theme(self):
return self.configparser.get("general", "rofi_theme")
def get_rofi_theme_input(self):
return self.configparser.get("general", "rofi_theme_input")
def get_rofi_theme_confirm(self):
return self.configparser.get("general", "rofi_theme_confirm")
def get_force_forward_tracking(self):
return self.configparser.getboolean("general", "force_forward_tracking")
def get_cache_requests(self):
return self.configparser.getboolean("general", "cache_requests")
def get_default_media_list_tracking(self):
return self.configparser.get("general", "default_media_list_tracking")
def get_normalize_titles(self):
return self.configparser.getboolean("general", "normalize_titles")
# --- stream section ---
def get_skip(self):
return self.configparser.getboolean("stream", "skip")
def get_auto_next(self):
return self.configparser.getboolean("stream", "auto_next")
def get_auto_select(self):
return self.configparser.getboolean("stream", "auto_select")
def get_continue_from_history(self):
return self.configparser.getboolean("stream", "continue_from_history")
def get_use_mpv_mod(self):
return self.configparser.getboolean("stream", "use_python_mpv")
def get_notification_duration(self):
return self.configparser.getint("general", "notification_duration")
def get_episode_complete_at(self):
return self.configparser.getint("stream", "episode_complete_at")
def get_force_window(self):
return self.configparser.get("stream", "force_window")
def get_translation_type(self):
return self.configparser.get("stream", "translation_type")
def get_preferred_history(self):
return self.configparser.get("stream", "preferred_history")
def get_quality(self):
return self.configparser.get("stream", "quality")
def get_server(self):
return self.configparser.get("stream", "server")
def get_format(self):
return self.configparser.get("stream", "format")
def get_player(self):
return self.configparser.get("stream", "player")
def get_sort_by(self):
return self.configparser.get("anilist", "sort_by")
def update_config(self, section: str, key: str, value: str): def update_config(self, section: str, key: str, value: str):
self.configparser.set(section, key, value) self.configparser.set(section, key, value)
with open(USER_CONFIG_PATH, "w") as config: with open(USER_CONFIG_PATH, "w") as config:
self.configparser.write(config) self.configparser.write(config)
def __repr__(self): def __repr__(self):
new_line = "\n"
tab = "\t"
current_config_state = f"""\ current_config_state = f"""\
# #
# ███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗ ░█████╗░░█████╗░███╗░░██╗███████╗██╗░██████╗░ # ███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗ ░█████╗░░█████╗░███╗░░██╗███████╗██╗░██████╗░
@@ -298,31 +257,57 @@ class Config(object):
# ╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝ ░╚════╝░░╚════╝░╚═╝░░╚══╝╚═╝░░░░░╚═╝░╚═════╝░ # ╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝ ░╚════╝░░╚════╝░╚═╝░░╚══╝╚═╝░░░░░╚═╝░╚═════╝░
# #
[general] [general]
# Can you rice it?
# for the preview pane
preview_separator_color = {self.preview_separator_color}
preview_header_color = {self.preview_header_color}
# for the header
# be sure to indent
header_ascii_art = {new_line.join([tab+line for line in self.header_ascii_art.split(new_line)])}
header_color = {self.header_color}
# to be passed to fzf
# be sure to indent
fzf_opts = {new_line.join([tab+line for line in self.fzf_opts.split(new_line)])}
# whether to show the icons in the tui [True/False] # whether to show the icons in the tui [True/False]
# more like emojis # more like emojis
# by the way if you have any recommendations to which should be used where please # by the way if you have any recommendations
# to which should be used where please
# don't hesitate to share your opinion # don't hesitate to share your opinion
# cause it's a lot of work to look for the right one for each menu option # cause it's a lot of work
# to look for the right one for each menu option
# be sure to also give the replacement emoji # be sure to also give the replacement emoji
icons = {self.icons} icons = {self.icons}
# the quality of the stream [1080,720,480,360]
# this option is usually only reliable when:
# provider=animepahe
# since it provides links that actually point to streams of different qualities
# while the rest just point to another link that can provide the anime from the same server
quality = {self.quality}
# whether to normalize provider titles [True/False] # whether to normalize provider titles [True/False]
# basically takes the provider titles and finds the corresponding anilist title then changes the title to that # basically takes the provider titles and finds the corresponding anilist title then changes the title to that
# useful for uniformity especially when downloading from different providers # useful for uniformity especially when downloading from different providers
# this also applies to episode titles # this also applies to episode titles
normalize_titles = {self.normalize_titles} normalize_titles = {self.normalize_titles}
# can be [allanime, animepahe, hianime] # whether to check for updates every time you run the script [True/False]
# this is useful for keeping your script up to date
# cause there are always new features being added 😄
check_for_updates = {self.check_for_updates}
# can be [allanime, animepahe, hianime, nyaa, yugen]
# allanime is the most realible # allanime is the most realible
# animepahe provides different links to streams of different quality so a quality can be selected reliably with --quality option # animepahe provides different links to streams of different quality so a quality can be selected reliably with --quality option
# hianime which is now hianime usually provides subs in different languuages and its servers are generally faster # hianime usually provides subs in different languuages and its servers are generally faster
# NOTE: currently they are encrypting the video links
# though am working on it
# however, you can still get the links to the subs
# with ```fastanime grab``` command
# yugen meh
# nyaa those who prefer torrents, though not reliable due to auto selection of results
# as most of the data in nyaa is not structured
# though works relatively well for new anime
# esp with subsplease and horriblesubs
# oh and you should have webtorrent cli to use this
provider = {self.provider} provider = {self.provider}
# Display language [english, romaji] # Display language [english, romaji]
@@ -336,19 +321,26 @@ downloads_dir = {self.downloads_dir}
# whether to show a preview window when using fzf or rofi [True/False] # whether to show a preview window when using fzf or rofi [True/False]
# the preview requires you have a commandline image viewer as documented in the README # the preview requires you have a commandline image viewer as documented in the README
# this is only when usinf fzf # this is only when using fzf or rofi
# if you dont care about image previews it doesnt matter # if you dont care about image and text previews it doesnt matter
# though its awesome # though its awesome
# try it and you will see # try it and you will see
preview = {self.preview} preview = {self.preview}
# whether to show images in the preview [true/false] # whether to show images in the preview [true/false]
# windows users just swtich to linux 😄
# cause even if you enable it
# it won't look pretty
# just be satisfied with the text previews
# so forget it exists 🤣
image_previews = {self.image_previews} image_previews = {self.image_previews}
# the time to seek when using ffmpegthumbnailer [-1 to 100] # the time to seek when using ffmpegthumbnailer [-1 to 100]
# -1 means random and is the default # -1 means random and is the default
# ffmpegthumbnailer is used to generate previews and you can select at what time in the video to extract an image # ffmpegthumbnailer is used to generate previews
# and you can select at what time in the video to extract an image
# random makes things quite exciting cause you never no at what time it will extract the image from # random makes things quite exciting cause you never no at what time it will extract the image from
# used by the ```fastanime downloads``` command
ffmpegthumbnailer_seek_time = {self.ffmpegthumbnailer_seek_time} ffmpegthumbnailer_seek_time = {self.ffmpegthumbnailer_seek_time}
# whether to use fzf as the interface for the anilist command and others. [True/False] # whether to use fzf as the interface for the anilist command and others. [True/False]
@@ -360,13 +352,17 @@ use_fzf = {self.use_fzf}
# though if you want it to be your sole interface even when fastanime is run directly from the terminal # though if you want it to be your sole interface even when fastanime is run directly from the terminal
use_rofi = {self.use_rofi} use_rofi = {self.use_rofi}
# rofi themes to use # rofi themes to use <path>
# the values of this option is the path to the rofi config files to use # the values of this option is the path to the rofi config files to use
# i choose to split it into three since it gives the best look and feel # i choose to split it into 4 since it gives the best look and feel
# you can refer to the rofi demo on github to see for your self # you can refer to the rofi demo on github to see for your self
# by the way i recommend getting the rofi themes from this project; # i need help designing the default rofi themes
# if you fancy yourself a rofi ricer please contribute to making
# the default theme better
rofi_theme = {self.rofi_theme} rofi_theme = {self.rofi_theme}
rofi_theme_preview = {self.rofi_theme_preview}
rofi_theme_input = {self.rofi_theme_input} rofi_theme_input = {self.rofi_theme_input}
rofi_theme_confirm = {self.rofi_theme_confirm} rofi_theme_confirm = {self.rofi_theme_confirm}
@@ -378,7 +374,7 @@ notification_duration = {self.notification_duration}
# used when the provider gives subs of different languages # used when the provider gives subs of different languages
# currently its the case for: # currently its the case for:
# hianime # hianime
# the values for this option are the short names for countries # the values for this option are the short names for languages
# regex is used to determine what you selected # regex is used to determine what you selected
sub_lang = {self.sub_lang} sub_lang = {self.sub_lang}
@@ -408,8 +404,19 @@ cache_requests = {self.cache_requests}
# leave it as is # leave it as is
use_persistent_provider_store = {self.use_persistent_provider_store} use_persistent_provider_store = {self.use_persistent_provider_store}
# no of recent anime to keep [0-50]
# 0 will disable recent anime tracking
recent = {self.recent}
[stream] [stream]
# the quality of the stream [1080,720,480,360]
# this option is usually only reliable when:
# provider=animepahe
# since it provides links that actually point to streams of different qualities
# while the rest just point to another link that can provide the anime from the same server
quality = {self.quality}
# Auto continue from watch history [True/False] # Auto continue from watch history [True/False]
# this will make fastanime to choose the episode that you last watched to completion # this will make fastanime to choose the episode that you last watched to completion
# and increment it by one # and increment it by one
@@ -418,9 +425,12 @@ continue_from_history = {self.continue_from_history}
# which history to use [local/remote] # which history to use [local/remote]
# local history means it will just use the watch history stored locally in your device # local history means it will just use the watch history stored locally in your device
# the file that stores it is called watch_history.json and is stored next to your config file # the file that stores it is called watch_history.json
# remote means it ignores the last episode stored locally and instead uses the one in your anilist anime list # and is stored next to your config file
# this config option is useful if you want to overwrite your local history or import history covered from another device or platform # remote means it ignores the last episode stored locally
# and instead uses the one in your anilist anime list
# this config option is useful if you want to overwrite your local history
# or import history covered from another device or platform
# since remote history will take precendence over whats available locally # since remote history will take precendence over whats available locally
preferred_history = {self.preferred_history} preferred_history = {self.preferred_history}
@@ -431,6 +441,7 @@ translation_type = {self.translation_type}
# allanime: [dropbox, sharepoint, wetransfer, gogoanime, wixmp] # allanime: [dropbox, sharepoint, wetransfer, gogoanime, wixmp]
# animepahe: [kwik] # animepahe: [kwik]
# hianime: [HD1, HD2, StreamSB, StreamTape] # hianime: [HD1, HD2, StreamSB, StreamTape]
# yugen: [gogoanime]
# 'top' can also be used as a value for this option # 'top' can also be used as a value for this option
# 'top' will cause fastanime to auto select the first server it sees # 'top' will cause fastanime to auto select the first server it sees
# this saves on resources and is faster since not all servers are being fetched # this saves on resources and is faster since not all servers are being fetched
@@ -447,15 +458,21 @@ auto_next = {self.auto_next}
# this is because the providers sometime use non-standard names # this is because the providers sometime use non-standard names
# that are there own preference rather than the official names # that are there own preference rather than the official names
# But 99% of the time will be accurate # But 99% of the time will be accurate
# if this happens just turn of auto_select in the menus or from the commandline and manually select the correct anime title # if this happens just turn off auto_select in the menus or from the commandline
# and then please open an issue at <> highlighting the normalized title and the title given by the provider for the anime you wished to watch # and manually select the correct anime title
# or even better edit this file <> and open a pull request # edit this file <https://github.com/Benex254/FastAnime/blob/master/fastanime/Utility/data.py>
# and to the dictionary of the provider
# the provider title (key) and their corresponding anilist names (value)
# and then please open a pr
# issues on the same will be ignored and then closed 😆
auto_select = {self.auto_select} auto_select = {self.auto_select}
# whether to skip the opening and ending theme songs [True/False] # whether to skip the opening and ending theme songs [True/False]
# NOTE: requires ani-skip to be in path # NOTE: requires ani-skip to be in path
# for python-mpv users am planning to create this functionality n python without the use of an external script # for python-mpv users am planning to create this functionality n python without the use of an external script
# so its disabled for now # so its disabled for now
# and anyways Dan Da Dan
# taught as the importance of letting it flow 🙃
skip = {self.skip} skip = {self.skip}
# at what percentage progress should the episode be considered as completed [0-100] # at what percentage progress should the episode be considered as completed [0-100]
@@ -467,7 +484,8 @@ episode_complete_at = {self.episode_complete_at}
# whether to use python-mpv [True/False] # whether to use python-mpv [True/False]
# to enable superior control over the player # to enable superior control over the player
# adding more options to it # adding more options to it
# Enable this one and you will be wonder why you did not discover fastanime sooner # Enabling this option and you will ask yourself
# why you did not discover fastanime sooner 🙃
# Since you basically don't have to close the player window # Since you basically don't have to close the player window
# to go to the next or previous episode, switch servers, # to go to the next or previous episode, switch servers,
# change translation type or change to a given episode x # change translation type or change to a given episode x
@@ -479,9 +497,18 @@ episode_complete_at = {self.episode_complete_at}
# personally it took me quite sometime to figure it out # personally it took me quite sometime to figure it out
# this is because of how windows handles shared libraries # this is because of how windows handles shared libraries
# so just ask when you find yourself stuck # so just ask when you find yourself stuck
# or just switch to arch linux # or just switch to nixos 😄
use_python_mpv = {self.use_python_mpv} use_python_mpv = {self.use_python_mpv}
# whether to use popen to get the timestamps for continue_from_history
# implemented because popen does not work for some reason in nixos and apparently on mac as well
# if you are on nixos or mac and you have a solution to this problem please share
# i will be glad to hear it 😄
# So for now ignore this option
# and anyways the new method of getting timestamps is better
disable_mpv_popen = {self.disable_mpv_popen}
# force mpv window # force mpv window
# the default 'immediate' just makes mpv to open the window even if the video has not yet loaded # the default 'immediate' just makes mpv to open the window even if the video has not yet loaded
# done for asthetics # done for asthetics
@@ -503,15 +530,13 @@ format = {self.format}
# since you will miss out on some features if you use the others # since you will miss out on some features if you use the others
player = {self.player} player = {self.player}
# NOTE:
# if you have any trouble setting up your config
# please don't be afraid to ask in our discord
# plus if there are any errors, improvements or suggestions please tell us in the discord
# or help us by contributing
# we appreciate all the help we can get
# since we may not always have the time to immediately implement the changes
# #
# HOPE YOU ENJOY FASTANIME AND BE SURE TO STAR THE PROJECT ON GITHUB # HOPE YOU ENJOY FASTANIME AND BE SURE TO STAR THE PROJECT ON GITHUB
# https://github.com/Benex254/FastAnime
#
# Also join the discord server
# where the anime tech community lives :)
# https://discord.gg/C4rhMA4mmK
# #
""" """
return current_config_state return current_config_state

View File

@@ -539,6 +539,14 @@ def provider_anime_episode_servers_menu(
episode_title = episode_detail["title"] episode_title = episode_detail["title"]
break break
if config.recent:
config.update_recent(
[
fastanime_runtime_state.selected_anime_anilist,
*config.user_data["recent_anime"],
]
)
print("Updating recent anime...")
if config.sync_play: if config.sync_play:
from ..utils.syncplay import SyncPlayer from ..utils.syncplay import SyncPlayer
@@ -585,22 +593,18 @@ def provider_anime_episode_servers_menu(
# this will try to update the episode to be the next episode if delta has reached a specific threshhold # this will try to update the episode to be the next episode if delta has reached a specific threshhold
# this update will only apply locally # this update will only apply locally
# the remote(anilist) is only updated when its certain you are going to open the player # the remote(anilist) is only updated when its certain you are going to open the player
available_episodes: list[str] = sorted(
fastanime_runtime_state.provider_available_episodes, key=float
)
if stop_time == "0" or total_time == "0": if stop_time == "0" or total_time == "0":
# increment the episodes # increment the episodes
next_episode = available_episodes.index(current_episode_number) + 1 # next_episode = available_episodes.index(current_episode_number) + 1
if next_episode >= len(available_episodes): # if next_episode >= len(available_episodes):
next_episode = len(available_episodes) - 1 # next_episode = len(available_episodes) - 1
episode = available_episodes[next_episode] # episode = available_episodes[next_episode]
pass
else: else:
percentage_completion_of_episode = calculate_percentage_completion( percentage_completion_of_episode = calculate_percentage_completion(
stop_time, total_time stop_time, total_time
) )
if percentage_completion_of_episode < config.episode_complete_at: if percentage_completion_of_episode > config.episode_complete_at:
episode = current_episode_number
else:
# -- update anilist progress if user -- # -- update anilist progress if user --
remote_progress = ( remote_progress = (
fastanime_runtime_state.selected_anime_anilist["mediaListEntry"] or {} fastanime_runtime_state.selected_anime_anilist["mediaListEntry"] or {}
@@ -626,16 +630,16 @@ def provider_anime_episode_servers_menu(
) )
# increment the episodes # increment the episodes
next_episode = available_episodes.index(current_episode_number) + 1 # next_episode = available_episodes.index(current_episode_number) + 1
if next_episode >= len(available_episodes): # if next_episode >= len(available_episodes):
next_episode = len(available_episodes) - 1 # next_episode = len(available_episodes) - 1
episode = available_episodes[next_episode] # episode = available_episodes[next_episode]
stop_time = "0" # stop_time = "0"
total_time = "0" # total_time = "0"
config.media_list_track( config.media_list_track(
anime_id_anilist, anime_id_anilist,
episode_no=episode, episode_no=current_episode_number,
episode_stopped_at=stop_time, episode_stopped_at=stop_time,
episode_total_length=total_time, episode_total_length=total_time,
progress_tracking=fastanime_runtime_state.progress_tracking, progress_tracking=fastanime_runtime_state.progress_tracking,
@@ -670,7 +674,7 @@ def provider_anime_episodes_menu(
) )
# prompt for episode number # prompt for episode number
total_episodes = sorted( available_episodes = sorted(
provider_anime["availableEpisodesDetail"][translation_type], key=float provider_anime["availableEpisodesDetail"][translation_type], key=float
) )
current_episode_number = "" current_episode_number = ""
@@ -681,7 +685,7 @@ def provider_anime_episodes_menu(
# will be preferred over remote # will be preferred over remote
if ( if (
user_watch_history.get(str(anime_id_anilist), {}).get("episode_no") user_watch_history.get(str(anime_id_anilist), {}).get("episode_no")
in total_episodes in available_episodes
): ):
if ( if (
config.preferred_history == "local" config.preferred_history == "local"
@@ -690,6 +694,29 @@ def provider_anime_episodes_menu(
current_episode_number = user_watch_history[str(anime_id_anilist)][ current_episode_number = user_watch_history[str(anime_id_anilist)][
"episode_no" "episode_no"
] ]
stop_time = user_watch_history.get(str(anime_id_anilist), {}).get(
"episode_stopped_at", "0"
)
total_time = user_watch_history.get(str(anime_id_anilist), {}).get(
"episode_total_length", "0"
)
if stop_time != "0" or total_time != "0":
percentage_completion_of_episode = calculate_percentage_completion(
stop_time, total_time
)
if percentage_completion_of_episode > config.episode_complete_at:
# increment the episodes
next_episode = (
available_episodes.index(current_episode_number) + 1
)
if next_episode >= len(available_episodes):
next_episode = len(available_episodes) - 1
episode = available_episodes[next_episode]
stop_time = "0"
total_time = "0"
current_episode_number = episode
else: else:
current_episode_number = str( current_episode_number = str(
(selected_anime_anilist["mediaListEntry"] or {"progress": 0}).get( (selected_anime_anilist["mediaListEntry"] or {"progress": 0}).get(
@@ -707,7 +734,7 @@ def provider_anime_episodes_menu(
"progress" "progress"
) )
) )
if current_episode_number not in total_episodes: if current_episode_number not in available_episodes:
current_episode_number = "" current_episode_number = ""
print( print(
f"[bold cyan]Continuing from Episode:[/] [bold]{current_episode_number}[/]" f"[bold cyan]Continuing from Episode:[/] [bold]{current_episode_number}[/]"
@@ -717,8 +744,8 @@ def provider_anime_episodes_menu(
current_episode_number = "" current_episode_number = ""
# prompt for episode number if not set # prompt for episode number if not set
if not current_episode_number or current_episode_number not in total_episodes: if not current_episode_number or current_episode_number not in available_episodes:
choices = [*total_episodes, "Back"] choices = [*available_episodes, "Back"]
preview = None preview = None
if config.preview: if config.preview:
from .utils import get_fzf_episode_preview from .utils import get_fzf_episode_preview
@@ -727,7 +754,7 @@ def provider_anime_episodes_menu(
if e: if e:
eps = range(0, e + 1) eps = range(0, e + 1)
else: else:
eps = total_episodes eps = available_episodes
preview = get_fzf_episode_preview( preview = get_fzf_episode_preview(
fastanime_runtime_state.selected_anime_anilist, eps fastanime_runtime_state.selected_anime_anilist, eps
) )
@@ -756,7 +783,7 @@ def provider_anime_episodes_menu(
# ) # )
# update runtime data # update runtime data
fastanime_runtime_state.provider_available_episodes = total_episodes fastanime_runtime_state.provider_available_episodes = available_episodes
fastanime_runtime_state.provider_current_episode_number = current_episode_number fastanime_runtime_state.provider_current_episode_number = current_episode_number
# next interface # next interface
@@ -1562,6 +1589,12 @@ def fastanime_main_menu(
watch_history = list(map(int, config.watch_history.keys())) watch_history = list(map(int, config.watch_history.keys()))
return AniList.search(id_in=watch_history, sort="TRENDING_DESC") return AniList.search(id_in=watch_history, sort="TRENDING_DESC")
def _recent():
return (
True,
{"data": {"Page": {"media": config.user_data["recent_anime"]}}},
)
# WARNING: Will probably be depracated # WARNING: Will probably be depracated
def _anime_list(): def _anime_list():
anime_list = config.anime_list anime_list = config.anime_list
@@ -1580,6 +1613,8 @@ def fastanime_main_menu(
else: else:
config.load_config() config.load_config()
config.set_fastanime_config_environs()
config.anime_provider.provider = config.provider config.anime_provider.provider = config.provider
config.anime_provider.lazyload_provider(config.provider) config.anime_provider.lazyload_provider(config.provider)
@@ -1589,6 +1624,7 @@ def fastanime_main_menu(
# each option maps to anilist data that is described by the option name # each option maps to anilist data that is described by the option name
options = { options = {
f"{'🔥 ' if icons else ''}Trending": AniList.get_trending, f"{'🔥 ' if icons else ''}Trending": AniList.get_trending,
f"{'🎞️ ' if icons else ''}Recent": _recent,
f"{'📺 ' if icons else ''}Watching": lambda media_list_type="Watching": handle_animelist( f"{'📺 ' if icons else ''}Watching": lambda media_list_type="Watching": handle_animelist(
config, fastanime_runtime_state, media_list_type config, fastanime_runtime_state, media_list_type
), ),

View File

@@ -46,8 +46,12 @@ def aniskip(mal_id: int, episode: str):
# NOTE: May change this to a temp dir but there were issues so later # NOTE: May change this to a temp dir but there were issues so later
WORKING_DIR = APP_CACHE_DIR # tempfile.gettempdir() WORKING_DIR = APP_CACHE_DIR # tempfile.gettempdir()
HEADER_COLOR = 215, 0, 95 _HEADER_COLOR = os.environ.get("FASTANIME_PREVIEW_HEADER_COLOR", "215,0,95").split(",")
SEPARATOR_COLOR = 208, 208, 208 HEADER_COLOR = _HEADER_COLOR[0], _HEADER_COLOR[1], _HEADER_COLOR[2]
_SEPARATOR_COLOR = os.environ.get(
"FASTANIME_PREVIEW_SEPARATOR_COLOR", "208,208,208"
).split(",")
SEPARATOR_COLOR = _SEPARATOR_COLOR[0], _SEPARATOR_COLOR[1], _SEPARATOR_COLOR[2]
SINGLE_QUOTE = "'" SINGLE_QUOTE = "'"
IMAGES_CACHE_DIR = os.path.join(WORKING_DIR, "images") IMAGES_CACHE_DIR = os.path.join(WORKING_DIR, "images")
if not os.path.exists(IMAGES_CACHE_DIR): if not os.path.exists(IMAGES_CACHE_DIR):
@@ -65,7 +69,7 @@ def save_image_from_url(url: str, file_name: str):
file_name: filename to use file_name: filename to use
""" """
image = requests.get(url) image = requests.get(url)
with open(f"{IMAGES_CACHE_DIR}/{file_name}.png", "wb") as f: with open(os.path.join(IMAGES_CACHE_DIR, f"{file_name}.png"), "wb") as f:
f.write(image.content) f.write(image.content)
@@ -76,7 +80,14 @@ def save_info_from_str(info: str, file_name: str):
info: the information anilist has on the anime info: the information anilist has on the anime
file_name: the filename to use file_name: the filename to use
""" """
with open(f"{ANIME_INFO_CACHE_DIR}/{file_name}", "w") as f: with open(
os.path.join(
ANIME_INFO_CACHE_DIR,
file_name,
),
"w",
encoding="utf-8",
) as f:
f.write(info) f.write(info)
@@ -92,7 +103,6 @@ def write_search_results(
titles: sanitized anime titles titles: sanitized anime titles
workers:number of threads to use defaults to as many as possible workers:number of threads to use defaults to as many as possible
""" """
# NOTE: Will probably make this a configuraable option
# use concurency to download and write as fast as possible # use concurency to download and write as fast as possible
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
future_to_task = {} future_to_task = {}
@@ -274,7 +284,7 @@ def get_fzf_episode_preview(
anilist_results: the anilist results from an anilist action anilist_results: the anilist results from an anilist action
""" """
HEADER_COLOR = 215, 0, 95 # HEADER_COLOR = 215, 0, 95
import re import re
def _worker(): def _worker():
@@ -282,18 +292,16 @@ def get_fzf_episode_preview(
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs # load the jobs
future_to_url = {} future_to_url = {}
for episode in episodes: for episode in episodes:
episode_title = "" episode_title = ""
image_url = "" image_url = ""
for episode_detail in anilist_result["streamingEpisodes"]: for episode_detail in anilist_result["streamingEpisodes"]:
if re.match(f"Episode {episode} ", episode_detail["title"]): if re.match(f".*Episode {episode} .*", episode_detail["title"]):
episode_title = episode_detail["title"] episode_title = episode_detail["title"]
image_url = episode_detail["thumbnail"] image_url = episode_detail["thumbnail"]
if episode_title and image_url: if episode_title and image_url:
# actual link to download image from
if not image_url:
continue
future_to_url[ future_to_url[
executor.submit(save_image_from_url, image_url, episode) executor.submit(save_image_from_url, image_url, episode)
] = image_url ] = image_url
@@ -304,13 +312,25 @@ def get_fzf_episode_preview(
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}" echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++)) ((ll++))
done done
echo "{get_true_fg('Anime Title:',*HEADER_COLOR)} {(anilist_result['title']['romaji'] or anilist_result['title']['english']).replace('"',SINGLE_QUOTE)}" echo "{get_true_fg('Anime Title(eng):',*HEADER_COLOR)} {('' or anilist_result['title']['english']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Episode Title:',*HEADER_COLOR)} {str(episode_title).replace('"',SINGLE_QUOTE)}" echo "{get_true_fg('Anime Title(jp):',*HEADER_COLOR)} {(anilist_result['title']['romaji'] or '').replace('"',SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
echo "{str(episode_title).replace('"',SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
""" """
) )
future_to_url[ future_to_url[
executor.submit(save_info_from_str, template, episode) executor.submit(save_info_from_str, template, str(episode))
] = episode_title ] = str(episode)
# execute the jobs # execute the jobs
for future in concurrent.futures.as_completed(future_to_url): for future in concurrent.futures.as_completed(future_to_url):
@@ -360,14 +380,15 @@ def get_fzf_episode_preview(
) )
else: else:
preview = """ preview = """
title={}
%s %s
show_image_previews="%s" show_image_previews="%s"
if [ $show_image_previews = "true" ];then if [ $show_image_previews = "true" ];then
if [ -s %s/{} ]; then fzf-preview %s/{} if [ -s %s/${title}.png ]; then fzf-preview %s/${title}.png
else echo Loading... else echo Loading...
fi fi
fi fi
if [ -s %s/{} ]; then source %s/{} if [ -f %s/${title} ]; then source %s/${title}
else echo Loading... else echo Loading...
fi fi
""" % ( """ % (

View File

@@ -1,50 +1,76 @@
import re import re
import os
import shutil import shutil
import subprocess import subprocess
import logging
import time
from fastanime.constants import S_PLATFORM from ...constants import S_PLATFORM
logger = logging.getLogger(__name__)
mpv_av_time_pattern = re.compile(r"AV: ([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)")
def stream_video(MPV, url, mpv_args, custom_args): def stream_video(MPV, url, mpv_args, custom_args):
process = subprocess.Popen(
[MPV, url, *mpv_args, *custom_args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
last_time = None
av_time_pattern = re.compile(r"AV: ([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)")
last_time = "0" last_time = "0"
total_time = "0" total_time = "0"
if os.environ.get("FASTANIME_DISABLE_MPV_POPEN", "False") == "False":
process = subprocess.Popen(
[
MPV,
url,
*mpv_args,
*custom_args,
"--no-terminal",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
encoding="utf-8",
)
try: try:
while True: while True:
if not process.stderr: if not process.stderr:
continue time.sleep(0.1)
output = process.stderr.readline() continue
output = process.stderr.readline()
if output: if output:
# Match the timestamp in the output # Match the timestamp in the output
match = av_time_pattern.search(output.strip()) match = mpv_av_time_pattern.search(output.strip())
if match:
current_time = match.group(1)
total_time = match.group(2)
last_time = current_time
# Check if the process has terminated
retcode = process.poll()
if retcode is not None:
break
except Exception as e:
print(f"An error occurred: {e}")
logger.error(f"An error occurred: {e}")
finally:
process.terminate()
process.wait()
else:
proc = subprocess.run(
[MPV, url, *mpv_args, *custom_args],
capture_output=True,
text=True,
encoding="utf-8",
)
if proc.stdout:
for line in reversed(proc.stdout.split("\n")):
match = mpv_av_time_pattern.search(line.strip())
if match: if match:
current_time = match.group(1) last_time = match.group(1)
total_time = match.group(2) total_time = match.group(2)
match.group(3) break
last_time = current_time
# print(f"Current stream time: {current_time}, Total time: {total_time}, Progress: {percentage}%")
# Check if the process has terminated
retcode = process.poll()
if retcode is not None:
print("Finshed at: ", last_time)
break
except Exception as e:
print(f"An error occurred: {e}")
finally:
process.terminate()
return last_time, total_time return last_time, total_time
@@ -74,7 +100,7 @@ def run_mpv(
time.sleep(120) time.sleep(120)
return "0", "0" return "0", "0"
cmd = [WEBTORRENT_CLI, link, f"--{player}"] cmd = [WEBTORRENT_CLI, link, f"--{player}"]
subprocess.run(cmd) subprocess.run(cmd, encoding="utf-8")
return "0", "0" return "0", "0"
if player == "vlc": if player == "vlc":
VLC = shutil.which("vlc") VLC = shutil.which("vlc")
@@ -125,7 +151,7 @@ def run_mpv(
if title: if title:
args.append("--video-title") args.append("--video-title")
args.append(title) args.append(title)
subprocess.run(args) subprocess.run(args, encoding="utf-8")
return "0", "0" return "0", "0"
else: else:
# Determine if mpv is available # Determine if mpv is available
@@ -184,13 +210,3 @@ def run_mpv(
mpv_args.append(f"--ytdl-format={ytdl_format}") mpv_args.append(f"--ytdl-format={ytdl_format}")
stop_time, total_time = stream_video(MPV, link, mpv_args, custom_args) stop_time, total_time = stream_video(MPV, link, mpv_args, custom_args)
return stop_time, total_time return stop_time, total_time
# Example usage
if __name__ == "__main__":
run_mpv(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"Example Video",
"--fullscreen",
"--volume=50",
)

View File

@@ -24,4 +24,10 @@ def print_img(url: str):
print("Error fetching image") print("Error fetching image")
return return
img_bytes = res.content img_bytes = res.content
"""
Change made in call to chafa. Chafa dev dropped abilty
to pull from urls. Keeping old line here just in case.
subprocess.run([EXECUTABLE, url, "--size=15x15"], input=img_bytes) subprocess.run([EXECUTABLE, url, "--size=15x15"], input=img_bytes)
"""
subprocess.run([EXECUTABLE, "--size=15x15"], input=img_bytes)

View File

@@ -108,7 +108,7 @@ def format_bytes_to_human(num_of_bytes: float, suffix: str = "B"):
return f"{num_of_bytes:.1f}Yi{suffix}" return f"{num_of_bytes:.1f}Yi{suffix}"
def get_true_fg(string: str, r: int, g: int, b: int, bold: bool = True) -> str: def get_true_fg(string: str, r, g, b, bold: bool = True) -> str:
"""Custom helper function that enables colored text in the terminal """Custom helper function that enables colored text in the terminal
Args: Args:

14
fastanime/fastanime.py Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env python3
import os
import sys
# Add the application root directory to Python path
if getattr(sys, "frozen", False):
application_path = os.path.dirname(sys.executable)
sys.path.insert(0, application_path)
# Import and run the main application
from fastanime import FastAnime
if __name__ == "__main__":
FastAnime()

View File

@@ -306,6 +306,7 @@ class AniListApi:
def search( def search(
self, self,
max_results=50,
query: str | None = None, query: str | None = None,
sort: str | None = None, sort: str | None = None,
genre_in: list[str] | None = None, genre_in: list[str] | None = None,

View File

@@ -281,6 +281,7 @@ query ($userId: Int, $status: MediaListStatus, $type: MediaType) {
optional_variables = "\ optional_variables = "\
$max_results:Int,\
$page:Int,\ $page:Int,\
$sort:[MediaSort],\ $sort:[MediaSort],\
$id_in:[Int],\ $id_in:[Int],\
@@ -310,7 +311,7 @@ $on_list:Boolean\
search_query = ( search_query = (
""" """
query($query:String,%s){ query($query:String,%s){
Page(perPage: 50, page: $page) { Page(perPage: $max_results, page: $page) {
pageInfo { pageInfo {
total total
currentPage currentPage

View File

@@ -7,5 +7,6 @@ anime_sources = {
"animepahe": "api.AnimePaheApi", "animepahe": "api.AnimePaheApi",
"hianime": "api.HiAnimeApi", "hianime": "api.HiAnimeApi",
"nyaa": "api.NyaaApi", "nyaa": "api.NyaaApi",
"yugen": "api.YugenApi"
} }
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS] SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS]

View File

@@ -233,7 +233,7 @@ class AllAnimeAPI(AnimeProvider):
logger.debug("allanime:Found streams from gogoanime") logger.debug("allanime:Found streams from gogoanime")
return { return {
"server": "gogoanime", "server": "gogoanime",
"headers": {}, "headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [], "subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f"{anime_title}" allanime_episode["notes"] or f"{anime_title}"
@@ -245,7 +245,7 @@ class AllAnimeAPI(AnimeProvider):
logger.debug("allanime:Found streams from wetransfer") logger.debug("allanime:Found streams from wetransfer")
return { return {
"server": "wetransfer", "server": "wetransfer",
"headers": {}, "headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [], "subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f"{anime_title}" allanime_episode["notes"] or f"{anime_title}"
@@ -257,7 +257,7 @@ class AllAnimeAPI(AnimeProvider):
logger.debug("allanime:Found streams from sharepoint") logger.debug("allanime:Found streams from sharepoint")
return { return {
"server": "sharepoint", "server": "sharepoint",
"headers": {}, "headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [], "subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f"{anime_title}" allanime_episode["notes"] or f"{anime_title}"
@@ -269,7 +269,7 @@ class AllAnimeAPI(AnimeProvider):
logger.debug("allanime:Found streams from dropbox") logger.debug("allanime:Found streams from dropbox")
return { return {
"server": "dropbox", "server": "dropbox",
"headers": {}, "headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [], "subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f"{anime_title}" allanime_episode["notes"] or f"{anime_title}"
@@ -281,7 +281,7 @@ class AllAnimeAPI(AnimeProvider):
logger.debug("allanime:Found streams from wixmp") logger.debug("allanime:Found streams from wixmp")
return { return {
"server": "wixmp", "server": "wixmp",
"headers": {}, "headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [], "subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f"{anime_title}" allanime_episode["notes"] or f"{anime_title}"

View File

@@ -0,0 +1,216 @@
import base64
from itertools import cycle
from yt_dlp.utils import (
get_element_text_and_html_by_tag,
get_elements_text_and_html_by_attribute,
extract_attributes,
get_element_by_attribute,
)
import re
from yt_dlp.utils.traversal import get_element_html_by_attribute
from .constants import YUGEN_ENDPOINT, SEARCH_URL
from ..decorators import debug_provider
from ..base_provider import AnimeProvider
# ** Adapted from anipy-cli **
class YugenApi(AnimeProvider):
"""
Provides a fast and effective interface to YugenApi site.
"""
PROVIDER = "yugen"
api_endpoint = YUGEN_ENDPOINT
# HEADERS = {
# "Referer": ALLANIME_REFERER,
# }
@debug_provider(PROVIDER.upper())
def search_for_anime(
self,
user_query: str,
translation_type: str = "sub",
nsfw=True,
unknown=True,
**kwargs,
):
results = []
has_next = True
page = 0
while has_next:
page += 1
response = self.session.get(
SEARCH_URL, params={"q": user_query, "page": page}
)
search_results = response.json()
has_next = search_results["hasNext"]
results_html = search_results["query"]
anime = get_elements_text_and_html_by_attribute(
"class", "anime-meta", results_html, tag="a"
)
id_regex = re.compile(r"(\d+)\/([^\/]+)")
for _a in anime:
if not _a:
continue
a = extract_attributes(_a[1])
if not a:
continue
uri = a["href"]
identifier = id_regex.search(uri) # pyright:ignore
if identifier is None:
continue
if len(identifier.groups()) != 2:
continue
identifier = base64.b64encode(
f"{identifier.group(1)}/{identifier.group(2)}".encode()
).decode()
anime_title = a["title"]
languages = {"sub": 1, "dub": 0}
excl = get_element_by_attribute(
"class", "ani-exclamation", _a[1], tag="div"
)
if excl is not None:
if "dub" in excl.lower():
languages["dub"] = 1
#
results.append(
{
"id": identifier,
"title": anime_title,
"availableEpisodes": languages,
}
)
page += 1
return {
"pageInfo": {"total": len(results)},
"results": results,
}
@debug_provider(PROVIDER.upper())
def get_anime(self, anime_id: str, **kwargs):
identifier = base64.b64decode(anime_id).decode()
response = self.session.get(f"{YUGEN_ENDPOINT}/anime/{identifier}")
html_page = response.text
data_map = {
"id": anime_id,
"title": None,
"poster": None,
"genres": [],
"synopsis": None,
"release_year": None,
"status": None,
"otherTitles": [],
"availableEpisodesDetail": {},
}
sub_match = re.search(
r'<div class="ap-.+?">Episodes</div><span class="description" .+?>(\d+)</span></div>',
html_page,
)
if sub_match:
eps = int(sub_match.group(1))
data_map["availableEpisodesDetail"]["sub"] = list(map(str,range(1, eps + 1)))
dub_match = re.search(
r'<div class="ap-.+?">Episodes \(Dub\)</div><span class="description" .+?>(\d+)</span></div>',
html_page,
)
if dub_match:
eps = int(dub_match.group(1))
data_map["availableEpisodesDetail"]["dub"] = list(map(str,range(1, eps + 1)))
name = get_element_text_and_html_by_tag("h1", html_page)
if name is not None:
data_map["title"] = name[0].strip()
synopsis = get_element_by_attribute("class", "description", html_page, tag="p")
if synopsis is not None:
data_map["synopsis"] = synopsis
# FIXME: This is not working because ytdl is too strict on also getting a closing tag
try:
image = get_element_html_by_attribute(
"class", "cover", html_page, tag="img"
)
img_attrs = extract_attributes(image)
if img_attrs is not None:
data_map["image"] = img_attrs.get("src")
except Exception:
pass
data = get_elements_text_and_html_by_attribute(
"class", "data", html_page, tag="div"
)
for d in data:
title = get_element_text_and_html_by_tag("div", d[1])
desc = get_element_text_and_html_by_tag("span", d[1])
if title is None or desc is None:
continue
title = title[0]
desc = desc[0]
if title in ["Native", "Romaji"]:
data_map["alternative_names"].append(desc)
elif title == "Synonyms":
data_map["alternative_names"].extend(desc.split(","))
elif title == "Premiered":
try:
data_map["release_year"] = int(desc.split()[-1])
except (ValueError, TypeError):
pass
elif title == "Status":
data_map["status"] = title
elif title == "Genres":
data_map["genres"].extend([g.strip() for g in desc.split(",")])
return data_map
@debug_provider(PROVIDER.upper())
def get_episode_streams(
self, anime_id, episode_number: str, translation_type="sub"
):
"""get the streams of an episode
Args:
translation_type ([TODO:parameter]): [TODO:description]
anime: [TODO:description]
episode_number: [TODO:description]
Yields:
[TODO:description]
"""
identifier = base64.b64decode(anime_id).decode()
id_num, anime_title = identifier.split("/")
if translation_type == "dub":
video_query = f"{id_num}|{episode_number}|dub"
else:
video_query = f"{id_num}|{episode_number}"
#
res = self.session.post(
f"{YUGEN_ENDPOINT}/api/embed/",
data={
"id": base64.b64encode(video_query.encode()).decode(),
"ac": "0",
},
headers={"x-requested-with": "XMLHttpRequest"},
)
res = res.json()
yield {
"server": "gogoanime",
"episode_title": f"{anime_title}; Episode {episode_number}",
"headers": {},
"subtitles": [],
"links": [{"quality": quality, "link": link} for quality,link in zip(cycle(["1080","720","480","360"]),res["hls"])],
}

View File

@@ -0,0 +1,5 @@
YUGEN_ENDPOINT: str = "https://yugenanime.tv"
SEARCH_URL = YUGEN_ENDPOINT + "/api/discover/"
SERVERS_AVAILABLE = ["gogoanime"]

View File

@@ -1,5 +1,6 @@
import json import json
import logging import logging
import os
import re import re
import time import time
from datetime import datetime from datetime import datetime
@@ -49,7 +50,7 @@ class CachedRequestsSession(requests.Session):
def __init__( def __init__(
self, self,
cache_db_path: str, cache_db_path: str,
max_lifetime: int = 604800, max_lifetime: int = 259200,
max_size: int = (1024**2) * 10, max_size: int = (1024**2) * 10,
table_name: str = "fastanime_requests_cache", table_name: str = "fastanime_requests_cache",
clean_db=False, clean_db=False,
@@ -89,16 +90,10 @@ class CachedRequestsSession(requests.Session):
url, url,
params=None, params=None,
force_caching=False, force_caching=False,
fresh=0, fresh=int(os.environ.get("FASTANIME_FRESH_REQUESTS", 0)),
*args, *args,
**kwargs, **kwargs,
): ):
# TODO: improve the caching functionality and add a layer to auto delete
# expired requests
if fresh:
logger.debug("Executing fresh request")
return super().request(method, url, params=params, *args, **kwargs)
if params: if params:
url += "?" + urlencode(params) url += "?" + urlencode(params)
@@ -128,7 +123,7 @@ class CachedRequestsSession(requests.Session):
cached_request = cursor.fetchone() cached_request = cursor.fetchone()
time_after_access_db = datetime.now() time_after_access_db = datetime.now()
if cached_request: if cached_request and not fresh:
logger.debug("Found existing request in cache") logger.debug("Found existing request in cache")
( (
status_code, status_code,

View File

@@ -41,8 +41,8 @@ class FZF:
stdout: [TODO:attribute] stdout: [TODO:attribute]
""" """
if not os.getenv("FZF_DEFAULT_OPTS"): # if not os.getenv("FZF_DEFAULT_OPTS"):
os.environ["FZF_DEFAULT_OPTS"] = FZF_DEFAULT_OPTS # os.environ["FZF_DEFAULT_OPTS"] = FZF_DEFAULT_OPTS
FZF_EXECUTABLE = shutil.which("fzf") FZF_EXECUTABLE = shutil.which("fzf")
default_options = [ default_options = [
"--cycle", "--cycle",
@@ -157,10 +157,18 @@ class FZF:
Returns: Returns:
[TODO:return] [TODO:return]
""" """
_HEADER_COLOR = os.environ.get("FASTANIME_HEADER_COLOR", "215,0,95").split(",")
header = os.environ.get("FASTANIME_HEADER_ASCII_ART", HEADER)
header = "\n".join(
[
f"\033[38;2;{_HEADER_COLOR[0]};{_HEADER_COLOR[1]};{_HEADER_COLOR[2]};m{line}\033[0m"
for line in header.split("\n")
]
)
_commands = [ _commands = [
*self.default_options, *self.default_options,
"--header", "--header",
HEADER, header,
"--header-first", "--header-first",
"--prompt", "--prompt",
f"{prompt.title()}: ", f"{prompt.title()}: ",
@@ -182,6 +190,7 @@ class FZF:
print(info) print(info)
input("Enter to try again") input("Enter to try again")
return self.run(fzf_input, prompt, header, preview, expect, validator) return self.run(fzf_input, prompt, header, preview, expect, validator)
# os.environ["FZF_DEFAULT_OPTS"] = ""
return result return result

View File

@@ -11,6 +11,7 @@ class RofiApi:
ROFI_EXECUTABLE = which("rofi") ROFI_EXECUTABLE = which("rofi")
rofi_theme = "" rofi_theme = ""
rofi_theme_preview = ""
rofi_theme_confirm = "" rofi_theme_confirm = ""
rofi_theme_input = "" rofi_theme_input = ""
@@ -21,8 +22,8 @@ class RofiApi:
raise Exception("Rofi not found") raise Exception("Rofi not found")
args = [self.ROFI_EXECUTABLE] args = [self.ROFI_EXECUTABLE]
if self.rofi_theme: if self.rofi_theme_preview:
args.extend(["-no-config", "-theme", self.rofi_theme]) args.extend(["-no-config", "-theme", self.rofi_theme_preview])
args.extend(["-p", f"{prompt_text.title()}", "-i", "-show-icons", "-dmenu"]) args.extend(["-p", f"{prompt_text.title()}", "-i", "-show-icons", "-dmenu"])
result = subprocess.run( result = subprocess.run(
args, args,

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1731676054,
"narHash": "sha256-OZiZ3m8SCMfh3B6bfGC/Bm4x3qc1m2SVEAlkV6iY7Yg=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5e4fbfb6b3de1aa2872b76d49fafc942626e2add",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

60
flake.nix Normal file
View File

@@ -0,0 +1,60 @@
{
description = "FastAnime Project Flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
python = pkgs.python312;
pythonPackages = python.pkgs;
fastanimeEnv = pythonPackages.buildPythonApplication {
pname = "fastanime";
version = "2.8.3";
src = ./.;
preBuild = ''
sed -i 's/rich>=13.9.2/rich>=13.8.1/' pyproject.toml
'';
# Add runtime dependencies
propagatedBuildInputs = with pythonPackages; [
click
inquirerpy
requests
rich
thefuzz
yt-dlp
dbus-python
hatchling
plyer
mpv
fastapi
];
# Ensure compatibility with the pyproject.toml
format = "pyproject";
};
in
{
packages.default = fastanimeEnv;
# DevShell for development
devShells.default = pkgs.mkShell {
buildInputs = [
fastanimeEnv
pythonPackages.hatchling
pkgs.mpv
pkgs.libmpv
pkgs.fzf
pkgs.rofi
];
};
});
}

View File

@@ -5,7 +5,12 @@ VERSION=$1
[ "$VERSION" = "current" ] && fastanime --version && exit 0 [ "$VERSION" = "current" ] && fastanime --version && exit 0
sed -i "s/^version.*/version = \"$VERSION\"/" "$CLI_DIR/pyproject.toml" && sed -i "s/^version.*/version = \"$VERSION\"/" "$CLI_DIR/pyproject.toml" &&
sed -i "s/__version__.*/__version__ = \"v$VERSION\"/" "$CLI_DIR/fastanime/__init__.py" && sed -i "s/__version__.*/__version__ = \"v$VERSION\"/" "$CLI_DIR/fastanime/__init__.py" &&
git stage "$CLI_DIR/pyproject.toml" "$CLI_DIR/fastanime/__init__.py" && sed -i "s/version = .*/version = \"$VERSION\";/" "$CLI_DIR/flake.nix" &&
git stage "$CLI_DIR/pyproject.toml" "$CLI_DIR/fastanime/__init__.py" "$CLI_DIR/flake.nix" &&
git commit -m "chore: bump version (v$VERSION)" && git commit -m "chore: bump version (v$VERSION)" &&
nix flake lock &&
uv lock &&
git stage "$CLI_DIR/flake.lock" "$CLI_DIR/uv.lock" &&
git commit -m "chore: update lock files" &&
git push && git push &&
gh release create "v$VERSION" gh release create "v$VERSION"

1365
poetry.lock generated

File diff suppressed because it is too large Load Diff

65
pyinstaller.spec Normal file
View File

@@ -0,0 +1,65 @@
# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
block_cipher = None
# Collect all required data files
datas = [
('fastanime/assets/*', 'fastanime/assets'),
]
# Collect all required hidden imports
hiddenimports = [
'click',
'rich',
'requests',
'yt_dlp',
'python_mpv',
'fuzzywuzzy',
'fastanime',
] + collect_submodules('fastanime')
a = Analysis(
['./fastanime/fastanime.py'], # Changed entry point
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
strip=True, # Strip debug information
optimize=2 # Optimize bytecode noarchive=False
)
pyz = PYZ(
a.pure,
a.zipped_data,
optimize=2 # Optimize bytecode cipher=block_cipher
)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='fastanime',
debug=False,
bootloader_ignore_signals=False,
strip=True,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='fastanime/assets/logo.ico'
)

View File

@@ -1,41 +1,36 @@
[tool.poetry] [project]
name = "fastanime" name = "fastanime"
version = "2.6.4" version = "2.8.3"
description = "A browser anime site experience from the terminal" description = "A browser anime site experience from the terminal"
authors = ["Benextempest <benextempest@gmail.com>"]
license = "UNLICENSE" license = "UNLICENSE"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"click>=8.1.7",
"inquirerpy>=0.3.4",
"requests>=2.32.3",
"rich>=13.9.2",
"thefuzz>=0.22.1",
"yt-dlp[default]>=2024.10.7",
]
[tool.poetry.dependencies] [project.scripts]
python = "^3.10"
yt-dlp = "^2024.5.27"
thefuzz = "^0.22.1"
requests = "^2.32.3"
rich = { version = "^13.7.1", optional = false }
click = { version = "^8.1.7", optional = false }
inquirerpy = { version = "^0.3.4", optional = false }
mpv = { version = "^1.0.7", optional = true }
plyer = { version = "^2.1.0", optional = true }
[tool.poetry.extras]
full = ["plyer", "mpv"]
# cli = ["rich", "click", "inquirerpy"]
mpv = ["mpv"]
notifications = ["plyer"]
[tool.poetry.group.dev.dependencies]
black = "^24.4.2"
isort = "^5.13.2"
pytest = "^8.2.2"
ruff = "^0.4.10"
pre-commit = "^3.7.1"
autoflake = "^2.3.1"
tox = "^4.16.0"
pyright = "^1.1.374"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
fastanime = 'fastanime:FastAnime' fastanime = 'fastanime:FastAnime'
[project.optional-dependencies]
standard = ["fastapi[standard]>=0.115.0", "mpv>=1.0.7", "plyer>=2.1.0"]
api = ["fastapi[standard]>=0.115.0"]
notifications = ["plyer>=2.1.0"]
mpv = ["mpv>=1.0.7"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
dev-dependencies = [
"pyinstaller>=6.11.1",
"pyright>=1.1.384",
"pytest>=8.3.3",
"ruff>=0.6.9",
]

18
shell.nix Normal file
View File

@@ -0,0 +1,18 @@
let
pkgs = import <nixpkgs> {};
in pkgs.mkShell {
packages = [
(pkgs.python3.withPackages (python-pkgs: [
python-pkgs.yt-dlp
python-pkgs.dbus-python
python-pkgs.requests
python-pkgs.rich
python-pkgs.click
python-pkgs.inquirerpy
python-pkgs.mpv
python-pkgs.fastapi
python-pkgs.thefuzz
python-pkgs.plyer
]))
];
}

View File

@@ -1,5 +1,6 @@
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
from unittest.mock import patch
from fastanime.cli import run_cli from fastanime.cli import run_cli
@@ -147,3 +148,10 @@ def test_anilist_upcoming_help(runner: CliRunner):
def test_anilist_watching_help(runner: CliRunner): def test_anilist_watching_help(runner: CliRunner):
result = runner.invoke(run_cli, ["anilist", "watching", "--help"]) result = runner.invoke(run_cli, ["anilist", "watching", "--help"])
assert result.exit_code == 0 assert result.exit_code == 0
def test_check_for_updates_not_called_on_completions(runner):
with patch('fastanime.cli.app_updater.check_for_updates') as mock_check_for_updates:
result = runner.invoke(run_cli, ["completions"])
assert result.exit_code == 0
mock_check_for_updates.assert_not_called()

18
tox.ini
View File

@@ -5,23 +5,23 @@ env_list = lint, pyright, py{310,311}
[testenv] [testenv]
description = run unit tests description = run unit tests
deps =poetry deps =uv
commands = commands =
poetry install --all-extras uv sync --dev --all-extras
poetry run pytest uv run pytest
[testenv:lint] [testenv:lint]
description = run linters description = run linters
skip_install = true skip_install = true
deps =poetry deps =uv
commands = commands =
poetry install --all-extras uv sync --dev --all-extras
poetry run black . uv run ruff format .
[testenv:pyright] [testenv:pyright]
description = run type checking description = run type checking
skip_install = true skip_install = true
deps =poetry deps =uv
commands = commands =
poetry install --no-root --all-extras uv sync --dev --all-extras
poetry run pyright uv run pyright

1410
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff