Compare commits

..

37 Commits

Author SHA1 Message Date
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
Benex254
9185a08102 fix(downloads): typing issue 2024-08-11 17:55:02 +03:00
Benex254
4f754a5129 chore: bump version 2024-08-11 17:51:21 +03:00
Benex254
cec7aaebdb refactor: media player controls 2024-08-11 17:50:09 +03:00
Benex254
bfd580ec79 refactor: provider_anime_episode_servers_menu 2024-08-11 17:19:10 +03:00
Benex254
aabd356c0b refactor: provider anime episodes menu 2024-08-11 16:32:02 +03:00
Benex254
f929e83a62 refactor: anime provider search results menu 2024-08-11 15:56:00 +03:00
Benex254
74cacded6e feat(interface): add episodes and toggle continue from history to the anilist media actions menu 2024-08-11 15:33:56 +03:00
Benex254
82b6b849cf refactor: anilist_media_actions_menu 2024-08-11 15:24:01 +03:00
Benex254
5dbc3a16f7 refactor: a whole lot of it 2024-08-11 14:58:49 +03:00
Benex254
912535d166 docs: document anilist menu 2024-08-11 13:35:28 +03:00
46 changed files with 1515 additions and 880 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)
@@ -309,15 +310,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 +349,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 +378,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.0"
__version__ = "v1.1.0"
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

@@ -1,6 +1,6 @@
import click
from ...utils.tools import QueryDict
from ...utils.tools import FastAnimeRuntimeState
from .__lazyloader__ import LazyGroup
commands = {
@@ -36,7 +36,9 @@ def anilist(ctx: click.Context):
from ....anilist import AniList
from ....AnimeProvider import AnimeProvider
from ...interfaces.anilist_interfaces import anilist as anilist_interface
from ...interfaces.anilist_interfaces import (
fastanime_main_menu as anilist_interface,
)
if TYPE_CHECKING:
from ...config import Config
@@ -45,5 +47,5 @@ def anilist(ctx: click.Context):
if user := ctx.obj.user:
AniList.update_login_info(user, user["token"])
if ctx.invoked_subcommand is None:
anilist_config = QueryDict()
anilist_interface(ctx.obj, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
anilist_interface(ctx.obj, fastanime_runtime_state)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def completed(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def completed(config: "Config"):
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def dropped(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def dropped(config: "Config"):
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -8,11 +8,11 @@ import click
@click.pass_obj
def favourites(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_favourite()
if anime_data[0]:
anilist_config = QueryDict()
anilist_config.data = anime_data[1]
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def paused(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def paused(config: "Config"):
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config = FastAnimeRuntimeState()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)
anilist_interfaces.anilist_results_menu(config, anilist_config)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def planning(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def planning(config: "Config"):
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -7,11 +7,11 @@ import click
@click.pass_obj
def popular(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_popular()
if anime_data[0]:
anilist_config = QueryDict()
anilist_config.data = anime_data[1]
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -10,8 +10,8 @@ def random_anime(config):
import random
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
random_anime = range(1, 15000)
@@ -20,8 +20,8 @@ def random_anime(config):
anime_data = AniList.search(id_in=list(random_anime))
if anime_data[0]:
anilist_config = QueryDict()
anilist_config.data = anime_data[1]
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)
else:
print(anime_data[1])

View File

@@ -8,11 +8,11 @@ import click
@click.pass_obj
def recent(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_recently_updated()
if anime_data[0]:
anilist_config = QueryDict()
anilist_config.data = anime_data[1]
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def rewatching(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def rewatching(config: "Config"):
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -7,11 +7,11 @@ import click
@click.pass_obj
def scores(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
anime_data = AniList.get_most_scored()
if anime_data[0]:
anilist_config = QueryDict()
anilist_config.data = anime_data[1]
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.data = anime_data[1]
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -11,11 +11,11 @@ import click
@click.pass_obj
def search(config, title):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
success, search_results = AniList.search(title)
if success:
anilist_config = QueryDict()
anilist_config.data = search_results
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = search_results
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -8,11 +8,11 @@ import click
@click.pass_obj
def trending(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
success, data = AniList.get_trending()
if success:
anilist_config = QueryDict()
anilist_config.data = data
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = data
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -7,11 +7,11 @@ import click
@click.pass_obj
def upcoming(config):
from ....anilist import AniList
from ...interfaces.anilist_interfaces import select_anime
from ...utils.tools import QueryDict
from ...interfaces.anilist_interfaces import anilist_results_menu
from ...utils.tools import FastAnimeRuntimeState
success, data = AniList.get_upcoming_anime()
if success:
anilist_config = QueryDict()
anilist_config.data = data
select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = data
anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
def watching(config: "Config"):
from ....anilist import AniList
from ...interfaces import anilist_interfaces
from ...utils.tools import QueryDict, exit_app
from ...utils.tools import FastAnimeRuntimeState, exit_app
if not config.user:
print("Not authenticated")
@@ -27,6 +27,6 @@ def watching(config: "Config"):
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
] # pyright:ignore
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
anilist_config = QueryDict()
anilist_config.data = anime_list[1]
anilist_interfaces.select_anime(config, anilist_config)
fastanime_runtime_state = FastAnimeRuntimeState()
fastanime_runtime_state.anilist_data = anime_list[1]
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)

View File

@@ -25,14 +25,14 @@ def cache(clean, path, size):
elif size:
import os
from ..utils.utils import sizeof_fmt
from ..utils.utils import format_bytes_to_human
total_size = 0
for dirpath, dirnames, filenames in os.walk(APP_CACHE_DIR):
for f in filenames:
fp = os.path.join(dirpath, f)
total_size += os.path.getsize(fp)
print("Total Size: ", sizeof_fmt(total_size))
print("Total Size: ", format_bytes_to_human(total_size))
else:
import click

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
@@ -70,7 +71,10 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
if config.use_fzf:
search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
else:
search_result = fuzzy_inquirer("Please Select title", choices)
search_result = fuzzy_inquirer(
choices,
"Please Select title",
)
# ---- fetch anime ----
with Progress() as progress:
@@ -125,7 +129,10 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
if config.use_fzf:
server = fzf.run(servers_names, "Select an link: ")
else:
server = fuzzy_inquirer("Select link", servers_names)
server = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
)
@@ -139,14 +146,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

@@ -36,7 +36,10 @@ def downloads(config: "Config", path: bool):
elif config.use_rofi:
playlist_name = Rofi.run(playlists, "Enter Playlist Name")
else:
playlist_name = fuzzy_inquirer("Enter Playlist Name: ", playlists)
playlist_name = fuzzy_inquirer(
playlists,
"Enter Playlist Name: ",
)
if playlist_name == "Exit":
exit_app()
return

View File

@@ -63,8 +63,8 @@ def search(config: Config, anime_title: str, episode_range: str):
search_result = Rofi.run(choices, "Please Select Title")
else:
search_result = fuzzy_inquirer(
"Please Select Title",
choices,
"Please Select Title",
)
# ---- fetch selected anime ----
@@ -110,7 +110,10 @@ def search(config: Config, anime_title: str, episode_range: str):
elif config.use_rofi:
episode = Rofi.run(episodes, "Select an episode")
else:
episode = fuzzy_inquirer("Select episode", episodes)
episode = fuzzy_inquirer(
episodes,
"Select episode",
)
# ---- fetch streams ----
with Progress() as progress:
@@ -147,7 +150,10 @@ def search(config: Config, anime_title: str, episode_range: str):
elif config.use_rofi:
server = Rofi.run(servers_names, "Select an link")
else:
server = fuzzy_inquirer("Select link", servers_names)
server = fuzzy_inquirer(
servers_names,
"Select link",
)
stream_link = filter_by_quality(
config.quality, servers[server]["links"]
)

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...")

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,17 @@ 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__)
# this script was written by the fzf devs as an example on how to preview images
# its only here for convinience
fzf_preview = r"""
#
# The purpose of this script is to demonstrate how to preview a file or an
@@ -95,7 +97,16 @@ fzf-preview(){
# ---- aniskip intergration ----
def aniskip(mal_id, episode):
def aniskip(mal_id: int, episode: str):
"""helper function to be used for setting and getting skip data
Args:
mal_id: mal id of the anime
episode: episode number
Returns:
mpv chapter options
"""
ANISKIP = shutil.which("ani-skip")
if not ANISKIP:
print("Aniskip not found, please install and try again")
@@ -111,37 +122,61 @@ def aniskip(mal_id, episode):
# ---- prevew stuff ----
# import tempfile
# NOTE: May change this to a temp dir but there were issues so later
WORKING_DIR = APP_CACHE_DIR # tempfile.gettempdir()
IMAGES_DIR = os.path.join(WORKING_DIR, "images")
if not os.path.exists(IMAGES_DIR):
os.mkdir(IMAGES_DIR)
INFO_DIR = os.path.join(WORKING_DIR, "info")
if not os.path.exists(INFO_DIR):
os.mkdir(INFO_DIR)
IMAGES_CACHE_DIR = os.path.join(WORKING_DIR, "images")
if not os.path.exists(IMAGES_CACHE_DIR):
os.mkdir(IMAGES_CACHE_DIR)
ANIME_INFO_CACHE_DIR = os.path.join(WORKING_DIR, "info")
if not os.path.exists(ANIME_INFO_CACHE_DIR):
os.mkdir(ANIME_INFO_CACHE_DIR)
def save_image_from_url(url: str, file_name: str):
"""Helper function that downloads an image to the FastAnime images cache dir given its url and filename
Args:
url: image url to download
file_name: filename to use
"""
image = requests.get(url)
with open(f"{IMAGES_DIR}/{file_name}", "wb") as f:
with open(f"{IMAGES_CACHE_DIR}/{file_name}", "wb") as f:
f.write(image.content)
def save_info_from_str(info: str, file_name: str):
with open(f"{INFO_DIR}/{file_name}", "w") as f:
"""Helper function that writes text (anime details and info) to a file given its filename
Args:
info: the information anilist has on the anime
file_name: the filename to use
"""
with open(f"{ANIME_INFO_CACHE_DIR}/{file_name}", "w") as f:
f.write(info)
def write_search_results(
search_results: list[AnilistBaseMediaDataSchema],
titles,
workers=None,
anilist_results: list[AnilistBaseMediaDataSchema],
titles: list[str],
workers: int | None = None,
):
H_COLOR = 215, 0, 95
S_COLOR = 208, 208, 208
S_WIDTH = 45
"""A helper function used by and run in a background thread by get_fzf_preview function inorder to get the actual preview data to be displayed by fzf
Args:
anilist_results: the anilist results from an anilist action
titles: sanitized anime titles
workers:number of threads to use defaults to as many as possible
"""
# NOTE: Will probably make this a configuraable option
HEADER_COLOR = 215, 0, 95
SEPARATOR_COLOR = 208, 208, 208
SEPARATOR_WIDTH = 45
# use concurency to download and write as fast as possible
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
future_to_task = {}
for anime, title in zip(search_results, titles):
for anime, title in zip(anilist_results, titles):
# actual image url
image_url = anime["coverImage"]["large"]
future_to_task[executor.submit(save_image_from_url, image_url, title)] = (
image_url
@@ -149,24 +184,24 @@ def write_search_results(
# handle the text data
template = f"""
{get_true_fg("-"*S_WIDTH,*S_COLOR,bold=False)}
{get_true_fg('Title(jp):',*H_COLOR)} {anime['title']['romaji']}
{get_true_fg('Title(eng):',*H_COLOR)} {anime['title']['english']}
{get_true_fg('Popularity:',*H_COLOR)} {anime['popularity']}
{get_true_fg('Favourites:',*H_COLOR)} {anime['favourites']}
{get_true_fg('Status:',*H_COLOR)} {anime['status']}
{get_true_fg('Episodes:',*H_COLOR)} {anime['episodes']}
{get_true_fg('Genres:',*H_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres'])}
{get_true_fg('Next Episode:',*H_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode'])}
{get_true_fg('Start Date:',*H_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate'])}
{get_true_fg('End Date:',*H_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate'])}
{get_true_fg("-"*S_WIDTH,*S_COLOR,bold=False)}
{get_true_fg('Description:',*H_COLOR)}
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
{get_true_fg('Title(jp):',*HEADER_COLOR)} {anime['title']['romaji']}
{get_true_fg('Title(eng):',*HEADER_COLOR)} {anime['title']['english']}
{get_true_fg('Popularity:',*HEADER_COLOR)} {anime['popularity']}
{get_true_fg('Favourites:',*HEADER_COLOR)} {anime['favourites']}
{get_true_fg('Status:',*HEADER_COLOR)} {anime['status']}
{get_true_fg('Episodes:',*HEADER_COLOR)} {anime['episodes']}
{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres'])}
{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode'])}
{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate'])}
{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate'])}
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
{get_true_fg('Description:',*HEADER_COLOR)}
"""
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
@@ -181,11 +216,22 @@ def write_search_results(
# get rofi icons
def get_icons(search_results: list[AnilistBaseMediaDataSchema], titles, workers=None):
def get_rofi_icons(
anilist_results: list[AnilistBaseMediaDataSchema], titles, workers=None
):
"""A helper function to make sure that the images are downloaded so they can be used as icons
Args:
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
anilist_results: the anilist results from an anilist action
"""
# use concurrency to download the images as fast as possible
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
# load the jobs
future_to_url = {}
for anime, title in zip(search_results, titles):
for anime, title in zip(anilist_results, titles):
# actual link to download image from
image_url = anime["coverImage"]["large"]
future_to_url[executor.submit(save_image_from_url, image_url, title)] = (
image_url
@@ -196,19 +242,32 @@ def get_icons(search_results: list[AnilistBaseMediaDataSchema], titles, workers=
url = future_to_url[future]
try:
future.result()
except Exception as exc:
logger.error("%r generated an exception: %s" % (url, exc))
except Exception as e:
logger.error("%r generated an exception: %s" % (url, e))
def get_preview(search_results: list[AnilistBaseMediaDataSchema], titles, wait=False):
def get_fzf_preview(
anilist_results: list[AnilistBaseMediaDataSchema], titles, wait=False
):
"""A helper function that constructs data to be used for the fzf preview
Args:
titles (list[str]): The sanitized titles to use, NOTE: its important that they are sanitized since thay will be used as filenames
wait (bool): whether to block the ui as we wait for preview defaults to false
anilist_results: the anilist results got from an anilist action
Returns:
THe fzf preview script to use
"""
# ensure images and info exists
background_worker = Thread(
target=write_search_results, args=(search_results, titles)
target=write_search_results, args=(anilist_results, titles)
)
background_worker.daemon = True
background_worker.start()
os.environ["SHELL"] = shutil.which("bash") or "sh"
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
os.environ["SHELL"] = shutil.which("bash") or "bash"
preview = """
%s
if [ -s %s/{} ]; then fzf-preview %s/{}
@@ -219,12 +278,11 @@ def get_preview(search_results: list[AnilistBaseMediaDataSchema], titles, wait=F
fi
""" % (
fzf_preview,
IMAGES_DIR,
IMAGES_DIR,
INFO_DIR,
INFO_DIR,
IMAGES_CACHE_DIR,
IMAGES_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
ANIME_INFO_CACHE_DIR,
)
# preview.replace("\n", ";")
if wait:
background_worker.join()
return preview

View File

@@ -36,13 +36,17 @@ class MpvPlayer(object):
ep_no=None,
server="top",
):
anilist_config = self.anilist_config
fastanime_runtime_state = self.fastanime_runtime_state
config = self.config
episode_number: str = anilist_config.episode_number
current_episode_number: str = (
fastanime_runtime_state.provider_current_episode_number
)
quality = config.quality
episodes: list = sorted(anilist_config.episodes, key=float)
anime_id: int = anilist_config.anime_id
anime = anilist_config.anime
total_episodes: list = sorted(
fastanime_runtime_state.provider_available_episodes, key=float
)
anime_id_anilist: int = fastanime_runtime_state.selected_anime_id_anilist
provider_anime = fastanime_runtime_state.provider_anime
translation_type = config.translation_type
anime_provider = config.anime_provider
self.last_stop_time: str = "0"
@@ -53,51 +57,59 @@ class MpvPlayer(object):
# next or prev
if type == "next":
self.mpv_player.show_text("Fetching next episode...")
next_episode = episodes.index(episode_number) + 1
if next_episode >= len(episodes):
next_episode = len(episodes) - 1
anilist_config.episode_number = episodes[next_episode]
episode_number = anilist_config.episode_number
config.update_watch_history(anime_id, str(episode_number))
next_episode = total_episodes.index(current_episode_number) + 1
if next_episode >= len(total_episodes):
next_episode = len(total_episodes) - 1
fastanime_runtime_state.provider_current_episode_number = total_episodes[
next_episode
]
current_episode_number = (
fastanime_runtime_state.provider_current_episode_number
)
config.update_watch_history(anime_id_anilist, str(current_episode_number))
elif type == "reload":
if episode_number not in episodes:
if current_episode_number not in total_episodes:
self.mpv_player.show_text("Episode not available")
return
self.mpv_player.show_text("Replaying Episode...")
elif type == "custom":
if not ep_no or ep_no not in episodes:
if not ep_no or ep_no not in total_episodes:
self.mpv_player.show_text("Episode number not specified or invalid")
self.mpv_player.show_text(
f"Acceptable episodes are: {episodes}",
f"Acceptable episodes are: {total_episodes}",
)
return
self.mpv_player.show_text(f"Fetching episode {ep_no}")
episode_number = ep_no
config.update_watch_history(anime_id, str(ep_no))
anilist_config.episode_number = str(ep_no)
current_episode_number = ep_no
config.update_watch_history(anime_id_anilist, str(ep_no))
fastanime_runtime_state.provider_current_episode_number = str(ep_no)
else:
self.mpv_player.show_text("Fetching previous episode...")
prev_episode = episodes.index(episode_number) - 1
prev_episode = total_episodes.index(current_episode_number) - 1
if prev_episode <= 0:
prev_episode = 0
anilist_config.episode_number = episodes[prev_episode]
episode_number = anilist_config.episode_number
config.update_watch_history(anime_id, str(episode_number))
fastanime_runtime_state.provider_current_episode_number = total_episodes[
prev_episode
]
current_episode_number = (
fastanime_runtime_state.provider_current_episode_number
)
config.update_watch_history(anime_id_anilist, str(current_episode_number))
# update episode progress
if config.user and episode_number:
if config.user and current_episode_number:
AniList.update_anime_list(
{
"mediaId": anime_id,
"progress": episode_number,
"mediaId": anime_id_anilist,
"progress": current_episode_number,
}
)
# get them juicy streams
episode_streams = anime_provider.get_episode_streams(
anime,
episode_number,
provider_anime,
current_episode_number,
translation_type,
anilist_config.selected_anime_anilist,
fastanime_runtime_state.selected_anime_anilist,
)
if not episode_streams:
self.mpv_player.show_text("No streams were found")
@@ -124,6 +136,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
@@ -131,12 +144,12 @@ class MpvPlayer(object):
self,
stream_link,
anime_provider: "AnimeProvider",
anilist_config,
fastanime_runtime_state,
config: "Config",
title,
):
self.anime_provider = anime_provider
self.anilist_config = anilist_config
self.fastanime_runtime_state = fastanime_runtime_state
self.config = config
self.last_stop_time: str = "0"
self.last_total_time: str = "0"
@@ -219,13 +232,15 @@ class MpvPlayer(object):
def _toggle_translation_type():
translation_type = "sub" if config.translation_type == "dub" else "dub"
anime = anime_provider.get_anime(
anilist_config._anime["id"],
anilist_config.selected_anime_anilist,
fastanime_runtime_state.provider_anime_search_result["id"],
fastanime_runtime_state.selected_anime_anilist,
)
if not anime:
mpv_player.show_text("Failed to update translation type")
return
anilist_config.episodes = anime["availableEpisodesDetail"][translation_type]
fastanime_runtime_state.provider_available_episodes = anime[
"availableEpisodesDetail"
][translation_type]
config.translation_type = translation_type
if config.translation_type == "dub":
@@ -276,7 +291,7 @@ class MpvPlayer(object):
return
q = ["360", "720", "1080"]
quality = quality_raw.decode()
links: list = anilist_config.current_stream_links
links: list = fastanime_runtime_state.provider_server_episode_streams
q = [link["quality"] for link in links]
if quality in q:
config.quality = quality

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 @@
class QueryDict(dict):
"""dot.notation access to dictionary attributes"""
# TODO: add typing
class FastAnimeRuntimeState(dict):
"""A class that manages fastanime runtime during anilist command runtime"""
def __getattr__(self, attr):
try:
@@ -13,7 +14,7 @@ class QueryDict(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:
@@ -23,20 +20,51 @@ GREEN = "\033[38;2;45;24;45;m"
def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]"):
"""Helper function used to filter a list of EpisodeStream objects to one that has a corresponding quality
Args:
quality: the quality to use
stream_links: a list of EpisodeStream objects
Returns:
an EpisodeStream object or None incase the quality was not found
"""
for stream_link in stream_links:
if stream_link["quality"] == quality:
return stream_link
def sizeof_fmt(num, suffix="B"):
def format_bytes_to_human(num_of_bytes: float, suffix: str = "B"):
"""Helper function usedd to format bytes to human
Args:
num_of_bytes: the number of bytes to format
suffix: the suffix to use
Returns:
formated bytes
"""
for unit in ("", "K", "M", "G", "T", "P", "E", "Z"):
if abs(num) < 1024.0:
return f"{num:3.1f}{unit}{suffix}"
num /= 1024.0
return f"{num:.1f}Yi{suffix}"
if abs(num_of_bytes) < 1024.0:
return f"{num_of_bytes:3.1f}{unit}{suffix}"
num_of_bytes /= 1024.0
return f"{num_of_bytes:.1f}Yi{suffix}"
def get_true_fg(string: str, r: int, g: int, b: int, bold=True) -> str:
def get_true_fg(string: str, r: int, g: int, b: int, bold: bool = True) -> str:
"""Custom helper function that enables colored text in the terminal
Args:
bold: whether to bolden the text
string: string to color
r: red
g: green
b: blue
Returns:
colored string
"""
# NOTE: Currently only supports terminals that support true color
if bold:
return f"{BOLD}\033[38;2;{r};{g};{b};m{string}{RESET}"
else:
@@ -47,7 +75,17 @@ def get_true_bg(string, r: int, g: int, b: int) -> str:
return f"\033[48;2;{r};{g};{b};m{string}{RESET}"
def fuzzy_inquirer(prompt: str, choices, **kwargs):
def fuzzy_inquirer(choices: list, prompt: str, **kwargs):
"""helper function that enables easier interaction with InquirerPy lib
Args:
choices: the choices to prompt
prompt: the prompt string to use
**kwargs: other options to pass to fuzzy_inquirer
Returns:
a choice
"""
from click import clear
clear()
@@ -60,29 +98,3 @@ def fuzzy_inquirer(prompt: str, choices, **kwargs):
**kwargs,
).execute()
return action
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

@@ -121,9 +121,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 +158,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 +175,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 +194,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.0.dev1"
version = "1.1.0.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