Compare commits

...

35 Commits

Author SHA1 Message Date
Benex254
6eb28cfa3d feat(mpv): show feedback on toggle translation type 2024-08-14 20:43:26 +03:00
Benex254
542d39fa6a chore: bump version 2024-08-14 20:40:45 +03:00
Benex254
e5e328148f fix: failed quality selection 2024-08-14 20:39:58 +03:00
Benex254
cea1a67d64 chore: bump version 2024-08-14 20:07:35 +03:00
Benex254
97c6dc7968 feat(animepahe): make it random 2024-08-14 20:07:35 +03:00
Benex254
d97072e298 feat(anime_pahe): load all pages 2024-08-14 20:07:35 +03:00
Benex254
7cd246478e feat: improve error handling when fetching servers 2024-08-14 20:07:35 +03:00
BenedictX
8afe1df3a9 Update README.md 2024-08-13 20:57:16 +03:00
Benex254
452c2a3569 chore: bump version 2024-08-13 20:25:29 +03:00
Benex254
f738069794 docs: fix doc on search command 2024-08-13 20:22:51 +03:00
Benex254
d178eb976e fix(mpv): not starting from begining of episode 2024-08-13 20:22:27 +03:00
Benex254
d58dae6d6b fix(mpv): watch history not updating to correct position 2024-08-13 20:21:48 +03:00
Benex254
136cf841e1 feat: drop pyshortcuts and python-dotenv as a dependency 2024-08-13 19:58:14 +03:00
Benex254
748d321f36 feat: remove platformdirs as dep 2024-08-13 19:01:57 +03:00
Benex254
3e71239981 Revert "feat(mpv): update episode progress timestamp"
This reverts commit 571ab488f8.
2024-08-13 01:36:06 +03:00
Benex254
571ab488f8 feat(mpv): update episode progress timestamp 2024-08-13 01:05:21 +03:00
Benex254
62878311c6 chore: bump version 2024-08-12 00:50:39 +03:00
Benex254
432e9374cb chore: update deps 2024-08-12 00:49:10 +03:00
Benex254
a4974fbba7 test: add test for update command 2024-08-12 00:48:55 +03:00
Benex254
b935e80928 docs: update readme 2024-08-12 00:45:29 +03:00
Benex254
8999d88d23 feat(cli): add help to completions command 2024-08-12 00:45:10 +03:00
benex
9608bace07 format: reformat to pep8 2024-08-11 20:53:02 +03:00
benex
09b88df49a fix: issues with anime pahe provider 2024-08-11 20:34:26 +03:00
benex
ccaacc9948 fix(allanime): invalid key 2024-08-11 20:10:13 +03:00
Benex254
96d88b0f47 feat(allanime): remove useless module 2024-08-11 22:06:26 +03:00
Benex254
6939471e48 feat(cli): add update app command 2024-08-11 21:57:11 +03:00
Benex254
b1a2307c4d feat(updater): update it 2024-08-11 21:21:26 +03:00
Benex254
1ead2fb176 chore(cli): get rid of depracted function 2024-08-11 21:09:18 +03:00
Benex254
51e3ca004e feat(cli): clean tools 2024-08-11 21:06:07 +03:00
Benex254
3814db460f feat(cli): default print_image function to use icat 2024-08-11 21:00:38 +03:00
Benex254
4102685cbc refactor: downloader 2024-08-11 20:54:32 +03:00
Benex254
c80a8235e1 feat: use clean_html yt-dlp function over custom remove_html function 2024-08-11 20:46:54 +03:00
Benex254
622427b748 feat(cli): add config --view command 2024-08-11 20:38:00 +03:00
Benex254
e36413cdef refactor: move user_data_helper functionality to config class 2024-08-11 20:15:45 +03:00
Benex254
7e9a510706 feat(anilist_data_helper): only import types if type checking 2024-08-11 19:49:15 +03:00
29 changed files with 678 additions and 469 deletions

View File

@@ -44,6 +44,7 @@ Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magi
- [downloads subcommand](#downloads-subcommand)
- [config subcommand](#config-subcommand)
- [cache subcommand](#cache-subcommand)
- [update subcommand](#update-subcommand)
- [completions subcommand](#completions-subcommand)
- [MPV specific commands](#mpv-specific-commands)
- [Added keybindings](#added-keybindings)
@@ -163,7 +164,8 @@ The only required external dependency, unless you won't be streaming, is [MPV](h
**Other external dependencies that will just make your experience better:**
- [fzf](https://github.com/junegunn/fzf) :fire: which is used as a better alternative to the ui.
- [fzf](https://github.com/junegunn/fzf) 🔥 which is used as a better alternative to the ui.
- [rofi](https://github.com/davatorium/rofi) 🔥 which is used as another alternative ui + the the desktop entry ui
- [chafa](https://github.com/hpjansson/chafa) currently the best cross platform and cross terminal image viewer for the terminal.
- [icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) an image viewer that only works in [kitty terminal](https://sw.kovidgoyal.net/kitty/), which is currently the best terminal in my opinion, and by far the best image renderer for the terminal thanks to kitty's terminal graphics protocol. Its terminal graphics is so op that you can [run a browser on it](https://github.com/chase/awrit?tab=readme-ov-file)!!
- [bash](https://www.gnu.org/software/bash/) is used as the preview script language.
@@ -309,15 +311,15 @@ Powerful command mainly aimed at binging anime. Since it doesn't require interac
**Syntax:**
```bash
# basic form where you will still be promted for the episode number
# basic form where you will still be prompted for the episode number
fastanime search <anime-title>
# binge all episodes with this command
fastanime search <anime-title> -
fastanime search <anime-title> -r -
# binge a specific episode range with this command
# be sure to observe the range Syntax
fastanime search <anime-title> <episodes-start>-<episodes-end>
fastanime search <anime-title> -r <episodes-start>-<episodes-end>
```
#### downloads subcommand
@@ -348,6 +350,9 @@ fastanime config --path
# add a desktop entry
fastanime config --desktop-entry
# view current contents of your configuration or can be used to get an example config
fastanime config --view
```
> [!Note]
@@ -374,6 +379,20 @@ fastanime cache --size
fastanime cache
```
#### update subcommand
Easily update fastanime to latest
**Syntax:**
```bash
# update fastanime to latest
fastanime update
# check for latest release
fastanime update --check
```
#### completions subcommand
Helper command to setup shell completions

View File

@@ -0,0 +1,4 @@
"""This package exist as away to expose functions and classes that my be useful to a developer using the fastanime library
[TODO:description]
"""

View File

@@ -1,10 +1,12 @@
from datetime import datetime
from typing import TYPE_CHECKING
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
if TYPE_CHECKING:
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
# TODO: Add formating options for the final date
def format_anilist_date_object(anilist_date_object: AnilistDateObject):
def format_anilist_date_object(anilist_date_object: "AnilistDateObject"):
if anilist_date_object:
return f"{anilist_date_object['day']}/{anilist_date_object['month']}/{anilist_date_object['year']}"
else:
@@ -25,7 +27,7 @@ def format_list_data_with_comma(data: list | None):
return "None"
def extract_next_airing_episode(airing_episode: AnilistMediaNextAiringEpisode):
def extract_next_airing_episode(airing_episode: "AnilistMediaNextAiringEpisode"):
if airing_episode:
return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"
else:

View File

@@ -27,9 +27,27 @@ class YtDLPDownloader:
# Function to download the file
# TODO: untpack the title to its actual values episode_title and anime_title
def _download_file(self, url: str, download_dir, title, silent, vid_format="best"):
anime_title = sanitize_filename(title[0])
episode_title = sanitize_filename(title[1])
def _download_file(
self,
url: str,
anime_title: str,
episode_title: str,
download_dir: str,
silent: bool,
vid_format: str = "best",
):
"""Helper function that downloads anime given url and path details
Args:
url: [TODO:description]
anime_title: [TODO:description]
episode_title: [TODO:description]
download_dir: [TODO:description]
silent: [TODO:description]
vid_format: [TODO:description]
"""
anime_title = sanitize_filename(anime_title)
episode_title = sanitize_filename(episode_title)
ydl_opts = {
# Specify the output path and template
"outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s",
@@ -41,7 +59,15 @@ class YtDLPDownloader:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
# WARN: May remove this legacy functionality
def download_file(self, url: str, title, silent=True):
"""A helper that just does things in the background
Args:
title ([TODO:parameter]): [TODO:description]
silent ([TODO:parameter]): [TODO:description]
url: [TODO:description]
"""
self.downloads_queue.put((self._download_file, (url, title, silent)))

View File

@@ -1,40 +0,0 @@
import json
import logging
import os
from ..constants import USER_DATA_PATH
logger = logging.getLogger(__name__)
# TODO: merger this functionality with the config object
class UserData:
user_data = {"watch_history": {}, "animelist": [], "user": {}}
def __init__(self):
try:
if os.path.isfile(USER_DATA_PATH):
with open(USER_DATA_PATH, "r") as f:
user_data = json.load(f)
self.user_data.update(user_data)
except Exception as e:
logger.error(e)
def update_watch_history(self, watch_history: dict):
self.user_data["watch_history"] = watch_history
self._update_user_data()
def update_user_info(self, user: dict):
self.user_data["user"] = user
self._update_user_data()
def update_animelist(self, anime_list: list):
self.user_data["animelist"] = list(set(anime_list))
self._update_user_data()
def _update_user_data(self):
with open(USER_DATA_PATH, "w") as f:
json.dump(self.user_data, f)
user_data_helper = UserData()

View File

@@ -1,79 +1,18 @@
import logging
import os
import re
from functools import lru_cache
from typing import TYPE_CHECKING
from thefuzz import fuzz
from fastanime.libs.anilist.types import AnilistBaseMediaDataSchema
from .data import anime_normalizer
if TYPE_CHECKING:
from ..libs.anilist.types import AnilistBaseMediaDataSchema
logger = logging.getLogger(__name__)
@lru_cache()
def remove_html_tags(text: str):
clean = re.compile("<.*?>")
return re.sub(clean, "", text)
@lru_cache()
def sanitize_filename(filename: str):
"""
Sanitize a string to be safe for use as a file name.
:param filename: The original filename string.
:return: A sanitized filename string.
"""
# List of characters not allowed in filenames on various operating systems
invalid_chars = r'[<>:"/\\|?*\0]'
reserved_names = {
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
}
# Replace invalid characters with an underscore
sanitized = re.sub(invalid_chars, " ", filename)
# Remove leading and trailing whitespace
sanitized = sanitized.strip()
# Check for reserved filenames
name, ext = os.path.splitext(sanitized)
if name.upper() in reserved_names:
name += "_file"
sanitized = name + ext
# Ensure the filename is not empty
if not sanitized:
sanitized = "default_filename"
return sanitized
def anime_title_percentage_match(
possible_user_requested_anime_title: str, anime: AnilistBaseMediaDataSchema
possible_user_requested_anime_title: str, anime: "AnilistBaseMediaDataSchema"
) -> float:
"""Returns the percentage match between the possible title and user title
@@ -97,10 +36,3 @@ def anime_title_percentage_match(
)
logger.info(f"{locals()}")
return percentage_ratio
if __name__ == "__main__":
# Example usage
unsafe_filename = "CON:example?file*name.txt"
safe_filename = sanitize_filename(unsafe_filename)
print(safe_filename) # Output: 'CON_example_file_name.txt'

View File

@@ -6,7 +6,7 @@ if sys.version_info < (3, 10):
) # noqa: F541
__version__ = "v0.62.5"
__version__ = "v1.1.6"
APP_NAME = "FastAnime"
AUTHOR = "Benex254"

View File

@@ -15,6 +15,7 @@ commands = {
"downloads": "downloads.downloads",
"cache": "cache.cache",
"completions": "completions.completions",
"update": "update.update",
}
@@ -42,7 +43,6 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option("--log", help="Allow logging to stdout", is_flag=True)
@click.option("--log-file", help="Allow logging to a file", is_flag=True)
@click.option("--rich-traceback", help="Use rich to output tracebacks", is_flag=True)
@click.option("--update", help="Update fastanime to the latest version", is_flag=True)
@click.option(
"-p",
"--provider",
@@ -135,7 +135,6 @@ def run_cli(
log,
log_file,
rich_traceback,
update,
provider,
server,
format,
@@ -192,11 +191,6 @@ def run_cli(
from rich.traceback import install
install()
if update and None:
from .app_updater import update_app
update_app()
return
if provider:
ctx.obj.provider = provider

View File

@@ -2,8 +2,8 @@ import pathlib
import re
import shlex
import shutil
import subprocess
import sys
from subprocess import PIPE, Popen
import requests
from rich import print
@@ -34,71 +34,72 @@ def check_for_updates():
def is_git_repo(author, repository):
# Check if the current directory contains a .git folder
if not pathlib.Path("./.git").exists():
git_dir = pathlib.Path(".git")
if not git_dir.exists() or not git_dir.is_dir():
return False
repository_qualname = f"{author}/{repository}"
# Read the .git/config file to find the remote repository URL
config_path = pathlib.Path("./.git/config")
# Check if the config file exists
config_path = git_dir / "config"
if not config_path.exists():
return False
print("here")
with open(config_path, "r") as git_config:
git_config_content = git_config.read()
# Use regex to find the repository URL in the config file
repo_name_pattern = r"\[remote \"origin\"\]\s+url = .*\/([^/]+\/[^/]+)\.git"
match = re.search(repo_name_pattern, git_config_content)
print(match)
if match is None:
try:
# Read the .git/config file to find the remote repository URL
with config_path.open("r") as git_config:
git_config_content = git_config.read()
except (FileNotFoundError, PermissionError):
return False
# Extract the repository name and compare with the expected repository_qualname
config_repo_name = match.group(1)
return config_repo_name == repository_qualname
# Use regex to find the repository URL in the config file
repo_name_pattern = r"url\s*=\s*.+/([^/]+/[^/]+)\.git"
match = re.search(repo_name_pattern, git_config_content)
# Return True if match found and repository name matches
return bool(match) and match.group(1) == f"{author}/{repository}"
def update_app():
is_latest, release_json = check_for_updates()
if is_latest:
print("[green]App is up to date[/]")
return
return False, release_json
tag_name = release_json["tag_name"]
print("[cyan]Updating app to version %s[/]" % tag_name)
if is_git_repo(AUTHOR, APP_NAME):
executable = shutil.which("git")
GIT_EXECUTABLE = shutil.which("git")
args = [
executable,
GIT_EXECUTABLE,
"pull",
]
print(f"Pulling latest changes from the repository via git: {shlex.join(args)}")
if not executable:
return print("[red]Cannot find git.[/]")
if not GIT_EXECUTABLE:
print("[red]Cannot find git please install it.[/]")
return False, release_json
process = Popen(
process = subprocess.run(
args,
stdout=PIPE,
stderr=PIPE,
)
process.communicate()
else:
executable = sys.executable
if PIPX_EXECUTABLE := shutil.which("pipx"):
process = subprocess.run([PIPX_EXECUTABLE, "upgrade", APP_NAME])
else:
PYTHON_EXECUTABLE = sys.executable
args = [
executable,
"-m",
"pip",
"install",
APP_NAME,
"--user",
"--no-warn-script-location",
]
process = Popen(args)
process.communicate()
args = [
PYTHON_EXECUTABLE,
"-m",
"pip",
"install",
APP_NAME,
"--user",
"--no-warn-script-location",
]
process = subprocess.run(args)
if process.returncode == 0:
return True, release_json
else:
return False, release_json

View File

@@ -2,9 +2,9 @@ import click
@click.command(help="Helper command to get shell completions")
@click.option("--fish", is_flag=True)
@click.option("--zsh", is_flag=True)
@click.option("--bash", is_flag=True)
@click.option("--fish", is_flag=True, help="print fish completions")
@click.option("--zsh", is_flag=True, help="print zsh completions")
@click.option("--bash", is_flag=True, help="print bash completions")
def completions(fish, zsh, bash):
if not fish or not zsh or not bash:
import os

View File

@@ -1,47 +1,92 @@
from typing import TYPE_CHECKING
import click
if TYPE_CHECKING:
from ..config import Config
@click.command(
help="Opens up your fastanime config in your preferred editor",
short_help="Edit your config",
)
@click.option("--path", "-p", help="Print the config location and exit", is_flag=True)
@click.option(
"--view", "-v", help="View the current contents of your config", is_flag=True
)
@click.option(
"--desktop-entry",
"-d",
help="Configure the desktop entry of fastanime",
is_flag=True,
)
# @click.pass_obj
def config(path, desktop_entry):
pass
@click.pass_obj
def config(config: "Config", path, view, desktop_entry):
import sys
from pyshortcuts import make_shortcut
from rich import print
from ...constants import APP_NAME, ICON_PATH, USER_CONFIG_PATH
from ... import __version__
from ...constants import APP_NAME, ICON_PATH, S_PLATFORM, USER_CONFIG_PATH
if path:
print(USER_CONFIG_PATH)
elif view:
print(config)
elif desktop_entry:
import os
import shutil
from pathlib import Path
from textwrap import dedent
from rich import print
from rich.prompt import Confirm
from ..utils.tools import exit_app
FASTANIME_EXECUTABLE = shutil.which("fastanime")
if FASTANIME_EXECUTABLE:
cmds = f"{FASTANIME_EXECUTABLE} --rofi anilist"
else:
cmds = "_ -m fastanime --rofi anilist"
shortcut = make_shortcut(
name=APP_NAME,
description="Watch Anime from the terminal",
icon=ICON_PATH,
script=cmds,
terminal=False,
)
if shortcut:
print("Success", shortcut)
cmds = f"{sys.executable} -m fastanime --rofi anilist"
# TODO: Get funs of the other platforms to complete this lol
if S_PLATFORM == "win32":
print(
"Not implemented; the author thinks its not straight forward so welcomes lovers of windows to try and implement it themselves or to switch to a proper os like arch linux or pray the author gets bored 😜"
)
elif S_PLATFORM == "darwin":
print(
"Not implemented; the author thinks its not straight forward so welcomes lovers of mac to try and implement it themselves or to switch to a proper os like arch linux or pray the author gets bored 😜"
)
else:
print("Failed")
desktop_entry = dedent(
f"""
[Desktop Entry]
Name={APP_NAME}
Type=Application
version={__version__}
Path={Path().home()}
Comment=Watch anime from your terminal
Terminal=false
Icon={ICON_PATH}
Exec={cmds}
Categories=Entertainment
"""
)
base = os.path.expanduser("~/.local/share/applications")
desktop_entry_path = os.path.join(base, f"{APP_NAME}.desktop")
if os.path.exists(desktop_entry_path):
if not Confirm.ask(
f"The file already exists {desktop_entry_path}; or would you like to rewrite it",
default=False,
):
exit_app(1)
with open(desktop_entry_path, "w") as f:
f.write(desktop_entry)
with open(desktop_entry_path) as f:
print(f"Successfully wrote \n{f.read()}")
exit_app(0)
else:
import click

View File

@@ -1,3 +1,4 @@
import time
from typing import TYPE_CHECKING
import click
@@ -111,7 +112,10 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams)
server = next(streams, None)
if not server:
print("Sth went wrong when fetching the server")
continue
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")
@@ -145,14 +149,16 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
downloader._download_file(
link,
anime["title"],
episode_title,
download_dir,
(anime["title"], episode_title),
True,
config.format,
)
except Exception as e:
print(e)
time.sleep(1)
print("Continuing")
clear()
print("Done")
print("Done Downloading")
exit_app()

View File

@@ -130,7 +130,12 @@ def search(config: Config, anime_title: str, episode_range: str):
if config.server == "top":
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
server = next(streams)
server = next(streams, None)
if not server:
print("Sth went wrong when fetching the episode")
input("Enter to continue")
stream_anime()
return
stream_link = filter_by_quality(config.quality, server["links"])
if not stream_link:
print("Quality not found")

View File

@@ -0,0 +1,37 @@
import click
@click.command(help="Helper command to update fastanime to latest")
@click.option("--check", "-c", help="Check for the latest release", is_flag=True)
def update(
check,
):
from rich.console import Console
from rich.markdown import Markdown
from ..app_updater import check_for_updates, update_app
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 check:
is_update, github_release_data = check_for_updates()
if is_update:
print(
"You are running an older version of fastanime please update to get the latest features"
)
_print_release(github_release_data)
else:
print("You are running the latest version of fastanime")
_print_release(github_release_data)
else:
success, github_release_data = update_app()
_print_release(github_release_data)

View File

@@ -1,39 +1,85 @@
import json
import logging
import os
from configparser import ConfigParser
from typing import TYPE_CHECKING
from rich import print
from ..constants import USER_CONFIG_PATH, USER_VIDEOS_DIR
from ..constants import USER_CONFIG_PATH, USER_DATA_PATH, USER_VIDEOS_DIR
from ..libs.rofi import Rofi
from ..Utility.user_data_helper import user_data_helper
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ..AnimeProvider import AnimeProvider
class Config(object):
"""class that handles and manages configuration and user data throughout the clis lifespan
Attributes:
anime_list: [TODO:attribute]
watch_history: [TODO:attribute]
fastanime_anilist_app_login_url: [TODO:attribute]
anime_provider: [TODO:attribute]
user_data: [TODO:attribute]
configparser: [TODO:attribute]
downloads_dir: [TODO:attribute]
provider: [TODO:attribute]
use_fzf: [TODO:attribute]
use_rofi: [TODO:attribute]
skip: [TODO:attribute]
icons: [TODO:attribute]
preview: [TODO:attribute]
translation_type: [TODO:attribute]
sort_by: [TODO:attribute]
continue_from_history: [TODO:attribute]
auto_next: [TODO:attribute]
auto_select: [TODO:attribute]
use_mpv_mod: [TODO:attribute]
quality: [TODO:attribute]
notification_duration: [TODO:attribute]
error: [TODO:attribute]
server: [TODO:attribute]
format: [TODO:attribute]
force_window: [TODO:attribute]
preferred_language: [TODO:attribute]
rofi_theme: [TODO:attribute]
rofi_theme: [TODO:attribute]
rofi_theme_input: [TODO:attribute]
rofi_theme_input: [TODO:attribute]
rofi_theme_confirm: [TODO:attribute]
rofi_theme_confirm: [TODO:attribute]
watch_history: [TODO:attribute]
anime_list: [TODO:attribute]
user: [TODO:attribute]
"""
anime_list: list
watch_history: dict
fastanime_anilist_app_login_url = (
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
)
anime_provider: "AnimeProvider"
user_data = {"watch_history": {}, "animelist": [], "user": {}}
def __init__(self) -> None:
self.initialize_user_data()
self.load_config()
def load_config(self):
self.configparser = ConfigParser(
{
"server": "top",
"continue_from_history": "True",
"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",
"use_mpv_mod": "false",
"force_window": "immediate",
"preferred_language": "english",
"use_fzf": "False",
"preview": "False",
@@ -47,8 +93,6 @@ class Config(object):
"rofi_theme": "",
"rofi_theme_input": "",
"rofi_theme_confirm": "",
"use_mpv_mod": "false",
"force_window": "immediate",
}
)
self.configparser.add_section("stream")
@@ -60,7 +104,7 @@ class Config(object):
self.configparser.read(USER_CONFIG_PATH)
# --- set defaults ---
# --- set config values from file or using defaults ---
self.downloads_dir = self.get_downloads_dir()
self.provider = self.get_provider()
self.use_fzf = self.get_use_fzf()
@@ -88,13 +132,14 @@ class Config(object):
self.rofi_theme_confirm = self.get_rofi_theme_confirm()
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
# ---- setup user data ------
self.watch_history: dict = user_data_helper.user_data.get("watch_history", {})
self.anime_list: list = user_data_helper.user_data.get("animelist", [])
self.user: dict = user_data_helper.user_data.get("user", {})
self.watch_history: dict = self.user_data.get("watch_history", {})
self.anime_list: list = self.user_data.get("animelist", [])
self.user: dict = self.user_data.get("user", {})
def update_user(self, user):
self.user = user
user_data_helper.update_user_info(user)
self.user_data["user"] = user
self._update_user_data()
def update_watch_history(
self, anime_id: int, episode: str | None, start_time="0", total_time="0"
@@ -108,24 +153,48 @@ class Config(object):
}
}
)
user_data_helper.update_watch_history(self.watch_history)
self.user_data["watch_history"] = self.watch_history
self._update_user_data()
def update_anime_list(self, anime_id: int, remove=False):
if remove:
try:
self.anime_list.remove(anime_id)
print("Succesfully removed :cry:")
except Exception:
print(anime_id, "Nothing to remove :confused:")
else:
self.anime_list.append(anime_id)
user_data_helper.update_animelist(self.anime_list)
print("Succesfully added :smile:")
input("Enter to continue...")
def initialize_user_data(self):
try:
if os.path.isfile(USER_DATA_PATH):
with open(USER_DATA_PATH, "r") as f:
user_data = json.load(f)
self.user_data.update(user_data)
except Exception as e:
logger.error(e)
def _update_user_data(self):
"""method that updates the actual user data file"""
with open(USER_DATA_PATH, "w") as f:
json.dump(self.user_data, f)
# getters for user configuration
# --- general section ---
def get_provider(self):
return self.configparser.get("general", "provider")
def get_preferred_language(self):
return self.configparser.get("general", "preferred_language")
def get_downloads_dir(self):
return self.configparser.get("general", "downloads_dir")
def get_icons(self):
return self.configparser.getboolean("general", "icons")
def get_preview(self):
return self.configparser.getboolean("general", "preview")
def get_use_fzf(self):
return self.configparser.getboolean("general", "use_fzf")
# rofi conifiguration
def get_use_rofi(self):
return self.configparser.getboolean("general", "use_rofi")
def get_rofi_theme(self):
return self.configparser.get("general", "rofi_theme")
@@ -135,47 +204,18 @@ class Config(object):
def get_rofi_theme_confirm(self):
return self.configparser.get("general", "rofi_theme_confirm")
def get_downloads_dir(self):
return self.configparser.get("general", "downloads_dir")
def get_use_fzf(self):
return self.configparser.getboolean("general", "use_fzf")
def get_use_rofi(self):
return self.configparser.getboolean("general", "use_rofi")
# --- stream section ---
def get_skip(self):
return self.configparser.getboolean("stream", "skip")
def get_force_window(self):
return self.configparser.get("stream", "force_window")
def get_icons(self):
return self.configparser.getboolean("general", "icons")
def get_preview(self):
return self.configparser.getboolean("general", "preview")
def get_preferred_language(self):
return self.configparser.get("general", "preferred_language")
def get_sort_by(self):
return self.configparser.get("anilist", "sort_by")
def get_continue_from_history(self):
return self.configparser.getboolean("stream", "continue_from_history")
def get_translation_type(self):
return self.configparser.get("stream", "translation_type")
def get_auto_next(self):
return self.configparser.getboolean("stream", "auto_next")
def get_auto_select(self):
return self.configparser.getboolean("stream", "auto_select")
def get_quality(self):
return self.configparser.get("stream", "quality")
def get_continue_from_history(self):
return self.configparser.getboolean("stream", "continue_from_history")
def get_use_mpv_mod(self):
return self.configparser.getboolean("stream", "use_mpv_mod")
@@ -186,19 +226,117 @@ class Config(object):
def get_error(self):
return self.configparser.getint("stream", "error")
def get_force_window(self):
return self.configparser.get("stream", "force_window")
def get_translation_type(self):
return self.configparser.get("stream", "translation_type")
def get_quality(self):
return self.configparser.get("stream", "quality")
def get_server(self):
return self.configparser.get("stream", "server")
def get_format(self):
return self.configparser.get("stream", "format")
def get_sort_by(self):
return self.configparser.get("anilist", "sort_by")
def update_config(self, section: str, key: str, value: str):
self.configparser.set(section, key, value)
with open(USER_CONFIG_PATH, "w") as config:
self.configparser.write(config)
def __repr__(self):
return f"Config(server:{self.get_server()},quality:{self.get_quality()},auto_next:{self.get_auto_next()},continue_from_history:{self.get_continue_from_history()},sort_by:{self.get_sort_by()},downloads_dir:{self.get_downloads_dir()})"
current_config_state = f"""
[stream]
# Auto continue from watch history
continue_from_history = {self.continue_from_history}
# Preferred language for anime (options: dub, sub)
translation_type = {self.translation_type}
# Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top, wixmp)
server = {self.server}
# Auto-select next episode
auto_next = {self.auto_next}
# Auto select the anime provider results with fuzzy find.
# Note this wont always be correct.But 99% of the time will be.
auto_select = {self.auto_select}
# whether to skip the opening and ending theme songs
# NOTE: requires ani-skip to be in path
skip = {self.skip}
# the maximum delta time in minutes after which the episode should be considered as completed
# used in the continue from time stamp
error = {self.error}
# whether to use python-mpv
# to enable superior control over the player
# adding more options to it
use_mpv_mod = {self.use_mpv_mod}
# the format of downloaded anime and trailer
# based on yt-dlp format and passed directly to it
# learn more by looking it up on their site
# only works for downloaded anime if server=gogoanime
# since its the only one that offers different formats
# the others tend not to
format = {self.format}
[general]
# can be [allanime,animepahe]
provider = {self.provider}
# Display language (options: english, romaji)
preferred_language = {self.preferred_language}
# Download directory
downloads_dir = {self.downloads_dir}
# whether to show a preview window when using fzf or rofi
preview = {self.preview}
# whether to use fzf as the interface for the anilist command and others.
use_fzf = {self.use_fzf}
# whether to use rofi for the ui
use_rofi = {self.use_rofi}
# rofi theme to use
rofi_theme = {self.rofi_theme}
rofi_theme_input = {self.rofi_theme_input}
rofi_theme_confirm = {self.rofi_theme_confirm}
# whether to show the icons
icons = {self.icons}
# the duration in minutes a notification will stay in the screen
# used by notifier command
notification_duration = {self.notification_duration}
"""
return current_config_state
def __str__(self):
return self.__repr__()
# WARNING: depracated and will probably be removed
def update_anime_list(self, anime_id: int, remove=False):
if remove:
try:
self.anime_list.remove(anime_id)
print("Succesfully removed :cry:")
except Exception:
print(anime_id, "Nothing to remove :confused:")
else:
self.anime_list.append(anime_id)
self.user_data["animelist"] = list(set(self.anime_list))
self._update_user_data()
print("Succesfully added :smile:")
input("Enter to continue...")

View File

@@ -20,7 +20,7 @@ from ...libs.rofi import Rofi
from ...Utility.data import anime_normalizer
from ...Utility.utils import anime_title_percentage_match
from ..utils.mpv import run_mpv
from ..utils.tools import FastAnimeRuntimeState, exit_app
from ..utils.tools import exit_app
from ..utils.utils import filter_by_quality, fuzzy_inquirer
from .utils import aniskip
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...libs.anime_provider.types import Anime, SearchResult, Server
from ..config import Config
from ..utils.tools import FastAnimeRuntimeState
def calculate_time_delta(start_time, end_time):
@@ -316,7 +317,7 @@ def media_player_controls(
def provider_anime_episode_servers_menu(
config: "Config", fastanime_runtime_state: FastAnimeRuntimeState
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
"""Menu that enables selection of a server either manually or automatically based on user config then plays the stream link of the quality the user prefers
@@ -364,8 +365,35 @@ def provider_anime_episode_servers_menu(
# no need to get all servers if top just works
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
selected_server = next(episode_streams_generator)
server_name = "top"
try:
selected_server = next(episode_streams_generator, None)
if not selected_server:
if config.use_rofi:
if Rofi.confirm("Sth went wrong enter to continue"):
provider_anime_episode_servers_menu(
config, fastanime_runtime_state
)
else:
exit_app(1)
else:
print("Sth went wrong")
input("Enter to continue...")
provider_anime_episode_servers_menu(
config, fastanime_runtime_state
)
return
server_name = "top"
except Exception as e:
print("Failed to get streams. Reason:", e)
if not config.use_rofi:
input("Enter to coninue...")
else:
if not Rofi.confirm(f"!!Sth went wrong!!: {e} Enter to continue"):
exit_app(1)
server_name = None
selected_server = ""
media_actions_menu(config, fastanime_runtime_state)
return
else:
with Progress() as progress:
progress.add_task("Fetching servers...", total=None)
@@ -400,9 +428,21 @@ def provider_anime_episode_servers_menu(
provider_anime_episodes_menu(config, fastanime_runtime_state)
return
elif server_name == "top":
elif server_name == "top" and episode_streams_dict.keys():
selected_server = episode_streams_dict[list(episode_streams_dict.keys())[0]]
else:
if server_name == "top" or server_name == "back":
if config.use_rofi:
if not Rofi.confirm("No severs available..."):
exit_app()
else:
media_actions_menu(config, fastanime_runtime_state)
return
else:
print("Failed to set server")
input("Enter to continue")
media_actions_menu(config, fastanime_runtime_state)
return
selected_server = episode_streams_dict[server_name]
# get the stream of the preferred quality
@@ -478,7 +518,7 @@ def provider_anime_episode_servers_menu(
mpv.terminate()
stop_time = player.last_stop_time
total_time = player.last_total_time
current_episode_number = fastanime_runtime_state.provider_current_episode_number
else:
stop_time, total_time = run_mpv(
current_stream_link,
@@ -506,7 +546,10 @@ def provider_anime_episode_servers_menu(
total_time = "0"
config.update_watch_history(
anime_id_anilist, episode, start_time=stop_time, total_time=total_time
anime_id_anilist,
episode,
start_time=stop_time,
total_time=total_time,
)
# switch to controls
@@ -735,7 +778,7 @@ def anime_provider_search_results_menu(
# ---- ANILIST MEDIA ACTIONS MENU ----
#
def media_actions_menu(
config: "Config", fastanime_runtime_state: FastAnimeRuntimeState
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
"""The menu responsible for handling all media actions such as watching a trailer or streaming it
@@ -757,7 +800,7 @@ def media_actions_menu(
episodes_total = selected_anime_anilist["episodes"] or "Inf"
def _watch_trailer(
config: "Config", fastanime_runtime_state: FastAnimeRuntimeState
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
"""Helper function to watch trailers with
@@ -782,7 +825,9 @@ def media_actions_menu(
exit(0)
media_actions_menu(config, fastanime_runtime_state)
def _add_to_list(config: "Config", fastanime_runtime_state: FastAnimeRuntimeState):
def _add_to_list(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
"""Helper function to update an anime's media_list_type
Args:
@@ -824,7 +869,9 @@ def media_actions_menu(
input("Enter to continue...")
media_actions_menu(config, fastanime_runtime_state)
def _score_anime(config: "Config", fastanime_runtime_state: FastAnimeRuntimeState):
def _score_anime(
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
"""Helper function to score anime on anilist from terminal or rofi
Args:
@@ -855,7 +902,7 @@ def media_actions_menu(
# FIX: For some reason this fails to delete
def _remove_from_list(
config: "Config", fastanime_runtime_state: FastAnimeRuntimeState
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
"""Remove an anime from your media list
@@ -917,9 +964,9 @@ def media_actions_menu(
"""
from rich.console import Console
from rich.prompt import Confirm
from yt_dlp.utils import clean_html
from ...Utility import anilist_data_helper
from ...Utility.utils import remove_html_tags
from ..utils.print_img import print_img
clear()
@@ -970,7 +1017,7 @@ def media_actions_menu(
)
console.print(
"[bold underline cyan]Description\n[/]",
remove_html_tags(str(selected_anime_anilist["description"])),
clean_html(str(selected_anime_anilist["description"])),
)
if Confirm.ask("Enter to continue...", default=True):
media_actions_menu(config, fastanime_runtime_state)
@@ -1097,7 +1144,7 @@ def media_actions_menu(
# ---- ANILIST RESULTS MENU ----
#
def anilist_results_menu(
config: "Config", fastanime_runtime_state: FastAnimeRuntimeState
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
"""The menu that handles and displays the results of an anilist action enabling using to select anime of choice
@@ -1264,7 +1311,7 @@ def handle_animelist(
def fastanime_main_menu(
config: "Config", fastanime_runtime_state: FastAnimeRuntimeState
config: "Config", fastanime_runtime_state: "FastAnimeRuntimeState"
):
"""The main entry point to the anilist command

View File

@@ -7,11 +7,11 @@ import textwrap
from threading import Thread
import requests
from yt_dlp.utils import clean_html
from ...constants import APP_CACHE_DIR
from ...libs.anilist.types import AnilistBaseMediaDataSchema
from ...Utility import anilist_data_helper
from ...Utility.utils import remove_html_tags
from ..utils.utils import get_true_fg
logger = logging.getLogger(__name__)
@@ -201,7 +201,7 @@ def write_search_results(
template = textwrap.dedent(template)
template = f"""
{template}
{textwrap.fill(remove_html_tags(
{textwrap.fill(clean_html(
str(anime['description'])), width=45)}
"""
future_to_task[executor.submit(save_info_from_str, template, title)] = title

View File

@@ -117,7 +117,10 @@ class MpvPlayer(object):
# always select the first
if server == "top":
selected_server = next(episode_streams)
selected_server = next(episode_streams, None)
if not selected_server:
self.mpv_player.show_text("Sth went wrong when loading the episode")
return
else:
episode_streams_dict = {
episode_stream["server"]: episode_stream
@@ -136,6 +139,7 @@ class MpvPlayer(object):
if not stream_link_:
self.mpv_player.show_text("Quality not found")
return
self.mpv_player._set_property("start", "0")
stream_link = stream_link_["link"]
return stream_link
@@ -230,6 +234,7 @@ class MpvPlayer(object):
@mpv_player.on_key_press("shift+t")
def _toggle_translation_type():
translation_type = "sub" if config.translation_type == "dub" else "dub"
mpv_player.show_text("Changing translation type...")
anime = anime_provider.get_anime(
fastanime_runtime_state.provider_anime_search_result["id"],
fastanime_runtime_state.selected_anime_anilist,

View File

@@ -5,20 +5,23 @@ import requests
def print_img(url: str):
executable = shutil.which("chafa")
curl = shutil.which("curl")
# curl -sL "$1" | chafa /dev/stdin
"""helper funtion to print an image given its url
if executable is None or curl is None:
print("chafa or curl not found")
return
Args:
url: [TODO:description]
"""
if EXECUTABLE := shutil.which("icat"):
subprocess.run([EXECUTABLE, url])
else:
EXECUTABLE = shutil.which("chafa")
res = requests.get(url)
if res.status_code != 200:
print("Error fetching image")
return
img_bytes = res.content
if not img_bytes:
print("No image found")
img_bytes = subprocess.check_output([curl, "-sL", url])
subprocess.run([executable, url, "--size=15x15"], input=img_bytes)
if EXECUTABLE is None:
print("chafanot found")
return
res = requests.get(url)
if res.status_code != 200:
print("Error fetching image")
return
img_bytes = res.content
subprocess.run([EXECUTABLE, url, "--size=15x15"], input=img_bytes)

View File

@@ -1,5 +1,6 @@
# TODO: add typing
class FastAnimeRuntimeState(dict):
"""dot.notation access to dictionary attributes"""
"""A class that manages fastanime runtime during anilist command runtime"""
def __getattr__(self, attr):
try:
@@ -13,7 +14,7 @@ class FastAnimeRuntimeState(dict):
self.__setitem__(attr, value)
def exit_app(*args):
def exit_app(exit_code=0, *args):
import os
import shutil
import sys
@@ -24,7 +25,8 @@ def exit_app(*args):
try:
shutil.get_terminal_size()
return (
sys.stdin.isatty()
sys.stdin
and sys.stdin.isatty()
and sys.stdout.isatty()
and os.getenv("TERM") is not None
)
@@ -44,17 +46,4 @@ def exit_app(*args):
from rich import print
print("Have a good day :smile:", USER_NAME)
sys.exit(0)
def get_formatted_str(string: str, style):
from rich.text import Text
# Create a Text object with desired style
text = Text(string, style="bold red")
# Convert the Text object to an ANSI string
ansi_output = text.__rich_console__(None, None) # pyright:ignore
# Join the ANSI strings to form the final output
"".join(segment.text for segment in ansi_output)
sys.exit(exit_code)

View File

@@ -2,9 +2,6 @@ import logging
from typing import TYPE_CHECKING
from InquirerPy import inquirer
from thefuzz import fuzz
from ...Utility.data import anime_normalizer
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
@@ -22,7 +19,7 @@ BG_GREEN = "\033[48;2;120;233;12;m"
GREEN = "\033[38;2;45;24;45;m"
def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]"):
def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]", default=True):
"""Helper function used to filter a list of EpisodeStream objects to one that has a corresponding quality
Args:
@@ -33,8 +30,20 @@ def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]"):
an EpisodeStream object or None incase the quality was not found
"""
for stream_link in stream_links:
if stream_link["quality"] == quality:
q = float(quality)
Q = float(stream_link["quality"])
# some providers have inaccurate eg qualities 718 instead of 720
if Q < q + 80 and Q > q - 80:
return stream_link
else:
if stream_links and default:
try:
print("Qualities were: ", stream_links)
print("Using default of quality: ", stream_links[0]["quality"])
return stream_links[0]
except Exception as e:
print(e)
return
def format_bytes_to_human(num_of_bytes: float, suffix: str = "B"):
@@ -101,30 +110,3 @@ def fuzzy_inquirer(choices: list, prompt: str, **kwargs):
**kwargs,
).execute()
return action
# WARNING: This function is depracated use the one from the main utility package
def anime_title_percentage_match(
possible_user_requested_anime_title: str, title: tuple
) -> float:
"""Returns the percentage match between the possible title and user title
Args:
possible_user_requested_anime_title (str): an Animdl search result title
title (str): the anime title the user wants
Returns:
int: the percentage match
"""
if normalized_anime_title := anime_normalizer.get(
possible_user_requested_anime_title
):
possible_user_requested_anime_title = normalized_anime_title
for key, value in locals().items():
logger.info(f"{key}: {value}")
# compares both the romaji and english names and gets highest Score
percentage_ratio = max(
fuzz.ratio(title[0].lower(), possible_user_requested_anime_title.lower()),
fuzz.ratio(title[1].lower(), possible_user_requested_anime_title.lower()),
)
return percentage_ratio

View File

@@ -1,13 +1,11 @@
import os
import sys
from pathlib import Path
from platform import system
from platformdirs import PlatformDirs
from . import APP_NAME, AUTHOR
from . import APP_NAME, AUTHOR, __version__
PLATFORM = system()
dirs = PlatformDirs(appname=APP_NAME, appauthor=AUTHOR, ensure_exists=True)
# ---- app deps ----
APP_DIR = os.path.abspath(os.path.dirname(__file__))
@@ -24,19 +22,63 @@ PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
# ----- user configs and data -----
APP_DATA_DIR = dirs.user_config_dir
if not APP_DATA_DIR:
APP_DATA_DIR = dirs.user_data_dir
S_PLATFORM = sys.platform
if S_PLATFORM == "win32":
# app data
app_data_dir_base = os.getenv("LOCALAPPDATA")
if not app_data_dir_base:
raise RuntimeError("Could not determine app data dir please report to devs")
APP_DATA_DIR = os.path.join(app_data_dir_base, AUTHOR, APP_NAME)
# cache dir
APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache")
# videos dir
video_dir_base = os.path.expanduser("~/Videos")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
elif S_PLATFORM == "darwin":
# app data
app_data_dir_base = os.path.expanduser("~/Library/Application Support")
APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__)
# cache dir
cache_dir_base = os.path.expanduser("~/Library/Caches")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME, __version__)
# videos dir
video_dir_base = os.path.expanduser("~/Movies")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
else:
# app data
app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "")
if not app_data_dir_base.strip():
app_data_dir_base = os.path.expanduser("~/.config")
APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME)
# cache dir
cache_dir_base = os.environ.get("XDG_CACHE_HOME", "")
if not cache_dir_base.strip():
cache_dir_base = os.path.expanduser("~/.cache")
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME)
# videos dir
video_dir_base = os.environ.get("XDG_VIDEOS_DIR", "")
if not video_dir_base.strip():
video_dir_base = os.path.expanduser("~/Videos")
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
# ensure paths exist
Path(APP_DATA_DIR).mkdir(parents=True, exist_ok=True)
Path(APP_CACHE_DIR).mkdir(parents=True, exist_ok=True)
Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
# useful paths
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
NOTIFIER_LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "notifier.log")
# cache dir
APP_CACHE_DIR = dirs.user_cache_dir
# video dir
USER_VIDEOS_DIR = os.path.join(dirs.user_videos_dir, APP_NAME)
USER_NAME = os.environ.get("USERNAME", "Anime fun")

View File

@@ -18,7 +18,6 @@ from .constants import (
USER_AGENT,
)
from .gql_queries import ALLANIME_EPISODES_GQL, ALLANIME_SEARCH_GQL, ALLANIME_SHOW_GQL
from .normalizer import normalize_anime, normalize_search_results
if TYPE_CHECKING:
from typing import Iterator
@@ -106,7 +105,23 @@ class AllAnimeAPI(AnimeProvider):
}
try:
search_results = self._fetch_gql(ALLANIME_SEARCH_GQL, variables)
return normalize_search_results(search_results) # pyright:ignore
page_info = search_results["shows"]["pageInfo"]
results = []
for result in search_results["shows"]["edges"]:
normalized_result = {
"id": result["_id"],
"title": result["name"],
"type": result["__typename"],
"availableEpisodes": result["availableEpisodes"],
}
results.append(normalized_result)
normalized_search_results = {
"pageInfo": page_info,
"results": results,
}
return normalized_search_results
except Exception as e:
logger.error(f"FA(AllAnime): {e}")
return {}
@@ -123,9 +138,19 @@ class AllAnimeAPI(AnimeProvider):
variables = {"showId": allanime_show_id}
try:
anime = self._fetch_gql(ALLANIME_SHOW_GQL, variables)
return normalize_anime(anime["show"])
id: str = anime["show"]["_id"]
title: str = anime["show"]["name"]
availableEpisodesDetail = anime["show"]["availableEpisodesDetail"]
type = anime.get("__typename")
normalized_anime = {
"id": id,
"title": title,
"availableEpisodesDetail": availableEpisodesDetail,
"type": type,
}
return normalized_anime
except Exception as e:
logger.error(f"FA(AllAnime): {e}")
logger.error(f"AllAnime(get_anime): {e}")
return None
def _get_anime_episode(
@@ -323,7 +348,9 @@ if __name__ == "__main__":
print("Sth went wrong")
break
episode_streams_ = anime_provider.get_episode_streams(
anime_data, episode, translation.strip()
anime_data, # pyright: ignore
episode,
translation.strip(),
)
if episode_streams_ is None:
raise Exception("Episode not found")

View File

@@ -1,44 +0,0 @@
from ..types import Anime, AnimeEpisodeDetails, SearchResults
from .types import AllAnimeEpisode, AllAnimeSearchResults, AllAnimeShow
# TODO: scrap this module and do the transformations directly from the provider class
def normalize_search_results(search_results: AllAnimeSearchResults) -> SearchResults:
page_info = search_results["shows"]["pageInfo"]
results = []
for result in search_results["shows"]["edges"]:
normalized_result = {
"id": result["_id"],
"title": result["name"],
"type": result["__typename"],
"availableEpisodes": result["availableEpisodes"],
}
results.append(normalized_result)
normalized_search_results: SearchResults = {
"pageInfo": page_info, # pyright:ignore
"results": results,
}
return normalized_search_results
def normalize_anime(anime: AllAnimeShow) -> Anime:
id: str = anime["_id"]
title: str = anime["name"]
availableEpisodesDetail: AnimeEpisodeDetails = anime[
"availableEpisodesDetail"
] # pyright:ignore
type = anime.get("__typename")
normalized_anime: Anime = { # pyright:ignore
"id": id,
"title": title,
"availableEpisodesDetail": availableEpisodesDetail,
"type": type,
}
return normalized_anime
def normalize_episode(episode: AllAnimeEpisode):
pass

View File

@@ -1,7 +1,9 @@
import logging
import random
import re
import shutil
import subprocess
import time
from typing import TYPE_CHECKING
from yt_dlp.utils import (
@@ -68,20 +70,55 @@ class AnimePaheApi(AnimeProvider):
return {}
def get_anime(self, session_id: str, *args):
page = 1
try:
anime_result: "AnimeSearchResult" = [
anime
for anime in self.search_page["data"]
if anime["session"] == session_id
][0]
url = (
f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page=1"
data: "AnimePaheAnimePage" = {} # pyright:ignore
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
def _pages_loader(
url,
page,
):
response = self.session.get(url, headers=REQUEST_HEADERS)
if response.status_code == 200:
if not data:
data.update(response.json())
if ep_data := response.json().get("data"):
data["data"].extend(ep_data)
if data["next_page_url"]:
# TODO: Refine this
time.sleep(
random.choice(
[
0.25,
0.1,
0.5,
0.75,
1,
]
)
)
page += 1
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
_pages_loader(
url,
page,
)
_pages_loader(
url,
page,
)
response = self.session.get(url, headers=REQUEST_HEADERS)
if not response.status_code == 200:
if not data:
return {}
data: "AnimePaheAnimePage" = response.json()
self.anime = data
self.anime = data # pyright:ignore
episodes = list(map(str, range(data["total"])))
title = ""
return {
@@ -121,9 +158,10 @@ class AnimePaheApi(AnimeProvider):
for episode in self.anime["data"]
if float(episode["episode"]) == float(episode_number)
]
if not episode:
logger.error(f"AnimePahe(streams): episode {episode_number} doesn't exist")
raise Exception("Episode not found")
return []
episode = episode[0]
anime_id = anime["id"]
@@ -157,7 +195,7 @@ class AnimePaheApi(AnimeProvider):
logger.warn(
"AnimePahe: embed url not found please report to the developers"
)
raise Exception("Episode not found")
return []
# get embed page
embed_response = self.session.get(embed_url, headers=SERVER_HEADERS)
embed = embed_response.text
@@ -174,14 +212,14 @@ class AnimePaheApi(AnimeProvider):
logger.warn(
"AnimePahe: Encoded js not found please report to the developers"
)
raise Exception("Episode not found")
return []
# execute the encoded js with node for now or maybe forever in odrder to get a more workable info
NODE = shutil.which("node")
if not NODE:
logger.warn(
"AnimePahe: animepahe currently requires node js to extract them juicy streams"
)
raise Exception("Episode not found")
return []
result = subprocess.run(
[NODE, "-e", encoded_js],
text=True,
@@ -193,14 +231,14 @@ class AnimePaheApi(AnimeProvider):
logger.warn(
"AnimePahe: could not decode encoded js using node please report to developers"
)
raise Exception("Episode not found")
return []
# get that juicy stream
match = JUICY_STREAM_REGEX.search(evaluted_js)
if not match:
logger.warn(
"AnimePahe: could not find the juicy stream please report to developers"
)
raise Exception("Episode not found")
return []
# get the actual hls stream link
juicy_stream = match.group(1)
# add the link

View File

@@ -67,5 +67,5 @@ class EpisodeStream(TypedDict):
class Server(TypedDict):
server: str
episode_title: str | None
episode_title: str
links: list[EpisodeStream]

59
poetry.lock generated
View File

@@ -861,20 +861,6 @@ nodeenv = ">=1.6.0"
all = ["twine (>=3.4.1)"]
dev = ["twine (>=3.4.1)"]
[[package]]
name = "pyshortcuts"
version = "1.9.0"
description = "Create desktop and Start Menu shortcuts for python scripts"
optional = false
python-versions = ">=3.8"
files = [
{file = "pyshortcuts-1.9.0-py3-none-any.whl", hash = "sha256:54d12ed8cd29bf83ac15153ce882a77072f2032b5f979474c519a2bac5af849d"},
{file = "pyshortcuts-1.9.0.tar.gz", hash = "sha256:016e89111337f74ce1ba3f4b79b295a643bc70b3e63ce4600247aa4bafa06877"},
]
[package.dependencies]
pywin32 = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "pytest"
version = "8.3.2"
@@ -897,43 +883,6 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "python-dotenv"
version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.8"
files = [
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
{file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
]
[package.extras]
cli = ["click (>=5.0)"]
[[package]]
name = "pywin32"
version = "306"
description = "Python for Window Extensions"
optional = false
python-versions = "*"
files = [
{file = "pywin32-306-cp310-cp310-win32.whl", hash = "sha256:06d3420a5155ba65f0b72f2699b5bacf3109f36acbe8923765c22938a69dfc8d"},
{file = "pywin32-306-cp310-cp310-win_amd64.whl", hash = "sha256:84f4471dbca1887ea3803d8848a1616429ac94a4a8d05f4bc9c5dcfd42ca99c8"},
{file = "pywin32-306-cp311-cp311-win32.whl", hash = "sha256:e65028133d15b64d2ed8f06dd9fbc268352478d4f9289e69c190ecd6818b6407"},
{file = "pywin32-306-cp311-cp311-win_amd64.whl", hash = "sha256:a7639f51c184c0272e93f244eb24dafca9b1855707d94c192d4a0b4c01e1100e"},
{file = "pywin32-306-cp311-cp311-win_arm64.whl", hash = "sha256:70dba0c913d19f942a2db25217d9a1b726c278f483a919f1abfed79c9cf64d3a"},
{file = "pywin32-306-cp312-cp312-win32.whl", hash = "sha256:383229d515657f4e3ed1343da8be101000562bf514591ff383ae940cad65458b"},
{file = "pywin32-306-cp312-cp312-win_amd64.whl", hash = "sha256:37257794c1ad39ee9be652da0462dc2e394c8159dfd913a8a4e8eb6fd346da0e"},
{file = "pywin32-306-cp312-cp312-win_arm64.whl", hash = "sha256:5821ec52f6d321aa59e2db7e0a35b997de60c201943557d108af9d4ae1ec7040"},
{file = "pywin32-306-cp37-cp37m-win32.whl", hash = "sha256:1c73ea9a0d2283d889001998059f5eaaba3b6238f767c9cf2833b13e6a685f65"},
{file = "pywin32-306-cp37-cp37m-win_amd64.whl", hash = "sha256:72c5f621542d7bdd4fdb716227be0dd3f8565c11b280be6315b06ace35487d36"},
{file = "pywin32-306-cp38-cp38-win32.whl", hash = "sha256:e4c092e2589b5cf0d365849e73e02c391c1349958c5ac3e9d5ccb9a28e017b3a"},
{file = "pywin32-306-cp38-cp38-win_amd64.whl", hash = "sha256:e8ac1ae3601bee6ca9f7cb4b5363bf1c0badb935ef243c4733ff9a393b1690c0"},
{file = "pywin32-306-cp39-cp39-win32.whl", hash = "sha256:e25fd5b485b55ac9c057f67d94bc203f3f6595078d1fb3b458c9c28b7153a802"},
{file = "pywin32-306-cp39-cp39-win_amd64.whl", hash = "sha256:39b61c15272833b5c329a2989999dcae836b1eed650252ab1b7bfbe1d59f30f4"},
]
[[package]]
name = "pyyaml"
version = "6.0.2"
@@ -1208,13 +1157,13 @@ files = [
[[package]]
name = "tox"
version = "4.17.0"
version = "4.17.1"
description = "tox is a generic virtualenv management and test command line tool"
optional = false
python-versions = ">=3.8"
files = [
{file = "tox-4.17.0-py3-none-any.whl", hash = "sha256:82ef41e7e54182e2143daf0b2920d9030c2e1c4291e12091ebad66860c7be7a4"},
{file = "tox-4.17.0.tar.gz", hash = "sha256:b1e2e1dfbfdc174d9be95ae78ec2c4d2cf4800d4c15571deddb197a2c90d2de6"},
{file = "tox-4.17.1-py3-none-any.whl", hash = "sha256:2974597c0353577126ab014f52d1a399fb761049e165ff34427f84e8cfe6c990"},
{file = "tox-4.17.1.tar.gz", hash = "sha256:2c41565a571e34480bd401d668a4899806169a4633e972ac296c54406d2ded8a"},
]
[package.dependencies]
@@ -1407,4 +1356,4 @@ test = ["pytest (>=8.1,<9.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "83ec7de7d9466dcd1fadef4b21eec2a879cc9a7d526992ed280b6af53b49d9f1"
content-hash = "7d20e2d0c0c3c8f3a48d9160a2b4a11a5f353d23bb5d7a06ec527fe08e425b91"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "fastanime"
version = "0.62.5.dev1"
version = "1.1.6.dev1"
description = "A browser anime site experience from the terminal"
authors = ["Benextempest <benextempest@gmail.com>"]
license = "UNLICENSE"
@@ -12,12 +12,9 @@ yt-dlp = "^2024.5.27"
rich = "^13.7.1"
click = "^8.1.7"
inquirerpy = "^0.3.4"
platformdirs = "^4.2.2"
python-dotenv = "^1.0.1"
thefuzz = "^0.22.1"
requests = "^2.32.3"
plyer = "^2.1.0"
pyshortcuts = "^1.9.0"
mpv = "^1.0.7"
[tool.poetry.group.dev.dependencies]

View File

@@ -55,6 +55,11 @@ def test_completions_help(runner: CliRunner):
assert result.exit_code == 0
def test_update_help(runner: CliRunner):
result = runner.invoke(run_cli, ["update", "--help"])
assert result.exit_code == 0
def test_anilist_help(runner: CliRunner):
result = runner.invoke(run_cli, ["anilist", "--help"])
assert result.exit_code == 0