Compare commits

..

84 Commits

Author SHA1 Message Date
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
Benex254
1dfdcc27ce chore: bump version (v2.6.4) 2024-10-05 12:33:32 +03:00
Benex254
3c03289453 fix: add git push to make_release 2024-10-05 12:33:23 +03:00
Benex254
06fd446a72 chore: bump version (v2.6.3) 2024-10-05 12:29:29 +03:00
Benex254
172d912d8b chore(release): improve the make release script to also stage changes after bumping version 2024-10-05 12:29:15 +03:00
Benex254
2396018607 feat: make script to automate releases 2024-10-05 12:19:03 +03:00
Benex254
a9be9779c5 feat(fa): improve fa script 2024-10-05 12:14:45 +03:00
Benex254
2f76b26a99 feat(fzf): add some bindings 2024-10-05 11:54:22 +03:00
Benex254
2fe5edf810 feat(cli): make all threads daemon threads 2024-10-05 11:47:52 +03:00
Benex254
d67ee6a779 feat(downloader): add progress hook option to be passed to yt-dlp 2024-10-05 11:47:30 +03:00
Benex254
e06ec5dbd4 feat(cli): make the image previews optional 2024-10-05 11:31:13 +03:00
Benex254
c1b24ba2aa feat(cli): save images with .png extenstion to enable easier viewing by external apps 2024-10-05 11:05:07 +03:00
Benex254
59e9cf9fd0 feat: improve previews 2024-10-05 10:12:14 +03:00
Benex254
58761f5b96 chore: bump version 2024-10-04 19:44:54 +03:00
Benex254
ac959da229 feat: renable bg downloading function 2024-10-04 19:42:53 +03:00
benex
bacc8c48ec fix: image previews not showing up on windows 2024-10-04 11:03:54 +03:00
Benex254
905a159428 chore: add a mapping for re:zero s3 in normalizer 2024-10-03 15:09:51 +03:00
Benex254
20f734cab2 feat: also compare synonymns 2024-10-03 15:09:14 +03:00
Benex254
7c2c644aef chore: bump version 2024-10-03 14:18:22 +03:00
Benex254
0efc92081a feat: use .get in normlizer 2024-10-03 14:18:05 +03:00
Benex254
fafeee2367 chore: bump version 2024-10-03 12:48:41 +03:00
Benex254
e03063cd76 feat: let configuration of providers be managed by AnimeProvider wrapper 2024-10-03 12:34:34 +03:00
Benex254
93b38b055f docs: update readme 2024-10-03 12:33:52 +03:00
Benex254
045635fb55 feat: update config.py 2024-10-03 12:33:40 +03:00
Benex254
de7f773e9e feat: make the threads non-daemon 2024-10-03 11:47:27 +03:00
Benex254
ef6a465bd2 fix: typing issue 2024-10-02 22:19:02 +03:00
Benex254
0c623af8a4 chore: update poetry lockfile 2024-10-02 21:57:17 +03:00
Benex254
0589f83998 chore: bump version 2024-10-02 21:56:50 +03:00
Benex254
e17608afd5 feat: add provider store 2024-10-02 21:33:41 +03:00
Benex254
b915654685 feat: make the requests cache allow multiple connections by switching to wal 2024-10-02 16:49:36 +03:00
Benex254
2ce9bf6c47 feat: use a more meaningful name for the request caching files 2024-09-30 13:20:14 +03:00
Benex254
3c22232432 feat: add option to delete the db file 2024-09-30 13:19:33 +03:00
Benex254
3474e9520c tests: pass custom env 2024-09-29 22:09:57 +03:00
Benex254
e9bacf4f9c fix: extra atexit callbacks 2024-09-29 21:31:58 +03:00
Benex254
ef422ed6fd fix: inability to reload provider dynamically when using cached sessions 2024-09-29 21:19:15 +03:00
Benex254
d0f5366908 feat: allow access of fastanime config from environment variables 2024-09-29 21:00:41 +03:00
Benex254
3557205feb chore: cleanup requests cacher 2024-09-29 20:41:55 +03:00
Benex254
ba4c41d888 feat: implement usage of the requests cacher 2024-09-29 20:40:14 +03:00
Benex254
1427a3193c feat: implement requests cacher for fastanime 2024-09-29 20:39:27 +03:00
Benex254
b5cee20e56 fix: episodes range generated by mini anilist 2024-09-28 10:31:33 +03:00
Benex254
be7f464073 chore: update deps 2024-09-24 15:56:09 +03:00
Benex254
c7f8f168f5 docs: update readme 2024-09-24 15:55:59 +03:00
Benex254
ba59fbdcb0 chore: bump version 2024-09-24 15:55:47 +03:00
Benex254
9f54fa4998 feat: handle abscence of webtorrent-cli 2024-09-24 15:55:24 +03:00
Benex254
3c9688b32c feat: add nyaa as provider 2024-09-24 15:45:34 +03:00
Benex254
1f046447bb chore: update all instances of aniwatch to hianime 2024-09-24 10:02:49 +03:00
Benex254
87e3a275bb chore: bump version 2024-09-23 11:29:22 +03:00
Benex254
037b5c36a4 fix: normalize unknown_video to mp4 2024-09-23 11:04:31 +03:00
Benex254
7d8b60fb14 fix: change aniwatch to hianime in data.py 2024-09-23 11:04:06 +03:00
Benex254
0ad16fee53 fix: typing issue in player 2024-09-22 22:34:07 +03:00
Benex254
249243aeb4 chore: use --all-extras flag in poetry install 2024-09-22 22:31:35 +03:00
Benex254
c208dc3579 chore: bump version 2024-09-22 22:27:04 +03:00
Benex254
ea93f2ba23 chore: make some dependencies optional 2024-09-22 22:26:37 +03:00
Benex254
d910a0bb6a chore: update depenedencies 2024-09-22 22:25:52 +03:00
Benex254
550fcfeddc feat: make plyer an optional dependency 2024-09-22 22:13:12 +03:00
Benex254
c6910e5a1c feat: improve prompt text 2024-09-22 22:13:12 +03:00
Benex254
8555edb521 feat: dont pass obj to providers 2024-09-22 22:13:12 +03:00
Benex254
139193ce29 chore: remove aniwave as a provider; you shall forever live in our hearts 2024-09-22 22:13:12 +03:00
Benex254
1a87375ccd feat: add debug mode for providers 2024-09-22 22:13:12 +03:00
BeneX254
83cbef40f6 Update README.md 2024-09-21 18:06:09 +03:00
Benex254
85b4fc75a1 docs: update the readme 2024-09-20 17:58:29 +03:00
Benex254
f2e2da378f feat: improved medi list tracking 2024-09-20 17:58:06 +03:00
Benex254
7c34bc9120 feat: restrict some genres in mini_anilist 2024-09-19 19:12:10 +03:00
Benex254
6f153f2acb feat: immprove help messages for all cli commands 2024-09-19 19:11:15 +03:00
Benex254
8171083978 chore: update deps 2024-09-18 20:10:35 +03:00
Benex254
db5b9a59b4 fix: fastanime update not working with pip installs 2024-09-18 20:09:34 +03:00
65 changed files with 4062 additions and 2616 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 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 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

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"]

223
README.md
View File

@@ -1,13 +1,19 @@
# FastAnime # **FastAnime**
![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)
![Discord](https://img.shields.io/discord/1250887070906323096?label=Discord)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/Benex254/FastAnime)
![GitHub deployments](https://img.shields.io/github/deployments/Benex254/fastanime/pypi?label=PyPi%20Publish)
![PyPI - License](https://img.shields.io/pypi/l/fastanime)
Welcome to **FastAnime**, anime site experience from the terminal. Welcome to **FastAnime**, anime site experience from the terminal.
![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> <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>
@@ -29,7 +35,7 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [jerr
<!--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 pipx](#using-pipx) - [Using pipx](#using-pipx)
@@ -62,10 +68,16 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [jerr
> [!IMPORTANT] > [!IMPORTANT]
> >
> This project currently scrapes allanime, aniwatch and animepahe. The site is in the public domain and can be accessed by any one with a browser. > 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)
![Linux/BSD](https://img.shields.io/badge/-Linux/BSD-red.svg?style=for-the-badge&logo=linux)
![Arch Linux](https://img.shields.io/badge/-Arch_Linux-black.svg?style=for-the-badge&logo=archlinux)
![MacOS](https://img.shields.io/badge/-MacOS-lightblue.svg?style=for-the-badge&logo=apple)
![Android](https://img.shields.io/badge/-Android-green.svg?style=for-the-badge&logo=android)
The app can run wherever python can run. So all you need to have is python installed on your device. The app can run wherever python can run. So all you need to have is python installed on your device.
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)
@@ -73,11 +85,31 @@ If you have any difficulty consult for help on the [discord channel](https://dis
### 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
```bash
# generally:
uv tool install fastanime
# if you want other functionality:
uv tool install fastanime[standard]
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
@@ -166,9 +198,11 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
> player because we believe nothing beats **MPV** and it provides > player because we believe nothing beats **MPV** and it provides
> everything you could ever need with a small footprint. > everything you could ever need with a small footprint.
> But if you have a reason feel free to encourage as to do so. > But if you have a reason feel free to encourage as to do so.
> However, on android this is not the case so vlc is also supported
**Other external dependencies that will just make your experience better:** **Other external dependencies that will just make your experience better:**
- [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 the desktop entry ui
@@ -178,16 +212,17 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
- [ani-skip](https://github.com/synacktraa/ani-skip) used for skipping the opening and ending theme songs - [ani-skip](https://github.com/synacktraa/ani-skip) used for skipping the opening and ending theme songs
- [ffmpegthumbnailer](https://github.com/dirkvdb/ffmpegthumbnailer) used for local previews of downloaded anime - [ffmpegthumbnailer](https://github.com/dirkvdb/ffmpegthumbnailer) used for local previews of downloaded anime
- [syncplay](https://syncplay.pl/) to enable watch together. - [syncplay](https://syncplay.pl/) to enable watch together.
- [feh]() used in manga mode - [feh](https://github.com/derf/feh) used in manga mode
## Usage ## Usage
The project offers a featureful command-line interface and MPV interface through the use of python-mpv. The project offers a featureful command-line interface and MPV interface through the use of python-mpv.
The project also offers subs in different languages thanks to aniwatch provider. The project also offers subs in different languages thanks to hianime provider.
### The Commandline interface :fire: ### The Commandline interface :fire:
Designed for efficiency and automation. Plus has a beautiful pseudo-TUI in some of the commands. Designed for efficiency and automation. Plus has a beautiful pseudo-TUI in some of the commands.
If you are stuck anywhere just use `--help` before the command you would like to get help on
**Overview of main commands:** **Overview of main commands:**
@@ -226,7 +261,7 @@ Available options for the fastanime include:
- `--default` use the default ui - `--default` use the default ui
- `--preview` show a preview when using fzf - `--preview` show a preview when using fzf
- `--no-preview` dont show a preview when using fzf - `--no-preview` dont show a preview when using fzf
- `--format <yt-dlp format string>` or `-f <yt-dlp format string>` set the format of anime downloaded and streamed based on [yt-dlp format](https://github.com/yt-dlp/yt-dlp#format-selection). Works when `--server gogoanime` or on providers that provide multi quality streams eg aniwatch - `--format <yt-dlp format string>` or `-f <yt-dlp format string>` set the format of anime downloaded and streamed based on [yt-dlp format](https://github.com/yt-dlp/yt-dlp#format-selection). Works when `--server gogoanime` or on providers that provide multi quality streams eg hianime
- `--icons/--no-icons` toggle the visibility of the icons - `--icons/--no-icons` toggle the visibility of the icons
- `--skip/--no-skip` whether to skip the opening and ending theme songs. - `--skip/--no-skip` whether to skip the opening and ending theme songs.
- `--rofi` use rofi for the ui - `--rofi` use rofi for the ui
@@ -237,9 +272,9 @@ Available options for the fastanime include:
- `--log-file` allow logging to a file - `--log-file` allow logging to a file
- `--rich-traceback` allow rich traceback - `--rich-traceback` allow rich traceback
- `--use-mpv-mod/--use-default-player` whether to use python-mpv - `--use-mpv-mod/--use-default-player` whether to use python-mpv
- `--provider <allanime/animepahe>` anime site of choice to scrape from - `--provider <allanime/animepahe/hianime/nyaa>` anime site of choice to scrape from
- `--sync-play` or `-sp` use syncplay for streaming anime so you can watch with your friends - `--sync-play` or `-sp` use syncplay for streaming anime so you can watch with your friends
- `--sub-lang <en/or any other common shortform for country>` regex is used to determine the appropriate. Only works when provider is aniwatch. - `--sub-lang <en/or any other common shortform for country>` regex is used to determine the appropriate. Only works when provider is hianime.
- `--normalize-titles/--no-normalize-titles` whether to normalize provider titles - `--normalize-titles/--no-normalize-titles` whether to normalize provider titles
- `--manga` toggle experimental manga mode - `--manga` toggle experimental manga mode
@@ -417,7 +452,7 @@ fastanime download -t <anime-title> -r ':<episodes-end>'
# remember python indexing starts at 0 # remember python indexing starts at 0
fastanime download -t <anime-title> -r '<episode-1>:<episode>' fastanime download -t <anime-title> -r '<episode-1>:<episode>'
# merge subtitles with ffmpeg to mkv format; aniwatch tends to give subs as separate files # merge subtitles with ffmpeg to mkv format; hianime tends to give subs as separate files
# and dont prompt for anything # and dont prompt for anything
# eg existing file in destination instead remove # eg existing file in destination instead remove
# and clean # and clean
@@ -622,6 +657,19 @@ 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>
```
### 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.
@@ -664,205 +712,82 @@ The default interface uses inquirerPy which is customizable. Read here to findou
The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on arch linux; for the other operating systems you can check by running `fastanime config --path`. The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on arch linux; for the other operating systems you can check by running `fastanime config --path`.
> [!TIP] > [!TIP]
> You can now use the option `--update` to update your config file from the command-line > You can now use the option `--update` to update your config file from the command-line
> For Example: > For Example:
> `fastanime --icons --fzf --preview config --update` > `fastanime --icons --fzf --preview config --update`
> the above will set icons to true, use_fzf to true and preview to true in your config file > the above will set icons to true, use_fzf to true and preview to true in your config file
>
By default if a config file does not exist it will be auto created with comments to explain each and every option.
The default config: The default config:
```ini ```ini
#
# ███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗ ░█████╗░░█████╗░███╗░░██╗███████╗██╗░██████╗░
# ██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝ ██╔══██╗██╔══██╗████╗░██║██╔════╝██║██╔════╝░
# █████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░ ██║░░╚═╝██║░░██║██╔██╗██║█████╗░░██║██║░░██╗░
# ██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░ ██║░░██╗██║░░██║██║╚████║██╔══╝░░██║██║░░╚██╗
# ██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗ ╚█████╔╝╚█████╔╝██║░╚███║██║░░░░░██║╚██████╔╝
# ╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝ ░╚════╝░░╚════╝░╚═╝░░╚══╝╚═╝░░░░░╚═╝░╚═════╝░
#
[general] [general]
# whether to show the icons in the tui [True/False]
# more like emojis
# by the way if you have any recommendations to which should be used where please
# don't hesitate to share your opinion
# 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
icons = False icons = False
# 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 = 1080 quality = 1080
# whether to normalize provider titles [True/False]
# 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
# this also applies to episode titles
normalize_titles = True normalize_titles = True
# can be [allanime, animepahe, aniwatch]
# allanime is the most realible
# animepahe provides different links to streams of different quality so a quality can be selected reliably with --quality option
# aniwatch which is now hianime usually provides subs in different languuages and its servers are generally faster
provider = allanime provider = allanime
# Display language [english, romaji]
# this is passed to anilist directly and is used to set the language which the anime titles will be in
# when using the anilist interface
preferred_language = english preferred_language = english
# Download directory
# where you will find your videos after downloading them with 'fastanime download' command
downloads_dir = ~/Videos/FastAnime downloads_dir = ~/Videos/FastAnime
# 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
# this is only when usinf fzf
# if you dont care about image previews it doesnt matter
# though its awesome
# try it and you will see
preview = False preview = False
# the time to seek when using ffmpegthumbnailer [-1 to 100]
# -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
# random makes things quite exciting cause you never no at what time it will extract the image from
ffmpegthumbnailer_seek_time = -1 ffmpegthumbnailer_seek_time = -1
# whether to use fzf as the interface for the anilist command and others. [True/False]
use_fzf = False use_fzf = False
# whether to use rofi for the ui [True/False]
# it's more useful if you want to create a desktop entry
# which can be setup with 'fastanime config --desktop-entry'
# though if you want it to be your sole interface even when fastanime is run directly from the terminal
use_rofi = False use_rofi = False
# rofi themes 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
# 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;
rofi_theme = rofi_theme =
rofi_theme_input = rofi_theme_input =
rofi_theme_confirm = rofi_theme_confirm =
# the duration in minutes a notification will stay in the screen
# used by notifier command
notification_duration = 2 notification_duration = 2
# used when the provider gives subs of different languages
# currently its the case for:
# aniwatch
# the values for this option are the short names for countries
# regex is used to determine what you selected
sub_lang = eng sub_lang = eng
default_media_list_tracking = None
force_forward_tracking = True
cache_requests = True
use_persistent_provider_store = False
recent = 50
[stream] [stream]
# Auto continue from watch history [True/False]
# this will make fastanime to choose the episode that you last watched to completion
# and increment it by one
# and use that to auto select the episode you want to watch
continue_from_history = True continue_from_history = True
# which history to use [local/remote]
# 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
# 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
preferred_history = local preferred_history = local
# Preferred language for anime [dub/sub]
translation_type = sub translation_type = sub
# what server to use for a particular provider
# allanime: [dropbox, sharepoint, wetransfer, gogoanime, wixmp]
# animepahe: [kwik]
# aniwatch: [HD1, HD2, StreamSB, StreamTape]
# 'top' can also be used as a value for this option
# '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
server = top server = top
# Auto select next episode [True/False]
# this makes fastanime increment the current episode number
# then after using that value to fetch the next episode instead of prompting
# this option is useful for binging
auto_next = False auto_next = False
# Auto select the anime provider results with fuzzy find. [True/False]
# Note this won't always be correct
# this is because the providers sometime use non-standard names
# that are there own preference rather than the official names
# 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
# 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
# or even better edit this file <> and open a pull request
auto_select = True auto_select = True
# whether to skip the opening and ending theme songs [True/False]
# 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
# so its disabled for now
skip = False skip = False
# the maximum delta time in minutes after which the episode should be considered as completed episode_complete_at = 80
# used in the continue from time stamp
error = 3
# whether to use python-mpv [True/False]
# to enable superior control over the player
# adding more options to it
# Enable this one and you will be wonder why you did not discover fastanime sooner
# Since you basically don't have to close the player window to go to the next or previous episode, switch servers, change translation type or
change to a given episode x
# so try it if you haven't already
# if you have any issues setting it up
# don't be afraid to ask
# especially on windows
# honestly it can be a pain to set it up there
# personally it took me quite sometime to figure it out
# this is because of how windows handles shared libraries
# so just ask when you find yourself stuck
# or just switch to arch linux
use_python_mpv = False use_python_mpv = False
# force mpv window
# the default 'immediate' just makes mpv to open the window even if the video has not yet loaded
# done for asthetics
# passed directly to mpv so values are same
force_window = immediate force_window = immediate
# the format of downloaded anime and trailer
# based on yt-dlp format and passed directly to it
# learn more by looking it up on their site
# only works for downloaded anime if:
# provider=allanime, server=gogoanime
# provider=allanime, server=wixmp
# provider=aniwatch
# this is because they provider a m3u8 file that contans multiple quality streams
format = best[height<=1080]/bestvideo[height<=1080]+bestaudio/best format = best[height<=1080]/bestvideo[height<=1080]+bestaudio/best
# NOTE: player = mpv
# 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
#
``` ```
## Contributing ## Contributing

5
fa
View File

@@ -1,4 +1,3 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# exec "${PYTHON:-python3}" -Werror -Xdev -m "$(dirname "$(realpath "$0")")/fastanime" "$@" CLI_DIR="$(dirname "$(realpath "$0")")"
cd "$(dirname "$(realpath "$0")")" || exit 1 exec uv run --directory "$CLI_DIR/../" fastanime "$@"
exec python -m fastanime "$@"

View File

@@ -1,10 +1,8 @@
"""An abstraction over all providers offering added features with a simple and well typed api """An abstraction over all providers offering added features with a simple and well typed api"""
[TODO:description]
"""
import importlib import importlib
import logging import logging
import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .libs.anime_provider import anime_sources from .libs.anime_provider import anime_sources
@@ -32,19 +30,36 @@ class AnimeProvider:
PROVIDERS = list(anime_sources.keys()) PROVIDERS = list(anime_sources.keys())
provider = PROVIDERS[0] provider = PROVIDERS[0]
def __init__(self, provider, dynamic=False, retries=0) -> None: def __init__(
self,
provider,
cache_requests=os.environ.get("FASTANIME_CACHE_REQUESTS", "false"),
use_persistent_provider_store=os.environ.get(
"FASTANIME_USE_PERSISTENT_PROVIDER_STORE", "false"
),
dynamic=False,
retries=0,
) -> None:
self.provider = provider self.provider = provider
self.dynamic = dynamic self.dynamic = dynamic
self.retries = retries self.retries = retries
self.cache_requests = cache_requests
self.use_persistent_provider_store = use_persistent_provider_store
self.lazyload_provider(self.provider) self.lazyload_provider(self.provider)
def lazyload_provider(self, provider): def lazyload_provider(self, provider):
"""updates the current provider being used""" """updates the current provider being used"""
try:
self.anime_provider.session.kill_connection_to_db()
except Exception:
pass
_, anime_provider_cls_name = anime_sources[provider].split(".", 1) _, anime_provider_cls_name = anime_sources[provider].split(".", 1)
package = f"fastanime.libs.anime_provider.{provider}" package = f"fastanime.libs.anime_provider.{provider}"
provider_api = importlib.import_module(".api", package) provider_api = importlib.import_module(".api", package)
anime_provider = getattr(provider_api, anime_provider_cls_name) anime_provider = getattr(provider_api, anime_provider_cls_name)
self.anime_provider = anime_provider() self.anime_provider = anime_provider(
self.cache_requests, self.use_persistent_provider_store
)
def search_for_anime( def search_for_anime(
self, self,
@@ -66,13 +81,9 @@ class AnimeProvider:
[TODO:return] [TODO:return]
""" """
anime_provider = self.anime_provider anime_provider = self.anime_provider
try:
results = anime_provider.search_for_anime( results = anime_provider.search_for_anime(
user_query, translation_type, nsfw, unknown user_query, translation_type, nsfw, unknown
) )
except Exception as e:
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
results = None
return results return results
@@ -90,17 +101,13 @@ class AnimeProvider:
[TODO:return] [TODO:return]
""" """
anime_provider = self.anime_provider anime_provider = self.anime_provider
try:
results = anime_provider.get_anime(anime_id) results = anime_provider.get_anime(anime_id)
except Exception as e:
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
results = None
return results return results
def get_episode_streams( def get_episode_streams(
self, self,
anime, anime_id,
episode: str, episode: str,
translation_type: str, translation_type: str,
) -> "Iterator[Server] | None": ) -> "Iterator[Server] | None":
@@ -116,12 +123,7 @@ class AnimeProvider:
[TODO:return] [TODO:return]
""" """
anime_provider = self.anime_provider anime_provider = self.anime_provider
try:
results = anime_provider.get_episode_streams( results = anime_provider.get_episode_streams(
anime, episode, translation_type anime_id, episode, translation_type
) )
except Exception as e:
logger.error(f"[ANIMEPROVIDER-ERROR]: {e}")
results = None
return results return results

View File

@@ -1,13 +1,16 @@
import re
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
COMMA_REGEX = re.compile(r"([0-9]{3})(?=\d)")
# TODO: Add formating options for the final date # TODO: Add formating options for the final date
def format_anilist_date_object(anilist_date_object: "AnilistDateObject"): def format_anilist_date_object(anilist_date_object: "AnilistDateObject"):
if anilist_date_object: if anilist_date_object and anilist_date_object["day"]:
return f"{anilist_date_object['day']}/{anilist_date_object['month']}/{anilist_date_object['year']}" return f"{anilist_date_object['day']}/{anilist_date_object['month']}/{anilist_date_object['year']}"
else: else:
return "Unknown" return "Unknown"
@@ -27,6 +30,12 @@ def format_list_data_with_comma(data: list | None):
return "None" return "None"
def format_number_with_commas(number: int | None):
if not number:
return "0"
return COMMA_REGEX.sub(lambda match: f"{match.group(1)},", str(number)[::-1])[::-1]
def extract_next_airing_episode(airing_episode: "AnilistMediaNextAiringEpisode"): def extract_next_airing_episode(airing_episode: "AnilistMediaNextAiringEpisode"):
if airing_episode: if airing_episode:
return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}" return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"

View File

@@ -9,9 +9,11 @@ anime_normalizer_raw = {
"Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica", "Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica",
"Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka", "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka",
'Hazurewaku no "Joutai Ijou Skill" de Saikyou ni Natta Ore ga Subete wo Juurin suru made': "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made", 'Hazurewaku no "Joutai Ijou Skill" de Saikyou ni Natta Ore ga Subete wo Juurin suru made': "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made",
"Re:Zero kara Hajimeru Isekai Seikatsu Season 3": "Re:Zero kara Hajimeru Isekai Seikatsu 3rd Season",
}, },
"aniwatch": {"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": {},
} }
@@ -19,7 +21,7 @@ def get_anime_normalizer():
"""Used because there are different providers""" """Used because there are different providers"""
import os import os
current_provider = os.environ["CURRENT_FASTANIME_PROVIDER"] current_provider = os.environ.get("FASTANIME_PROVIDER", "allanime")
return anime_normalizer_raw[current_provider] return anime_normalizer_raw[current_provider]

View File

@@ -0,0 +1,6 @@
from yt_dlp import YoutubeDL
# TODO: create a class that makes yt-dlp's YoutubeDL fit in more with fastanime
class YtDlp(YoutubeDL):
pass

View File

@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
class YtDLPDownloader: class YtDLPDownloader:
downloads_queue = Queue() downloads_queue = Queue()
_thread = None
def _worker(self): def _worker(self):
while True: while True:
@@ -26,11 +27,6 @@ class YtDLPDownloader:
logger.error(f"Something went wrong {e}") logger.error(f"Something went wrong {e}")
self.downloads_queue.task_done() self.downloads_queue.task_done()
def __init__(self):
self._thread = Thread(target=self._worker)
self._thread.daemon = True
self._thread.start()
def _download_file( def _download_file(
self, self,
url: str, url: str,
@@ -38,6 +34,7 @@ class YtDLPDownloader:
episode_title: str, episode_title: str,
download_dir: str, download_dir: str,
silent: bool, silent: bool,
progress_hooks=[],
vid_format: str = "best", vid_format: str = "best",
force_unknown_ext=False, force_unknown_ext=False,
verbose=False, verbose=False,
@@ -59,6 +56,25 @@ class YtDLPDownloader:
""" """
anime_title = sanitize_filename(anime_title) anime_title = sanitize_filename(anime_title)
episode_title = sanitize_filename(episode_title) episode_title = sanitize_filename(episode_title)
if url.endswith(".torrent"):
WEBTORRENT_CLI = shutil.which("webtorrent")
if not WEBTORRENT_CLI:
import time
print(
"webtorrent cli is not installed which is required for downloading and streaming from nyaa\nplease install it or use another provider"
)
time.sleep(120)
return
cmd = [
WEBTORRENT_CLI,
"download",
url,
"--out",
os.path.join(download_dir, anime_title, episode_title),
]
subprocess.run(cmd)
return
ydl_opts = { ydl_opts = {
# Specify the output path and template # Specify the output path and template
"http_headers": headers, "http_headers": headers,
@@ -67,6 +83,7 @@ class YtDLPDownloader:
"verbose": verbose, "verbose": verbose,
"format": vid_format, "format": vid_format,
"compat_opts": ("allow-unsafe-ext",) if force_unknown_ext else tuple(), "compat_opts": ("allow-unsafe-ext",) if force_unknown_ext else tuple(),
"progress_hooks": progress_hooks,
} }
urls = [url] urls = [url]
if sub: if sub:
@@ -79,7 +96,14 @@ class YtDLPDownloader:
if not info: if not info:
continue continue
if i == 0: if i == 0:
vid_path = info["requested_downloads"][0]["filepath"] vid_path: str = info["requested_downloads"][0]["filepath"]
if vid_path.endswith(".unknown_video"):
print("Normalizing path...")
_vid_path = vid_path.replace(".unknown_video", ".mp4")
shutil.move(vid_path, _vid_path)
vid_path = _vid_path
print("successfully normalized path")
else: else:
sub_path = info["requested_downloads"][0]["filepath"] sub_path = info["requested_downloads"][0]["filepath"]
if sub_path and vid_path and merge: if sub_path and vid_path and merge:
@@ -148,8 +172,15 @@ class YtDLPDownloader:
except Exception as e: except Exception as e:
print(f"[red bold]An error[/] occurred: {e}") print(f"[red bold]An error[/] occurred: {e}")
# WARN: May remove this legacy functionality def download_file(
def download_file(self, url: str, title, silent=True): self,
url: str,
anime_title: str,
episode_title: str,
download_dir: str,
silent: bool = True,
**kwargs,
):
"""A helper that just does things in the background """A helper that just does things in the background
Args: Args:
@@ -157,7 +188,17 @@ class YtDLPDownloader:
silent ([TODO:parameter]): [TODO:description] silent ([TODO:parameter]): [TODO:description]
url: [TODO:description] url: [TODO:description]
""" """
self.downloads_queue.put((self._download_file, (url, title, silent))) if not self._thread:
self._thread = Thread(target=self._worker)
self._thread.daemon = True
self._thread.start()
self.downloads_queue.put(
(
self._download_file,
(url, anime_title, episode_title, download_dir, silent),
)
)
downloader = YtDLPDownloader() downloader = YtDLPDownloader()

View File

@@ -37,6 +37,10 @@ def anime_title_percentage_match(
title_a = str(anime["title"]["romaji"]) title_a = str(anime["title"]["romaji"])
title_b = str(anime["title"]["english"]) title_b = str(anime["title"]["english"])
percentage_ratio = max( 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_a.lower(), possible_user_requested_anime_title.lower()),
fuzz.ratio(title_b.lower(), possible_user_requested_anime_title.lower()), fuzz.ratio(title_b.lower(), possible_user_requested_anime_title.lower()),
) )

View File

@@ -2,11 +2,11 @@ import sys
if sys.version_info < (3, 10): if sys.version_info < (3, 10):
raise ImportError( raise ImportError(
"You are using an unsupported version of Python. Only Python versions 3.8 and above are supported by yt-dlp" "You are using an unsupported version of Python. Only Python versions 3.10 and above are supported by FastAnime"
) # noqa: F541 ) # noqa: F541
__version__ = "v2.5.3" __version__ = "v2.6.8"
APP_NAME = "FastAnime" APP_NAME = "FastAnime"
AUTHOR = "Benex254" AUTHOR = "Benex254"

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

@@ -0,0 +1,25 @@
from typing import Literal
from fastapi import FastAPI
from ..AnimeProvider import AnimeProvider
app = FastAPI()
anime_provider = AnimeProvider("allanime", "true", "true")
@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)

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",
} }
@@ -38,6 +39,29 @@ signal.signal(signal.SIGINT, handle_exit)
cls=LazyGroup, cls=LazyGroup,
help="A command line application for streaming anime that provides a complete and featureful interface", help="A command line application for streaming anime that provides a complete and featureful interface",
short_help="Stream Anime", short_help="Stream Anime",
epilog="""
\b
\b\bExamples:
# example of syncplay intergration
fastanime --sync-play --server sharepoint search -t <anime-title>
\b
# --- or ---
\b
# to watch with anilist intergration
fastanime --sync-play --server sharepoint anilist
\b
# downloading dubbed anime
fastanime --dub download -t <anime>
\b
# use icons and fzf for a more elegant ui with preview
fastanime --icons --preview --fzf anilist
\b
# use icons with default ui
fastanime --icons --default anilist
\b
# viewing manga
fastanime --manga search -t <manga-title>
""",
) )
@click.version_option(__version__, "--version") @click.version_option(__version__, "--version")
@click.option("--manga", "-m", help="Enable manga mode", is_flag=True) @click.option("--manga", "-m", help="Enable manga mode", is_flag=True)
@@ -154,6 +178,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,
@@ -188,7 +215,10 @@ def run_cli(
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()
@@ -227,13 +257,12 @@ 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:
import os
ctx.obj.provider = provider ctx.obj.provider = provider
os.environ["CURRENT_FASTANIME_PROVIDER"] = provider
if server: if server:
ctx.obj.server = server ctx.obj.server = server
if format: if format:
@@ -307,3 +336,4 @@ def run_cli(
if rofi_theme_confirm: if rofi_theme_confirm:
ctx.obj.rofi_theme_confirm = rofi_theme_confirm ctx.obj.rofi_theme_confirm = rofi_theme_confirm
Rofi.rofi_theme_confirm = rofi_theme_confirm Rofi.rofi_theme_confirm = rofi_theme_confirm
ctx.obj.set_fastanime_config_environs()

View File

@@ -75,9 +75,9 @@ 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"]
@@ -101,8 +101,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
@@ -112,6 +114,7 @@ def update_app():
"pip", "pip",
"install", "install",
APP_NAME, APP_NAME,
"-U",
"--user", "--user",
"--no-warn-script-location", "--no-warn-script-location",
] ]

View File

@@ -30,6 +30,53 @@ commands = {
invoke_without_command=True, invoke_without_command=True,
help="A beautiful interface that gives you access to a commplete streaming experience", help="A beautiful interface that gives you access to a commplete streaming experience",
short_help="Access all streaming options", short_help="Access all streaming options",
epilog="""
\b
\b\bExamples:
# ---- search ----
\b
# get anime with the tag of isekai
fastanime anilist search -T isekai
\b
# 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 search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
\b
# get anime of 2024 season WINTER
fastanime anilist search -y 2024 --season WINTER
\b
# get anime genre action and tag isekai,magic
fastanime anilist search -g Action -T Isekai -T Magic
\b
# get anime of 2024 thats finished airing
fastanime anilist search -y 2024 -S FINISHED
\b
# get the most favourite anime movies
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
\b
# ---- login ----
\b
# To sign in just run
fastanime anilist login
\b
# To view your login status
fastanime anilist login --status
\b
# To erase login data
fastanime anilist login --erase
\b
# ---- notifier ----
\b
# basic form
fastanime anilist notifier
\b
# with logging to stdout
fastanime --log anilist notifier
\b
# with logging to a file. stored in the same place as your config
fastanime --log-file anilist notifier
""",
) )
@click.pass_context @click.pass_context
def anilist(ctx: click.Context): def anilist(ctx: click.Context):

View File

@@ -16,7 +16,12 @@ def notifier(config: "Config"):
from sys import exit from sys import exit
import requests import requests
try:
from plyer import notification from plyer import notification
except ImportError:
print("Please install plyer to use this command")
exit(1)
from ....anilist import AniList from ....anilist import AniList
from ....constants import APP_CACHE_DIR, APP_DATA_DIR, APP_NAME, ICON_PATH, PLATFORM from ....constants import APP_CACHE_DIR, APP_DATA_DIR, APP_NAME, ICON_PATH, PLATFORM

View File

@@ -1,7 +1,24 @@
import click import click
@click.command(help="Helper command to manage cache") @click.command(
help="Helper command to manage cache",
epilog="""
\b
\b\bExamples:
# delete everything in the cache dir
fastanime cache --clean
\b
# print the path to the cache dir and exit
fastanime cache --path
\b
# print the current size of the cache dir and exit
fastanime cache --size
\b
# open the cache dir and exit
fastanime cache
""",
)
@click.option("--clean", help="Clean the cache dir", is_flag=True) @click.option("--clean", help="Clean the cache dir", is_flag=True)
@click.option("--path", help="The path to the cache dir", is_flag=True) @click.option("--path", help="The path to the cache dir", is_flag=True)
@click.option("--size", help="The size of the cache dir", is_flag=True) @click.option("--size", help="The size of the cache dir", is_flag=True)

View File

@@ -1,7 +1,24 @@
import click import click
@click.command(help="Helper command to get shell completions") @click.command(
help="Helper command to get shell completions",
epilog="""
\b
\b\bExamples:
# try to detect your shell and print completions
fastanime completions
\b
# print fish completions
fastanime completions --fish
\b
# print bash completions
fastanime completions --bash
\b
# print zsh completions
fastanime completions --zsh
""",
)
@click.option("--fish", is_flag=True, help="print fish completions") @click.option("--fish", is_flag=True, help="print fish completions")
@click.option("--zsh", is_flag=True, help="print zsh completions") @click.option("--zsh", is_flag=True, help="print zsh completions")
@click.option("--bash", is_flag=True, help="print bash completions") @click.option("--bash", is_flag=True, help="print bash completions")

View File

@@ -9,6 +9,25 @@ if TYPE_CHECKING:
@click.command( @click.command(
help="Manage your config with ease", help="Manage your config with ease",
short_help="Edit your config", short_help="Edit your config",
epilog="""
\b
\b\bExamples:
# Edit your config in your default editor
# NB: If it opens vim or vi exit with `:q`
fastanime config
\b
# get the path of the config file
fastanime config --path
\b
# print desktop entry info
fastanime config --desktop-entry
\b
# update your config without opening an editor
fastanime --icons --fzf --preview config --update
\b
# view the current contents of your config
fastanime config --view
""",
) )
@click.option("--path", "-p", help="Print the config location and exit", is_flag=True) @click.option("--path", "-p", help="Print the config location and exit", is_flag=True)
@click.option( @click.option(
@@ -94,7 +113,7 @@ def config(user_config: "Config", path, view, desktop_entry, update):
print(f"Successfully wrote \n{f.read()}") print(f"Successfully wrote \n{f.read()}")
exit_app(0) exit_app(0)
elif update: elif update:
with open(USER_CONFIG_PATH, "w",encoding="utf-8") as file: with open(USER_CONFIG_PATH, "w", encoding="utf-8") as file:
file.write(user_config.__str__()) file.write(user_config.__str__())
print("update successfull") print("update successfull")
else: else:

View File

@@ -11,6 +11,53 @@ if TYPE_CHECKING:
@click.command( @click.command(
help="Download anime using the anime provider for a specified range", help="Download anime using the anime provider for a specified range",
short_help="Download anime", short_help="Download anime",
epilog="""
\b
\b\bExamples:
# Download all available episodes
# multiple titles can be specified with -t option
fastanime download -t <anime-title> -t <anime-title>
# -- or --
fastanime download -t <anime-title> -t <anime-title> -r ':'
\b
# download latest episode for the two anime titles
# the number can be any no of latest episodes but a minus sign
# must be present
fastanime download -t <anime-title> -t <anime-title> -r '-1'
\b
# latest 5
fastanime download -t <anime-title> -t <anime-title> -r '-5'
\b
# Download specific episode range
# be sure to observe the range Syntax
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
\b
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>'
\b
fastanime download -t <anime-title> -r '<episodes-start>:'
\b
fastanime download -t <anime-title> -r ':<episodes-end>'
\b
# download specific episode
# remember python indexing starts at 0
fastanime download -t <anime-title> -r '<episode-1>:<episode>'
\b
# merge subtitles with ffmpeg to mkv format; hianime tends to give subs as separate files
# and dont prompt for anything
# eg existing file in destination instead remove
# and clean
# ie remove original files (sub file and vid file)
# only keep merged files
fastanime download -t <anime-title> --merge --clean --no-prompt
\b
# EOF is used since -t always expects a title
# you can supply anime titles from file or -t at the same time
# from stdin
echo -e "<anime-title>\\n<anime-title>\\n<anime-title>" | fastanime download -t "EOF" -r <range> -f -
\b
# from file
fastanime download -t "EOF" -r <range> -f <file-path>
""",
) )
@click.option( @click.option(
"--anime-titles", "--anime-titles",
@@ -160,7 +207,7 @@ def download(
choices = list(search_results_.keys()) choices = list(search_results_.keys())
if config.use_fzf: if config.use_fzf:
selected_anime_title = fzf.run( selected_anime_title = fzf.run(
choices, "Please Select title: ", "FastAnime" choices, "Please Select title", "FastAnime"
) )
else: else:
selected_anime_title = fuzzy_inquirer( selected_anime_title = fuzzy_inquirer(
@@ -237,7 +284,7 @@ def download(
with Progress() as progress: with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None) progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams( streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type anime["id"], episode, config.translation_type
) )
if not streams: if not streams:
print("No streams skipping") print("No streams skipping")
@@ -272,7 +319,7 @@ def download(
server_name = config.server server_name = config.server
else: else:
if config.use_fzf: if config.use_fzf:
server_name = fzf.run(servers_names, "Select an link: ") server_name = fzf.run(servers_names, "Select an link")
else: else:
server_name = fuzzy_inquirer( server_name = fuzzy_inquirer(
servers_names, servers_names,
@@ -314,9 +361,9 @@ def download(
episode_title, episode_title,
download_dir, download_dir,
silent, silent,
config.format, vid_format=config.format,
force_unknown_ext, force_unknown_ext=force_unknown_ext,
verbose, verbose=verbose,
headers=provider_headers, headers=provider_headers,
sub=subtitles[0]["url"] if subtitles else "", sub=subtitles[0]["url"] if subtitles else "",
merge=merge, merge=merge,

View File

@@ -11,7 +11,32 @@ if TYPE_CHECKING:
@click.command( @click.command(
help="View and watch your downloads using mpv", short_help="Watch downloads" 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("--path", "-p", help="print the downloads folder and exit", is_flag=True)
@click.option( @click.option(
@@ -268,7 +293,7 @@ def downloads(
else: else:
episode_title = fuzzy_inquirer( episode_title = fuzzy_inquirer(
downloaded_episodes, downloaded_episodes,
"Enter Playlist Name: ", "Enter Playlist Name",
) )
if episode_title == "Back": if episode_title == "Back":
stream_anime() stream_anime()
@@ -308,7 +333,7 @@ def downloads(
else: else:
playlist_name = fuzzy_inquirer( playlist_name = fuzzy_inquirer(
anime_downloads, anime_downloads,
"Enter Playlist Name: ", "Enter Playlist Name",
) )
if playlist_name == "Exit": if playlist_name == "Exit":
exit_app() exit_app()

View File

@@ -11,6 +11,41 @@ if TYPE_CHECKING:
@click.command( @click.command(
help="Helper command to get streams for anime to use externally in a non-python application", help="Helper command to get streams for anime to use externally in a non-python application",
short_help="Print anime streams to standard out", short_help="Print anime streams to standard out",
epilog="""
\b
\b\bExamples:
# --- print anime info + episode streams ---
\b
# multiple titles can be specified with the -t option
fastanime grab -t <anime-title> -t <anime-title>
# -- or --
# print all available episodes
fastanime grab -t <anime-title> -r ':'
\b
# print the latest episode
fastanime grab -t <anime-title> -r '-1'
\b
# print a specific episode range
# be sure to observe the range Syntax
fastanime grab -t <anime-title> -r '<start>:<stop>'
\b
fastanime grab -t <anime-title> -r '<start>:<stop>:<step>'
\b
fastanime grab -t <anime-title> -r '<start>:'
\b
fastanime grab -t <anime-title> -r ':<end>'
\b
# --- grab options ---
\b
# print search results only
fastanime grab -t <anime-title> -r <range> --search-results-only
\b
# print anime info only
fastanime grab -t <anime-title> -r <range> --anime-info-only
\b
# print episode streams only
fastanime grab -t <anime-title> -r <range> --episode-streams-only
""",
) )
@click.option( @click.option(
"--anime-titles", "--anime-titles",
@@ -182,7 +217,7 @@ def grab(
if episode not in episodes: if episode not in episodes:
continue continue
streams = anime_provider.get_episode_streams( streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type anime["id"], episode, config.translation_type
) )
if not streams: if not streams:
continue continue

View File

@@ -11,6 +11,29 @@ if TYPE_CHECKING:
@click.command( @click.command(
help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.", help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.",
short_help="Binge anime", short_help="Binge anime",
epilog="""
\b
\b\bExamples:
# basic form where you will still be prompted for the episode number
# multiple titles can be specified with the -t option
fastanime search -t <anime-title> -t <anime-title>
\b
# binge all episodes with this command
fastanime search -t <anime-title> -r ':'
\b
# watch latest episode
fastanime search -t <anime-title> -r '-1'
\b
# binge a specific episode range with this command
# be sure to observe the range Syntax
fastanime search -t <anime-title> -r '<start>:<stop>'
\b
fastanime search -t <anime-title> -r '<start>:<stop>:<step>'
\b
fastanime search -t <anime-title> -r '<start>:'
\b
fastanime search -t <anime-title> -r ':<end>'
""",
) )
@click.option( @click.option(
"--anime-titles", "--anime-titles",
@@ -76,7 +99,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
preview = get_fzf_manga_preview(search_results) preview = get_fzf_manga_preview(search_results)
if config.use_fzf: if config.use_fzf:
search_result_manga_title = fzf.run( search_result_manga_title = fzf.run(
choices, "Please Select title: ", preview=preview choices, "Please Select title", preview=preview
) )
elif config.use_rofi: elif config.use_rofi:
search_result_manga_title = Rofi.run(choices, "Please Select Title") search_result_manga_title = Rofi.run(choices, "Please Select Title")
@@ -166,7 +189,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
choices = list(search_results_.keys()) choices = list(search_results_.keys())
if config.use_fzf: if config.use_fzf:
search_result_manga_title = fzf.run( search_result_manga_title = fzf.run(
choices, "Please Select title: ", "FastAnime" choices, "Please Select title", "FastAnime"
) )
elif config.use_rofi: elif config.use_rofi:
search_result_manga_title = Rofi.run(choices, "Please Select Title") search_result_manga_title = Rofi.run(choices, "Please Select Title")
@@ -224,7 +247,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
anilist_anime_info = get_basic_anime_info_by_title(anime["title"]) anilist_anime_info = get_basic_anime_info_by_title(anime["title"])
def stream_anime(): def stream_anime(anime: "Anime"):
clear() clear()
episode = None episode = None
@@ -243,7 +266,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
if config.use_fzf: if config.use_fzf:
episode = fzf.run( episode = fzf.run(
choices, choices,
"Select an episode: ", "Select an episode",
header=search_result_manga_title, header=search_result_manga_title,
) )
elif config.use_rofi: elif config.use_rofi:
@@ -260,7 +283,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
with Progress() as progress: with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None) progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams( streams = anime_provider.get_episode_streams(
anime, episode, config.translation_type anime["id"], episode, config.translation_type
) )
if not streams: if not streams:
print("Failed to get streams") print("Failed to get streams")
@@ -275,13 +298,13 @@ def search(config: "Config", anime_titles: str, episode_range: str):
if not server: if not server:
print("Sth went wrong when fetching the episode") print("Sth went wrong when fetching the episode")
input("Enter to continue") input("Enter to continue")
stream_anime() stream_anime(anime)
return return
stream_link = filter_by_quality(config.quality, server["links"]) stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link: if not stream_link:
print("Quality not found") print("Quality not found")
input("Enter to continue") input("Enter to continue")
stream_anime() stream_anime(anime)
return return
link = stream_link["link"] link = stream_link["link"]
subtitles = server["subtitles"] subtitles = server["subtitles"]
@@ -297,7 +320,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
server = config.server server = config.server
else: else:
if config.use_fzf: if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ") server = fzf.run(servers_names, "Select an link")
elif config.use_rofi: elif config.use_rofi:
server = Rofi.run(servers_names, "Select an link") server = Rofi.run(servers_names, "Select an link")
else: else:
@@ -311,7 +334,7 @@ def search(config: "Config", anime_titles: str, episode_range: str):
if not stream_link: if not stream_link:
print("Quality not found") print("Quality not found")
input("Enter to continue") input("Enter to continue")
stream_anime() stream_anime(anime)
return return
link = stream_link["link"] link = stream_link["link"]
stream_headers = servers[server]["headers"] stream_headers = servers[server]["headers"]
@@ -357,6 +380,6 @@ def search(config: "Config", anime_titles: str, episode_range: str):
except IndexError as e: except IndexError as e:
print(e) print(e)
input("Enter to continue") input("Enter to continue")
stream_anime() stream_anime(anime)
stream_anime() stream_anime(anime)

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

@@ -1,11 +1,24 @@
import click import click
@click.command(help="Helper command to update fastanime to latest") @click.command(
help="Helper command to update fastanime to latest",
epilog="""
\b
\b\bExamples:
# update fastanime to latest
fastanime update
\b
# check for latest release
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
@@ -34,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

@@ -4,7 +4,12 @@ import os
from configparser import ConfigParser from configparser import ConfigParser
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..constants import USER_CONFIG_PATH, USER_DATA_PATH, USER_VIDEOS_DIR from ..constants import (
USER_CONFIG_PATH,
USER_DATA_PATH,
USER_VIDEOS_DIR,
USER_WATCH_HISTORY_PATH,
)
from ..libs.rofi import Rofi from ..libs.rofi import Rofi
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -16,49 +21,55 @@ class Config(object):
manga = False manga = False
sync_play = False sync_play = False
anime_list: list anime_list: list
watch_history: dict watch_history: dict = {}
fastanime_anilist_app_login_url = ( fastanime_anilist_app_login_url = (
"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_options = { default_config = {
"quality": "1080",
"auto_next": "False", "auto_next": "False",
"auto_select": "True", "auto_select": "True",
"sort_by": "search match", "cache_requests": "true",
"downloads_dir": USER_VIDEOS_DIR,
"translation_type": "sub",
"server": "top",
"continue_from_history": "True", "continue_from_history": "True",
"preferred_history": "local", "default_media_list_tracking": "None",
"use_python_mpv": "false", "downloads_dir": USER_VIDEOS_DIR,
"episode_complete_at": "80",
"ffmpegthumbnailer_seek_time": "-1",
"force_forward_tracking": "true",
"force_window": "immediate", "force_window": "immediate",
"preferred_language": "english",
"use_fzf": "False",
"preview": "False",
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best", "format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
"provider": "allanime",
"error": "3",
"icons": "false", "icons": "false",
"notification_duration": "2", "image_previews": "true",
"skip": "false",
"use_rofi": "false",
"rofi_theme": "",
"rofi_theme_input": "",
"rofi_theme_confirm": "",
"ffmpegthumnailer_seek_time": "-1",
"sub_lang": "eng",
"normalize_titles": "true", "normalize_titles": "true",
"notification_duration": "2",
"player": "mpv", "player": "mpv",
"preferred_history": "local",
"preferred_language": "english",
"preview": "False",
"provider": "allanime",
"quality": "1080",
"recent": "50",
"rofi_theme": "",
"rofi_theme_confirm": "",
"rofi_theme_input": "",
"server": "top",
"skip": "false",
"sort_by": "search match",
"sub_lang": "eng",
"translation_type": "sub",
"use_fzf": "False",
"use_persistent_provider_store": "false",
"use_python_mpv": "false",
"use_rofi": "false",
} }
def __init__(self) -> None: def __init__(self) -> None:
self.initialize_user_data() self.initialize_user_data_and_watch_history_recent_anime()
self.load_config() self.load_config()
def load_config(self): def load_config(self):
self.configparser = ConfigParser(self.default_options) self.configparser = ConfigParser(self.default_config)
self.configparser.add_section("stream") self.configparser.add_section("stream")
self.configparser.add_section("general") self.configparser.add_section("general")
self.configparser.add_section("anilist") self.configparser.add_section("anilist")
@@ -67,68 +78,102 @@ 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.downloads_dir = self.get_downloads_dir()
self.sub_lang = self.get_sub_lang()
self.provider = self.get_provider()
self.use_fzf = self.get_use_fzf()
self.use_rofi = self.get_use_rofi()
self.skip = self.get_skip()
self.icons = self.get_icons()
self.preview = self.get_preview()
self.translation_type = self.get_translation_type()
self.sort_by = self.get_sort_by()
self.continue_from_history = self.get_continue_from_history()
self.auto_next = self.get_auto_next() self.auto_next = self.get_auto_next()
self.normalize_titles = self.get_normalize_titles()
self.auto_select = self.get_auto_select() self.auto_select = self.get_auto_select()
self.use_python_mpv = self.get_use_mpv_mod() self.cache_requests = self.get_cache_requests()
self.quality = self.get_quality() self.continue_from_history = self.get_continue_from_history()
self.notification_duration = self.get_notification_duration() self.default_media_list_tracking = self.get_default_media_list_tracking()
self.error = self.get_error() self.downloads_dir = self.get_downloads_dir()
self.server = self.get_server() self.episode_complete_at = self.get_episode_complete_at()
self.format = self.get_format()
self.player = self.get_player()
self.force_window = self.get_force_window()
self.preferred_language = self.get_preferred_language()
self.preferred_history = self.get_preferred_history()
self.rofi_theme = self.get_rofi_theme()
Rofi.rofi_theme = self.rofi_theme
self.rofi_theme_input = self.get_rofi_theme_input()
Rofi.rofi_theme_input = self.rofi_theme_input
self.rofi_theme_confirm = self.get_rofi_theme_confirm()
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
self.ffmpegthumbnailer_seek_time = self.get_ffmpegthumnailer_seek_time() self.ffmpegthumbnailer_seek_time = self.get_ffmpegthumnailer_seek_time()
self.force_forward_tracking = self.get_force_forward_tracking()
self.force_window = self.get_force_window()
self.format = self.get_format()
self.icons = self.get_icons()
self.image_previews = self.get_image_previews()
self.normalize_titles = self.get_normalize_titles()
self.notification_duration = self.get_notification_duration()
self.player = self.get_player()
self.preferred_history = self.get_preferred_history()
self.preferred_language = self.get_preferred_language()
self.preview = self.get_preview()
self.provider = self.get_provider()
self.quality = self.get_quality()
self.recent = self.get_recent()
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
self.server = self.get_server()
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.watch_history: dict = self.user_data.get("watch_history", {})
self.anime_list: list = self.user_data.get("animelist", []) self.anime_list: list = self.user_data.get("animelist", [])
self.user: dict = self.user_data.get("user", {}) self.user: dict = self.user_data.get("user", {})
os.environ["CURRENT_FASTANIME_PROVIDER"] = self.provider
if not os.path.exists(USER_CONFIG_PATH): if not os.path.exists(USER_CONFIG_PATH):
with open(USER_CONFIG_PATH, "w", encoding="utf-8") as config: with open(USER_CONFIG_PATH, "w", encoding="utf-8") as config:
config.write(self.__repr__()) config.write(self.__repr__())
def set_fastanime_config_environs(self):
current_config = []
for key in self.default_config:
current_config.append((f"FASTANIME_{key.upper()}", str(getattr(self, key))))
os.environ.update(current_config)
def update_user(self, user): def update_user(self, user):
self.user = user self.user = user
self.user_data["user"] = user self.user_data["user"] = user
self._update_user_data() self._update_user_data()
def update_watch_history( def update_recent(self, recent_anime: list):
self, anime_id: int, episode: str, start_time="0", total_time="0" recent_anime_ids = []
_recent_anime = []
for anime in recent_anime[::-1]:
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(
self,
anime_id: int,
episode_no: str,
episode_stopped_at="0",
episode_total_length="0",
progress_tracking="prompt",
): ):
self.watch_history.update( self.watch_history.update(
{ {
str(anime_id): { str(anime_id): {
"episode": episode, "episode_no": episode_no,
"start_time": start_time, "episode_stopped_at": episode_stopped_at,
"total_time": total_time, "episode_total_length": episode_total_length,
"progress_tracking": progress_tracking,
} }
} }
) )
self.user_data["watch_history"] = self.watch_history with open(USER_WATCH_HISTORY_PATH, "w") as f:
self._update_user_data() json.dump(self.watch_history, f)
def initialize_user_data(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:
@@ -136,6 +181,13 @@ class Config(object):
self.user_data.update(user_data) self.user_data.update(user_data)
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
try:
if os.path.isfile(USER_WATCH_HISTORY_PATH):
with open(USER_WATCH_HISTORY_PATH, "r") as f:
watch_history = json.load(f)
self.watch_history.update(watch_history)
except Exception as e:
logger.error(e)
def _update_user_data(self): def _update_user_data(self):
"""method that updates the actual user data file""" """method that updates the actual user data file"""
@@ -149,7 +201,7 @@ class Config(object):
return self.configparser.get("general", "provider") return self.configparser.get("general", "provider")
def get_ffmpegthumnailer_seek_time(self): def get_ffmpegthumnailer_seek_time(self):
return self.configparser.getint("general", "ffmpegthumnailer_seek_time") return self.configparser.getint("general", "ffmpegthumbnailer_seek_time")
def get_preferred_language(self): def get_preferred_language(self):
return self.configparser.get("general", "preferred_language") return self.configparser.get("general", "preferred_language")
@@ -163,12 +215,18 @@ class Config(object):
def get_icons(self): def get_icons(self):
return self.configparser.getboolean("general", "icons") return self.configparser.getboolean("general", "icons")
def get_image_previews(self):
return self.configparser.getboolean("general", "image_previews")
def get_preview(self): def get_preview(self):
return self.configparser.getboolean("general", "preview") return self.configparser.getboolean("general", "preview")
def get_use_fzf(self): def get_use_fzf(self):
return self.configparser.getboolean("general", "use_fzf") 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 # rofi conifiguration
def get_use_rofi(self): def get_use_rofi(self):
return self.configparser.getboolean("general", "use_rofi") return self.configparser.getboolean("general", "use_rofi")
@@ -182,9 +240,21 @@ class Config(object):
def get_rofi_theme_confirm(self): def get_rofi_theme_confirm(self):
return self.configparser.get("general", "rofi_theme_confirm") 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): def get_normalize_titles(self):
return self.configparser.getboolean("general", "normalize_titles") return self.configparser.getboolean("general", "normalize_titles")
def get_recent(self):
return self.configparser.getint("general", "recent")
# --- stream section --- # --- stream section ---
def get_skip(self): def get_skip(self):
return self.configparser.getboolean("stream", "skip") return self.configparser.getboolean("stream", "skip")
@@ -204,8 +274,8 @@ class Config(object):
def get_notification_duration(self): def get_notification_duration(self):
return self.configparser.getint("general", "notification_duration") return self.configparser.getint("general", "notification_duration")
def get_error(self): def get_episode_complete_at(self):
return self.configparser.getint("stream", "error") return self.configparser.getint("stream", "episode_complete_at")
def get_force_window(self): def get_force_window(self):
return self.configparser.get("stream", "force_window") return self.configparser.get("stream", "force_window")
@@ -268,10 +338,10 @@ quality = {self.quality}
# 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, aniwatch] # can be [allanime, animepahe, hianime]
# 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
# aniwatch which is now hianime usually provides subs in different languuages and its servers are generally faster # hianime which is now hianime usually provides subs in different languuages and its servers are generally faster
provider = {self.provider} provider = {self.provider}
# Display language [english, romaji] # Display language [english, romaji]
@@ -291,6 +361,9 @@ downloads_dir = {self.downloads_dir}
# 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]
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
@@ -323,11 +396,41 @@ 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:
# aniwatch # hianime
# the values for this option are the short names for countries # the values for this option are the short names for countries
# 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}
# what is your default media list tracking [track/disabled/prompt]
# only affects your anilist anime list
# track - means your progress will always be reflected in your anilist anime list
# disabled - means progress tracking will no longer be reflected in your anime list
# prompt - means for every anime you will be prompted whether you want your progress to be tracked or not
default_media_list_tracking = {self.default_media_list_tracking}
# whether media list tracking should only be updated when the next episode is greater than the previous
# this affects only your anilist anime list
force_forward_tracking = {self.force_forward_tracking}
# whether to cache requests [true/false]
# this makes the experience better and more faster
# as data need not always be fetched from web server
# and instead can be gotten locally
# from the cached_requests_db
cache_requests = {self.cache_requests}
# whether to use a persistent store (basically a sqlitedb) for storing some data the provider requires
# to enable a seamless experience [true/false]
# this option exists primarily because i think it may help in the optimization
# of fastanime as a library in a website project
# for now i don't recommend changing it
# leave it as is
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]
# Auto continue from watch history [True/False] # Auto continue from watch history [True/False]
@@ -350,7 +453,7 @@ translation_type = {self.translation_type}
# what server to use for a particular provider # what server to use for a particular provider
# allanime: [dropbox, sharepoint, wetransfer, gogoanime, wixmp] # allanime: [dropbox, sharepoint, wetransfer, gogoanime, wixmp]
# animepahe: [kwik] # animepahe: [kwik]
# aniwatch: [HD1, HD2, StreamSB, StreamTape] # hianime: [HD1, HD2, StreamSB, StreamTape]
# '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
@@ -378,15 +481,19 @@ auto_select = {self.auto_select}
# so its disabled for now # so its disabled for now
skip = {self.skip} skip = {self.skip}
# the maximum delta time in minutes after which the episode should be considered as completed # at what percentage progress should the episode be considered as completed [0-100]
# used in the continue from time stamp # this value is used to determine whether to increment the current episode number and save it to your local list
error = {self.error} # so you can continue immediately to the next episode without select it the next time you decide to watch the anime
# it is also used to determine whether your anilist anime list should be updated or not
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 # Enable this one and you will be wonder why you did not discover fastanime sooner
# Since you basically don't have to close the player window to go to the next or previous episode, switch servers, change translation type or change to a given episode x # Since you basically don't have to close the player window
# to go to the next or previous episode, switch servers,
# change translation type or change to a given episode x
# so try it if you haven't already # so try it if you haven't already
# if you have any issues setting it up # if you have any issues setting it up
# don't be afraid to ask # don't be afraid to ask
@@ -410,7 +517,7 @@ force_window = immediate
# only works for downloaded anime if: # only works for downloaded anime if:
# provider=allanime, server=gogoanime # provider=allanime, server=gogoanime
# provider=allanime, server=wixmp # provider=allanime, server=wixmp
# provider=aniwatch # provider=hianime
# this is because they provider a m3u8 file that contans multiple quality streams # this is because they provider a m3u8 file that contans multiple quality streams
format = {self.format} format = {self.format}

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import os import os
import random import random
from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from click import clear from click import clear
@@ -35,8 +34,7 @@ if TYPE_CHECKING:
from ..utils.tools import FastAnimeRuntimeState from ..utils.tools import FastAnimeRuntimeState
# TODO: make the error handling more sane def calculate_percentage_completion(start_time, end_time):
def calculate_time_delta(start_time, end_time):
"""helper function used to calculate the difference between two timestamps in seconds """helper function used to calculate the difference between two timestamps in seconds
Args: Args:
@@ -46,16 +44,12 @@ def calculate_time_delta(start_time, end_time):
Returns: Returns:
[TODO:return] [TODO:return]
""" """
time_format = "%H:%M:%S"
# Convert string times to datetime objects start = start_time.split(":")
start = datetime.strptime(start_time, time_format) end = end_time.split(":")
end = datetime.strptime(end_time, time_format) start_secs = int(start[0]) * 3600 + int(start[1]) * 60 + int(start[2])
end_secs = int(end[0]) * 3600 + int(end[1]) * 60 + int(end[2])
# Calculate the difference return start_secs / end_secs * 100
delta = end - start
return delta
def media_player_controls( def media_player_controls(
@@ -103,10 +97,12 @@ def media_player_controls(
) )
if ( if (
config.watch_history[str(anime_id_anilist)]["episode"] config.watch_history[str(anime_id_anilist)]["episode_no"]
== current_episode_number == current_episode_number
): ):
start_time = config.watch_history[str(anime_id_anilist)]["start_time"] start_time = config.watch_history[str(anime_id_anilist)][
"episode_stopped_at"
]
print("[green]Continuing from:[/] ", start_time) print("[green]Continuing from:[/] ", start_time)
else: else:
start_time = "0" start_time = "0"
@@ -171,9 +167,10 @@ def media_player_controls(
if stop_time == "0" or total_time == "0": if stop_time == "0" or total_time == "0":
episode = str(int(current_episode_number) + 1) episode = str(int(current_episode_number) + 1)
else: else:
error = 5 * 60 percentage_completion_of_episode = calculate_percentage_completion(
delta = calculate_time_delta(stop_time, total_time) stop_time, total_time
if delta.total_seconds() > error: )
if percentage_completion_of_episode < config.episode_complete_at:
episode = current_episode_number episode = current_episode_number
else: else:
episode = str(int(current_episode_number) + 1) episode = str(int(current_episode_number) + 1)
@@ -181,28 +178,34 @@ def media_player_controls(
total_time = "0" total_time = "0"
clear() clear()
config.update_watch_history(anime_id_anilist, episode, stop_time, total_time) config.media_list_track(
anime_id_anilist,
episode_no=episode,
episode_stopped_at=stop_time,
episode_total_length=total_time,
progress_tracking=fastanime_runtime_state.progress_tracking,
)
media_player_controls(config, fastanime_runtime_state) media_player_controls(config, fastanime_runtime_state)
def _next_episode(): def _next_episode():
"""watch the next episode""" """watch the next episode"""
# ensures you dont accidentally erase your progress for an in complete episode # ensures you dont accidentally erase your progress for an in complete episode
stop_time = config.watch_history.get(str(anime_id_anilist), {}).get( stop_time = config.watch_history.get(str(anime_id_anilist), {}).get(
"start_time", "0" "episode_stopped_at", "0"
) )
total_time = config.watch_history.get(str(anime_id_anilist), {}).get( total_time = config.watch_history.get(str(anime_id_anilist), {}).get(
"total_time", "0" "episode_total_length", "0"
) )
# compute if the episode is actually completed # compute if the episode is actually completed
error = config.error * 60
if stop_time == "0" or total_time == "0": if stop_time == "0" or total_time == "0":
dt = 0 percentage_completion_of_episode = 0
else: else:
delta = calculate_time_delta(stop_time, total_time) percentage_completion_of_episode = calculate_percentage_completion(
dt = delta.total_seconds() stop_time, total_time
if dt > error: )
if percentage_completion_of_episode < config.episode_complete_at:
if config.auto_next: if config.auto_next:
if config.use_rofi: if config.use_rofi:
if not Rofi.confirm( if not Rofi.confirm(
@@ -236,7 +239,11 @@ def media_player_controls(
] ]
# update user config # update user config
config.update_watch_history(anime_id_anilist, available_episodes[next_episode]) config.media_list_track(
anime_id_anilist,
episode_no=available_episodes[next_episode],
progress_tracking=fastanime_runtime_state.progress_tracking,
)
# call interface # call interface
provider_anime_episode_servers_menu(config, fastanime_runtime_state) provider_anime_episode_servers_menu(config, fastanime_runtime_state)
@@ -260,7 +267,11 @@ def media_player_controls(
] ]
# update user config # update user config
config.update_watch_history(anime_id_anilist, available_episodes[prev_episode]) config.media_list_track(
anime_id_anilist,
episode_no=available_episodes[prev_episode],
progress_tracking=fastanime_runtime_state.progress_tracking,
)
# call interface # call interface
provider_anime_episode_servers_menu(config, fastanime_runtime_state) provider_anime_episode_servers_menu(config, fastanime_runtime_state)
@@ -273,7 +284,7 @@ def media_player_controls(
# prompt for new quality # prompt for new quality
if config.use_fzf: if config.use_fzf:
quality = fzf.run( quality = fzf.run(
options, prompt="Select Quality:", header="Quality Options" options, prompt="Select Quality", header="Quality Options"
) )
elif config.use_rofi: elif config.use_rofi:
quality = Rofi.run(options, "Select Quality") quality = Rofi.run(options, "Select Quality")
@@ -291,7 +302,7 @@ def media_player_controls(
options = ["sub", "dub"] options = ["sub", "dub"]
if config.use_fzf: if config.use_fzf:
translation_type = fzf.run( translation_type = fzf.run(
options, prompt="Select Translation Type: ", header="Lang Options" options, prompt="Select Translation Type", header="Lang Options"
).lower() ).lower()
elif config.use_rofi: elif config.use_rofi:
translation_type = Rofi.run(options, "Select Translation Type") translation_type = Rofi.run(options, "Select Translation Type")
@@ -337,7 +348,7 @@ def media_player_controls(
if config.use_fzf: if config.use_fzf:
action = fzf.run( action = fzf.run(
choices, choices,
prompt="Select Action:", prompt="Select Action",
) )
elif config.use_rofi: elif config.use_rofi:
action = Rofi.run(choices, "Select Action") action = Rofi.run(choices, "Select Action")
@@ -376,7 +387,7 @@ def provider_anime_episode_servers_menu(
with Progress() as progress: with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None) progress.add_task("Fetching Episode Streams...", total=None)
episode_streams_generator = anime_provider.get_episode_streams( episode_streams_generator = anime_provider.get_episode_streams(
provider_anime, provider_anime["id"],
current_episode_number, current_episode_number,
translation_type, translation_type,
) )
@@ -435,7 +446,7 @@ def provider_anime_episode_servers_menu(
if config.use_fzf: if config.use_fzf:
server_name = fzf.run( server_name = fzf.run(
choices, choices,
prompt="Select Server: ", prompt="Select Server",
header="Servers", header="Servers",
) )
elif config.use_rofi: elif config.use_rofi:
@@ -496,21 +507,12 @@ def provider_anime_episode_servers_menu(
"[bold magenta] Episode: [/]", "[bold magenta] Episode: [/]",
current_episode_number, current_episode_number,
) )
# -- update anilist progress if user --
if config.user and current_episode_number:
AniList.update_anime_list(
{
"mediaId": anime_id_anilist,
"progress": int(float(current_episode_number)),
}
)
# try to get the timestamp you left off from if available # try to get the timestamp you left off from if available
start_time = config.watch_history.get(str(anime_id_anilist), {}).get( start_time = config.watch_history.get(str(anime_id_anilist), {}).get(
"start_time", "0" "episode_stopped_at", "0"
) )
episode_in_history = config.watch_history.get(str(anime_id_anilist), {}).get( episode_in_history = config.watch_history.get(str(anime_id_anilist), {}).get(
"episode", "" "episode_no", ""
) )
if start_time != "0" and episode_in_history == current_episode_number: if start_time != "0" and episode_in_history == current_episode_number:
print("[green]Continuing from:[/] ", start_time) print("[green]Continuing from:[/] ", start_time)
@@ -537,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(
[
*config.user_data["recent_anime"],
fastanime_runtime_state.selected_anime_anilist,
]
)
print("Updating recent anime...")
if config.sync_play: if config.sync_play:
from ..utils.syncplay import SyncPlayer from ..utils.syncplay import SyncPlayer
@@ -593,11 +603,36 @@ def provider_anime_episode_servers_menu(
next_episode = len(available_episodes) - 1 next_episode = len(available_episodes) - 1
episode = available_episodes[next_episode] episode = available_episodes[next_episode]
else: else:
error = config.error * 60 percentage_completion_of_episode = calculate_percentage_completion(
delta = calculate_time_delta(stop_time, total_time) stop_time, total_time
if delta.total_seconds() > error: )
if percentage_completion_of_episode < config.episode_complete_at:
episode = current_episode_number episode = current_episode_number
else: else:
# -- update anilist progress if user --
remote_progress = (
fastanime_runtime_state.selected_anime_anilist["mediaListEntry"] or {}
).get("progress")
disable_anilist_update = False
if remote_progress:
if (
float(remote_progress) > float(current_episode_number)
and config.force_forward_tracking
):
disable_anilist_update = True
if (
fastanime_runtime_state.progress_tracking == "track"
and config.user
and not disable_anilist_update
and current_episode_number
):
AniList.update_anime_list(
{
"mediaId": anime_id_anilist,
"progress": int(float(current_episode_number)),
}
)
# 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):
@@ -606,11 +641,12 @@ def provider_anime_episode_servers_menu(
stop_time = "0" stop_time = "0"
total_time = "0" total_time = "0"
config.update_watch_history( config.media_list_track(
anime_id_anilist, anime_id_anilist,
episode, episode_no=episode,
start_time=stop_time, episode_stopped_at=stop_time,
total_time=total_time, episode_total_length=total_time,
progress_tracking=fastanime_runtime_state.progress_tracking,
) )
# switch to controls # switch to controls
@@ -652,7 +688,7 @@ def provider_anime_episodes_menu(
# the user watch history thats locally available # the user watch history thats locally available
# will be preferred over remote # will be preferred over remote
if ( if (
user_watch_history.get(str(anime_id_anilist), {}).get("episode") user_watch_history.get(str(anime_id_anilist), {}).get("episode_no")
in total_episodes in total_episodes
): ):
if ( if (
@@ -660,7 +696,7 @@ def provider_anime_episodes_menu(
or not selected_anime_anilist["mediaListEntry"] or not selected_anime_anilist["mediaListEntry"]
): ):
current_episode_number = user_watch_history[str(anime_id_anilist)][ current_episode_number = user_watch_history[str(anime_id_anilist)][
"episode" "episode_no"
] ]
else: else:
current_episode_number = str( current_episode_number = str(
@@ -705,7 +741,7 @@ def provider_anime_episodes_menu(
) )
if config.use_fzf: if config.use_fzf:
current_episode_number = fzf.run( current_episode_number = fzf.run(
choices, prompt="Select Episode:", header=anime_title, preview=preview choices, prompt="Select Episode", header=anime_title, preview=preview
) )
elif config.use_rofi: elif config.use_rofi:
current_episode_number = Rofi.run(choices, "Select Episode") current_episode_number = Rofi.run(choices, "Select Episode")
@@ -765,6 +801,39 @@ def fetch_anime_episode(
# #
# ---- ANIME PROVIDER SEARCH RESULTS MENU ---- # ---- ANIME PROVIDER SEARCH RESULTS MENU ----
# #
def set_prefered_progress_tracking(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState", update=False
):
if (
fastanime_runtime_state.progress_tracking == ""
or update
or fastanime_runtime_state.progress_tracking == "prompt"
):
if config.default_media_list_tracking == "track":
fastanime_runtime_state.progress_tracking = "track"
elif config.default_media_list_tracking == "disabled":
fastanime_runtime_state.progress_tracking = "disabled"
else:
options = ["disabled", "track"]
if config.use_fzf:
fastanime_runtime_state.progress_tracking = fzf.run(
options,
"Enter your preferred progress tracking for the current anime",
)
elif config.use_rofi:
fastanime_runtime_state.progress_tracking = Rofi.run(
options,
"Enter your preferred progress tracking for the current anime",
)
else:
fastanime_runtime_state.progress_tracking = fuzzy_inquirer(
options,
"Enter your preferred progress tracking for the current anime",
)
def anime_provider_search_results_menu( def anime_provider_search_results_menu(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState" config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
): ):
@@ -830,7 +899,7 @@ def anime_provider_search_results_menu(
if config.use_fzf: if config.use_fzf:
provider_anime_title = fzf.run( provider_anime_title = fzf.run(
choices, choices,
prompt="Select Search Result:", prompt="Select Search Result",
header="Anime Search Results", header="Anime Search Results",
) )
@@ -852,6 +921,11 @@ def anime_provider_search_results_menu(
fastanime_runtime_state.provider_anime_search_result = provider_search_results[ fastanime_runtime_state.provider_anime_search_result = provider_search_results[
provider_anime_title provider_anime_title
] ]
fastanime_runtime_state.progress_tracking = config.watch_history.get(
str(fastanime_runtime_state.selected_anime_id_anilist), {}
).get("progress_tracking", "prompt")
set_prefered_progress_tracking(config, fastanime_runtime_state)
fetch_anime_episode(config, fastanime_runtime_state) fetch_anime_episode(config, fastanime_runtime_state)
@@ -900,7 +974,7 @@ def media_actions_menu(
media_actions_menu(config, fastanime_runtime_state) media_actions_menu(config, fastanime_runtime_state)
else: else:
if not config.use_rofi: if not config.use_rofi:
print("no trailer available :confused:") print("no trailer available :confused")
input("Enter to continue...") input("Enter to continue...")
else: else:
if not Rofi.confirm("No trailler found!!Enter to continue"): if not Rofi.confirm("No trailler found!!Enter to continue"):
@@ -1033,7 +1107,7 @@ def media_actions_menu(
options = ["Sub", "Dub"] options = ["Sub", "Dub"]
if config.use_fzf: if config.use_fzf:
translation_type = fzf.run( translation_type = fzf.run(
options, prompt="Select Translation Type:", header="Language Options" options, prompt="Select Translation Type", header="Language Options"
) )
elif config.use_rofi: elif config.use_rofi:
translation_type = Rofi.run(options, "Select Translation Type") translation_type = Rofi.run(options, "Select Translation Type")
@@ -1062,10 +1136,10 @@ def media_actions_menu(
if config.use_fzf: if config.use_fzf:
player = fzf.run( player = fzf.run(
options, options,
prompt="Select Player:", prompt="Select Player",
) )
elif config.use_rofi: elif config.use_rofi:
player = Rofi.run(options, "Select Player: ") player = Rofi.run(options, "Select Player")
else: else:
player = fuzzy_inquirer( player = fuzzy_inquirer(
options, options,
@@ -1202,7 +1276,7 @@ def media_actions_menu(
options = list(anime_sources.keys()) options = list(anime_sources.keys())
if config.use_fzf: if config.use_fzf:
provider = fzf.run( provider = fzf.run(
options, prompt="Select Translation Type:", header="Language Options" options, prompt="Select Translation Type", header="Language Options"
) )
elif config.use_rofi: elif config.use_rofi:
provider = Rofi.run(options, "Select Translation Type") provider = Rofi.run(options, "Select Translation Type")
@@ -1241,12 +1315,19 @@ def media_actions_menu(
config.continue_from_history = False config.continue_from_history = False
anime_provider_search_results_menu(config, fastanime_runtime_state) anime_provider_search_results_menu(config, fastanime_runtime_state)
def _set_progress_tracking(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
set_prefered_progress_tracking(config, fastanime_runtime_state, update=True)
media_actions_menu(config, fastanime_runtime_state)
icons = config.icons icons = config.icons
options = { options = {
f"{'📽️ ' if icons else ''}Stream ({progress}/{episodes_total})": _stream_anime, f"{'📽️ ' if icons else ''}Stream ({progress}/{episodes_total})": _stream_anime,
f"{'📽️ ' if icons else ''}Episodes": _select_episode_to_stream, f"{'📽️ ' if icons else ''}Episodes": _select_episode_to_stream,
f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer, f"{'📼 ' if icons else ''}Watch Trailer": _watch_trailer,
f"{'' if icons else ''}Score Anime": _score_anime, f"{'' if icons else ''}Score Anime": _score_anime,
f"{'' if icons else ''}Progress Tracking": _set_progress_tracking,
f"{'📥 ' if icons else ''}Add to List": _add_to_list, f"{'📥 ' if icons else ''}Add to List": _add_to_list,
f"{'📤 ' if icons else ''}Remove from List": _remove_from_list, f"{'📤 ' if icons else ''}Remove from List": _remove_from_list,
f"{'📖 ' if icons else ''}View Info": _view_info, f"{'📖 ' if icons else ''}View Info": _view_info,
@@ -1261,7 +1342,7 @@ def media_actions_menu(
} }
choices = list(options.keys()) choices = list(options.keys())
if config.use_fzf: if config.use_fzf:
action = fzf.run(choices, prompt="Select Action:", header="Anime Menu") action = fzf.run(choices, prompt="Select Action", header="Anime Menu")
elif config.use_rofi: elif config.use_rofi:
action = Rofi.run(choices, "Select Action") action = Rofi.run(choices, "Select Action")
else: else:
@@ -1329,14 +1410,14 @@ def anilist_results_menu(
preview = get_fzf_anime_preview(search_results, anime_data.keys()) preview = get_fzf_anime_preview(search_results, anime_data.keys())
selected_anime_title = fzf.run( selected_anime_title = fzf.run(
choices, choices,
prompt="Select Anime: ", prompt="Select Anime",
header="Search Results", header="Search Results",
preview=preview, preview=preview,
) )
else: else:
selected_anime_title = fzf.run( selected_anime_title = fzf.run(
choices, choices,
prompt="Select Anime: ", prompt="Select Anime",
header="Search Results", header="Search Results",
) )
elif config.use_rofi: elif config.use_rofi:
@@ -1347,7 +1428,7 @@ def anilist_results_menu(
choices = [] choices = []
for title in anime_data.keys(): for title in anime_data.keys():
icon_path = os.path.join(IMAGES_CACHE_DIR, title) icon_path = os.path.join(IMAGES_CACHE_DIR, title)
choices.append(f"{title}\0icon\x1f{icon_path}") choices.append(f"{title}\0icon\x1f{icon_path}.png")
choices.append("Back") choices.append("Back")
selected_anime_title = Rofi.run_with_icons(choices, "Select Anime") selected_anime_title = Rofi.run_with_icons(choices, "Select Anime")
else: else:
@@ -1489,6 +1570,9 @@ 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
@@ -1516,6 +1600,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
), ),
@@ -1551,7 +1636,7 @@ def fastanime_main_menu(
if config.use_fzf: if config.use_fzf:
action = fzf.run( action = fzf.run(
choices, choices,
prompt="Select Action: ", prompt="Select Action",
header="Anilist Menu", header="Anilist Menu",
) )
elif config.use_rofi: elif config.use_rofi:

View File

@@ -9,7 +9,7 @@ from threading import Thread
import requests import requests
from yt_dlp.utils import clean_html, sanitize_filename from yt_dlp.utils import clean_html, sanitize_filename
from ...constants import APP_CACHE_DIR from ...constants import APP_CACHE_DIR, S_PLATFORM
from ...libs.anilist.types import AnilistBaseMediaDataSchema from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...Utility import anilist_data_helper from ...Utility import anilist_data_helper
from ..utils.scripts import fzf_preview from ..utils.scripts import fzf_preview
@@ -46,7 +46,9 @@ 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
SEPARATOR_COLOR = 208, 208, 208
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):
os.mkdir(IMAGES_CACHE_DIR) os.mkdir(IMAGES_CACHE_DIR)
@@ -63,7 +65,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}", "wb") as f: with open(f"{IMAGES_CACHE_DIR}/{file_name}.png", "wb") as f:
f.write(image.content) f.write(image.content)
@@ -91,18 +93,16 @@ def write_search_results(
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 # NOTE: Will probably make this a configuraable option
HEADER_COLOR = 215, 0, 95
SEPARATOR_COLOR = 208, 208, 208
SEPARATOR_WIDTH = 30
# 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 = {}
for anime, title in zip(anilist_results, titles): for anime, title in zip(anilist_results, titles):
# actual image url # actual image url
if os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower() == "true":
image_url = anime["coverImage"]["large"] image_url = anime["coverImage"]["large"]
future_to_task[executor.submit(save_image_from_url, image_url, title)] = ( future_to_task[
image_url executor.submit(save_image_from_url, image_url, title)
) ] = image_url
mediaListName = "Not in any of your lists" mediaListName = "Not in any of your lists"
progress = "UNKNOWN" progress = "UNKNOWN"
@@ -111,28 +111,57 @@ def write_search_results(
progress = anime_list["progress"] progress = anime_list["progress"]
# handle the text data # handle the text data
template = f""" template = f"""
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)} ll=2
{get_true_fg('Title(jp):',*HEADER_COLOR)} {anime['title']['romaji']} while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
{get_true_fg('Title(eng):',*HEADER_COLOR)} {anime['title']['english']} echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
{get_true_fg('Popularity:',*HEADER_COLOR)} {anime['popularity']} ((ll++))
{get_true_fg('Favourites:',*HEADER_COLOR)} {anime['favourites']} done
{get_true_fg('Status:',*HEADER_COLOR)} {anime['status']} echo
{get_true_fg('Episodes:',*HEADER_COLOR)} {anime['episodes']} echo "{get_true_fg('Title(jp):',*HEADER_COLOR)} {(anime['title']['romaji'] or "").replace('"',SINGLE_QUOTE)}"
{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres'])} echo "{get_true_fg('Title(eng):',*HEADER_COLOR)} {(anime['title']['english'] or "").replace('"',SINGLE_QUOTE)}"
{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode'])} ll=2
{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate'])} while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate'])} echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)} ((ll++))
{get_true_fg('Media List:',*HEADER_COLOR)} {mediaListName} done
{get_true_fg('Progress:',*HEADER_COLOR)} {progress} echo
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)} echo "{get_true_fg('Popularity:',*HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime['popularity'])}"
{get_true_fg('Description:',*HEADER_COLOR)} echo "{get_true_fg('Favourites:',*HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime['favourites'])}"
echo "{get_true_fg('Status:',*HEADER_COLOR)} {str(anime['status']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres']).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
echo "{get_true_fg('Episodes:',*HEADER_COLOR)} {(anime['episodes']) or 'UNKNOWN'}"
echo "{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate']).replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate']).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
echo "{get_true_fg('Media List:',*HEADER_COLOR)} {mediaListName.replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Progress:',*HEADER_COLOR)} {progress}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
echo
# echo "{get_true_fg('Description:',*HEADER_COLOR).replace('"',SINGLE_QUOTE)}"
""" """
template = textwrap.dedent(template) template = textwrap.dedent(template)
template = f""" template = f"""
{template} {template}
echo "
{textwrap.fill(clean_html( {textwrap.fill(clean_html(
str(anime['description'])), width=45)} (anime['description']) or "").replace('"',SINGLE_QUOTE), width=45)}
"
""" """
future_to_task[executor.submit(save_info_from_str, template, title)] = title future_to_task[executor.submit(save_info_from_str, template, title)] = title
@@ -212,8 +241,8 @@ def get_fzf_manga_preview(manga_results, workers=None, wait=False):
background_worker = Thread( background_worker = Thread(
target=_worker, target=_worker,
) )
# ensure images and info exists
background_worker.daemon = True background_worker.daemon = True
# ensure images and info exists
background_worker.start() background_worker.start()
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script # the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
@@ -270,8 +299,13 @@ def get_fzf_episode_preview(
] = image_url ] = image_url
template = textwrap.dedent( template = textwrap.dedent(
f""" f"""
{get_true_fg('Anime Title:',*HEADER_COLOR)} {anilist_result['title']['romaji'] or anilist_result['title']['english']} ll=2
{get_true_fg('Episode Title:',*HEADER_COLOR)} {episode_title} while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
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('Episode Title:',*HEADER_COLOR)} {str(episode_title).replace('"',SINGLE_QUOTE)}"
""" """
) )
future_to_url[ future_to_url[
@@ -289,22 +323,56 @@ def get_fzf_episode_preview(
background_worker = Thread( background_worker = Thread(
target=_worker, target=_worker,
) )
# ensure images and info exists
background_worker.daemon = True background_worker.daemon = True
# ensure images and info exists
background_worker.start() background_worker.start()
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script # the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
os.environ["SHELL"] = shutil.which("bash") or "bash" os.environ["SHELL"] = shutil.which("bash") or "bash"
if S_PLATFORM == "win32":
preview = """ preview = """
%s %s
if [ -s %s/{} ]; then fzf-preview %s/{} title={}
else echo Loading... show_image_previews="%s"
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [ $show_image_previews = "true" ];then
if [ -s "%s\\\\\\${title}.png" ]; then
if command -v "chafa">/dev/null;then
chafa -s $dim "%s\\\\\\${title}.png"
else
echo please install chafa to enjoy image previews
fi fi
if [ -s %s/{} ]; then cat %s/{} echo
else
echo Loading...
fi
fi
if [ -s "%s\\\\\\$title" ]; then source "%s\\\\\\$title"
else echo Loading... else echo Loading...
fi fi
""" % ( """ % (
fzf_preview, fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
)
else:
preview = """
%s
show_image_previews="%s"
if [ $show_image_previews = "true" ];then
if [ -s %s/{} ]; then fzf-preview %s/{}
else echo Loading...
fi
fi
if [ -s %s/{} ]; then source %s/{}
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
IMAGES_CACHE_DIR, IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR, IMAGES_CACHE_DIR,
ANIME_INFO_CACHE_DIR, ANIME_INFO_CACHE_DIR,
@@ -329,7 +397,7 @@ def get_fzf_anime_preview(
THe fzf preview script to use THe fzf preview script to use
""" """
# ensure images and info exists # ensure images and info exists
from ...constants import S_PLATFORM
background_worker = Thread( background_worker = Thread(
target=write_search_results, args=(anilist_results, titles) target=write_search_results, args=(anilist_results, titles)
) )
@@ -342,34 +410,47 @@ def get_fzf_anime_preview(
preview = """ preview = """
%s %s
title={} title={}
show_image_previews="%s"
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES} dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [ -s "%s\\\\\\$title" ]; then if [ $show_image_previews = "true" ];then
if command -v chafa >/dev/null;then if [ -s "%s\\\\\\${title}.png" ]; then
chafa -f kitty -s $dim "%s\\\\\\$title" if command -v "chafa">/dev/null;then
chafa -s $dim "%s\\\\\\${title}.png"
else
echo please install chafa to enjoy image previews
fi fi
else echo Loading... echo
else
echo Loading...
fi fi
if [ -s "%s\\\\\\$title" ]; then cat "%s\\\\\\$title" fi
if [ -s "%s\\\\\\$title" ]; then source "%s\\\\\\$title"
else echo Loading... else echo Loading...
fi fi
""" % ( """ % (
fzf_preview, fzf_preview,
IMAGES_CACHE_DIR.replace("\\","\\\\\\"), os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
IMAGES_CACHE_DIR.replace("\\","\\\\\\"), IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\","\\\\\\"), IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\","\\\\\\"), ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
) )
else: else:
preview = """ preview = """
%s %s
if [ -s %s/{} ]; then fzf-preview %s/{} title={}
show_image_previews="%s"
if [ $show_image_previews = "true" ];then
if [ -s "%s/${title}.png" ]; then fzf-preview "%s/${title}.png"
else echo Loading... else echo Loading...
fi fi
if [ -s %s/{} ]; then cat %s/{} fi
if [ -s "%s/$title" ]; then source "%s/$title"
else echo Loading... else echo Loading...
fi fi
""" % ( """ % (
fzf_preview, fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
IMAGES_CACHE_DIR, IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR, IMAGES_CACHE_DIR,
ANIME_INFO_CACHE_DIR, ANIME_INFO_CACHE_DIR,

View File

@@ -63,6 +63,19 @@ def run_mpv(
# Regex to check if the link is a YouTube URL # Regex to check if the link is a YouTube URL
youtube_regex = r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+" youtube_regex = r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+"
if link.endswith(".torrent"):
WEBTORRENT_CLI = shutil.which("webtorrent")
if not WEBTORRENT_CLI:
import time
print(
"webtorrent cli is not installed which is required for downloading and streaming from nyaa\nplease install it or use another provider"
)
time.sleep(120)
return "0", "0"
cmd = [WEBTORRENT_CLI, link, f"--{player}"]
subprocess.run(cmd)
return "0", "0"
if player == "vlc": if player == "vlc":
VLC = shutil.which("vlc") VLC = shutil.which("vlc")
if not VLC and not S_PLATFORM == "win32": if not VLC and not S_PLATFORM == "win32":

View File

@@ -68,7 +68,11 @@ class MpvPlayer(object):
current_episode_number = ( current_episode_number = (
fastanime_runtime_state.provider_current_episode_number fastanime_runtime_state.provider_current_episode_number
) )
config.update_watch_history(anime_id_anilist, str(current_episode_number)) config.media_list_track(
anime_id_anilist,
episode_no=str(current_episode_number),
progress_tracking=fastanime_runtime_state.progress_tracking,
)
elif type == "reload": elif type == "reload":
if current_episode_number not in total_episodes: if current_episode_number not in total_episodes:
self.mpv_player.show_text("Episode not available") self.mpv_player.show_text("Episode not available")
@@ -84,7 +88,11 @@ class MpvPlayer(object):
self.mpv_player.show_text(f"Fetching episode {ep_no}") self.mpv_player.show_text(f"Fetching episode {ep_no}")
current_episode_number = ep_no current_episode_number = ep_no
config.update_watch_history(anime_id_anilist, str(ep_no)) config.media_list_track(
anime_id_anilist,
episode_no=str(ep_no),
progress_tracking=fastanime_runtime_state.progress_tracking,
)
fastanime_runtime_state.provider_current_episode_number = str(ep_no) fastanime_runtime_state.provider_current_episode_number = str(ep_no)
else: else:
self.mpv_player.show_text("Fetching previous episode...") self.mpv_player.show_text("Fetching previous episode...")
@@ -97,7 +105,11 @@ class MpvPlayer(object):
current_episode_number = ( current_episode_number = (
fastanime_runtime_state.provider_current_episode_number fastanime_runtime_state.provider_current_episode_number
) )
config.update_watch_history(anime_id_anilist, str(current_episode_number)) config.media_list_track(
anime_id_anilist,
episode_no=str(current_episode_number),
progress_tracking=fastanime_runtime_state.progress_tracking,
)
# update episode progress # update episode progress
if config.user and current_episode_number: if config.user and current_episode_number:
AniList.update_anime_list( AniList.update_anime_list(
@@ -108,7 +120,7 @@ class MpvPlayer(object):
) )
# get them juicy streams # get them juicy streams
episode_streams = anime_provider.get_episode_streams( episode_streams = anime_provider.get_episode_streams(
provider_anime, provider_anime["id"],
current_episode_number, current_episode_number,
translation_type, translation_type,
) )

View File

@@ -19,6 +19,7 @@ class FastAnimeRuntimeState(object):
provider_anime_title: str provider_anime_title: str
provider_anime: "Anime" provider_anime: "Anime"
provider_anime_search_result: "SearchResult" provider_anime_search_result: "SearchResult"
progress_tracking: str = ""
selected_anime_anilist: "AnilistBaseMediaDataSchema" selected_anime_anilist: "AnilistBaseMediaDataSchema"
selected_anime_id_anilist: int selected_anime_id_anilist: int
@@ -36,8 +37,13 @@ def exit_app(exit_code=0, *args):
console = Console() console = Console()
if not console.is_terminal: if not console.is_terminal:
try:
from plyer import notification from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
exit(1)
notification.notify( notification.notify(
app_name=APP_NAME, app_name=APP_NAME,
app_icon=ICON_PATH, app_icon=ICON_PATH,

View File

@@ -25,7 +25,7 @@ else:
# ----- user configs and data ----- # ----- user configs and data -----
S_PLATFORM = sys.platform S_PLATFORM = sys.platform
APP_DATA_DIR = click.get_app_dir(APP_NAME,roaming=False) APP_DATA_DIR = click.get_app_dir(APP_NAME, roaming=False)
if S_PLATFORM == "win32": if S_PLATFORM == "win32":
# app data # app data
# app_data_dir_base = os.getenv("LOCALAPPDATA") # app_data_dir_base = os.getenv("LOCALAPPDATA")
@@ -78,6 +78,7 @@ Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
# useful paths # useful paths
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json") USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
USER_WATCH_HISTORY_PATH = os.path.join(APP_DATA_DIR, "watch_history.json")
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini") USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log") LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log")

View File

@@ -225,6 +225,7 @@ query ($userId: Int, $status: MediaListStatus, $type: MediaType) {
averageScore averageScore
episodes episodes
genres genres
synonyms
studios { studios {
nodes { nodes {
name name
@@ -369,6 +370,7 @@ query($query:String,%s){
averageScore averageScore
episodes episodes
genres genres
synonyms
studios { studios {
nodes { nodes {
name name
@@ -428,6 +430,7 @@ query ($type: MediaType) {
favourites favourites
averageScore averageScore
genres genres
synonyms
episodes episodes
description description
studios { studios {
@@ -503,6 +506,7 @@ query ($type: MediaType) {
episodes episodes
description description
genres genres
synonyms
studios { studios {
nodes { nodes {
name name
@@ -566,6 +570,7 @@ query ($type: MediaType) {
averageScore averageScore
description description
genres genres
synonyms
studios { studios {
nodes { nodes {
name name
@@ -624,6 +629,7 @@ query ($type: MediaType) {
description description
episodes episodes
genres genres
synonyms
mediaListEntry { mediaListEntry {
status status
id id
@@ -698,6 +704,7 @@ query ($type: MediaType) {
averageScore averageScore
description description
genres genres
synonyms
episodes episodes
studios { studios {
nodes { nodes {
@@ -759,6 +766,7 @@ query ($type: MediaType) {
id id
} }
genres genres
synonyms
averageScore averageScore
popularity popularity
streamingEpisodes { streamingEpisodes {
@@ -862,6 +870,7 @@ query ($id: Int, $type: MediaType) {
id id
} }
genres genres
synonyms
averageScore averageScore
popularity popularity
streamingEpisodes { streamingEpisodes {
@@ -954,6 +963,7 @@ query ($page: Int, $type: MediaType) {
favourites favourites
averageScore averageScore
genres genres
synonyms
episodes episodes
description description
studios { studios {

View File

@@ -1,11 +1,11 @@
from .allanime.constants import SERVERS_AVAILABLE as ALLANIME_SERVERS from .allanime.constants import SERVERS_AVAILABLE as ALLANIME_SERVERS
from .animepahe.constants import SERVERS_AVAILABLE as ANIMEPAHESERVERS from .animepahe.constants import SERVERS_AVAILABLE as ANIMEPAHE_SERVERS
from .aniwatch.constants import SERVERS_AVAILABLE as ANIWATCHSERVERS from .hianime.constants import SERVERS_AVAILABLE as HIANIME_SERVERS
anime_sources = { anime_sources = {
"allanime": "api.AllAnimeAPI", "allanime": "api.AllAnimeAPI",
"animepahe": "api.AnimePaheApi", "animepahe": "api.AnimePaheApi",
"aniwatch": "api.AniWatchApi", "hianime": "api.HiAnimeApi",
"aniwave": "api.AniWaveApi", "nyaa": "api.NyaaApi",
} }
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHESERVERS, *ANIWATCHSERVERS] SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS]

View File

@@ -7,9 +7,8 @@ import json
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from requests.exceptions import Timeout
from ...anime_provider.base_provider import AnimeProvider from ...anime_provider.base_provider import AnimeProvider
from ..decorators import debug_provider
from ..utils import give_random_quality, one_digit_symmetric_xor from ..utils import give_random_quality, one_digit_symmetric_xor
from .constants import ALLANIME_API_ENDPOINT, ALLANIME_BASE, ALLANIME_REFERER from .constants import ALLANIME_API_ENDPOINT, ALLANIME_BASE, ALLANIME_REFERER
from .gql_queries import ALLANIME_EPISODES_GQL, ALLANIME_SEARCH_GQL, ALLANIME_SHOW_GQL from .gql_queries import ALLANIME_EPISODES_GQL, ALLANIME_SEARCH_GQL, ALLANIME_SHOW_GQL
@@ -27,6 +26,7 @@ class AllAnimeAPI(AnimeProvider):
Provides a fast and effective interface to AllAnime site. Provides a fast and effective interface to AllAnime site.
""" """
PROVIDER = "allanime"
api_endpoint = ALLANIME_API_ENDPOINT api_endpoint = ALLANIME_API_ENDPOINT
HEADERS = { HEADERS = {
"Referer": ALLANIME_REFERER, "Referer": ALLANIME_REFERER,
@@ -42,7 +42,6 @@ class AllAnimeAPI(AnimeProvider):
Returns: Returns:
[TODO:return] [TODO:return]
""" """
try:
response = self.session.get( response = self.session.get(
self.api_endpoint, self.api_endpoint,
params={ params={
@@ -56,15 +55,8 @@ class AllAnimeAPI(AnimeProvider):
else: else:
logger.error("[ALLANIME-ERROR]: ", response.text) logger.error("[ALLANIME-ERROR]: ", response.text)
return {} return {}
except Timeout:
logger.error(
"[ALLANIME-ERROR]: Timeout exceeded this could mean allanime is down or you have lost internet connection"
)
return {}
except Exception as e:
logger.error(f"[ALLANIME-ERROR]: {e}")
return {}
@debug_provider(PROVIDER.upper())
def search_for_anime( def search_for_anime(
self, self,
user_query: str, user_query: str,
@@ -97,7 +89,6 @@ class AllAnimeAPI(AnimeProvider):
"translationtype": translationtype, "translationtype": translationtype,
"countryorigin": countryorigin, "countryorigin": countryorigin,
} }
try:
search_results = self._fetch_gql(ALLANIME_SEARCH_GQL, variables) search_results = self._fetch_gql(ALLANIME_SEARCH_GQL, variables)
page_info = search_results["shows"]["pageInfo"] page_info = search_results["shows"]["pageInfo"]
results = [] results = []
@@ -116,10 +107,7 @@ class AllAnimeAPI(AnimeProvider):
} }
return normalized_search_results return normalized_search_results
except Exception as e: @debug_provider(PROVIDER.upper())
logger.error(f"[ALLANIME-ERROR]: {e}")
return {}
def get_anime(self, allanime_show_id: str): def get_anime(self, allanime_show_id: str):
"""get an anime details given its id """get an anime details given its id
@@ -130,11 +118,11 @@ class AllAnimeAPI(AnimeProvider):
[TODO:return] [TODO:return]
""" """
variables = {"showId": allanime_show_id} variables = {"showId": allanime_show_id}
try:
anime = self._fetch_gql(ALLANIME_SHOW_GQL, variables) anime = self._fetch_gql(ALLANIME_SHOW_GQL, variables)
id: str = anime["show"]["_id"] id: str = anime["show"]["_id"]
title: str = anime["show"]["name"] title: str = anime["show"]["name"]
availableEpisodesDetail = anime["show"]["availableEpisodesDetail"] availableEpisodesDetail = anime["show"]["availableEpisodesDetail"]
self.store.set(allanime_show_id, "anime_info", {"title": title})
type = anime.get("__typename") type = anime.get("__typename")
normalized_anime = { normalized_anime = {
"id": id, "id": id,
@@ -143,12 +131,10 @@ class AllAnimeAPI(AnimeProvider):
"type": type, "type": type,
} }
return normalized_anime return normalized_anime
except Exception as e:
logger.error(f"[ALLANIME-ERROR]: {e}")
return {}
@debug_provider(PROVIDER.upper())
def _get_anime_episode( def _get_anime_episode(
self, allanime_show_id: str, episode_string: str, translation_type: str = "sub" self, allanime_show_id: str, episode, translation_type: str = "sub"
) -> "AllAnimeEpisode | dict": ) -> "AllAnimeEpisode | dict":
"""get the episode details and sources info """get the episode details and sources info
@@ -163,16 +149,15 @@ class AllAnimeAPI(AnimeProvider):
variables = { variables = {
"showId": allanime_show_id, "showId": allanime_show_id,
"translationType": translation_type, "translationType": translation_type,
"episodeString": episode_string, "episodeString": episode,
} }
try:
episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables) episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables)
return episode["episode"] return episode["episode"]
except Exception as e:
logger.error(f"[ALLANIME-ERROR]: {e}")
return {}
def get_episode_streams(self, anime, episode_number: str, translation_type="sub"): @debug_provider(PROVIDER.upper())
def get_episode_streams(
self, anime_id, episode_number: str, translation_type="sub"
):
"""get the streams of an episode """get the streams of an episode
Args: Args:
@@ -183,7 +168,10 @@ class AllAnimeAPI(AnimeProvider):
Yields: Yields:
[TODO:description] [TODO:description]
""" """
anime_id = anime["id"]
anime_title = (self.store.get(anime_id, "anime_info", "") or {"title": ""})[
"title"
]
allanime_episode = self._get_anime_episode( allanime_episode = self._get_anime_episode(
anime_id, episode_number, translation_type anime_id, episode_number, translation_type
) )
@@ -191,9 +179,9 @@ class AllAnimeAPI(AnimeProvider):
return [] return []
embeds = allanime_episode["sourceUrls"] embeds = allanime_episode["sourceUrls"]
try:
for embed in embeds: @debug_provider(self.PROVIDER.upper())
try: def _get_server(embed):
# filter the working streams no need to get all since the others are mostly hsl # filter the working streams no need to get all since the others are mostly hsl
# TODO: should i just get all the servers and handle the hsl?? # TODO: should i just get all the servers and handle the hsl??
if embed.get("sourceName", "") not in ( if embed.get("sourceName", "") not in (
@@ -209,19 +197,19 @@ class AllAnimeAPI(AnimeProvider):
# "Ss-Hls", # 5.5 # "Ss-Hls", # 5.5
# "Mp4", # 4 # "Mp4", # 4
): ):
continue return
url = embed.get("sourceUrl") url = embed.get("sourceUrl")
# #
if not url: if not url:
continue return
if url.startswith("--"): if url.startswith("--"):
url = url[2:] url = url[2:]
url = one_digit_symmetric_xor(56, url) url = one_digit_symmetric_xor(56, url)
if "tools.fast4speed.rsvp" in url: if "tools.fast4speed.rsvp" in url:
yield { return {
"server": "Yt", "server": "Yt",
"episode_title": f'{anime["title"]}; Episode {episode_number}', "episode_title": f"{anime_title}; Episode {episode_number}",
"headers": {"Referer": f"https://{ALLANIME_BASE}/"}, "headers": {"Referer": f"https://{ALLANIME_BASE}/"},
"subtitles": [], "subtitles": [],
"links": [ "links": [
@@ -231,12 +219,9 @@ class AllAnimeAPI(AnimeProvider):
} }
], ],
} }
continue
# get the stream url for an episode of the defined source names # get the stream url for an episode of the defined source names
embed_url = ( embed_url = f"https://{ALLANIME_BASE}{url.replace('clock', 'clock.json')}"
f"https://{ALLANIME_BASE}{url.replace('clock', 'clock.json')}"
)
resp = self.session.get( resp = self.session.get(
embed_url, embed_url,
timeout=10, timeout=10,
@@ -246,70 +231,65 @@ class AllAnimeAPI(AnimeProvider):
match embed["sourceName"]: match embed["sourceName"]:
case "Luf-mp4": case "Luf-mp4":
logger.debug("allanime:Found streams from gogoanime") logger.debug("allanime:Found streams from gogoanime")
yield { return {
"server": "gogoanime", "server": "gogoanime",
"headers": {}, "headers": {},
"subtitles": [], "subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f'{anime["title"]}' allanime_episode["notes"] or f"{anime_title}"
) )
+ f"; Episode {episode_number}", + f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]), "links": give_random_quality(resp.json()["links"]),
} }
case "Kir": case "Kir":
logger.debug("allanime:Found streams from wetransfer") logger.debug("allanime:Found streams from wetransfer")
yield { return {
"server": "wetransfer", "server": "wetransfer",
"headers": {}, "headers": {},
"subtitles": [], "subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f'{anime["title"]}' allanime_episode["notes"] or f"{anime_title}"
) )
+ f"; Episode {episode_number}", + f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]), "links": give_random_quality(resp.json()["links"]),
} }
case "S-mp4": case "S-mp4":
logger.debug("allanime:Found streams from sharepoint") logger.debug("allanime:Found streams from sharepoint")
yield { return {
"server": "sharepoint", "server": "sharepoint",
"headers": {}, "headers": {},
"subtitles": [], "subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f'{anime["title"]}' allanime_episode["notes"] or f"{anime_title}"
) )
+ f"; Episode {episode_number}", + f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]), "links": give_random_quality(resp.json()["links"]),
} }
case "Sak": case "Sak":
logger.debug("allanime:Found streams from dropbox") logger.debug("allanime:Found streams from dropbox")
yield { return {
"server": "dropbox", "server": "dropbox",
"headers": {}, "headers": {},
"subtitles": [], "subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f'{anime["title"]}' allanime_episode["notes"] or f"{anime_title}"
) )
+ f"; Episode {episode_number}", + f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]), "links": give_random_quality(resp.json()["links"]),
} }
case "Default": case "Default":
logger.debug("allanime:Found streams from wixmp") logger.debug("allanime:Found streams from wixmp")
yield { return {
"server": "wixmp", "server": "wixmp",
"headers": {}, "headers": {},
"subtitles": [], "subtitles": [],
"episode_title": ( "episode_title": (
allanime_episode["notes"] or f'{anime["title"]}' allanime_episode["notes"] or f"{anime_title}"
) )
+ f"; Episode {episode_number}", + f"; Episode {episode_number}",
"links": give_random_quality(resp.json()["links"]), "links": give_random_quality(resp.json()["links"]),
} }
except Timeout:
logger.error( for embed in embeds:
"[ALLANIME-ERROR]: Timeout has been exceeded this could mean allanime is down or you have lost internet connection" if server := _get_server(embed):
) yield server
except Exception as e:
logger.error(f"[ALLANIME-ERROR]: {e}")
except Exception as e:
logger.error(f"[ALLANIME-ERROR]: {e}")
return []

View File

@@ -11,6 +11,7 @@ from yt_dlp.utils import (
) )
from ..base_provider import AnimeProvider from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from .constants import ( from .constants import (
ANIMEPAHE_BASE, ANIMEPAHE_BASE,
ANIMEPAHE_ENDPOINT, ANIMEPAHE_ENDPOINT,
@@ -20,21 +21,21 @@ from .constants import (
from .utils import process_animepahe_embed_page from .utils import process_animepahe_embed_page
if TYPE_CHECKING: if TYPE_CHECKING:
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimeSearchResult from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimePaheSearchResult
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';") JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
KWIK_RE = re.compile(r"Player\|(.+?)'") KWIK_RE = re.compile(r"Player\|(.+?)'")
# TODO: hack this to completion
class AnimePaheApi(AnimeProvider): class AnimePaheApi(AnimeProvider):
search_page: "AnimePaheSearchPage" search_page: "AnimePaheSearchPage"
anime: "AnimePaheAnimePage" anime: "AnimePaheAnimePage"
HEADERS = REQUEST_HEADERS HEADERS = REQUEST_HEADERS
PROVIDER = "animepahe"
@debug_provider(PROVIDER.upper())
def search_for_anime(self, user_query: str, *args): def search_for_anime(self, user_query: str, *args):
try:
url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}" url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}"
response = self.session.get( response = self.session.get(
url, url,
@@ -43,6 +44,12 @@ class AnimePaheApi(AnimeProvider):
return return
data: "AnimePaheSearchPage" = response.json() data: "AnimePaheSearchPage" = response.json()
self.search_page = data self.search_page = data
for animepahe_search_result in data["data"]:
self.store.set(
str(animepahe_search_result["session"]),
"search_result",
animepahe_search_result,
)
return { return {
"pageInfo": { "pageInfo": {
@@ -66,18 +73,11 @@ class AnimePaheApi(AnimeProvider):
], ],
} }
except Exception as e: @debug_provider(PROVIDER.upper())
logger.error(f"[ANIMEPAHE-ERROR]: {e}")
return {}
def get_anime(self, session_id: str, *args): def get_anime(self, session_id: str, *args):
page = 1 page = 1
try: if d := self.store.get(str(session_id), "search_result"):
anime_result: "AnimeSearchResult" = [ anime_result: "AnimePaheSearchResult" = d
anime
for anime in self.search_page["data"]
if anime["session"] == session_id
][0]
data: "AnimePaheAnimePage" = {} # pyright:ignore data: "AnimePaheAnimePage" = {} # pyright:ignore
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}" url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
@@ -122,7 +122,8 @@ class AnimePaheApi(AnimeProvider):
if not data: if not data:
return {} return {}
self.anime = data # pyright:ignore data["title"] = anime_result["title"] # pyright:ignore
self.store.set(str(session_id), "anime_info", data)
episodes = list(map(str, [episode["episode"] for episode in data["data"]])) episodes = list(map(str, [episode["episode"] for episode in data["data"]]))
title = "" title = ""
return { return {
@@ -149,27 +150,27 @@ class AnimePaheApi(AnimeProvider):
for episode in data["data"] for episode in data["data"]
], ],
} }
except Exception as e:
logger.error(f"[ANIMEPAHE-ERROR]: {e}")
return {}
def get_episode_streams(self, anime, episode_number: str, translation_type, *args): @debug_provider(PROVIDER.upper())
try: def get_episode_streams(
self, anime_id, episode_number: str, translation_type, *args
):
anime_title = ""
episode = None
# extract episode details from memory # extract episode details from memory
if d := self.store.get(str(anime_id), "anime_info"):
anime_title = d["title"]
episode = [ episode = [
episode episode
for episode in self.anime["data"] for episode in d["data"]
if float(episode["episode"]) == float(episode_number) if float(episode["episode"]) == float(episode_number)
] ]
if not episode: if not episode:
logger.error( logger.error(f"[ANIMEPAHE-ERROR]: episode {episode_number} doesn't exist")
f"[ANIMEPAHE-ERROR]: episode {episode_number} doesn't exist"
)
return [] return []
episode = episode[0] episode = episode[0]
anime_id = anime["id"]
# fetch the episode page # fetch the episode page
url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}" url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
response = self.session.get(url) response = self.session.get(url)
@@ -184,7 +185,7 @@ class AnimePaheApi(AnimeProvider):
# get the episode title # get the episode title
episode_title = ( episode_title = (
f"{episode['title'] or anime['title']}; Episode {episode['episode']}" f"{episode['title'] or anime_title}; Episode {episode['episode']}"
) )
# get all links # get all links
streams = { streams = {
@@ -203,7 +204,7 @@ class AnimePaheApi(AnimeProvider):
continue continue
if not embed_url: if not embed_url:
logger.warn( logger.warning(
"[ANIMEPAHE-WARN]: embed url not found please report to the developers" "[ANIMEPAHE-WARN]: embed url not found please report to the developers"
) )
return [] return []
@@ -233,5 +234,3 @@ class AnimePaheApi(AnimeProvider):
} }
) )
yield streams yield streams
except Exception as e:
logger.error(f"[ANIMEPAHE-ERROR]: {e}")

View File

@@ -1,7 +1,7 @@
from typing import Literal, TypedDict from typing import Literal, TypedDict
class AnimeSearchResult(TypedDict): class AnimePaheSearchResult(TypedDict):
id: int id: int
title: str title: str
type: str type: str
@@ -21,7 +21,7 @@ class AnimePaheSearchPage(TypedDict):
last_page: int last_page: int
_from: int _from: int
to: int to: int
data: list[AnimeSearchResult] data: list[AnimePaheSearchResult]
class Episode(TypedDict): class Episode(TypedDict):

View File

@@ -1,236 +0,0 @@
import logging
import re
from html.parser import HTMLParser
from itertools import cycle
from urllib.parse import quote_plus
from yt_dlp.utils import (
clean_html,
extract_attributes,
get_element_by_class,
get_element_html_by_class,
get_elements_by_class,
get_elements_html_by_class,
)
from ..base_provider import AnimeProvider
from ..utils import give_random_quality
from .constants import SERVERS_AVAILABLE
from .types import AniWatchStream
logger = logging.getLogger(__name__)
LINK_TO_STREAMS_REGEX = re.compile(r".*://(.*)/embed-(2|4|6)/e-([0-9])/(.*)\?.*")
IMAGE_HTML_ELEMENT_REGEX = re.compile(r"<img.*?>")
class ParseAnchorAndImgTag(HTMLParser):
def __init__(self):
super().__init__()
self.img_tag = None
self.a_tag = None
def handle_starttag(self, tag, attrs):
if tag == "img":
self.img_tag = {attr[0]: attr[1] for attr in attrs}
if tag == "a":
self.a_tag = {attr[0]: attr[1] for attr in attrs}
class AniWatchApi(AnimeProvider):
# HEADERS = {"Referer": "https://hianime.to/home"}
def search_for_anime(self, anime_title: str, *args):
try:
query = quote_plus(anime_title)
url = f"https://hianime.to/search?keyword={query}"
response = self.session.get(url)
if not response.ok:
return
search_page = response.text
search_results_html_items = get_elements_by_class("flw-item", search_page)
results = []
for search_results_html_item in search_results_html_items:
film_poster_html = get_element_by_class(
"film-poster", search_results_html_item
)
if not film_poster_html:
continue
# get availableEpisodes
episodes_html = get_element_html_by_class("tick-sub", film_poster_html)
episodes = clean_html(episodes_html) or 12
# get anime id and poster image url
parser = ParseAnchorAndImgTag()
parser.feed(film_poster_html)
image_data = parser.img_tag
anime_link_data = parser.a_tag
if not image_data or not anime_link_data:
continue
episodes = int(episodes)
# finally!!
image_link = image_data["data-src"]
anime_id = anime_link_data["data-id"]
title = anime_link_data["title"]
results.append(
{
"availableEpisodes": list(range(1, episodes)),
"id": anime_id,
"title": title,
"poster": image_link,
}
)
self.search_results = results
return {"pageInfo": {}, "results": results}
except Exception as e:
logger.error(f"[ANIWATCH-ERROR]: {e}")
def get_anime(self, aniwatch_id, *args):
try:
anime_result = {}
for anime in self.search_results:
if anime["id"] == aniwatch_id:
anime_result = anime
break
anime_url = f"https://hianime.to/ajax/v2/episode/list/{aniwatch_id}"
response = self.session.get(anime_url, timeout=10)
if response.ok:
response_json = response.json()
aniwatch_anime_page = response_json["html"]
episodes_info_container_html = get_element_html_by_class(
"ss-list", aniwatch_anime_page
)
episodes_info_html_list = get_elements_html_by_class(
"ep-item", episodes_info_container_html
)
# keys: [ data-number: episode_number, data-id: episode_id, title: episode_title , href:episode_page_url]
episodes_info_dicts = [
extract_attributes(episode_dict)
for episode_dict in episodes_info_html_list
]
episodes = [episode["data-number"] for episode in episodes_info_dicts]
self.episodes_info = [
{
"id": episode["data-id"],
"title": (
(episode["title"] or "").replace(
f"Episode {episode['data-number']}", ""
)
or anime_result["title"]
)
+ f"; Episode {episode['data-number']}",
"episode": episode["data-number"],
}
for episode in episodes_info_dicts
]
return {
"id": aniwatch_id,
"availableEpisodesDetail": {
"dub": episodes,
"sub": episodes,
"raw": episodes,
},
"poster": anime_result["poster"],
"title": anime_result["title"],
"episodes_info": self.episodes_info,
}
except Exception as e:
logger.error(f"[ANIWACTCH-ERROR]: {e}")
def get_episode_streams(self, anime, episode, translation_type, *args):
try:
episode_details = [
episode_details
for episode_details in self.episodes_info
if episode_details["episode"] == episode
]
if not episode_details:
return
episode_details = episode_details[0]
episode_url = f"https://hianime.to/ajax/v2/episode/servers?episodeId={episode_details['id']}"
response = self.session.get(episode_url)
if response.ok:
response_json = response.json()
episode_page_html = response_json["html"]
servers_containers_html = get_elements_html_by_class(
"ps__-list", episode_page_html
)
if not servers_containers_html:
return
# sub servers
try:
servers_html_sub = get_elements_html_by_class(
"server-item", servers_containers_html[0]
)
except Exception:
logger.warn("AniWatch: sub not found")
servers_html_sub = None
# dub servers
try:
servers_html_dub = get_elements_html_by_class(
"server-item", servers_containers_html[1]
)
except Exception:
logger.warn("AniWatch: dub not found")
servers_html_dub = None
if translation_type == "dub":
servers_html = servers_html_dub
else:
servers_html = servers_html_sub
if not servers_html:
return
for server_name, server_html in zip(
cycle(SERVERS_AVAILABLE), servers_html
):
try:
# keys: [ data-type: translation_type, data-id: embed_id, data-server-id: server_id ]
servers_info = extract_attributes(server_html)
embed_url = f"https://hianime.to/ajax/v2/episode/sources?id={servers_info['data-id']}"
embed_response = self.session.get(embed_url)
if embed_response.ok:
embed_json = embed_response.json()
raw_link_to_streams = embed_json["link"]
match = LINK_TO_STREAMS_REGEX.match(raw_link_to_streams)
if not match:
continue
provider_domain = match.group(1)
embed_type = match.group(2)
episode_number = match.group(3)
source_id = match.group(4)
link_to_streams = f"https://{provider_domain}/embed-{embed_type}/ajax/e-{episode_number}/getSources?id={source_id}"
link_to_streams_response = self.session.get(link_to_streams)
if link_to_streams_response.ok:
juicy_streams_json: "AniWatchStream" = (
link_to_streams_response.json()
)
yield {
"headers": {},
"subtitles": [
{
"url": track["file"],
"language": track["label"],
}
for track in juicy_streams_json["tracks"]
if track["kind"] == "captions"
],
"server": server_name,
"episode_title": episode_details["title"],
"links": give_random_quality(
[
{"link": link["file"], "type": link["type"]}
for link in juicy_streams_json["sources"]
]
),
}
except Exception as e:
logger.error(f"[ANIWATCH_ERROR]: {e}")
except Exception as e:
logger.error(f"[ANIWATCH_ERROR]: {e}")

View File

@@ -1,26 +0,0 @@
from typing import Literal, TypedDict
class AniWatchSkipTime(TypedDict):
start: int
end: int
class AniWatchSource(TypedDict):
file: str
type: str
class AniWatchTrack(TypedDict):
file: str
label: str
kind: Literal["captions", "thumbnails", "audio"]
class AniWatchStream(TypedDict):
sources: list[AniWatchSource]
tracks: list[AniWatchTrack]
encrypted: bool
intro: AniWatchSkipTime
outro: AniWatchSkipTime
server: int

View File

@@ -1,65 +0,0 @@
from html.parser import HTMLParser
from yt_dlp.utils import clean_html, get_element_by_class, get_elements_by_class
from ..base_provider import AnimeProvider
from .constants import ANIWAVE_BASE, SEARCH_HEADERS
class ParseAnchorAndImgTag(HTMLParser):
def __init__(self):
super().__init__()
self.img_tag = None
self.a_tag = None
def handle_starttag(self, tag, attrs):
if tag == "img":
self.img_tag = {attr[0]: attr[1] for attr in attrs}
if tag == "a":
self.a_tag = {attr[0]: attr[1] for attr in attrs}
class AniWaveApi(AnimeProvider):
def search_for_anime(self, anime_title, *args):
self.session.headers.update(SEARCH_HEADERS)
search_url = f"{ANIWAVE_BASE}/filter"
params = {"keyword": anime_title}
res = self.session.get(search_url, params=params)
search_page = res.text
search_results_html_list = get_elements_by_class("item", search_page)
results = []
for result_html in search_results_html_list:
aniposter_html = get_element_by_class("poster", result_html)
episode_html = get_element_by_class("sub", aniposter_html)
episodes = clean_html(episode_html) or 12
if not aniposter_html:
return
parser = ParseAnchorAndImgTag()
parser.feed(aniposter_html)
image_data = parser.img_tag
anime_link_data = parser.a_tag
if not image_data or not anime_link_data:
continue
episodes = int(episodes)
# finally!!
image_link = image_data["src"]
title = image_data["alt"]
anime_id = anime_link_data["href"]
results.append(
{
"availableEpisodes": list(range(1, episodes)),
"id": anime_id,
"title": title,
"poster": image_link,
}
)
self.search_results = results
return {"pageInfo": {}, "results": results}
def get_anime(self, anime_id, *args):
anime_page_url = f"{ANIWAVE_BASE}{anime_id}"
self.session.get(anime_page_url)
# TODO: to be continued; mostly js so very difficult

View File

@@ -1,20 +0,0 @@
ANIWAVE_BASE = "https://aniwave.to"
SEARCH_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
# 'Accept-Encoding': 'Utf-8',
"Referer": "https://aniwave.to/filter",
"DNT": "1",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Connection": "keep-alive",
"Alt-Used": "aniwave.to",
# 'Cookie': '__pf=1; usertype=guest; session=BElk9DJdO3sFdDmLiGxuNiM9eGYO1TjktGsmdwjV',
"Priority": "u=0, i",
# Requests doesn't support trailers
# 'TE': 'trailers',
}

View File

@@ -1,13 +1,34 @@
import os
import requests import requests
from yt_dlp.utils.networking import random_user_agent from yt_dlp.utils.networking import random_user_agent
from ...constants import APP_CACHE_DIR
from .providers_store import ProviderStore
class AnimeProvider: class AnimeProvider:
session: requests.Session session: requests.Session
PROVIDER = ""
USER_AGENT = random_user_agent() USER_AGENT = random_user_agent()
HEADERS = {} HEADERS = {}
def __init__(self) -> None: def __init__(self, cache_requests, use_persistent_provider_store) -> None:
if cache_requests.lower() == "true":
from ..common.requests_cacher import CachedRequestsSession
self.session = CachedRequestsSession(
os.path.join(APP_CACHE_DIR, "cached_requests.db")
)
else:
self.session = requests.session() self.session = requests.session()
self.session.headers.update({"User-Agent": self.USER_AGENT, **self.HEADERS}) self.session.headers.update({"User-Agent": self.USER_AGENT, **self.HEADERS})
if use_persistent_provider_store.lower() == "true":
self.store = ProviderStore(
"persistent",
self.PROVIDER,
os.path.join(APP_CACHE_DIR, "anime_providers_store.db"),
)
else:
self.store = ProviderStore("memory")

View File

@@ -0,0 +1,39 @@
import functools
import logging
import os
logger = logging.getLogger(__name__)
def debug_provider(provider_name: str):
def _provider_function_decorator(provider_function):
@functools.wraps(provider_function)
def _provider_function_wrapper(*args, **kwargs):
if not os.environ.get("FASTANIME_DEBUG"):
try:
return provider_function(*args, **kwargs)
except Exception as e:
logger.error(f"[{provider_name}@{provider_function.__name__}]: {e}")
else:
return provider_function(*args, **kwargs)
return _provider_function_wrapper
return _provider_function_decorator
def ensure_internet_connection(provider_function):
@functools.wraps(provider_function)
def _wrapper(*args, **kwargs):
import requests
try:
requests.get("https://google.com", timeout=5)
except requests.ConnectionError:
from sys import exit
print("You are not connected to the internet;Aborting...")
exit(1)
return provider_function(*args, **kwargs)
return _wrapper

View File

@@ -0,0 +1,243 @@
import logging
import re
from html.parser import HTMLParser
from itertools import cycle
from urllib.parse import quote_plus
from yt_dlp.utils import (
clean_html,
extract_attributes,
get_element_by_class,
get_element_html_by_class,
get_elements_by_class,
get_elements_html_by_class,
)
from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from ..utils import give_random_quality
from .constants import SERVERS_AVAILABLE
from .types import HiAnimeStream
logger = logging.getLogger(__name__)
LINK_TO_STREAMS_REGEX = re.compile(r".*://(.*)/embed-(2|4|6)/e-([0-9])/(.*)\?.*")
IMAGE_HTML_ELEMENT_REGEX = re.compile(r"<img.*?>")
class ParseAnchorAndImgTag(HTMLParser):
def __init__(self):
super().__init__()
self.img_tag = None
self.a_tag = None
def handle_starttag(self, tag, attrs):
if tag == "img":
self.img_tag = {attr[0]: attr[1] for attr in attrs}
if tag == "a":
self.a_tag = {attr[0]: attr[1] for attr in attrs}
class HiAnimeApi(AnimeProvider):
# HEADERS = {"Referer": "https://hianime.to/home"}
PROVIDER = "hianime"
@debug_provider(PROVIDER.upper())
def search_for_anime(self, anime_title: str, *args):
query = quote_plus(anime_title)
url = f"https://hianime.to/search?keyword={query}"
response = self.session.get(url)
if not response.ok:
return
search_page = response.text
search_results_html_items = get_elements_by_class("flw-item", search_page)
results = []
for search_results_html_item in search_results_html_items:
film_poster_html = get_element_by_class(
"film-poster", search_results_html_item
)
if not film_poster_html:
continue
# get availableEpisodes
episodes_html = get_element_html_by_class("tick-sub", film_poster_html)
episodes = clean_html(episodes_html) or 12
# get anime id and poster image url
parser = ParseAnchorAndImgTag()
parser.feed(film_poster_html)
image_data = parser.img_tag
anime_link_data = parser.a_tag
if not image_data or not anime_link_data:
continue
episodes = int(episodes)
# finally!!
image_link = image_data["data-src"]
anime_id = anime_link_data["data-id"]
title = anime_link_data["title"]
result = {
"availableEpisodes": list(range(1, episodes)),
"id": anime_id,
"title": title,
"poster": image_link,
}
results.append(result)
self.store.set(result["id"], "search_result", result)
return {"pageInfo": {}, "results": results}
@debug_provider(PROVIDER.upper())
def get_anime(self, hianime_id, *args):
anime_result = {}
if d := self.store.get(str(hianime_id), "search_result"):
anime_result = d
anime_url = f"https://hianime.to/ajax/v2/episode/list/{hianime_id}"
response = self.session.get(anime_url, timeout=10)
if response.ok:
response_json = response.json()
hianime_anime_page = response_json["html"]
episodes_info_container_html = get_element_html_by_class(
"ss-list", hianime_anime_page
)
episodes_info_html_list = get_elements_html_by_class(
"ep-item", episodes_info_container_html
)
# keys: [ data-number: episode_number, data-id: episode_id, title: episode_title , href:episode_page_url]
episodes_info_dicts = [
extract_attributes(episode_dict)
for episode_dict in episodes_info_html_list
]
episodes = [episode["data-number"] for episode in episodes_info_dicts]
episodes_info = [
{
"id": episode["data-id"],
"title": (
(episode["title"] or "").replace(
f"Episode {episode['data-number']}", ""
)
or anime_result["title"]
)
+ f"; Episode {episode['data-number']}",
"episode": episode["data-number"],
}
for episode in episodes_info_dicts
]
self.store.set(
str(hianime_id),
"anime_info",
episodes_info,
)
return {
"id": hianime_id,
"availableEpisodesDetail": {
"dub": episodes,
"sub": episodes,
"raw": episodes,
},
"poster": anime_result["poster"],
"title": anime_result["title"],
"episodes_info": episodes_info,
}
@debug_provider(PROVIDER.upper())
def get_episode_streams(self, anime_id, episode, translation_type, *args):
if d := self.store.get(str(anime_id), "anime_info"):
episodes_info = d
episode_details = [
episode_details
for episode_details in episodes_info
if episode_details["episode"] == episode
]
if not episode_details:
return
episode_details = episode_details[0]
episode_url = f"https://hianime.to/ajax/v2/episode/servers?episodeId={episode_details['id']}"
response = self.session.get(episode_url)
if response.ok:
response_json = response.json()
episode_page_html = response_json["html"]
servers_containers_html = get_elements_html_by_class(
"ps__-list", episode_page_html
)
if not servers_containers_html:
return
# sub servers
try:
servers_html_sub = get_elements_html_by_class(
"server-item", servers_containers_html[0]
)
except Exception:
logger.warning("HiAnime: sub not found")
servers_html_sub = None
# dub servers
try:
servers_html_dub = get_elements_html_by_class(
"server-item", servers_containers_html[1]
)
except Exception:
logger.warning("HiAnime: dub not found")
servers_html_dub = None
if translation_type == "dub":
servers_html = servers_html_dub
else:
servers_html = servers_html_sub
if not servers_html:
return
@debug_provider(self.PROVIDER.upper())
def _get_server(server_name, server_html):
# keys: [ data-type: translation_type, data-id: embed_id, data-server-id: server_id ]
servers_info = extract_attributes(server_html)
embed_url = f"https://hianime.to/ajax/v2/episode/sources?id={servers_info['data-id']}"
embed_response = self.session.get(embed_url)
if embed_response.ok:
embed_json = embed_response.json()
raw_link_to_streams = embed_json["link"]
match = LINK_TO_STREAMS_REGEX.match(raw_link_to_streams)
if not match:
return
provider_domain = match.group(1)
embed_type = match.group(2)
episode_number = match.group(3)
source_id = match.group(4)
link_to_streams = f"https://{provider_domain}/embed-{embed_type}/ajax/e-{episode_number}/getSources?id={source_id}"
link_to_streams_response = self.session.get(link_to_streams)
if link_to_streams_response.ok:
juicy_streams_json: "HiAnimeStream" = (
link_to_streams_response.json()
)
# TODO: Hianime decided to fucking encrypt shit
# so got to fix it later
return {
"headers": {},
"subtitles": [
{
"url": track["file"],
"language": track["label"],
}
for track in juicy_streams_json["tracks"]
if track["kind"] == "captions"
],
"server": server_name,
"episode_title": episode_details["title"],
"links": give_random_quality(
[
{"link": link["file"]}
for link in juicy_streams_json["tracks"]
]
),
}
for server_name, server_html in zip(
cycle(SERVERS_AVAILABLE), servers_html
):
if server := _get_server(server_name, server_html):
yield server

View File

@@ -0,0 +1,26 @@
from typing import Literal, TypedDict
class HiAnimeSkipTime(TypedDict):
start: int
end: int
class HiAnimeSource(TypedDict):
file: str
type: str
class HiAnimeTrack(TypedDict):
file: str
label: str
kind: Literal["captions", "thumbnails", "audio"]
class HiAnimeStream(TypedDict):
sources: list[HiAnimeSource]
tracks: list[HiAnimeTrack]
encrypted: bool
intro: HiAnimeSkipTime
outro: HiAnimeSkipTime
server: int

View File

@@ -0,0 +1,345 @@
import os
import re
from logging import getLogger
from yt_dlp.utils import (
extract_attributes,
get_element_html_by_attribute,
get_element_html_by_class,
get_element_text_and_html_by_tag,
get_elements_html_by_class,
)
from ...common.mini_anilist import search_for_anime_with_anilist
from ..base_provider import AnimeProvider
from ..decorators import debug_provider
from ..types import SearchResults
from .constants import NYAA_ENDPOINT
logger = getLogger(__name__)
EXTRACT_USEFUL_INFO_PATTERN_1 = re.compile(
r"\[(\w+)\] (.+) - (\d+) [\[\(](\d+)p[\]\)].*"
)
EXTRACT_USEFUL_INFO_PATTERN_2 = re.compile(
r"\[(\w+)\] (.+)E(\d+) [\[\(]?(\d+)p.*[\]\)]?.*"
)
class NyaaApi(AnimeProvider):
search_results: SearchResults
PROVIDER = "nyaa"
@debug_provider(PROVIDER.upper())
def search_for_anime(self, user_query: str, *args, **_):
self.search_results = search_for_anime_with_anilist(
user_query, True
) # pyright: ignore
self.user_query = user_query
return self.search_results
@debug_provider(PROVIDER.upper())
def get_anime(self, anilist_id: str, *_):
for anime in self.search_results["results"]:
if anime["id"] == anilist_id:
self.titles = [anime["title"], *anime["otherTitles"], self.user_query]
return {
"id": anime["id"],
"title": anime["title"],
"poster": anime["poster"],
"availableEpisodesDetail": {
"dub": anime["availableEpisodes"],
"sub": anime["availableEpisodes"],
"raw": anime["availableEpisodes"],
},
}
@debug_provider(PROVIDER.upper())
def get_episode_streams(
self,
anime_id: str,
episode_number: str,
translation_type: str,
trusted_only=bool(int(os.environ.get("FA_NYAA_TRUSTED_ONLY", "0"))),
allow_dangerous=bool(int(os.environ.get("FA_NYAA_ALLOW_DANGEROUS", "0"))),
sort_by="seeders",
*args,
):
anime_title = self.titles[0]
logger.debug(f"Searching nyaa for query: '{anime_title} {episode_number}'")
servers = {}
torrents_table = ""
for title in self.titles:
try:
url_arguments: dict[str, str] = {
"c": "1_2", # Language (English)
"q": f"{title} {'0' if len(episode_number)==1 else ''}{episode_number}", # Search Query
}
# url_arguments["q"] = anime_title
# if trusted_only:
# url_arguments["f"] = "2" # Trusted uploaders only
# What to sort torrents by
if sort_by == "seeders":
url_arguments["s"] = "seeders"
elif sort_by == "date":
url_arguments["s"] = "id"
elif sort_by == "size":
url_arguments["s"] = "size"
elif sort_by == "comments":
url_arguments["s"] = "comments"
logger.debug(f"URL Arguments: {url_arguments}")
response = self.session.get(NYAA_ENDPOINT, params=url_arguments)
if not response.ok:
logger.error(f"[NYAA]: {response.text}")
return
try:
torrents_table = get_element_text_and_html_by_tag(
"table", response.text
)
except Exception as e:
logger.error(f"[NYAA]: {e}")
continue
if not torrents_table:
continue
for anime_torrent in get_elements_html_by_class(
"success", torrents_table[1]
):
td_title = get_element_html_by_attribute(
"colspan", "2", anime_torrent
)
if not td_title:
continue
title_anchor_tag = get_element_text_and_html_by_tag("a", td_title)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
if "class" in title_anchor_tag_attrs:
td_title = td_title.replace(title_anchor_tag[1], "")
title_anchor_tag = get_element_text_and_html_by_tag(
"a", td_title
)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
anime_title_info = title_anchor_tag_attrs["title"]
if not anime_title_info:
continue
match = EXTRACT_USEFUL_INFO_PATTERN_1.search(
anime_title_info.strip()
)
if not match:
continue
server = match[1]
match[2]
_episode_number = match[3]
quality = match[4]
if float(episode_number) != float(_episode_number):
continue
links_td = get_element_html_by_class("text-center", anime_torrent)
if not links_td:
continue
torrent_anchor_tag = get_element_text_and_html_by_tag("a", links_td)
if not torrent_anchor_tag:
continue
torrent_anchor_tag_atrrs = extract_attributes(torrent_anchor_tag[1])
if not torrent_anchor_tag_atrrs:
continue
torrent_file_url = (
f'{NYAA_ENDPOINT}{torrent_anchor_tag_atrrs["href"]}'
)
if server in servers:
link = {
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
if link not in servers[server]["links"]:
servers[server]["links"].append(link)
else:
servers[server] = {
"server": server,
"headers": {},
"episode_title": f"{anime_title}; Episode {episode_number}",
"subtitles": [],
"links": [
{
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
],
}
for anime_torrent in get_elements_html_by_class(
"default", torrents_table[1]
):
td_title = get_element_html_by_attribute(
"colspan", "2", anime_torrent
)
if not td_title:
continue
title_anchor_tag = get_element_text_and_html_by_tag("a", td_title)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
if "class" in title_anchor_tag_attrs:
td_title = td_title.replace(title_anchor_tag[1], "")
title_anchor_tag = get_element_text_and_html_by_tag(
"a", td_title
)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
anime_title_info = title_anchor_tag_attrs["title"]
if not anime_title_info:
continue
match = EXTRACT_USEFUL_INFO_PATTERN_2.search(
anime_title_info.strip()
)
if not match:
continue
server = match[1]
match[2]
_episode_number = match[3]
quality = match[4]
if float(episode_number) != float(_episode_number):
continue
links_td = get_element_html_by_class("text-center", anime_torrent)
if not links_td:
continue
torrent_anchor_tag = get_element_text_and_html_by_tag("a", links_td)
if not torrent_anchor_tag:
continue
torrent_anchor_tag_atrrs = extract_attributes(torrent_anchor_tag[1])
if not torrent_anchor_tag_atrrs:
continue
torrent_file_url = (
f'{NYAA_ENDPOINT}{torrent_anchor_tag_atrrs["href"]}'
)
if server in servers:
link = {
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
if link not in servers[server]["links"]:
servers[server]["links"].append(link)
else:
servers[server] = {
"server": server,
"headers": {},
"episode_title": f"{anime_title}; Episode {episode_number}",
"subtitles": [],
"links": [
{
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
],
}
if not allow_dangerous:
break
for anime_torrent in get_elements_html_by_class(
"danger", torrents_table[1]
):
td_title = get_element_html_by_attribute(
"colspan", "2", anime_torrent
)
if not td_title:
continue
title_anchor_tag = get_element_text_and_html_by_tag("a", td_title)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
if "class" in title_anchor_tag_attrs:
td_title = td_title.replace(title_anchor_tag[1], "")
title_anchor_tag = get_element_text_and_html_by_tag(
"a", td_title
)
if not title_anchor_tag:
continue
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
if not title_anchor_tag_attrs:
continue
anime_title_info = title_anchor_tag_attrs["title"]
if not anime_title_info:
continue
match = EXTRACT_USEFUL_INFO_PATTERN_2.search(
anime_title_info.strip()
)
if not match:
continue
server = match[1]
match[2]
_episode_number = match[3]
quality = match[4]
if float(episode_number) != float(_episode_number):
continue
links_td = get_element_html_by_class("text-center", anime_torrent)
if not links_td:
continue
torrent_anchor_tag = get_element_text_and_html_by_tag("a", links_td)
if not torrent_anchor_tag:
continue
torrent_anchor_tag_atrrs = extract_attributes(torrent_anchor_tag[1])
if not torrent_anchor_tag_atrrs:
continue
torrent_file_url = (
f'{NYAA_ENDPOINT}{torrent_anchor_tag_atrrs["href"]}'
)
if server in servers:
link = {
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
if link not in servers[server]["links"]:
servers[server]["links"].append(link)
else:
servers[server] = {
"server": server,
"headers": {},
"episode_title": f"{anime_title}; Episode {episode_number}",
"subtitles": [],
"links": [
{
"translation_type": "sub",
"link": torrent_file_url,
"quality": quality,
}
],
}
except Exception as e:
logger.error(f"[NYAA]: {e}")
continue
for server in servers:
yield servers[server]

View File

@@ -0,0 +1 @@
NYAA_ENDPOINT = "https://nyaa.si"

View File

@@ -0,0 +1,126 @@
import logging
import os
import sys
import time
import libtorrent # pyright: ignore
from rich import print
from rich.progress import (
BarColumn,
DownloadColumn,
Progress,
TextColumn,
TimeRemainingColumn,
TransferSpeedColumn,
)
logger = logging.getLogger("nyaa")
def download_torrent(
filename: str,
result_filename: str | None = None,
show_progress: bool = True,
base_path: str = "Anime",
) -> str:
session = libtorrent.session({"listen_interfaces": "0.0.0.0:6881"})
logger.debug("Started libtorrent session")
base_path = os.path.expanduser(base_path)
logger.debug(f"Downloading output to: '{base_path}'")
info = libtorrent.torrent_info(filename)
logger.debug("Started downloading torrent")
handle: libtorrent.torrent_handle = session.add_torrent(
{"ti": info, "save_path": base_path}
)
status: libtorrent.session_status = handle.status()
progress_bar = Progress(
"[progress.description]{task.description}",
BarColumn(bar_width=None),
"[progress.percentage]{task.percentage:>3.1f}%",
"",
DownloadColumn(),
"",
TransferSpeedColumn(),
"",
TimeRemainingColumn(),
"",
TextColumn("[green]Peers: {task.fields[peers]}[/green]"),
)
if show_progress:
with progress_bar:
download_task = progress_bar.add_task(
"downloading",
filename=status.name,
total=status.total_wanted,
peers=0,
start=False,
)
while not status.total_done:
# Checking files
status = handle.status()
description = "[bold yellow]Checking files[/bold yellow]"
progress_bar.update(
download_task,
completed=status.total_done,
peers=status.num_peers,
description=description,
)
# Started download
progress_bar.start_task(download_task)
description = f"[bold blue]Downloading[/bold blue] [bold yellow]{result_filename}[/bold yellow]"
while not status.is_seeding:
status = handle.status()
progress_bar.update(
download_task,
completed=status.total_done,
peers=status.num_peers,
description=description,
)
alerts = session.pop_alerts()
alert: libtorrent.alert
for alert in alerts:
if (
alert.category()
& libtorrent.alert.category_t.error_notification
):
logger.debug(f"[Alert] {alert}")
time.sleep(1)
progress_bar.update(
download_task,
description=f"[bold blue]Finished Downloading[/bold blue] [bold green]{result_filename}[/bold green]",
completed=status.total_wanted,
)
if result_filename:
old_name = f"{base_path}/{status.name}"
new_name = f"{base_path}/{result_filename}"
os.rename(old_name, new_name)
logger.debug(f"Finished torrent download, renamed '{old_name}' to '{new_name}'")
return new_name
return ""
if __name__ == "__main__":
if len(sys.argv) < 2:
print("You need to pass in the .torrent file path.")
sys.exit(1)
download_torrent(sys.argv[1])

View File

@@ -0,0 +1,114 @@
import json
import logging
import time
logger = logging.getLogger(__name__)
class ProviderStoreDB:
def __init__(
self,
provider_name,
cache_db_path: str,
max_lifetime: int = 604800,
max_size: int = (1024**2) * 10,
table_name: str = "fastanime_providers_store",
clean_db=False,
):
from ..common.sqlitedb_helper import SqliteDB
self.cache_db_path = cache_db_path
self.clean_db = clean_db
self.provider_name = provider_name
self.max_lifetime = max_lifetime
self.max_size = max_size
self.table_name = table_name
self.sqlite_db_connection = SqliteDB(self.cache_db_path)
# Prepare the cache table if it doesn't exist
self._create_store_table()
def _create_store_table(self):
"""Create cache table if it doesn't exist."""
with self.sqlite_db_connection as conn:
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS {self.table_name} (
id TEXT,
data_type TEXT,
provider_name TEXT,
data TEXT,
cache_expiry INTEGER
)"""
)
def get(self, id: str, data_type: str, default=None):
with self.sqlite_db_connection as conn:
cursor = conn.cursor()
cursor.execute(
f"""
SELECT
data
FROM {self.table_name}
WHERE
id = ?
AND data_type = ?
AND provider_name = ?
AND cache_expiry > ?
""",
(id, data_type, self.provider_name, int(time.time())),
)
cached_data = cursor.fetchone()
if cached_data:
logger.debug("Found existing request in cache")
(json_data,) = cached_data
return json.loads(json_data)
return default
def set(self, id: str, data_type: str, data):
with self.sqlite_db_connection as connection:
cursor = connection.cursor()
cursor.execute(
f"""
INSERT INTO {self.table_name}
VALUES ( ?, ?,?, ?, ?)
""",
(
id,
data_type,
self.provider_name,
json.dumps(data),
int(time.time()) + self.max_lifetime,
),
)
class ProviderStoreMem:
def __init__(self) -> None:
from collections import defaultdict
self._store = defaultdict(dict)
def get(self, id: str, data_type: str, default=None):
return self._store[id][data_type]
def set(self, id: str, data_type: str, data):
self._store[id][data_type] = data
def ProviderStore(store_type, *args, **kwargs):
if store_type == "persistent":
return ProviderStoreDB(*args, **kwargs)
else:
return ProviderStoreMem()
if __name__ == "__main__":
store = ProviderStore("persistent", "test_provider", "provider_store")
store.set("123", "test", {"hello": "world"})
print(store.get("123", "test"))
print("-------------------------------")
store = ProviderStore("memory")
store.set("1", "test", {"hello": "world"})
print(store.get("1", "test"))

View File

@@ -19,6 +19,7 @@ class PageInfo(TypedDict):
class SearchResult(TypedDict): class SearchResult(TypedDict):
id: str id: str
title: str title: str
otherTitles: list[str]
availableEpisodes: list[str] availableEpisodes: list[str]
type: str type: str
score: int score: int

View File

@@ -44,7 +44,7 @@ def search_for_manga_with_anilist(manga_title: str):
pageInfo { pageInfo {
currentPage currentPage
} }
media(search: $query, type: MANGA) { media(search: $query, type: MANGA,genre_not_in: ["hentai"]) {
id id
idMal idMal
title { title {
@@ -96,16 +96,16 @@ def search_for_manga_with_anilist(manga_title: str):
} }
def search_for_anime_with_anilist(anime_title: str): def search_for_anime_with_anilist(anime_title: str, prefer_eng_titles=False):
query = """ query = """
query ($query: String) { query ($query: String) {
Page(perPage: 50) { Page(perPage: 50) {
pageInfo { pageInfo {
total total
currentPage currentPage
hasNextPage hasNextPage
} }
media(search: $query, type: ANIME) { media(search: $query, type: ANIME, genre_not_in: ["hentai"]) {
id id
idMal idMal
title { title {
@@ -114,14 +114,18 @@ def search_for_anime_with_anilist(anime_title: str):
} }
episodes episodes
status status
synonyms
nextAiringEpisode { nextAiringEpisode {
timeUntilAiring timeUntilAiring
airingAt airingAt
episode episode
} }
coverImage {
large
} }
} }
} }
}
""" """
response = post( response = post(
ANILIST_ENDPOINT, ANILIST_ENDPOINT,
@@ -134,11 +138,37 @@ def search_for_anime_with_anilist(anime_title: str):
"pageInfo": anilist_data["data"]["Page"]["pageInfo"], "pageInfo": anilist_data["data"]["Page"]["pageInfo"],
"results": [ "results": [
{ {
"id": anime_result["id"], "id": str(anime_result["id"]),
"title": anime_result["title"]["romaji"] "title": (
or anime_result["title"]["english"], (
anime_result["title"]["english"]
or anime_result["title"]["romaji"]
)
if prefer_eng_titles
else (
anime_result["title"]["romaji"]
or anime_result["title"]["english"]
)
),
"otherTitles": [
(
(
anime_result["title"]["romaji"]
or anime_result["title"]["english"]
)
if prefer_eng_titles
else (
anime_result["title"]["english"]
or anime_result["title"]["romaji"]
)
),
*(anime_result["synonyms"] or []),
],
"type": "anime", "type": "anime",
"poster": anime_result["coverImage"]["large"],
"availableEpisodes": list( "availableEpisodes": list(
map(
str,
range( range(
1, 1,
( (
@@ -146,10 +176,17 @@ def search_for_anime_with_anilist(anime_title: str):
if not anime_result["status"] == "RELEASING" if not anime_result["status"] == "RELEASING"
and anime_result["episodes"] and anime_result["episodes"]
else ( else (
anime_result["nextAiringEpisode"]["episode"] - 1 (
anime_result["nextAiringEpisode"]["episode"]
- 1
if anime_result["nextAiringEpisode"] if anime_result["nextAiringEpisode"]
else 0 else 0
) )
if not anime_result["episodes"]
else anime_result["episodes"]
)
)
+ 1,
), ),
) )
), ),
@@ -228,7 +265,7 @@ def get_basic_anime_info_by_title(anime_title: str):
pageInfo { pageInfo {
total total
} }
media(search: $query, type: ANIME) { media(search: $query, type: ANIME,genre_not_in: ["hentai"]) {
id id
idMal idMal
title { title {

View File

@@ -0,0 +1,209 @@
import json
import logging
import os
import re
import time
from datetime import datetime
from urllib.parse import urlencode
import requests
from .sqlitedb_helper import SqliteDB
logger = logging.getLogger(__name__)
caching_mimetypes = {
"application": {
"json",
"xml",
"x-www-form-urlencoded",
"x-javascript",
"javascript",
},
"text": {"html", "css", "javascript", "plain", "xml", "xsl", "x-javascript"},
}
class CachedRequestsSession(requests.Session):
__request_functions__ = (
"get",
"options",
"head",
"post",
"put",
"patch",
"delete",
)
def __new__(cls, *args, **kwargs):
def caching_params(name: str):
def wrapper(self, *args, **kwargs):
return cls.request(self, name, *args, **kwargs)
return wrapper
for func in cls.__request_functions__:
setattr(cls, func, caching_params(func))
return super().__new__(cls)
def __init__(
self,
cache_db_path: str,
max_lifetime: int = 259200,
max_size: int = (1024**2) * 10,
table_name: str = "fastanime_requests_cache",
clean_db=False,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.cache_db_path = cache_db_path
self.max_lifetime = max_lifetime
self.max_size = max_size
self.table_name = table_name
self.sqlite_db_connection = SqliteDB(self.cache_db_path)
# Prepare the cache table if it doesn't exist
self._create_cache_table()
def _create_cache_table(self):
"""Create cache table if it doesn't exist."""
with self.sqlite_db_connection as conn:
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS {self.table_name} (
url TEXT,
status_code INTEGER,
request_headers TEXT,
response_headers TEXT,
data BLOB,
redirection_policy INT,
cache_expiry INTEGER
)"""
)
def request(
self,
method,
url,
params=None,
force_caching=False,
fresh=int(os.environ.get("FASTANIME_FRESH_REQUESTS", 0)),
*args,
**kwargs,
):
if params:
url += "?" + urlencode(params)
redirection_policy = int(kwargs.get("force_redirects", False))
with self.sqlite_db_connection as conn:
cursor = conn.cursor()
time_before_access_db = datetime.now()
logger.debug("Checking for existing request in cache")
cursor.execute(
f"""
SELECT
status_code,
request_headers,
response_headers,
data,
redirection_policy
FROM {self.table_name}
WHERE
url = ?
AND redirection_policy = ?
AND cache_expiry > ?
""",
(url, redirection_policy, int(time.time())),
)
cached_request = cursor.fetchone()
time_after_access_db = datetime.now()
if cached_request and not fresh:
logger.debug("Found existing request in cache")
(
status_code,
request_headers,
response_headers,
data,
redirection_policy,
) = cached_request
response = requests.Response()
response.headers.update(json.loads(response_headers))
response.status_code = status_code
response._content = data
if "timeout" in kwargs:
kwargs.pop("timeout")
if "headers" in kwargs:
kwargs.pop("headers")
_request = requests.Request(
method, url, headers=json.loads(request_headers), *args, **kwargs
)
response.request = _request.prepare()
response.elapsed = time_after_access_db - time_before_access_db
return response
# Perform the request and cache it
response = super().request(method, url, *args, **kwargs)
if response.ok and (
force_caching
or self.is_content_type_cachable(
response.headers.get("content-type"), caching_mimetypes
)
and len(response.content) < self.max_size
):
logger.debug("Caching the current request")
cursor.execute(
f"""
INSERT INTO {self.table_name}
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
url,
response.status_code,
json.dumps(dict(response.request.headers)),
json.dumps(dict(response.headers)),
response.content,
redirection_policy,
int(time.time()) + self.max_lifetime,
),
)
return response
@staticmethod
def is_content_type_cachable(content_type, caching_mimetypes):
"""Checks whether the given encoding is supported by the cacher"""
if content_type is None:
return True
mime, contents = content_type.split("/")
contents = re.sub(r";.*$", "", contents)
return mime in caching_mimetypes and any(
content in caching_mimetypes[mime] for content in contents.split("+")
)
if __name__ == "__main__":
with CachedRequestsSession("cache.db") as session:
response = session.get(
"https://google.com",
)
response_b = session.get(
"https://google.com",
)
print("A: ", response.elapsed)
print("B: ", response_b.elapsed)
print(response_b.text[0:30])

View File

@@ -0,0 +1,34 @@
import logging
import sqlite3
import time
logger = logging.getLogger(__name__)
class SqliteDB:
def __init__(self, db_path: str) -> None:
self.db_path = db_path
self.connection = sqlite3.connect(self.db_path)
logger.debug("Enabling WAL mode for concurrent access")
self.connection.execute("PRAGMA journal_mode=WAL;")
self.connection.close()
self.connection = None
def __enter__(self):
logger.debug("Starting new connection...")
start_time = time.time()
self.connection = sqlite3.connect(self.db_path)
logger.debug(
"Successfully got a new connection in {} seconds".format(
time.time() - start_time
)
)
return self.connection
def __exit__(self, exc_type, exc_val, exc_tb):
if self.connection:
logger.debug("Closing connection to cache db")
self.connection.commit()
self.connection.close()
self.connection = None
logger.debug("Successfully closed connection to cache db")

View File

@@ -49,7 +49,7 @@ class FZF:
"--info=hidden", "--info=hidden",
"--layout=reverse", "--layout=reverse",
"--height=100%", "--height=100%",
"--bind=right:accept", "--bind=right:accept,ctrl-/:toggle-preview,ctrl-space:toggle-wrap+toggle-preview-wrap",
"--no-margin", "--no-margin",
"+m", "+m",
"-i", "-i",
@@ -124,7 +124,7 @@ class FZF:
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
universal_newlines=True, universal_newlines=True,
text=True, text=True,
encoding="utf-8" encoding="utf-8",
) )
if not result or result.returncode != 0 or not result.stdout: if not result or result.returncode != 0 or not result.stdout:
print("sth went wrong:confused:") print("sth went wrong:confused:")
@@ -163,7 +163,7 @@ class FZF:
HEADER, HEADER,
"--header-first", "--header-first",
"--prompt", "--prompt",
prompt.title(), f"{prompt.title()}: ",
] # pyright:ignore ] # pyright:ignore
if preview: if preview:

View File

@@ -2,8 +2,6 @@ import subprocess
from shutil import which from shutil import which
from sys import exit from sys import exit
from plyer import notification
from fastanime import APP_NAME from fastanime import APP_NAME
from ...constants import ICON_PATH from ...constants import ICON_PATH
@@ -25,7 +23,7 @@ class RofiApi:
args = [self.ROFI_EXECUTABLE] args = [self.ROFI_EXECUTABLE]
if self.rofi_theme: if self.rofi_theme:
args.extend(["-no-config", "-theme", self.rofi_theme]) args.extend(["-no-config", "-theme", self.rofi_theme])
args.extend(["-p", prompt_text, "-i", "-show-icons", "-dmenu"]) args.extend(["-p", f"{prompt_text.title()}", "-i", "-show-icons", "-dmenu"])
result = subprocess.run( result = subprocess.run(
args, args,
input=rofi_input, input=rofi_input,
@@ -35,6 +33,13 @@ class RofiApi:
choice = result.stdout.strip() choice = result.stdout.strip()
if not choice: if not choice:
try:
from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
exit(1)
notification.notify( notification.notify(
app_name=APP_NAME, app_name=APP_NAME,
app_icon=ICON_PATH, app_icon=ICON_PATH,
@@ -64,6 +69,13 @@ class RofiApi:
choice = result.stdout.strip() choice = result.stdout.strip()
if not choice or choice not in options: if not choice or choice not in options:
try:
from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
exit(1)
notification.notify( notification.notify(
app_name=APP_NAME, app_name=APP_NAME,
app_icon=ICON_PATH, app_icon=ICON_PATH,
@@ -91,6 +103,13 @@ class RofiApi:
choice = result.stdout.strip() choice = result.stdout.strip()
if not choice: if not choice:
try:
from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
exit(1)
notification.notify( notification.notify(
app_name=APP_NAME, app_name=APP_NAME,
app_icon=ICON_PATH, app_icon=ICON_PATH,
@@ -120,6 +139,13 @@ class RofiApi:
user_input = result.stdout.strip() user_input = result.stdout.strip()
if not user_input: if not user_input:
try:
from plyer import notification
except ImportError:
print(
"Plyer is not installed; install it for desktop notifications to be enabled"
)
exit(1)
notification.notify( notification.notify(
app_name=APP_NAME, app_name=APP_NAME,
app_icon=ICON_PATH, app_icon=ICON_PATH,

11
make_release Executable file
View File

@@ -0,0 +1,11 @@
#! /usr/bin/env sh
CLI_DIR="$(dirname "$(realpath "$0")")"
VERSION=$1
[ -z "$VERSION" ] && echo no version provided && exit 1
[ "$VERSION" = "current" ] && fastanime --version && exit 0
sed -i "s/^version.*/version = \"$VERSION\"/" "$CLI_DIR/pyproject.toml" &&
sed -i "s/__version__.*/__version__ = \"v$VERSION\"/" "$CLI_DIR/fastanime/__init__.py" &&
git stage "$CLI_DIR/pyproject.toml" "$CLI_DIR/fastanime/__init__.py" &&
git commit -m "chore: bump version (v$VERSION)" &&
git push &&
gh release create "v$VERSION"

1373
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,44 @@
[tool.poetry] [project]
name = "fastanime" name = "fastanime"
version = "2.5.3" version = "2.6.8"
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",
"rich>=13.9.2",
"thefuzz>=0.22.1",
"yt-dlp>=2024.10.7",
]
[tool.poetry.dependencies] [project.scripts]
python = "^3.10"
yt-dlp = "^2024.5.27"
rich = "^13.7.1"
click = "^8.1.7"
inquirerpy = "^0.3.4"
thefuzz = "^0.22.1"
requests = "^2.32.3"
plyer = "^2.1.0"
mpv = "^1.0.7"
[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 = [
"pyright>=1.1.384",
"pytest>=8.3.3",
"ruff>=0.6.9",
]

View File

@@ -6,7 +6,7 @@ from fastanime.cli import run_cli
@pytest.fixture @pytest.fixture
def runner(): def runner():
return CliRunner() return CliRunner(env={"FASTANIME_CACHE_REQUESTS": "false"})
def test_main_help(runner: CliRunner): def test_main_help(runner: CliRunner):

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 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 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 uv sync --dev --all-extras
poetry run pyright uv run pyright

1315
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff