Compare commits

..

49 Commits

Author SHA1 Message Date
Benexl
06506fb47f chore: update lock files 2025-07-24 14:18:01 +03:00
Benexl
29480c64cd chore: update lock files 2025-07-24 14:14:58 +03:00
Benexl
474da2f1fd chore: bump version (v2.9.9) 2025-07-24 14:14:44 +03:00
Benexl
abac604ccd Merge pull request #97 from cornservant/feat/no-check-certificate-flag
Add a --no-check-certificate flag
2025-07-24 14:11:30 +03:00
Long Huynh Huu
27d71cbb23 feat: add --no-check-certificate flag 2025-07-09 03:52:19 +02:00
Long Huynh Huu
615b420c74 feat: reduce inefficient double copy (for determinate nix) 2025-07-09 03:45:43 +02:00
Long Huynh Huu
f9c2b6e939 fix: dev shell 2025-07-09 03:45:27 +02:00
Benexl
fd448ad701 Update README.md 2025-07-07 19:12:17 +03:00
Benexl
b223a34879 Update FUNDING.yml 2025-07-07 19:09:38 +03:00
Benexl
76460b6c54 chore: remove deprected attr 2025-07-04 16:53:20 +03:00
Benexl
e58fd33fe0 feat: allow going back on empty search term when using fzf anilist search 2025-07-04 16:44:58 +03:00
Benexl
931f9f10f8 chore: update deps 2025-07-04 16:39:01 +03:00
Benexl
0e9dbd2c6b feat: make experimental fzf anilist search disablable lol 2025-07-04 16:33:18 +03:00
Benexl
3bdfa27e1c feat: experimental search using fzf reload 2025-07-04 16:20:48 +03:00
Benedict Xavier
f46f09ffdf Merge pull request #91 from DerDestroyer/episode-number-animepahe
fix: fixed episode number for animepahe with multiple seasons
2025-05-12 13:09:39 +03:00
Benedict Xavier
d309c04214 Merge pull request #92 from DerDestroyer/anime-relations
fix: fixed relations menu and only show ANIME
2025-05-12 13:07:37 +03:00
Benedict Xavier
b19a323d15 Merge pull request #93 from iMithrellas/manga-icat
This pull request introduces a new manga viewer option, icat.
2025-05-12 13:04:55 +03:00
iMithrellas
ff94edfd05 fix: unbound test error 2025-05-07 01:01:13 +02:00
iMithrellas
59e1a82646 feat: config option for selecting manga viewer 2025-05-07 00:44:39 +02:00
iMithrellas
2e902fa4e7 feat: PoC icat for viewing manga 2025-05-07 00:13:00 +02:00
DerDestroyer
d2e17af7a9 fix .5 episodes being numbered as whole episodes 2025-05-04 20:13:50 +02:00
DerDestroyer
f1fa40c419 fixed relations menu and only show ANIME 2025-05-02 01:51:18 +02:00
DerDestroyer
8bbde97403 fixed episode number for animepahe with multiple seasons 2025-05-01 02:30:56 +02:00
Benexl
a2b7d71eb2 Merge branch 'sudoAlphaX-main-menu-on-blacnk-search' 2025-03-30 21:48:33 +03:00
Alpha
67b4f0ea38 Merge branch 'master' into main-menu-on-blacnk-search 2025-03-27 08:22:49 +00:00
Benedict Xavier
a6d5d5f37c Merge pull request #82 from sudoAlphaX/runtime-auto-next
feat: toggle auto-next during runtime from media_player_controls
2025-03-18 04:59:57 +03:00
Alpha
67a066f16e feat: toggle auto-next during runtime from media_player_controls
Allows user to set or unset auto-next episode from media_player_controls
during runtime. This feature was only available in media_actions_menu or
config file.
2025-03-18 06:49:20 +05:30
Alpha
e6297619d4 feat: return to main menu on blank search term
Return to fastanime_main_menu on blank search term. Can be used to
cancel the search.
2025-03-16 19:04:29 +05:30
Benedict Xavier
8f514858f2 Merge pull request #80 from sudoAlphaX/hide-next-episode-button-on-last
feat: hide next episode button on reaching last episode
2025-03-16 14:42:09 +03:00
Alpha
eb9cffbd7a feat: hide next episode button on reaching last episode
Hides the next episode button if the currently completed episode is the
last available episode on the server. Also affects auto-next feature,
where it returns to media actions menu on completion of last episode.
2025-03-16 16:06:46 +05:30
Benexl
c5f9c37d3a fix: preview images not showing in rofi menu 2025-03-16 09:46:23 +03:00
Benedict Xavier
44fd65ebab Merge pull request #74 from crispy-caesus/patch-1
remove yugen from description
2025-03-03 06:22:48 +03:00
crispy-caesus
e919980ff7 remove yugen from description
yugen and gogoanime shut down
2025-03-02 12:37:01 +01:00
Benedict Xavier
6887c6ff10 Merge pull request #72 from sudoAlphaX/multiple-download-ranges
feat: multiple download ranges in download in menu
2025-03-02 12:12:59 +03:00
Benedict Xavier
b394de0b23 Merge pull request #71 from crasband1/use_preffered_history_config_option
fix: config preferred_history option was unused
2025-02-24 08:45:59 +03:00
Benedict Xavier
71003049d6 Merge branch 'master' into use_preffered_history_config_option 2025-02-24 08:45:41 +03:00
Benexl
6f69b785d8 feat(config): mpv pre args 2025-02-23 20:52:52 +03:00
Alpha
6756540fd1 feat: multiple download ranges in download in menu
Improvement to 98e41e1e which allows selection of multiple download
ranges.

Example: :3 5:7 10 13 15:16 19:
will download 1,2,3,5,7,10,13,15,16,19 till the end

Variables can be named better.
2025-02-23 23:07:09 +05:30
Benexl
e6b9df25dd feat: ensure the environs externally provided by user are preferred 2025-02-23 20:28:41 +03:00
Benexl
f40dd2363a feat(updater): add instructions for post update 2025-02-23 20:24:26 +03:00
Benexl
61a525ff94 feat(config): pass custom mpv args 2025-02-23 20:18:23 +03:00
relive010
27d671f89d fix: config preferred_history option was unused due to switched if statements 2025-02-23 10:14:02 -07:00
Benedict Xavier
58edb0427f Merge pull request #70 from crasband1/not_history_continue_off_by_one_fix
fix: fixed off by one error in condition of continuing anime from Wat…
2025-02-23 19:39:09 +03:00
Benedict Xavier
7abcdc8f7c Merge pull request #69 from sudoAlphaX/move-next-eps-button-position
refactor: just move next episode to the top
2025-02-23 19:35:51 +03:00
Benedict Xavier
ca4ca0d476 Merge pull request #68 from sudoAlphaX/download-menu-range-fix
fix: download in menu range fix
2025-02-23 19:33:51 +03:00
relive010
5a337b1c97 fix: fixed off by one error in condition of continuing anime from Watching tab when logged into anilist when the anime is not in watch_history.json 2025-02-23 09:00:30 -07:00
Alpha
6c94dd22fc refactor: just move next episode to the top
When watching episodes, it makes sense to go to the next episode after
completing the current one. Currently, when an episode is completed,
replay button appears first in media_player_controls menu.

This patch just moves replay button below such that the next episode
button takes priority, and the user can watch the next episode with a
single key press (<return>) which is less immersion breaking that
(<down> <return>).
2025-02-23 16:48:53 +05:30
Alpha
8c7e1e201f fix: download in menu range fix
When using the download in menu feature introduced by 98e41e1,
downloading using range (3:5) doesn't work as expected. It starts
incremented by 1. Example: 3:5 selects episodes 4 and 5.

This patch addresses this issue by simply decrementing the start_episode
variable by 1 before adding adding to range list.
2025-02-23 16:44:19 +05:30
Benexl
98e41e1eb5 feat: download anime menu option 2025-02-23 09:53:31 +03:00
21 changed files with 1702 additions and 875 deletions

2
.github/FUNDING.yml vendored
View File

@@ -3,7 +3,7 @@
github: benexl # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: benexl # Replace with a single Ko-fi username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username

1
.gitignore vendored
View File

@@ -177,3 +177,4 @@ app/View/SearchScreen/search_screen.py~
app/user_data.json
.buildozer
result
repomix-output.xml

View File

@@ -831,12 +831,10 @@ More pr's less issues 🙃
Show your support by starring the GitHub repository.
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y8ZAA7N)
## Disclaimer
> [!IMPORTANT]
>
> This project currently scrapes allanime, hianime, nyaa, yugen and animepahe.
> This project currently scrapes allanime, hianime, nyaa and animepahe.
> The developer(s) of this application does not have any affiliation with the content providers available, and this application hosts zero content.
> [DISCLAIMER](https://github.com/Benexl/FastAnime/blob/master/DISCLAIMER.md)

View File

@@ -46,6 +46,7 @@ class YtDLPDownloader:
force_ffmpeg=False,
hls_use_mpegts=False,
hls_use_h264=False,
nocheckcertificate=False,
):
"""Helper function that downloads anime given url and path details
@@ -87,6 +88,7 @@ class YtDLPDownloader:
"format": vid_format,
"compat_opts": ("allow-unsafe-ext",) if force_unknown_ext else tuple(),
"progress_hooks": progress_hooks,
"nocheckcertificate": nocheckcertificate,
}
urls = [url]
if sub:

View File

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

View File

@@ -133,10 +133,14 @@ def update_app(force=False):
if sys.prefix == sys.base_prefix:
# ensure NOT in a venv, where --user flag can cause an error.
# TODO: Get value of 'include-system-site-packages' in pyenv.cfg.
args.append('--user')
args.append("--user")
process = subprocess.run(args)
if process.returncode == 0:
print(
"[green]Its recommended to run the following after updating:\n\tfastanime config --update (to get the latest config docs)\n\tfastanime cache --clean (to get rid of any potential issues)[/]",
file=sys.stderr,
)
return True, release_json
else:
return False, release_json

View File

@@ -34,7 +34,7 @@ class LazyGroup(click.Group):
# get the Command object from that module
cmd_object = getattr(mod, cmd_object_name)
# check the result to make debugging easier
if not isinstance(cmd_object, click.BaseCommand):
if not isinstance(cmd_object, click.Command):
raise ValueError(
f"Lazy loading of {import_path} failed by returning "
"a non-command object"

View File

@@ -123,6 +123,11 @@ from .data import (
is_flag=True,
help="Use H.264 (MP4) for hls streams, resulted in .mp4 file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
)
@click.option(
"--no-check-certificates",
is_flag=True,
help="Suppress HTTPS certificate validation",
)
@click.option(
"--max-results", "-M", type=int, help="The maximum number of results to show"
)
@@ -149,6 +154,7 @@ def download(
force_ffmpeg,
hls_use_mpegts,
hls_use_h264,
no_check_certificates,
max_results,
):
from rich import print
@@ -386,6 +392,7 @@ def download(
force_ffmpeg=force_ffmpeg,
hls_use_mpegts=hls_use_mpegts,
hls_use_h264=hls_use_h264,
nocheckcertificate=no_check_certificates,
)
except Exception as e:
print(e)

View File

@@ -129,6 +129,11 @@ if TYPE_CHECKING:
is_flag=True,
help="Use H.264 (MP4) for hls streams, resulted in .mp4 file (useful for some streams: see Docs) (this option forces --force-ffmpeg to be True)",
)
@click.option(
"--no-check-certificates",
is_flag=True,
help="Suppress HTTPS certificate validation",
)
@click.pass_obj
def download(
config: "Config",
@@ -145,6 +150,7 @@ def download(
force_ffmpeg,
hls_use_mpegts,
hls_use_h264,
no_check_certificates,
):
import time
@@ -164,7 +170,7 @@ def download(
move_preferred_subtitle_lang_to_top,
)
force_ffmpeg |= (hls_use_mpegts or hls_use_h264)
force_ffmpeg |= hls_use_mpegts or hls_use_h264
anime_provider = AnimeProvider(config.provider)
anilist_anime_info = None
@@ -208,6 +214,7 @@ def download(
force_ffmpeg,
hls_use_mpegts,
hls_use_h264,
no_check_certificates,
)
return
search_results = search_results["results"]
@@ -262,6 +269,7 @@ def download(
force_ffmpeg,
hls_use_mpegts,
hls_use_h264,
no_check_certificates,
)
return
@@ -294,6 +302,7 @@ def download(
else:
episodes_range = sorted(episodes, key=float)
print(f"[green bold]Downloading: [/] {episodes_range}")
if config.normalize_titles:
from ...libs.common.mini_anilist import get_basic_anime_info_by_title
@@ -398,6 +407,7 @@ def download(
force_ffmpeg=force_ffmpeg,
hls_use_mpegts=hls_use_mpegts,
hls_use_h264=hls_use_h264,
nocheckcertificate=no_check_certificates,
)
except Exception as e:
print(e)

View File

@@ -66,7 +66,9 @@ def search(config: "Config", anime_titles: str, episode_range: str):
from yt_dlp.utils import sanitize_filename
from ...MangaProvider import MangaProvider
from ..utils.feh import feh_manga_viewer
from ..utils.icat import icat_manga_viewer
manga_title = anime_titles[0]
@@ -136,7 +138,12 @@ def search(config: "Config", anime_titles: str, episode_range: str):
print(
f"[purple bold]Now Reading: [/] {search_result_manga_title} [cyan bold]Chapter:[/] {chapter_info['title']}"
)
feh_manga_viewer(chapter_info["thumbnails"], str(chapter_info["title"]))
if config.manga_viewer == "feh":
feh_manga_viewer(chapter_info["thumbnails"], str(chapter_info["title"]))
elif config.manga_viewer == "icat":
icat_manga_viewer(
chapter_info["thumbnails"], str(chapter_info["title"])
)
if anilist_helper:
anilist_helper.update_anime_list(
{"mediaId": anilist_id, "progress": chapter_number}

View File

@@ -1,9 +1,13 @@
import json
import logging
import os
import sys
import time
from configparser import ConfigParser
from typing import TYPE_CHECKING
from rich import print
from ..constants import (
ASSETS_DIR,
S_PLATFORM,
@@ -38,6 +42,7 @@ class Config(object):
default_config = {
"auto_next": "False",
"menu_order": "",
"manga_viewer": "feh",
"auto_select": "True",
"cache_requests": "true",
"check_for_updates": "True",
@@ -47,6 +52,7 @@ class Config(object):
"disable_mpv_popen": "True",
"discord": "False",
"episode_complete_at": "80",
"use_experimental_fzf_anilist_search": "True",
"ffmpegthumbnailer_seek_time": "-1",
"force_forward_tracking": "true",
"force_window": "immediate",
@@ -60,6 +66,8 @@ class Config(object):
"normalize_titles": "True",
"notification_duration": "120",
"max_cache_lifetime": "03:00:00",
"mpv_args": "",
"mpv_pre_args": "",
"per_page": "15",
"player": "mpv",
"preferred_history": "local",
@@ -96,8 +104,16 @@ class Config(object):
self.configparser.add_section("anilist")
# --- set config values from file or using defaults ---
if os.path.exists(USER_CONFIG_PATH) and not no_config:
self.configparser.read(USER_CONFIG_PATH, encoding="utf-8")
try:
if os.path.exists(USER_CONFIG_PATH) and not no_config:
self.configparser.read(USER_CONFIG_PATH, encoding="utf-8")
except Exception as e:
print(
"[yellow]Warning[/]: Failed to read config file using default configuration",
file=sys.stderr,
)
logger.error(f"Failed to read config file: {e}")
time.sleep(5)
# get the configuration
self.auto_next = self.configparser.getboolean("stream", "auto_next")
@@ -120,6 +136,9 @@ class Config(object):
self.episode_complete_at = self.configparser.getint(
"stream", "episode_complete_at"
)
self.use_experimental_fzf_anilist_search = self.configparser.getboolean(
"general", "use_experimental_fzf_anilist_search"
)
self.ffmpegthumbnailer_seek_time = self.configparser.getint(
"general", "ffmpegthumbnailer_seek_time"
)
@@ -149,6 +168,8 @@ class Config(object):
+ max_cache_lifetime[1] * 3600
+ max_cache_lifetime[2] * 60
)
self.mpv_args = self.configparser.get("general", "mpv_args")
self.mpv_pre_args = self.configparser.get("general", "mpv_pre_args")
self.per_page = self.configparser.get("anilist", "per_page")
self.player = self.configparser.get("stream", "player")
self.preferred_history = self.configparser.get("stream", "preferred_history")
@@ -171,6 +192,7 @@ class Config(object):
self.skip = self.configparser.getboolean("stream", "skip")
self.sort_by = self.configparser.get("anilist", "sort_by")
self.menu_order = self.configparser.get("general", "menu_order")
self.manga_viewer = self.configparser.get("general", "manga_viewer")
self.sub_lang = self.configparser.get("general", "sub_lang")
self.translation_type = self.configparser.get("stream", "translation_type")
self.use_fzf = self.configparser.getboolean("general", "use_fzf")
@@ -198,7 +220,10 @@ class Config(object):
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))))
if not os.environ.get(f"FASTANIME_{key.upper()}"):
current_config.append(
(f"FASTANIME_{key.upper()}", str(getattr(self, key)))
)
os.environ.update(current_config)
def update_user(self, user):
@@ -448,6 +473,27 @@ recent = {self.recent}
# https://discord.com/oauth2/authorize?client_id=1292070065583165512
discord = {self.discord}
# comma separated list of args that will be passed to mpv
# example: --vo=kitty,--fullscreen,--volume=50
mpv_args = {self.mpv_args}
# command line options passed before the mpv command
# example: kitty
# useful incase of wanting to run sth like: kitty mpv --vo=kitty <url>
mpv_pre_args = {self.mpv_pre_args}
# choose manga viewer [feh/icat]
# feh is the default and requires feh to be installed
# icat is for kitty terminal users only
manga_viewer = {self.manga_viewer}
# a little little something i introduced
# remember how in a browser site when you search for an anime it dynamically reloads
# after every type
# well who says it cant be done in the terminal lol
# though its still experimental lol
# use this to disable it
use_experimental_fzf_anilist_search = {self.use_experimental_fzf_anilist_search}
[stream]
# the quality of the stream [1080,720,480,360]

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import os
import random
import threading
from hashlib import sha256
from typing import TYPE_CHECKING
from click import clear
@@ -56,8 +57,10 @@ def calculate_percentage_completion(start_time, end_time):
except Exception:
return 0
def discord_updater(show,episode,switch):
discord.discord_connect(show,episode,switch)
def discord_updater(show, episode, switch):
discord.discord_connect(show, episode, switch)
def media_player_controls(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
@@ -326,30 +329,56 @@ def media_player_controls(
media_player_controls(config, fastanime_runtime_state)
icons = config.icons
options = {
f"{'🔂 ' if icons else ''}Replay": _replay,
f"{'' if icons else ''}Next Episode": _next_episode,
f"{'' if icons else ''}Previous Episode": _previous_episode,
f"{'🗃️ ' if icons else ''}Episodes": _episodes,
f"{'📀 ' if icons else ''}Change Quality": _change_quality,
f"{'🎧 ' if icons else ''}Change Translation Type": _change_translation_type,
f"{'💽 ' if icons else ''}Servers": _servers,
f"{'📱 ' if icons else ''}Main Menu": lambda: fastanime_main_menu(
config, fastanime_runtime_state
),
f"{'📜 ' if icons else ''}Media Actions Menu": lambda: media_actions_menu(
config, fastanime_runtime_state
),
f"{'🔎 ' if icons else ''}Anilist Results Menu": lambda: anilist_results_menu(
config, fastanime_runtime_state
),
f"{'' if icons else ''}Exit": exit_app,
}
options = {}
# Only show Next Episode option if the current episode is not the last one
current_index = available_episodes.index(current_episode_number)
if current_index < len(available_episodes) - 1:
options[f"{' ' if icons else ''}Next Episode"] = _next_episode
def _toggle_auto_next(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
"""helper function to toggle auto next
Args:
config: [TODO:description]
fastanime_runtime_state: [TODO:description]
"""
config.auto_next = not config.auto_next
media_player_controls(config, fastanime_runtime_state)
options.update(
{
f"{'🔂 ' if icons else ''}Replay": _replay,
f"{'' if icons else ''}Previous Episode": _previous_episode,
f"{'🗃️ ' if icons else ''}Episodes": _episodes,
f"{'📀 ' if icons else ''}Change Quality": _change_quality,
f"{'🎧 ' if icons else ''}Change Translation Type": _change_translation_type,
f"{'💠 ' if icons else ''}Toggle auto next episode": lambda: _toggle_auto_next(
config, fastanime_runtime_state
),
f"{'💽 ' if icons else ''}Servers": _servers,
f"{'📱 ' if icons else ''}Main Menu": lambda: fastanime_main_menu(
config, fastanime_runtime_state
),
f"{'📜 ' if icons else ''}Media Actions Menu": lambda: media_actions_menu(
config, fastanime_runtime_state
),
f"{'🔎 ' if icons else ''}Anilist Results Menu": lambda: anilist_results_menu(
config, fastanime_runtime_state
),
f"{'' if icons else ''}Exit": exit_app,
}
)
if config.auto_next:
print("Auto selecting next episode")
_next_episode()
return
if current_index < len(available_episodes) - 1:
print("Auto selecting next episode")
_next_episode()
return
else:
print("Last episode reached")
choices = list(options.keys())
if config.use_fzf:
@@ -517,7 +546,10 @@ def provider_anime_episode_servers_menu(
# update discord activity for user
switch = threading.Event()
if config.discord:
discord_proc = threading.Thread(target=discord_updater, args=(provider_anime_title,current_episode_number,switch))
discord_proc = threading.Thread(
target=discord_updater,
args=(provider_anime_title, current_episode_number, switch),
)
discord_proc.start()
# try to get the timestamp you left off from if available
@@ -701,12 +733,12 @@ def provider_anime_episodes_menu(
# the user watch history thats locally available
# will be preferred over remote
if (
user_watch_history.get(str(anime_id_anilist), {}).get("episode_no")
in available_episodes
config.preferred_history == "local"
or not selected_anime_anilist["mediaListEntry"]
):
if (
config.preferred_history == "local"
or not selected_anime_anilist["mediaListEntry"]
user_watch_history.get(str(anime_id_anilist), {}).get("episode_no")
in available_episodes
):
current_episode_number = user_watch_history[str(anime_id_anilist)][
"episode_no"
@@ -751,6 +783,7 @@ def provider_anime_episodes_menu(
"progress"
)
)
current_episode_number = str(int(current_episode_number) + 1)
if current_episode_number not in available_episodes:
current_episode_number = ""
print(
@@ -778,7 +811,9 @@ def provider_anime_episodes_menu(
)
if not preview:
print("Failed to find bash executable which is necessary for preview with fzf.\nIf you are on Windows, please make sure Git is installed and available in PATH.")
print(
"Failed to find bash executable which is necessary for preview with fzf.\nIf you are on Windows, please make sure Git is installed and available in PATH."
)
current_episode_number = fzf.run(
choices, prompt="Select Episode", header=anime_title, preview=preview
@@ -969,6 +1004,229 @@ def anime_provider_search_results_menu(
fetch_anime_episode(config, fastanime_runtime_state)
def download_anime(config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"):
import time
from rich.prompt import Confirm, Prompt
from thefuzz import fuzz
from ...Utility.downloader.downloader import downloader
download_dir = config.downloads_dir
force_unknown_ext = True
verbose = False
silent = True
merge = Confirm.ask("Merge audio and video", default=True)
clean = Confirm.ask("Clean up files", default=True)
prompt = Confirm.ask("Prompt incase for actions while downloading", default=True)
force_ffmpeg = Confirm.ask("Force ffmpeg", default=False)
hls_use_mpegts = Confirm.ask("Use mpegts", default=False)
hls_use_h264 = Confirm.ask("Use h264", default=False)
nocheckcertificate = True
force_ffmpeg |= hls_use_mpegts or hls_use_h264
anime_title = Prompt.ask(
"Anime title", default=fastanime_runtime_state.selected_anime_title_anilist
)
translation_type = Prompt.ask("Translation type", default=config.translation_type)
anime_provider = config.anime_provider
anilist_anime_info = fastanime_runtime_state.selected_anime_anilist
print(f"[green bold]Now Downloading: [/] {anime_title}")
# ---- search for anime ----
with Progress() as progress:
progress.add_task("Fetching Search Results...", total=None)
search_results = anime_provider.search_for_anime(
anime_title, translation_type=translation_type
)
if not search_results:
print("Search results failed")
input("Enter to retry")
return
search_results = search_results["results"]
if not search_results:
print("Nothing muches your search term")
return
search_results_ = {
search_result["title"]: search_result for search_result in search_results
}
if config.auto_select:
selected_anime_title = max(
search_results_.keys(),
key=lambda title: fuzz.ratio(
anime_normalizer.get(title, title), anime_title
),
)
print("[cyan]Auto selecting:[/] ", selected_anime_title)
else:
choices = list(search_results_.keys())
if config.use_fzf:
selected_anime_title = fzf.run(choices, "Please Select title", "FastAnime")
else:
selected_anime_title = fuzzy_inquirer(
choices,
"Please Select title",
)
# ---- fetch anime ----
with Progress() as progress:
progress.add_task("Fetching Anime...", total=None)
anime: Anime | None = anime_provider.get_anime(
search_results_[selected_anime_title]["id"]
)
if not anime:
print("Sth went wring anime no found")
input("Enter to continue...")
return
episodes = sorted(
anime["availableEpisodesDetail"][config.translation_type], key=float
)
episode_ranges = Prompt.ask(
"Enter episode ranges (e.g 1:12 or 1:12:2 or 1: or :12 or 5)"
).split(" ")
episodes_range = []
if episode_ranges != [""]:
for episode_range in episode_ranges:
if ":" in episode_range:
ep_range_tuple = episode_range.split(":")
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
episodes_start, episodes_end = ep_range_tuple
episodes_range += episodes[
int(episodes_start) - 1 : int(episodes_end)
]
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
episodes_start, episodes_end, step = ep_range_tuple
episodes_range += episodes[
int(episodes_start) - 1 : int(episodes_end) : int(step)
]
else:
episodes_start, episodes_end = ep_range_tuple
if episodes_start.strip():
episodes_range += episodes[int(episodes_start) - 1 :]
elif episodes_end.strip():
episodes_range += episodes[: int(episodes_end)]
else:
episodes_range += episodes
else:
episodes_range += [episode_range] if episode_range in episodes else []
else:
episodes_range = sorted(episodes, key=float)
episodes_range = list(
dict.fromkeys(episodes_range)
) # To preserve order while removing duplicates
print(f"[green bold]Downloading: [/] {episodes_range}")
if config.normalize_titles:
from ...libs.common.mini_anilist import get_basic_anime_info_by_title
anilist_anime_info = get_basic_anime_info_by_title(anime["title"])
# lets download em
for episode in episodes_range:
try:
episode = str(episode)
if episode not in episodes:
print(f"[cyan]Warning[/]: Episode {episode} not found, skipping")
continue
with Progress() as progress:
progress.add_task("Fetching Episode Streams...", total=None)
streams = anime_provider.get_episode_streams(
anime["id"], episode, config.translation_type
)
if not streams:
print("No streams skipping")
continue
# ---- fetch servers ----
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server_name = next(streams, None)
if not server_name:
print("Sth went wrong when fetching the server")
continue
stream_link = filter_by_quality(config.quality, server_name["links"])
if not stream_link:
print("[yellow bold]WARNING:[/] No streams found")
time.sleep(1)
print("Continuing...")
continue
link = stream_link["link"]
provider_headers = server_name["headers"]
episode_title = server_name["episode_title"]
subtitles = server_name["subtitles"]
else:
with Progress() as progress:
progress.add_task("Fetching servers", total=None)
# prompt for server selection
servers = {server["server"]: server for server in streams}
servers_names = list(servers.keys())
if config.server in servers_names:
server_name = config.server
else:
if config.use_fzf:
server_name = fzf.run(servers_names, "Select an link")
else:
server_name = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server_name]["links"]
)
if not stream_link:
print("[yellow bold]WARNING:[/] No streams found")
time.sleep(1)
print("Continuing...")
continue
link = stream_link["link"]
provider_headers = servers[server_name]["headers"]
subtitles = servers[server_name]["subtitles"]
episode_title = servers[server_name]["episode_title"]
if anilist_anime_info:
selected_anime_title = (
anilist_anime_info["title"][config.preferred_language]
or anilist_anime_info["title"]["romaji"]
or anilist_anime_info["title"]["english"]
)
import re
if anilist_anime_info.get("streamingEpisodes"):
for episode_detail in anilist_anime_info["streamingEpisodes"]:
if re.match(f"Episode {episode} ", episode_detail["title"]):
episode_title = episode_detail["title"]
break
print(f"[purple]Now Downloading:[/] {episode_title}")
subtitles = move_preferred_subtitle_lang_to_top(subtitles, config.sub_lang)
downloader._download_file(
link,
selected_anime_title,
episode_title,
download_dir,
silent,
vid_format=config.format,
force_unknown_ext=force_unknown_ext,
verbose=verbose,
headers=provider_headers,
sub=subtitles[0]["url"] if subtitles else "",
merge=merge,
clean=clean,
prompt=prompt,
force_ffmpeg=force_ffmpeg,
hls_use_mpegts=hls_use_mpegts,
hls_use_h264=hls_use_h264,
nocheckcertificate=nocheckcertificate,
)
except Exception as e:
print(e)
time.sleep(1)
print("Continuing...")
#
# ---- ANILIST MEDIA ACTIONS MENU ----
#
@@ -1376,7 +1634,10 @@ def media_actions_menu(
media_actions_menu(config, fastanime_runtime_state)
return
relations = relations[1]["data"]["Page"]["relations"] # pyright:ignore
relations = relations[1]["data"]["Media"]["relations"] # pyright:ignore
relations["nodes"] = [
node for node in relations["nodes"] if node.get("type") == "ANIME"
]
fastanime_runtime_state.anilist_results_data = {
"data": {"Page": {"media": relations["nodes"]}} # pyright:ignore
}
@@ -1413,6 +1674,12 @@ def media_actions_menu(
}
anilist_results_menu(config, fastanime_runtime_state)
def _download_anime(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
download_anime(config, fastanime_runtime_state)
media_actions_menu(config, fastanime_runtime_state)
icons = config.icons
options = {
f"{'📽️ ' if icons else ''}Stream ({progress}/{episodes_total})": _stream_anime,
@@ -1428,6 +1695,7 @@ def media_actions_menu(
f"{'🎧 ' if icons else ''}Change Translation Type": _change_translation_type,
f"{'💽 ' if icons else ''}Change Provider": _change_provider,
f"{'💽 ' if icons else ''}Change Player": _change_player,
f"{'📥 ' if icons else ''}Download Anime": _download_anime,
f"{'🔘 ' if icons else ''}Toggle auto select anime": _toggle_auto_select, # WARN: problematic if you choose an anime that doesnt match id
f"{'💠 ' if icons else ''}Toggle auto next episode": _toggle_auto_next,
f"{'🔘 ' if icons else ''}Toggle continue from history": _toggle_continue_from_history,
@@ -1503,7 +1771,9 @@ def anilist_results_menu(
preview = get_fzf_anime_preview(search_results, anime_data.keys())
if not preview:
print("Failed to find bash executable which is necessary for preview with fzf.\nIf you are on Windows, please make sure Git is installed and available in PATH.")
print(
"Failed to find bash executable which is necessary for preview with fzf.\nIf you are on Windows, please make sure Git is installed and available in PATH."
)
selected_anime_title = fzf.run(
choices,
@@ -1524,7 +1794,9 @@ def anilist_results_menu(
get_rofi_icons(search_results, anime_data.keys())
choices = []
for title in anime_data.keys():
icon_path = os.path.join(IMAGES_CACHE_DIR, title)
icon_path = os.path.join(
IMAGES_CACHE_DIR, sha256(title.encode("utf-8")).hexdigest()
)
choices.append(f"{title}\0icon\x1f{icon_path}.png")
choices.append("Back")
selected_anime_title = Rofi.run_with_icons(choices, "Select Anime")
@@ -1672,9 +1944,15 @@ def _anilist_search(config: "Config", page=1):
# TODO: Add filters and other search features
if config.use_rofi:
search_term = str(Rofi.ask("Search for"))
elif config.use_fzf and config.use_experimental_fzf_anilist_search:
search_term = fzf.search_for_anime()
else:
search_term = Prompt.ask("[cyan]Search for[/]")
# Return to main menu if search term is empty
if not search_term.strip():
return False, "Search canceled - return to main menu"
return AniList.search(query=search_term, page=page)

102
fastanime/cli/utils/icat.py Normal file
View File

@@ -0,0 +1,102 @@
import shutil
import subprocess
import sys
import termios
import tty
from sys import exit
from rich.console import Console
from rich.panel import Panel
from rich.align import Align
from rich.text import Text
console = Console()
def get_key():
"""Read a single keypress (including arrows)."""
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
ch1 = sys.stdin.read(1)
if ch1 == "\x1b":
ch2 = sys.stdin.read(2)
return ch1 + ch2
return ch1
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def draw_banner_at(msg: str, row: int):
"""Move cursor to `row`, then render a centered, cyan-bordered panel."""
sys.stdout.write(f"\x1b[{row};1H")
text = Text(msg, justify="center")
panel = Panel(Align(text, align="center"), border_style="cyan", padding=(1, 2))
console.print(panel)
def icat_manga_viewer(image_links: list[str], window_title: str):
ICAT = shutil.which("kitty")
if not ICAT:
console.print("[bold red]kitty (for icat) not found[/]")
exit(1)
idx, total = 0, len(image_links)
title = f"{window_title} ({total} images)"
show_banner = True
try:
while True:
console.clear()
term_width, term_height = shutil.get_terminal_size((80, 24))
panel_height = 0
# Calculate space for image based on banner visibility
if show_banner:
msg_lines = 3 # Title + blank + controls
panel_height = msg_lines + 4 # Padding and borders
image_height = term_height - panel_height - 1
else:
image_height = term_height
subprocess.run(
[
ICAT,
"+kitten",
"icat",
"--clear",
"--scale-up",
"--place",
f"{term_width}x{image_height}@0x0",
"--z-index",
"-1",
image_links[idx],
]
)
if show_banner:
controls = (
f"[{idx + 1}/{total}] Prev: [h/←] Next: [l/→] "
f"Toggle Banner: [b] Quit: [q/Ctrl-C]"
)
msg = f"{title}\n\n{controls}"
start_row = term_height - panel_height
draw_banner_at(msg, start_row)
# key handling
key = get_key()
if key in ("l", "\x1b[C"):
idx = (idx + 1) % total
elif key in ("h", "\x1b[D"):
idx = (idx - 1) % total
elif key == "b":
show_banner = not show_banner
elif key in ("q", "\x03"):
break
except KeyboardInterrupt:
pass
finally:
console.clear()
console.print("Exited viewer.", style="bold")

View File

@@ -12,12 +12,13 @@ logger = logging.getLogger(__name__)
mpv_av_time_pattern = re.compile(r"AV: ([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)")
def stream_video(MPV, url, mpv_args, custom_args):
def stream_video(MPV, url, mpv_args, custom_args, pre_args=[]):
last_time = "0"
total_time = "0"
if os.environ.get("FASTANIME_DISABLE_MPV_POPEN", "False") == "False":
process = subprocess.Popen(
[
pre_args
+ [
MPV,
url,
*mpv_args,
@@ -59,7 +60,7 @@ def stream_video(MPV, url, mpv_args, custom_args):
process.wait()
else:
proc = subprocess.run(
[MPV, url, *mpv_args, *custom_args],
pre_args + [MPV, url, *mpv_args, *custom_args],
capture_output=True,
text=True,
encoding="utf-8",
@@ -208,5 +209,14 @@ def run_mpv(
mpv_args.append(f"--title={title}")
if ytdl_format:
mpv_args.append(f"--ytdl-format={ytdl_format}")
stop_time, total_time = stream_video(MPV, link, mpv_args, custom_args)
if user_args := os.environ.get("FASTANIME_MPV_ARGS"):
mpv_args.extend(user_args.split(","))
pre_args = []
if user_args := os.environ.get("FASTANIME_MPV_PRE_ARGS"):
pre_args = user_args.split(",")
stop_time, total_time = stream_video(
MPV, link, mpv_args, custom_args, pre_args
)
return stop_time, total_time

View File

@@ -846,6 +846,7 @@ query ($id: Int) {
nodes {
id
idMal
type
title {
english
romaji

View File

@@ -75,6 +75,7 @@ class AnimePahe(AnimeProvider):
session_id,
params,
page,
standardized_episode_number,
):
response = self.session.get(ANIMEPAHE_ENDPOINT, params=params)
response.raise_for_status()
@@ -107,12 +108,23 @@ class AnimePahe(AnimeProvider):
"sort": "episode_asc",
},
page=page,
standardized_episode_number=standardized_episode_number,
)
else:
for episode in data.get("data", []):
if episode["episode"] % 1 == 0:
standardized_episode_number += 1
episode.update({"episode": standardized_episode_number})
else:
standardized_episode_number += episode["episode"] % 1
episode.update({"episode": standardized_episode_number})
standardized_episode_number = int(standardized_episode_number)
return data
@debug_provider
def get_anime(self, session_id: str, **kwargs):
page = 1
standardized_episode_number = 0
if d := self.store.get(str(session_id), "search_result"):
anime_result: "AnimePaheSearchResult" = d
data: "AnimePaheAnimePage" = {} # pyright:ignore
@@ -127,6 +139,7 @@ class AnimePahe(AnimeProvider):
"page": page,
},
page=page,
standardized_episode_number=standardized_episode_number,
)
if not data:

View File

@@ -9,6 +9,7 @@ from click import clear
from rich import print
from ...cli.utils.tools import exit_app
from .scripts import FETCH_ANIME_SCRIPT
logger = logging.getLogger(__name__)
@@ -129,7 +130,7 @@ class FZF:
encoding="utf-8",
)
if not result or result.returncode != 0 or not result.stdout:
if result.returncode == 130: # fzf terminated by ctrl-c
if result.returncode == 130: # fzf terminated by ctrl-c
exit_app()
print("sth went wrong :confused:")
@@ -198,9 +199,46 @@ class FZF:
# os.environ["FZF_DEFAULT_OPTS"] = ""
return result
def search_for_anime(self):
commands = [
"--preview",
f"{FETCH_ANIME_SCRIPT}fetch_anime_details {{}}",
"--prompt",
"Search For Anime: ",
"--header",
"Type to search, results are dynamically loaded, enter to select",
"--bind",
f"change:reload({FETCH_ANIME_SCRIPT}fetch_anime_for_fzf {{q}})",
"--preview-window",
"wrap",
# "--bind",
# f"enter:become(echo {{}})",
"--reverse",
]
if not self.FZF_EXECUTABLE:
raise Exception("fzf executable not found")
os.environ["SHELL"] = "bash"
result = subprocess.run(
[self.FZF_EXECUTABLE, *commands],
input="",
stdout=subprocess.PIPE,
universal_newlines=True,
text=True,
encoding="utf-8",
)
if not result or result.returncode != 0 or not result.stdout:
if result.returncode == 130: # fzf terminated by ctrl-c
exit_app()
return ""
return result.stdout.strip().split("|")[0].strip()
fzf = FZF()
if __name__ == "__main__":
action = fzf.run([*os.listdir(), "exit"], "Prompt: ", "Header", preview="bat {}")
print(action)
print(fzf.search_for_anime())
exit()

View File

@@ -0,0 +1,76 @@
FETCH_ANIME_SCRIPT = r"""
fetch_anime_for_fzf() {
local search_term="$1"
if [ -z "$search_term" ]; then exit 0; fi
local query='
query ($search: String) {
Page(page: 1, perPage: 25) {
media(search: $search, type: ANIME, sort: [SEARCH_MATCH]) {
id
title { romaji english }
meanScore
format
status
}
}
}
'
local json_payload
json_payload=$(jq -n --arg query "$query" --arg search "$search_term" \
'{query: $query, variables: {search: $search}}')
curl --silent \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--request POST \
--data "$json_payload" \
https://graphql.anilist.co | \
jq -r '.data.Page.media[]? | select(.title.romaji) |
"\(.title.english // .title.romaji) | Score: \(.meanScore // "N/A") | ID: \(.id)"'
}
fetch_anime_details() {
local anime_id
anime_id=$(echo "$1" | sed -n 's/.*ID: \([0-9]*\).*/\1/p')
if [ -z "$anime_id" ]; then echo "Select an item to see details..."; return; fi
local query='
query ($id: Int) {
Media(id: $id, type: ANIME) {
title { romaji english }
description(asHtml: false)
genres
meanScore
episodes
status
format
season
seasonYear
studios(isMain: true) { nodes { name } }
}
}
'
local json_payload
json_payload=$(jq -n --arg query "$query" --argjson id "$anime_id" \
'{query: $query, variables: {id: $id}}')
# Fetch and format details for the preview window
curl --silent \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--request POST \
--data "$json_payload" \
https://graphql.anilist.co | \
jq -r '
.data.Media |
"Title: \(.title.english // .title.romaji)\n" +
"Score: \(.meanScore // "N/A") | Episodes: \(.episodes // "N/A")\n" +
"Status: \(.status // "N/A") | Format: \(.format // "N/A")\n" +
"Season: \(.season // "N/A") \(.seasonYear // "")\n" +
"Genres: \(.genres | join(", "))\n" +
"Studio: \(.studios.nodes[0].name // "N/A")\n\n" +
"\(.description | gsub("<br><br>"; "\n\n") | gsub("<[^>]*>"; "") | gsub("&quot;"; "\""))"
'
}
"""

View File

@@ -14,9 +14,9 @@
pythonPackages = python.pkgs;
fastanimeEnv = pythonPackages.buildPythonApplication {
pname = "fastanime";
version = "2.8.8";
version = "2.9.9";
src = ./.;
src = self;
preBuild = ''
sed -i 's/rich>=13.9.2/rich>=13.8.1/' pyproject.toml
@@ -50,14 +50,20 @@
# DevShell for development
devShells.default = pkgs.mkShell {
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.libxcrypt-legacy ];
buildInputs = [
fastanimeEnv
pythonPackages.hatchling
pkgs.mpv
pkgs.libmpv
pkgs.fzf
pkgs.rofi
pkgs.uv
pkgs.pyright
];
shellHook = ''
uv venv -q
source ./.venv/bin/activate
'';
};
});
}

View File

@@ -1,6 +1,6 @@
[project]
name = "fastanime"
version = "2.8.8"
version = "2.9.9"
description = "A browser anime site experience from the terminal"
license = "UNLICENSE"
readme = "README.md"

1866
uv.lock generated

File diff suppressed because it is too large Load Diff