Compare commits

...

94 Commits

Author SHA1 Message Date
benex
2d3509ccc1 chore: bump version (v2.7.8) 2024-11-18 17:10:26 +03:00
benex
30babf2d69 feat(cli): alert the user that the cli is checking for updates 2024-11-18 17:10:10 +03:00
benex
cfbbabf898 chore: update uv.lock 2024-11-18 17:10:10 +03:00
benex
5ac6c45fdf fix(allanime_provider): give all the servers the referer header 2024-11-18 17:10:10 +03:00
Benedict Xavier
a14645b563 Update README.md 2024-11-18 15:17:32 +03:00
Benedict Xavier
90dbc26c46 Merge pull request #9 from KaylorBen/master
fix: for nix flake
2024-11-18 13:31:31 +03:00
Benjamin Kaylor
54cc830c35 sed flake preBuild 2024-11-18 03:23:32 -07:00
Benjamin Kaylor
4928ff5b74 undo pyproject.toml edit 2024-11-18 03:20:55 -07:00
Benjamin Kaylor
bb481fe21a fix for nix flake 2024-11-17 16:21:56 -07:00
benex
0d27b8f652 chore: bump version (v2.7.7) 2024-11-17 20:08:37 +03:00
benex
bdd3aae399 chore: update lockfile 2024-11-17 20:08:08 +03:00
benex
af94cd7eb5 fix: recent anime 2024-11-17 20:07:46 +03:00
benex
54044f9527 chore: bump version (v2.7.6) 2024-11-17 18:46:42 +03:00
benex
1e5c039ece chore: update flake.nix 2024-11-17 18:45:32 +03:00
benex
15555759dc feat: ask user if they want to update on new release 2024-11-17 18:45:17 +03:00
benex
0ed51e05cc chore: fix error is in flake.nix 2024-11-17 17:23:41 +03:00
benex
634ef6febf chore: update lock file 2024-11-17 14:28:02 +03:00
benex
bda4b2dbe1 chore: add flake.nix 2024-11-17 14:27:41 +03:00
benex
f015305e7c chore: add shell.nix 2024-11-17 14:27:32 +03:00
benex
d32b7e917f chore: bump version (v2.7.5) 2024-11-16 22:47:31 +03:00
benex
3b35e80199 chore: update lock file 2024-11-16 22:47:19 +03:00
benex
c65a1a2815 feat: by default check for updates when any command is ran 2024-11-16 22:47:04 +03:00
benex
0b3615c9f5 feat: add default rofi themes 2024-11-16 22:46:19 +03:00
benex
3ac4e1ac71 chore: bump version (v2.7.4) 2024-11-16 22:10:15 +03:00
benex
d62f580d7a docs: update readme 2024-11-16 22:09:41 +03:00
benex
02e35b66cb fix: implement another way to get timestamps from mpv due to issues on nixos 2024-11-16 22:09:41 +03:00
benex
7b11e0a301 feat: add rofi-theme-preview option 2024-11-16 22:09:41 +03:00
benex
aa8b91aed3 chore: remove unnecessary yt-dlp extras 2024-11-16 22:09:41 +03:00
benex
fe0fa97576 fix(player): workaround weird problem with mpv in nixos 2024-11-16 22:09:41 +03:00
Benedict Xavier
92059cd5ed Update README.md 2024-11-16 12:01:55 +03:00
benex
ed3064e3b1 feat(anime_provider): add yugen provider 2024-11-13 20:53:18 +03:00
Benex254
441d1e5e6c chore: bump version (v2.7.3) 2024-11-11 12:57:50 +03:00
Benex254
653b2cf4eb chore: add pyinstaller as dev dep 2024-11-11 12:57:29 +03:00
Benex254
8d4b71e0c8 feat: add entry point for pyinstaller executable 2024-11-11 12:57:29 +03:00
Benex254
29cc6cad09 build: pyinstaller spec 2024-11-11 12:57:28 +03:00
Benex254
8119eef263 docs(readme): update table of contents 2024-11-11 12:57:28 +03:00
Benex254
912c8674cf refactor(player): remove unnecessary orint statement 2024-11-11 12:57:28 +03:00
Benex254
6b3ca236dd fix: auto next episode 2024-11-11 12:57:28 +03:00
benex
f1c352d4ff chore: bump version (v2.7.2) 2024-11-10 12:29:46 +03:00
benex
714533d845 refactor(cli): update config options and set fastanime config environs 2024-11-10 12:06:44 +03:00
benex
56dd25df8d refactor(cli): update config options
This commit updates the configuration options in the CLI module. Specifically, it modifies the "image_previews" option to be platform-dependent, setting it to "True" for non-Windows platforms and "False" for Windows. Additionally, it sets the "normalize_titles" option to "True". These changes improve the behavior and user experience of the CLI.
2024-11-10 12:06:17 +03:00
benex
8248dc53df fix: text preview not showing on windows 2024-11-10 12:05:32 +03:00
Benex254
1a8a187de6 docs: update readme 2024-11-09 00:27:49 +03:00
Benex254
bc86be8c93 chore: bump version 2024-11-09 00:11:20 +03:00
Benex254
75026d4fc5 feat(api): add watch endpoint 2024-11-09 00:11:20 +03:00
Benedict Xavier
f8a5ccb8d2 Update README.md 2024-11-08 22:33:16 +03:00
Benex254
719d1bd187 chore: update deps 2024-11-08 16:26:43 +03:00
Benex254
0dd83463c6 docs: update readme 2024-10-20 11:28:39 +03:00
Benex254
1ee50e8a55 chore: bump version (v2.6.9) 2024-10-20 10:06:02 +03:00
Benex254
ae95c5ea3d docs: update readme 2024-10-20 10:04:58 +03:00
Benex254
d64ad5e11d fix: move quality to stream section in config 2024-10-20 10:03:49 +03:00
Benex254
d1a47c6d44 chore: bump version (v2.6.8) 2024-10-18 22:59:18 +03:00
Benex254
51a834a62f chore: update deps 2024-10-18 22:53:39 +03:00
Benex254
3a030bf6f7 feat: add ability to update fastanime uv installations 2024-10-18 22:53:26 +03:00
Benex254
eb6a6fc82c chore: use uv in fa script 2024-10-18 22:46:44 +03:00
Benex254
437ccd94e4 ci: update to use uv 2024-10-18 22:37:14 +03:00
Benex254
d65868cc30 chore: update workflows to work with uv 2024-10-18 21:50:20 +03:00
Benex254
8678aa6544 Merge branch 'master' into uv 2024-10-18 20:26:55 +03:00
Benex254
00e5141152 chore: bump version (v2.6.7) 2024-10-12 01:08:14 +03:00
Benex254
90e757dfe1 feat: init switch to uv 2024-10-11 11:57:29 +03:00
Benex254
8b471b08e8 chore: init switch to uv 2024-10-11 10:52:18 +03:00
Benex254
158bc5710f docs: update readme 2024-10-11 10:49:53 +03:00
Benex254
a0b946a13d feat: add recent menu 2024-10-11 10:22:23 +03:00
Benex254
b547b75f03 feat: add environment variable that force updating of the cache db 2024-10-11 09:34:40 +03:00
Benex254
58c7427a47 feat(cli:serve): use the full executable path to python 2024-10-06 01:25:22 +03:00
Benex254
6220b9c55d chore: bump version (v2.6.6) 2024-10-06 01:15:15 +03:00
Benex254
6b9b5c131c fix(cli): use str instead of ints in serve 2024-10-06 01:15:05 +03:00
Benex254
212f2af39c chore: bump version (v2.6.5) 2024-10-06 01:05:28 +03:00
Benex254
f7b2b4e0c9 feat: add serve command 2024-10-06 01:04:20 +03:00
Benedict Xavier
a747529279 Update README.md 2024-10-05 19:37:19 +03:00
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
45 changed files with 3426 additions and 1864 deletions

View File

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

View File

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

View File

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

View File

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

850
README.md

File diff suppressed because it is too large Load Diff

5
fa
View File

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

View File

@@ -2,6 +2,7 @@
import importlib
import logging
import os
from typing import TYPE_CHECKING
from .libs.anime_provider import anime_sources
@@ -29,10 +30,21 @@ class AnimeProvider:
PROVIDERS = list(anime_sources.keys())
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.dynamic = dynamic
self.retries = retries
self.cache_requests = cache_requests
self.use_persistent_provider_store = use_persistent_provider_store
self.lazyload_provider(self.provider)
def lazyload_provider(self, provider):
@@ -45,7 +57,9 @@ class AnimeProvider:
package = f"fastanime.libs.anime_provider.{provider}"
provider_api = importlib.import_module(".api", package)
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(
self,

View File

@@ -1,13 +1,16 @@
import re
from datetime import datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
COMMA_REGEX = re.compile(r"([0-9]{3})(?=\d)")
# TODO: Add formating options for the final date
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']}"
else:
return "Unknown"
@@ -27,6 +30,12 @@ def format_list_data_with_comma(data: list | 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"):
if airing_episode:
return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"

View File

@@ -9,10 +9,12 @@ anime_normalizer_raw = {
"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",
'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",
},
"hianime": {"My Star": "Oshi no Ko"},
"animepahe": {"Azumanga Daiou The Animation": "Azumanga Daioh"},
"nyaa": {},
"yugen": {},
}
@@ -20,7 +22,7 @@ def get_anime_normalizer():
"""Used because there are different providers"""
import os
current_provider = os.environ["FASTANIME_PROVIDER"]
current_provider = os.environ.get("FASTANIME_PROVIDER", "allanime")
return anime_normalizer_raw[current_provider]

View File

@@ -16,6 +16,7 @@ logger = logging.getLogger(__name__)
class YtDLPDownloader:
downloads_queue = Queue()
_thread = None
def _worker(self):
while True:
@@ -26,11 +27,6 @@ class YtDLPDownloader:
logger.error(f"Something went wrong {e}")
self.downloads_queue.task_done()
def __init__(self):
self._thread = Thread(target=self._worker)
self._thread.daemon = True
self._thread.start()
def _download_file(
self,
url: str,
@@ -38,6 +34,7 @@ class YtDLPDownloader:
episode_title: str,
download_dir: str,
silent: bool,
progress_hooks=[],
vid_format: str = "best",
force_unknown_ext=False,
verbose=False,
@@ -86,6 +83,7 @@ class YtDLPDownloader:
"verbose": verbose,
"format": vid_format,
"compat_opts": ("allow-unsafe-ext",) if force_unknown_ext else tuple(),
"progress_hooks": progress_hooks,
}
urls = [url]
if sub:
@@ -174,8 +172,15 @@ class YtDLPDownloader:
except Exception as e:
print(f"[red bold]An error[/] occurred: {e}")
# WARN: May remove this legacy functionality
def download_file(self, url: str, title, silent=True):
def download_file(
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
Args:
@@ -183,7 +188,17 @@ class YtDLPDownloader:
silent ([TODO:parameter]): [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()

View File

@@ -37,6 +37,10 @@ def anime_title_percentage_match(
title_a = str(anime["title"]["romaji"])
title_b = str(anime["title"]["english"])
percentage_ratio = max(
*[
fuzz.ratio(title.lower(), possible_user_requested_anime_title.lower())
for title in anime["synonyms"]
],
fuzz.ratio(title_a.lower(), possible_user_requested_anime_title.lower()),
fuzz.ratio(title_b.lower(), possible_user_requested_anime_title.lower()),
)

View File

@@ -2,11 +2,11 @@ import sys
if sys.version_info < (3, 10):
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
__version__ = "v2.5.8"
__version__ = "v2.7.8"
APP_NAME = "FastAnime"
AUTHOR = "Benex254"

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -45,8 +45,9 @@ def check_for_updates():
return (is_latest, release_json)
else:
print("Failed to check for updates")
print(request.text)
return (False, {})
return (True, {})
def is_git_repo(author, repository):
@@ -75,9 +76,9 @@ def is_git_repo(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()
if is_latest:
if is_latest and not force:
print("[green]App is up to date[/]")
return False, release_json
tag_name = release_json["tag_name"]
@@ -101,8 +102,10 @@ def update_app():
)
else:
if PIPX_EXECUTABLE := shutil.which("pipx"):
process = subprocess.run([PIPX_EXECUTABLE, "upgrade", APP_NAME])
if UV := shutil.which("uv"):
process = subprocess.run([UV, "tool", "upgrade", APP_NAME])
elif PIPX := shutil.which("pipx"):
process = subprocess.run([PIPX, "upgrade", APP_NAME])
else:
PYTHON_EXECUTABLE = sys.executable

View File

@@ -361,9 +361,9 @@ def download(
episode_title,
download_dir,
silent,
config.format,
force_unknown_ext,
verbose,
vid_format=config.format,
force_unknown_ext=force_unknown_ext,
verbose=verbose,
headers=provider_headers,
sub=subtitles[0]["url"] if subtitles else "",
merge=merge,

View File

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

View File

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

View File

@@ -8,7 +8,9 @@ from ..constants import (
USER_CONFIG_PATH,
USER_DATA_PATH,
USER_VIDEOS_DIR,
ASSETS_DIR,
USER_WATCH_HISTORY_PATH,
S_PLATFORM,
)
from ..libs.rofi import Rofi
@@ -26,43 +28,49 @@ class Config(object):
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
)
anime_provider: "AnimeProvider"
user_data = {"watch_history": {}, "animelist": [], "user": {}}
user_data = {"recent_anime": [], "animelist": [], "user": {}}
default_config = {
"quality": "1080",
"auto_next": "False",
"auto_select": "True",
"sort_by": "search match",
"downloads_dir": USER_VIDEOS_DIR,
"translation_type": "sub",
"server": "top",
"continue_from_history": "True",
"preferred_history": "local",
"use_python_mpv": "false",
"force_window": "immediate",
"preferred_language": "english",
"use_fzf": "False",
"preview": "False",
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
"provider": "allanime",
"icons": "false",
"notification_duration": "2",
"skip": "false",
"use_rofi": "false",
"rofi_theme": "",
"rofi_theme_input": "",
"rofi_theme_confirm": "",
"ffmpegthumbnailer_seek_time": "-1",
"sub_lang": "eng",
"normalize_titles": "true",
"player": "mpv",
"episode_complete_at": "80",
"force_forward_tracking": "true",
"default_media_list_tracking": "None",
"cache_requests": "true",
"check_for_updates": "True",
"continue_from_history": "True",
"default_media_list_tracking": "None",
"downloads_dir": USER_VIDEOS_DIR,
"disable_mpv_popen": "True",
"episode_complete_at": "80",
"ffmpegthumbnailer_seek_time": "-1",
"force_forward_tracking": "true",
"force_window": "immediate",
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
"icons": "false",
"image_previews": "True" if S_PLATFORM != "win32" else "False",
"normalize_titles": "True",
"notification_duration": "2",
"player": "mpv",
"preferred_history": "local",
"preferred_language": "english",
"preview": "False",
"provider": "allanime",
"quality": "1080",
"recent": "50",
"rofi_theme": os.path.join(ASSETS_DIR, "rofi_theme.rasi"),
"rofi_theme_preview": os.path.join(ASSETS_DIR, "rofi_theme_preview.rasi"),
"rofi_theme_confirm": os.path.join(ASSETS_DIR, "rofi_theme_confirm.rasi"),
"rofi_theme_input": os.path.join(ASSETS_DIR, "rofi_theme_input.rasi"),
"server": "top",
"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:
self.initialize_user_data_and_watch_history()
self.initialize_user_data_and_watch_history_recent_anime()
self.load_config()
def load_config(self):
@@ -75,40 +83,56 @@ class Config(object):
if os.path.exists(USER_CONFIG_PATH):
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()
# TODO: rewrite all this removing the useless functions
# hate technical debt
# why did i do this lol
self.auto_next = self.get_auto_next()
self.normalize_titles = self.get_normalize_titles()
self.auto_select = self.get_auto_select()
self.use_python_mpv = self.get_use_mpv_mod()
self.quality = self.get_quality()
self.notification_duration = self.get_notification_duration()
self.episode_complete_at = self.get_episode_complete_at()
self.default_media_list_tracking = self.get_default_media_list_tracking()
self.force_forward_tracking = self.get_force_forward_tracking()
self.cache_requests = self.get_cache_requests()
self.server = self.get_server()
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.check_for_updates = self.configparser.get("general", "check_for_updates")
self.continue_from_history = self.get_continue_from_history()
self.default_media_list_tracking = self.get_default_media_list_tracking()
self.disable_mpv_popen = self.configparser.getboolean(
"stream", "disable_mpv_popen"
)
self.downloads_dir = self.get_downloads_dir()
self.episode_complete_at = self.get_episode_complete_at()
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()
self.rofi_theme_preview = self.get_rofi_theme_preview()
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
Rofi.rofi_theme_input = self.rofi_theme_input
Rofi.rofi_theme = self.rofi_theme
Rofi.rofi_theme_preview = self.rofi_theme_preview
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 ------
self.anime_list: list = self.user_data.get("animelist", [])
self.user: dict = self.user_data.get("user", {})
@@ -128,6 +152,20 @@ class Config(object):
self.user_data["user"] = user
self._update_user_data()
def update_recent(self, recent_anime: list):
recent_anime_ids = []
_recent_anime = []
for anime in recent_anime:
if (
anime["id"] not in recent_anime_ids
and len(recent_anime_ids) <= self.recent
):
_recent_anime.append(anime)
recent_anime_ids.append(anime["id"])
self.user_data["recent_anime"] = _recent_anime
self._update_user_data()
def media_list_track(
self,
anime_id: int,
@@ -149,7 +187,7 @@ class Config(object):
with open(USER_WATCH_HISTORY_PATH, "w") as f:
json.dump(self.watch_history, f)
def initialize_user_data_and_watch_history(self):
def initialize_user_data_and_watch_history_recent_anime(self):
try:
if os.path.isfile(USER_DATA_PATH):
with open(USER_DATA_PATH, "r") as f:
@@ -191,12 +229,18 @@ class Config(object):
def get_icons(self):
return self.configparser.getboolean("general", "icons")
def get_image_previews(self):
return self.configparser.getboolean("general", "image_previews")
def get_preview(self):
return self.configparser.getboolean("general", "preview")
def get_use_fzf(self):
return self.configparser.getboolean("general", "use_fzf")
def get_use_persistent_provider_store(self):
return self.configparser.getboolean("general", "use_persistent_provider_store")
# rofi conifiguration
def get_use_rofi(self):
return self.configparser.getboolean("general", "use_rofi")
@@ -204,6 +248,9 @@ class Config(object):
def get_rofi_theme(self):
return self.configparser.get("general", "rofi_theme")
def get_rofi_theme_preview(self):
return self.configparser.get("general", "rofi_theme_preview")
def get_rofi_theme_input(self):
return self.configparser.get("general", "rofi_theme_input")
@@ -222,6 +269,9 @@ class Config(object):
def get_normalize_titles(self):
return self.configparser.getboolean("general", "normalize_titles")
def get_recent(self):
return self.configparser.getint("general", "recent")
# --- stream section ---
def get_skip(self):
return self.configparser.getboolean("stream", "skip")
@@ -292,19 +342,17 @@ class Config(object):
# be sure to also give the replacement emoji
icons = {self.icons}
# the quality of the stream [1080,720,480,360]
# this option is usually only reliable when:
# provider=animepahe
# since it provides links that actually point to streams of different qualities
# while the rest just point to another link that can provide the anime from the same server
quality = {self.quality}
# whether to normalize provider titles [True/False]
# 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 = {self.normalize_titles}
# whether to check for updates every time you run the script [True/False]
# this is useful for keeping your script up to date
# cause there are always new features being added 😄
check_for_updates = {self.check_for_updates}
# can be [allanime, animepahe, hianime]
# allanime is the most realible
# animepahe provides different links to streams of different quality so a quality can be selected reliably with --quality option
@@ -328,6 +376,9 @@ downloads_dir = {self.downloads_dir}
# try it and you will see
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]
# -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
@@ -350,6 +401,8 @@ use_rofi = {self.use_rofi}
# by the way i recommend getting the rofi themes from this project;
rofi_theme = {self.rofi_theme}
rofi_theme_preview = {self.rofi_theme_preview}
rofi_theme_input = {self.rofi_theme_input}
rofi_theme_confirm = {self.rofi_theme_confirm}
@@ -379,12 +432,31 @@ 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 from a locally
# 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]
# the quality of the stream [1080,720,480,360]
# this option is usually only reliable when:
# provider=animepahe
# since it provides links that actually point to streams of different qualities
# while the rest just point to another link that can provide the anime from the same server
quality = {self.quality}
# Auto continue from watch history [True/False]
# this will make fastanime to choose the episode that you last watched to completion
# and increment it by one
@@ -443,7 +515,9 @@ episode_complete_at = {self.episode_complete_at}
# 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
# 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
@@ -455,6 +529,15 @@ episode_complete_at = {self.episode_complete_at}
# or just switch to arch linux
use_python_mpv = {self.use_python_mpv}
# whether to use popen to get the timestamps for continue_from_history
# implemented because popen does not work for some reason in nixos
# if you are on nixos and you have a solution to this problem please share
# i will be glad to hear it 😄
# So for now ignore this option
# and anyways the new method of getting timestamps is better
disable_mpv_popen = {self.disable_mpv_popen}
# force mpv window
# the default 'immediate' just makes mpv to open the window even if the video has not yet loaded
# done for asthetics

View File

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

View File

@@ -9,7 +9,7 @@ from threading import Thread
import requests
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 ...Utility import anilist_data_helper
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
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")
if not os.path.exists(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
"""
image = requests.get(url)
with open(f"{IMAGES_CACHE_DIR}/{file_name}", "wb") as f:
with open(os.path.join(IMAGES_CACHE_DIR,f"{file_name}.png"), "wb") as f:
f.write(image.content)
@@ -74,7 +76,7 @@ def save_info_from_str(info: str, file_name: str):
info: the information anilist has on the anime
file_name: the filename to use
"""
with open(f"{ANIME_INFO_CACHE_DIR}/{file_name}", "w") as f:
with open(os.path.join(ANIME_INFO_CACHE_DIR,file_name,), "w",encoding="utf-8") as f:
f.write(info)
@@ -91,18 +93,16 @@ def write_search_results(
workers:number of threads to use defaults to as many as possible
"""
# 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
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
future_to_task = {}
for anime, title in zip(anilist_results, titles):
# actual image url
if os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower() == "true":
image_url = anime["coverImage"]["large"]
future_to_task[executor.submit(save_image_from_url, image_url, title)] = (
image_url
)
future_to_task[
executor.submit(save_image_from_url, image_url, title)
] = image_url
mediaListName = "Not in any of your lists"
progress = "UNKNOWN"
@@ -111,28 +111,57 @@ def write_search_results(
progress = anime_list["progress"]
# handle the text data
template = f"""
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
{get_true_fg('Title(jp):',*HEADER_COLOR)} {anime['title']['romaji']}
{get_true_fg('Title(eng):',*HEADER_COLOR)} {anime['title']['english']}
{get_true_fg('Popularity:',*HEADER_COLOR)} {anime['popularity']}
{get_true_fg('Favourites:',*HEADER_COLOR)} {anime['favourites']}
{get_true_fg('Status:',*HEADER_COLOR)} {anime['status']}
{get_true_fg('Episodes:',*HEADER_COLOR)} {anime['episodes']}
{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres'])}
{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode'])}
{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate'])}
{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate'])}
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
{get_true_fg('Media List:',*HEADER_COLOR)} {mediaListName}
{get_true_fg('Progress:',*HEADER_COLOR)} {progress}
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
{get_true_fg('Description:',*HEADER_COLOR)}
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('Title(jp):',*HEADER_COLOR)} {(anime['title']['romaji'] or "").replace('"',SINGLE_QUOTE)}"
echo "{get_true_fg('Title(eng):',*HEADER_COLOR)} {(anime['title']['english'] or "").replace('"',SINGLE_QUOTE)}"
ll=2
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
echo -n -e "{get_true_fg("",*SEPARATOR_COLOR,bold=False)}"
((ll++))
done
echo
echo "{get_true_fg('Popularity:',*HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime['popularity'])}"
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 = f"""
{template}
echo "
{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
@@ -212,8 +241,8 @@ def get_fzf_manga_preview(manga_results, workers=None, wait=False):
background_worker = Thread(
target=_worker,
)
# ensure images and info exists
background_worker.daemon = True
# ensure images and info exists
background_worker.start()
# 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
template = textwrap.dedent(
f"""
{get_true_fg('Anime Title:',*HEADER_COLOR)} {anilist_result['title']['romaji'] or anilist_result['title']['english']}
{get_true_fg('Episode Title:',*HEADER_COLOR)} {episode_title}
ll=2
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[
@@ -289,22 +323,56 @@ def get_fzf_episode_preview(
background_worker = Thread(
target=_worker,
)
# ensure images and info exists
background_worker.daemon = True
# ensure images and info exists
background_worker.start()
# 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"
if S_PLATFORM == "win32":
preview = """
%s
if [ -s %s/{} ]; then fzf-preview %s/{}
else echo Loading...
title={}
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
if [ -s %s/{} ]; then cat %s/{}
echo
else
echo Loading...
fi
fi
if [ -s "%s\\\\\\$title" ]; then source "%s\\\\\\$title"
else echo Loading...
fi
""" % (
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,
ANIME_INFO_CACHE_DIR,
@@ -329,7 +397,6 @@ def get_fzf_anime_preview(
THe fzf preview script to use
"""
# ensure images and info exists
from ...constants import S_PLATFORM
background_worker = Thread(
target=write_search_results, args=(anilist_results, titles)
@@ -343,18 +410,26 @@ def get_fzf_anime_preview(
preview = """
%s
title={}
show_image_previews="%s"
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
if [ -s "%s\\\\\\$title" ]; then
if command -v chafa >/dev/null;then
chafa -f kitty -s $dim "%s\\\\\\$title"
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
else echo Loading...
echo
else
echo Loading...
fi
if [ -s "%s\\\\\\$title" ]; then cat "%s\\\\\\$title"
fi
if [ -s "%s\\\\\\$title" ]; then source "%s\\\\\\$title"
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
@@ -363,14 +438,19 @@ def get_fzf_anime_preview(
else:
preview = """
%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...
fi
if [ -s %s/{} ]; then cat %s/{}
fi
if [ -s "%s/$title" ]; then source "%s/$title"
else echo Loading...
fi
""" % (
fzf_preview,
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR,
ANIME_INFO_CACHE_DIR,

View File

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

14
fastanime/fastanime.py Executable file
View File

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

View File

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

View File

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

View File

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

View File

@@ -14,11 +14,7 @@ class AnimeProvider:
USER_AGENT = random_user_agent()
HEADERS = {}
def __init__(
self,
cache_requests=os.environ.get("FASTANIME_CACHE_REQUESTS", "false"),
store_type=os.environ.get("FASTANIME_PROVIDER_STORE_TYPE", "persistent"),
) -> None:
def __init__(self, cache_requests, use_persistent_provider_store) -> None:
if cache_requests.lower() == "true":
from ..common.requests_cacher import CachedRequestsSession
@@ -28,7 +24,7 @@ class AnimeProvider:
else:
self.session = requests.session()
self.session.headers.update({"User-Agent": self.USER_AGENT, **self.HEADERS})
if store_type == "persistent":
if use_persistent_provider_store.lower() == "true":
self.store = ProviderStore(
"persistent",
self.PROVIDER,

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ class FZF:
"--info=hidden",
"--layout=reverse",
"--height=100%",
"--bind=right:accept",
"--bind=right:accept,ctrl-/:toggle-preview,ctrl-space:toggle-wrap+toggle-preview-wrap",
"--no-margin",
"+m",
"-i",

View File

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

61
flake.lock generated Normal file
View File

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

53
flake.nix Normal file
View File

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

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"

1365
poetry.lock generated

File diff suppressed because it is too large Load Diff

65
pyinstaller.spec Normal file
View File

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

View File

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

18
shell.nix Normal file
View File

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

18
tox.ini
View File

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

1207
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff