Compare commits

..

22 Commits

Author SHA1 Message Date
Benex254
1d19449ab7 chore: bump version 2024-08-15 10:52:59 +03:00
Benex254
e1f73334ef feat(cli): remove unknown as possible quality 2024-08-15 10:50:32 +03:00
Benex254
4faac017b5 feat(utils): add 480 as possible quality 2024-08-15 10:49:29 +03:00
Benex254
bfbd2a57a0 feat(cli): add 480 as a possible quality 2024-08-15 10:48:39 +03:00
Benex254
9519472f83 feat(utils): pretty colors when defaulting to quality 2024-08-15 10:48:15 +03:00
Benex254
5c0c119cbc feat(download command): use actual episodes if downloading all 2024-08-15 10:47:35 +03:00
Benex254
87eb257a10 chore: use plyer.sttoragepath if possible 2024-08-15 10:29:59 +03:00
Benex254
4a08076c3b feat: use actual episodes list than inference 2024-08-15 00:20:06 +03:00
Benex254
0d239e6793 chore: bump version 2024-08-14 22:43:05 +03:00
Benex254
0a0d47ae88 chore: raise search results for anilist 2024-08-14 22:42:35 +03:00
Benex254
2ba07d47b3 feat(cli): add anime title completions 2024-08-14 22:32:47 +03:00
Benex254
f1b520fe3c chore: bump version 2024-08-14 21:22:40 +03:00
Benex254
8cfcc26468 feat(animepahe): use true episodes 2024-08-14 21:20:49 +03:00
Benex254
cd51edf0b8 feat(interface): better post error response 2024-08-14 21:10:32 +03:00
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
15 changed files with 275 additions and 50 deletions

View File

@@ -164,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.

View File

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

View File

@@ -76,7 +76,14 @@ signal.signal(signal.SIGINT, handle_exit)
@click.option(
"-q",
"--quality",
type=click.Choice(["360", "720", "1080", "unknown"]),
type=click.Choice(
[
"360",
"480",
"720",
"1080",
]
),
help="set the quality of the stream",
)
@click.option(

View File

@@ -1,13 +1,13 @@
import click
from ...utils.completion_types import anime_titles_shell_complete
@click.command(
help="Search for anime using anilists api and get top ~50 results",
short_help="Search for anime",
)
@click.argument(
"title",
)
@click.argument("title", shell_complete=anime_titles_shell_complete)
@click.pass_obj
def search(config, title):
from ....anilist import AniList

View File

@@ -3,6 +3,8 @@ from typing import TYPE_CHECKING
import click
from ..utils.completion_types import anime_titles_shell_complete
if TYPE_CHECKING:
from ..config import Config
@@ -12,8 +14,7 @@ if TYPE_CHECKING:
short_help="Download anime",
)
@click.argument(
"anime-title",
required=True,
"anime-title", required=True, shell_complete=anime_titles_shell_complete
)
@click.option(
"--episode-range",
@@ -91,10 +92,12 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
episodes = anime["availableEpisodesDetail"][config.translation_type]
if episode_range:
episodes_start, episodes_end = episode_range.split("-")
episodes_range = range(round(float(episodes_start)), round(float(episodes_end)))
else:
episodes_start, episodes_end = 0, len(episodes)
for episode in range(round(float(episodes_start)), round(float(episodes_end))):
episodes_range = sorted(episodes, key=float)
for episode in episodes_range:
try:
episode = str(episode)
if episode not in episodes:
@@ -112,7 +115,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")

View File

@@ -1,6 +1,7 @@
import click
from ...cli.config import Config
from ..utils.completion_types import anime_titles_shell_complete
@click.command(
@@ -12,7 +13,9 @@ from ...cli.config import Config
"-r",
help="A range of episodes to binge",
)
@click.argument("anime_title", required=True, type=str)
@click.argument(
"anime_title", required=True, shell_complete=anime_titles_shell_complete
)
@click.pass_obj
def search(config: Config, anime_title: str, episode_range: str):
from click import clear
@@ -130,7 +133,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

@@ -366,7 +366,22 @@ def provider_anime_episode_servers_menu(
with Progress() as progress:
progress.add_task("Fetching top server...", total=None)
try:
selected_server = next(episode_streams_generator)
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)
@@ -517,16 +532,26 @@ def provider_anime_episode_servers_menu(
# this will try to update the episode to be the next episode if delta has reached a specific threshhold
# this update will only apply locally
# the remote(anilist) is only updated when its certain you are going to open the player
available_episodes: list = sorted(
fastanime_runtime_state.provider_available_episodes, key=float
)
if stop_time == "0" or total_time == "0":
# increment the episode
episode = str(int(current_episode_number) + 1)
# increment the episodes
next_episode = available_episodes.index(current_episode_number) + 1
if next_episode >= len(available_episodes):
next_episode = len(available_episodes) - 1
episode = available_episodes[next_episode]
else:
error = config.error * 60
delta = calculate_time_delta(stop_time, total_time)
if delta.total_seconds() > error:
episode = current_episode_number
else:
episode = str(int(current_episode_number) + 1)
# increment the episodes
next_episode = available_episodes.index(current_episode_number) + 1
if next_episode >= len(available_episodes):
next_episode = len(available_episodes) - 1
episode = available_episodes[next_episode]
stop_time = "0"
total_time = "0"
@@ -657,8 +682,7 @@ def fetch_anime_episode(config, fastanime_runtime_state: "FastAnimeRuntimeState"
else:
if not Rofi.confirm("Sth went wrong!!Enter to continue..."):
exit(1)
fetch_anime_episode(config, fastanime_runtime_state)
return
return fetch_anime_episode(config, fastanime_runtime_state)
fastanime_runtime_state.provider_anime = provider_anime
provider_anime_episodes_menu(config, fastanime_runtime_state)
@@ -704,8 +728,7 @@ def anime_provider_search_results_menu(
else:
if not Rofi.confirm("Sth went wrong!!Enter to continue..."):
exit(1)
anime_provider_search_results_menu(config, fastanime_runtime_state)
return
return anime_provider_search_results_menu(config, fastanime_runtime_state)
provider_search_results = {
anime["title"]: anime for anime in provider_search_results["results"]
@@ -1161,6 +1184,7 @@ def anilist_results_menu(
anime["status"] == "RELEASING"
and anime["nextAiringEpisode"]
and progress > 0
and anime["mediaListEntry"]
):
last_aired_episode = anime["nextAiringEpisode"]["episode"] - 1
if last_aired_episode - progress > 0:

View File

@@ -0,0 +1,98 @@
from typing import TYPE_CHECKING
import requests
if TYPE_CHECKING:
from ...libs.anilist.types import AnilistDataSchema
import logging
logger = logging.getLogger(__name__)
ANILIST_ENDPOINT = "https://graphql.anilist.co"
anime_title_query = """
query($query:String){
Page(perPage:50){
pageInfo{
total
currentPage
hasNextPage
}
media(search:$query,type:ANIME){
id
idMal
title{
romaji
english
}
}
}
}
"""
def get_anime_titles(query: str, variables: dict = {}):
"""the abstraction over all none authenticated requests and that returns data of a similar type
Args:
query: the anilist query
variables: the anilist api variables
Returns:
a boolean indicating success and none or an anilist object depending on success
"""
try:
response = requests.post(
ANILIST_ENDPOINT,
json={"query": query, "variables": variables},
timeout=10,
)
anilist_data: AnilistDataSchema = response.json()
# ensuring you dont get blocked
if (
int(response.headers.get("X-RateLimit-Remaining", 0)) < 30
and not response.status_code == 500
):
print("Warning you are exceeding the allowed number of calls per minute")
logger.warning(
"You are exceeding the allowed number of calls per minute for the AniList api enforcing timeout"
)
print("Forced timeout will now be initiated")
import time
print("sleeping...")
time.sleep(1 * 60)
if response.status_code == 200:
eng_titles = [
anime["title"]["english"]
for anime in anilist_data["data"]["Page"]["media"]
if anime["title"]["english"]
]
romaji_titles = [
anime["title"]["romaji"]
for anime in anilist_data["data"]["Page"]["media"]
if anime["title"]["romaji"]
]
return [*eng_titles, *romaji_titles]
else:
return ["non 200 status code"]
except requests.exceptions.Timeout:
logger.warning(
"Timeout has been exceeded this could mean anilist is down or you have lost internet connection"
)
return ["timeout exceeded"]
except requests.exceptions.ConnectionError:
logger.warning(
"ConnectionError this could mean anilist is down or you have lost internet connection"
)
return ["connection error"]
except Exception as e:
logger.error(f"Something unexpected occured {e}")
return ["unexpected error"]
def anime_titles_shell_complete(ctx, param, incomplete):
return [name for name in get_anime_titles(anime_title_query, {"query": incomplete})]

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
@@ -231,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

@@ -19,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:
@@ -30,8 +30,25 @@ 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:
from rich import print
try:
print("[yellow bold]WARNING Qualities were:[/] ", stream_links)
print(
"[cyan bold]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"):

View File

@@ -3,12 +3,19 @@ import sys
from pathlib import Path
from platform import system
from plyer import storagepath
from . import APP_NAME, AUTHOR, __version__
PLATFORM = system()
# ---- app deps ----
APP_DIR = os.path.abspath(os.path.dirname(__file__))
try:
APP_DIR = storagepath.get_application_dir() # pyright:ignore
except Exception:
APP_DIR = None
if not APP_DIR:
APP_DIR = os.path.abspath(os.path.dirname(__file__))
CONFIGS_DIR = os.path.join(APP_DIR, "configs")
ASSETS_DIR = os.path.join(APP_DIR, "assets")
@@ -24,10 +31,18 @@ PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
# ----- user configs and data -----
S_PLATFORM = sys.platform
try:
app_data_dir_base = None
video_dir_base = storagepath.get_videos_dir() # pyright:ignore
cache_dir_base = None
except Exception:
video_dir_base = None
cache_dir_base = None
app_data_dir_base = None
if S_PLATFORM == "win32":
# app data
app_data_dir_base = os.getenv("LOCALAPPDATA")
if not app_data_dir_base:
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)
@@ -36,38 +51,45 @@ if S_PLATFORM == "win32":
APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache")
# videos dir
video_dir_base = os.path.expanduser("~/Videos")
if not video_dir_base:
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")
if not app_data_dir_base:
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")
if not cache_dir_base:
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")
if not video_dir_base:
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")
if not app_data_dir_base:
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")
if not cache_dir_base:
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")
if not video_dir_base:
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

View File

@@ -231,7 +231,7 @@ $type:MediaType\
search_query = (
"""
query($query:String,%s){
Page(perPage:30,page:$page){
Page(perPage:50,page:$page){
pageInfo{
total
currentPage

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,21 +70,57 @@ 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())
else:
if ep_data := response.json().get("data"):
data["data"].extend(ep_data)
if response.json()["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
episodes = list(map(str, range(data["total"])))
self.anime = data # pyright:ignore
episodes = list(map(str, [episode["episode"] for episode in data["data"]]))
title = ""
return {
"id": session_id,

View File

@@ -36,7 +36,7 @@ hex_to_char = {
def give_random_quality(links: list[dict]):
qualities = cycle(["1080", "720", "360"])
qualities = cycle(["1080", "720", "480", "360"])
return [
{"link": link["link"], "quality": quality}

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "fastanime"
version = "1.1.0.dev1"
version = "1.6.2.dev1"
description = "A browser anime site experience from the terminal"
authors = ["Benextempest <benextempest@gmail.com>"]
license = "UNLICENSE"