mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
Merge branch 'roadmap-to-3.0'
This commit is contained in:
31
.github/copilot-instructions.md
vendored
Normal file
31
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
You are a senior Python developer with extensive experience in building robust, scalable applications. You excel at:
|
||||
|
||||
## Core Python Expertise
|
||||
|
||||
- Writing clean, maintainable, and efficient Python code
|
||||
- Following PEP 8 style guidelines and Python best practices
|
||||
- Implementing proper error handling and logging
|
||||
- Using type hints and modern Python features (3.8+)
|
||||
- Understanding memory management and performance optimization
|
||||
|
||||
## Development Practices
|
||||
|
||||
- Test-driven development (TDD) and writing comprehensive unit tests
|
||||
- Code reviews and mentoring junior developers
|
||||
- Designing modular, reusable code architectures
|
||||
- Implementing design patterns appropriately
|
||||
- Documentation and code commenting best practices
|
||||
|
||||
## Technical Skills
|
||||
|
||||
- CLI application development (argparse, click, typer)
|
||||
|
||||
## Problem-Solving Approach
|
||||
|
||||
- Break down complex problems into manageable components
|
||||
- Consider edge cases and error scenarios
|
||||
- Optimize for readability first, then performance
|
||||
- Provide multiple solution approaches when applicable
|
||||
- Explain trade-offs and design decisions
|
||||
|
||||
Always provide production-ready code with proper error handling, logging, and documentation.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,7 +20,6 @@ build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
bin/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
|
||||
7
fa
7
fa
@@ -1,3 +1,6 @@
|
||||
#!/usr/bin/env sh
|
||||
CLI_DIR="$(dirname "$(realpath "$0")")"
|
||||
exec uv run --directory "$CLI_DIR/../" fastanime "$@"
|
||||
provider_type=$1
|
||||
provider_name=$2
|
||||
[ -z "$provider_type" ] && echo "Please specify provider type" && exit
|
||||
[ -z "$provider_name" ] && echo "Please specify provider type" && exit
|
||||
uv run python -m fastanime.libs.providers.${provider_type}.${provider_name}.provider
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"""An abstraction over all providers offering added features with a simple and well typed api"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .libs.anime_provider import anime_sources
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Iterator
|
||||
|
||||
from .libs.anime_provider.types import Anime, SearchResults, Server
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TODO: add cool features like auto retry
|
||||
class AnimeProvider:
|
||||
"""Class that manages all anime sources adding some extra functionality to them.
|
||||
Attributes:
|
||||
PROVIDERS: [TODO:attribute]
|
||||
provider: [TODO:attribute]
|
||||
provider: [TODO:attribute]
|
||||
dynamic: [TODO:attribute]
|
||||
retries: [TODO:attribute]
|
||||
anime_provider: [TODO:attribute]
|
||||
"""
|
||||
|
||||
PROVIDERS = list(anime_sources.keys())
|
||||
provider = PROVIDERS[0]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider,
|
||||
cache_requests=os.environ.get("FASTANIME_CACHE_REQUESTS", "false"),
|
||||
use_persistent_provider_store=os.environ.get(
|
||||
"FASTANIME_USE_PERSISTENT_PROVIDER_STORE", "false"
|
||||
),
|
||||
dynamic=False,
|
||||
retries=0,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.dynamic = dynamic
|
||||
self.retries = retries
|
||||
self.cache_requests = cache_requests
|
||||
self.use_persistent_provider_store = use_persistent_provider_store
|
||||
self.lazyload_provider(self.provider)
|
||||
|
||||
def lazyload_provider(self, provider):
|
||||
"""updates the current provider being used"""
|
||||
try:
|
||||
self.anime_provider.session.kill_connection_to_db()
|
||||
except Exception:
|
||||
pass
|
||||
_, anime_provider_cls_name = anime_sources[provider].split(".", 1)
|
||||
package = f"fastanime.libs.anime_provider.{provider}"
|
||||
provider_api = importlib.import_module(".api", package)
|
||||
anime_provider = getattr(provider_api, anime_provider_cls_name)
|
||||
self.anime_provider = anime_provider(
|
||||
self.cache_requests, self.use_persistent_provider_store
|
||||
)
|
||||
|
||||
def search_for_anime(
|
||||
self, search_keywords, translation_type, **kwargs
|
||||
) -> "SearchResults | None":
|
||||
"""core abstraction over all providers search functionality
|
||||
|
||||
Args:
|
||||
user_query ([TODO:parameter]): [TODO:description]
|
||||
translation_type ([TODO:parameter]): [TODO:description]
|
||||
nsfw ([TODO:parameter]): [TODO:description]
|
||||
unknown ([TODO:parameter]): [TODO:description]
|
||||
anilist_obj: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
anime_provider = self.anime_provider
|
||||
results = anime_provider.search_for_anime(
|
||||
search_keywords, translation_type, **kwargs
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def get_anime(
|
||||
self,
|
||||
anime_id: str,
|
||||
**kwargs,
|
||||
) -> "Anime | None":
|
||||
"""core abstraction over getting info of an anime from all providers
|
||||
|
||||
Args:
|
||||
anime_id: [TODO:description]
|
||||
anilist_obj: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
anime_provider = self.anime_provider
|
||||
results = anime_provider.get_anime(anime_id, **kwargs)
|
||||
|
||||
return results
|
||||
|
||||
def get_episode_streams(
|
||||
self,
|
||||
anime_id,
|
||||
episode: str,
|
||||
translation_type: str,
|
||||
**kwargs,
|
||||
) -> "Iterator[Server] | None":
|
||||
"""core abstractions for getting juicy streams from all providers
|
||||
|
||||
Args:
|
||||
anime ([TODO:parameter]): [TODO:description]
|
||||
episode: [TODO:description]
|
||||
translation_type: [TODO:description]
|
||||
anilist_obj: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
anime_provider = self.anime_provider
|
||||
results = anime_provider.get_episode_streams(
|
||||
anime_id, episode, translation_type, **kwargs
|
||||
)
|
||||
return results
|
||||
@@ -1,4 +0,0 @@
|
||||
"""This package exist as away to expose functions and classes that my be useful to a developer using the fastanime library
|
||||
|
||||
[TODO:description]
|
||||
"""
|
||||
@@ -1,43 +0,0 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
|
||||
|
||||
COMMA_REGEX = re.compile(r"([0-9]{3})(?=\d)")
|
||||
|
||||
|
||||
# TODO: Add formating options for the final date
|
||||
def format_anilist_date_object(anilist_date_object: "AnilistDateObject"):
|
||||
if anilist_date_object and anilist_date_object["day"]:
|
||||
return f"{anilist_date_object['day']}/{anilist_date_object['month']}/{anilist_date_object['year']}"
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def format_anilist_timestamp(anilist_timestamp: int | None):
|
||||
if anilist_timestamp:
|
||||
return datetime.fromtimestamp(anilist_timestamp).strftime("%d/%m/%Y %H:%M:%S")
|
||||
else:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def format_list_data_with_comma(data: list | None):
|
||||
if data:
|
||||
return ", ".join(data)
|
||||
else:
|
||||
return "None"
|
||||
|
||||
|
||||
def format_number_with_commas(number: int | None):
|
||||
if not number:
|
||||
return "0"
|
||||
return COMMA_REGEX.sub(lambda match: f"{match.group(1)},", str(number)[::-1])[::-1]
|
||||
|
||||
|
||||
def extract_next_airing_episode(airing_episode: "AnilistMediaNextAiringEpisode"):
|
||||
if airing_episode:
|
||||
return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"
|
||||
else:
|
||||
return "Completed"
|
||||
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
Just contains some useful data used across the codebase
|
||||
"""
|
||||
|
||||
# useful incases where the anilist title is too different from the provider title
|
||||
anime_normalizer_raw = {
|
||||
"allanime": {
|
||||
"1P": "one piece",
|
||||
"Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica",
|
||||
"Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka",
|
||||
'Hazurewaku no "Joutai Ijou Skill" de Saikyou ni Natta Ore ga Subete wo Juurin suru made': "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made",
|
||||
"Re:Zero kara Hajimeru Isekai Seikatsu Season 3": "Re:Zero kara Hajimeru Isekai Seikatsu 3rd Season",
|
||||
},
|
||||
"hianime": {"My Star": "Oshi no Ko"},
|
||||
"animepahe": {
|
||||
"Azumanga Daiou The Animation": "Azumanga Daioh",
|
||||
"Mairimashita! Iruma-kun 2nd Season": "Mairimashita! Iruma-kun 2",
|
||||
"Mairimashita! Iruma-kun 3rd Season": "Mairimashita! Iruma-kun 3",
|
||||
},
|
||||
"nyaa": {},
|
||||
"yugen": {},
|
||||
}
|
||||
|
||||
|
||||
def get_anime_normalizer():
|
||||
"""Used because there are different providers"""
|
||||
import os
|
||||
|
||||
current_provider = os.environ.get("FASTANIME_PROVIDER", "allanime")
|
||||
return anime_normalizer_raw[current_provider]
|
||||
|
||||
|
||||
anime_normalizer = get_anime_normalizer()
|
||||
@@ -1,6 +0,0 @@
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
|
||||
# TODO: create a class that makes yt-dlp's YoutubeDL fit in more with fastanime
|
||||
class YtDlp(YoutubeDL):
|
||||
pass
|
||||
@@ -1,48 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
from .data import anime_normalizer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..libs.anilist.types import AnilistBaseMediaDataSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_by_episode_number(filename: str):
|
||||
import re
|
||||
|
||||
match = re.search(r"\d+", filename)
|
||||
return int(match.group()) if match else 0
|
||||
|
||||
|
||||
def anime_title_percentage_match(
|
||||
possible_user_requested_anime_title: str, anime: "AnilistBaseMediaDataSchema"
|
||||
) -> 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
|
||||
"""
|
||||
possible_user_requested_anime_title = anime_normalizer.get(
|
||||
possible_user_requested_anime_title, possible_user_requested_anime_title
|
||||
)
|
||||
# compares both the romaji and english names and gets highest Score
|
||||
title_a = str(anime["title"]["romaji"])
|
||||
title_b = str(anime["title"]["english"])
|
||||
percentage_ratio = max(
|
||||
*[
|
||||
fuzz.ratio(title.lower(), possible_user_requested_anime_title.lower())
|
||||
for title in anime["synonyms"]
|
||||
],
|
||||
fuzz.ratio(title_a.lower(), possible_user_requested_anime_title.lower()),
|
||||
fuzz.ratio(title_b.lower(), possible_user_requested_anime_title.lower()),
|
||||
)
|
||||
logger.info(f"{locals()}")
|
||||
return percentage_ratio
|
||||
@@ -1,3 +0,0 @@
|
||||
from .libs.anilist.api import AniListApi
|
||||
|
||||
AniList = AniListApi()
|
||||
@@ -1,93 +0,0 @@
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import FastAPI
|
||||
from requests import post
|
||||
from thefuzz import fuzz
|
||||
|
||||
from ..AnimeProvider import AnimeProvider
|
||||
from ..Utility.data import anime_normalizer
|
||||
|
||||
app = FastAPI()
|
||||
anime_provider = AnimeProvider("allanime", "true", "true")
|
||||
ANILIST_ENDPOINT = "https://graphql.anilist.co"
|
||||
|
||||
|
||||
@app.get("/search")
|
||||
def search_for_anime(title: str, translation_type: Literal["dub", "sub"] = "sub"):
|
||||
return anime_provider.search_for_anime(title, translation_type)
|
||||
|
||||
|
||||
@app.get("/anime/{anime_id}")
|
||||
def get_anime(anime_id: str):
|
||||
return anime_provider.get_anime(anime_id)
|
||||
|
||||
|
||||
@app.get("/anime/{anime_id}/watch")
|
||||
def get_episode_streams(
|
||||
anime_id: str, episode: str, translation_type: Literal["sub", "dub"]
|
||||
):
|
||||
return anime_provider.get_episode_streams(anime_id, episode, translation_type)
|
||||
|
||||
|
||||
def get_anime_by_anilist_id(anilist_id: int):
|
||||
query = f"""
|
||||
query {{
|
||||
Media(id: {anilist_id}) {{
|
||||
id
|
||||
title {{
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}}
|
||||
synonyms
|
||||
episodes
|
||||
duration
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
response = post(ANILIST_ENDPOINT, json={"query": query}).json()
|
||||
return response["data"]["Media"]
|
||||
|
||||
|
||||
@app.get("/watch/{anilist_id}")
|
||||
def get_episode_streams_by_anilist_id(
|
||||
anilist_id: int, episode: str, translation_type: Literal["sub", "dub"]
|
||||
):
|
||||
anime = get_anime_by_anilist_id(anilist_id)
|
||||
if not anime:
|
||||
return
|
||||
if search_results := anime_provider.search_for_anime(
|
||||
str(anime["title"]["romaji"] or anime["title"]["english"]), translation_type
|
||||
):
|
||||
if not search_results["results"]:
|
||||
return
|
||||
|
||||
def match_title(possible_user_requested_anime_title):
|
||||
possible_user_requested_anime_title = anime_normalizer.get(
|
||||
possible_user_requested_anime_title, possible_user_requested_anime_title
|
||||
)
|
||||
title_a = str(anime["title"]["romaji"])
|
||||
title_b = str(anime["title"]["english"])
|
||||
percentage_ratio = max(
|
||||
*[
|
||||
fuzz.ratio(
|
||||
title.lower(), possible_user_requested_anime_title.lower()
|
||||
)
|
||||
for title in anime["synonyms"]
|
||||
],
|
||||
fuzz.ratio(
|
||||
title_a.lower(), possible_user_requested_anime_title.lower()
|
||||
),
|
||||
fuzz.ratio(
|
||||
title_b.lower(), possible_user_requested_anime_title.lower()
|
||||
),
|
||||
)
|
||||
return percentage_ratio
|
||||
|
||||
provider_anime = max(
|
||||
search_results["results"], key=lambda x: match_title(x["title"])
|
||||
)
|
||||
anime_provider.get_anime(provider_anime["id"])
|
||||
return anime_provider.get_episode_streams(
|
||||
provider_anime["id"], episode, translation_type
|
||||
)
|
||||
6
fastanime/assets/defaults/ascii-art
Normal file
6
fastanime/assets/defaults/ascii-art
Normal file
@@ -0,0 +1,6 @@
|
||||
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
|
||||
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
|
||||
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
|
||||
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
|
||||
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
|
||||
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝
|
||||
23
fastanime/assets/defaults/fzf-opts
Normal file
23
fastanime/assets/defaults/fzf-opts
Normal file
@@ -0,0 +1,23 @@
|
||||
--color=fg:#d0d0d0,fg+:#d0d0d0,bg:#121212,bg+:#262626
|
||||
--color=hl:#5f87af,hl+:#5fd7ff,info:#afaf87,marker:#87ff00
|
||||
--color=prompt:#d7005f,spinner:#af5fff,pointer:#af5fff,header:#87afaf
|
||||
--color=border:#262626,label:#aeaeae,query:#d9d9d9
|
||||
--border=rounded
|
||||
--border-label=''
|
||||
--prompt='>'
|
||||
--marker='>'
|
||||
--pointer='◆'
|
||||
--separator='─'
|
||||
--scrollbar='│'
|
||||
--layout=reverse
|
||||
--cycle
|
||||
--info=hidden
|
||||
--height=100%
|
||||
--bind=right:accept,ctrl-/:toggle-preview,ctrl-space:toggle-wrap+toggle-preview-wrap
|
||||
--no-margin
|
||||
+m
|
||||
-i
|
||||
--exact
|
||||
--tabstop=1
|
||||
--preview-window=border-rounded,left,35%,wrap
|
||||
--wrap
|
||||
7
fastanime/assets/graphql/allanime/queries/anime.gql
Normal file
7
fastanime/assets/graphql/allanime/queries/anime.gql
Normal file
@@ -0,0 +1,7 @@
|
||||
query ($showId: String!) {
|
||||
show(_id: $showId) {
|
||||
_id
|
||||
name
|
||||
availableEpisodesDetail
|
||||
}
|
||||
}
|
||||
15
fastanime/assets/graphql/allanime/queries/episodes.gql
Normal file
15
fastanime/assets/graphql/allanime/queries/episodes.gql
Normal file
@@ -0,0 +1,15 @@
|
||||
query (
|
||||
$showId: String!
|
||||
$translationType: VaildTranslationTypeEnumType!
|
||||
$episodeString: String!
|
||||
) {
|
||||
episode(
|
||||
showId: $showId
|
||||
translationType: $translationType
|
||||
episodeString: $episodeString
|
||||
) {
|
||||
episodeString
|
||||
sourceUrls
|
||||
notes
|
||||
}
|
||||
}
|
||||
25
fastanime/assets/graphql/allanime/queries/search.gql
Normal file
25
fastanime/assets/graphql/allanime/queries/search.gql
Normal file
@@ -0,0 +1,25 @@
|
||||
query (
|
||||
$search: SearchInput
|
||||
$limit: Int
|
||||
$page: Int
|
||||
$translationType: VaildTranslationTypeEnumType
|
||||
$countryOrigin: VaildCountryOriginEnumType
|
||||
) {
|
||||
shows(
|
||||
search: $search
|
||||
limit: $limit
|
||||
page: $page
|
||||
translationType: $translationType
|
||||
countryOrigin: $countryOrigin
|
||||
) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
edges {
|
||||
_id
|
||||
name
|
||||
availableEpisodes
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation ($id: Int) {
|
||||
DeleteMediaListEntry(id: $id) {
|
||||
deleted
|
||||
}
|
||||
}
|
||||
5
fastanime/assets/graphql/anilist/mutations/mark-read.gql
Normal file
5
fastanime/assets/graphql/anilist/mutations/mark-read.gql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation {
|
||||
UpdateUser {
|
||||
unreadNotificationCount
|
||||
}
|
||||
}
|
||||
32
fastanime/assets/graphql/anilist/mutations/media-list.gql
Normal file
32
fastanime/assets/graphql/anilist/mutations/media-list.gql
Normal file
@@ -0,0 +1,32 @@
|
||||
mutation (
|
||||
$mediaId: Int
|
||||
$scoreRaw: Int
|
||||
$repeat: Int
|
||||
$progress: Int
|
||||
$status: MediaListStatus
|
||||
) {
|
||||
SaveMediaListEntry(
|
||||
mediaId: $mediaId
|
||||
scoreRaw: $scoreRaw
|
||||
progress: $progress
|
||||
repeat: $repeat
|
||||
status: $status
|
||||
) {
|
||||
id
|
||||
status
|
||||
mediaId
|
||||
score
|
||||
progress
|
||||
repeat
|
||||
startedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
completedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
}
|
||||
}
|
||||
13
fastanime/assets/graphql/anilist/queries/airing.gql
Normal file
13
fastanime/assets/graphql/anilist/queries/airing.gql
Normal file
@@ -0,0 +1,13 @@
|
||||
query ($id: Int, $type: MediaType) {
|
||||
Page {
|
||||
media(id: $id, sort: POPULARITY_DESC, type: $type) {
|
||||
airingSchedule(notYetAired: true) {
|
||||
nodes {
|
||||
airingAt
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
137
fastanime/assets/graphql/anilist/queries/anime.gql
Normal file
137
fastanime/assets/graphql/anilist/queries/anime.gql
Normal file
@@ -0,0 +1,137 @@
|
||||
query ($id: Int) {
|
||||
Page {
|
||||
media(id: $id) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
coverImage {
|
||||
extraLarge
|
||||
}
|
||||
characters(perPage: 5, sort: FAVOURITES_DESC) {
|
||||
edges {
|
||||
node {
|
||||
name {
|
||||
full
|
||||
}
|
||||
gender
|
||||
dateOfBirth {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
age
|
||||
image {
|
||||
medium
|
||||
large
|
||||
}
|
||||
description
|
||||
}
|
||||
voiceActors {
|
||||
name {
|
||||
full
|
||||
}
|
||||
image {
|
||||
medium
|
||||
large
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
season
|
||||
format
|
||||
status
|
||||
seasonYear
|
||||
description
|
||||
genres
|
||||
synonyms
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
duration
|
||||
countryOfOrigin
|
||||
averageScore
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
|
||||
favourites
|
||||
source
|
||||
hashtag
|
||||
siteUrl
|
||||
tags {
|
||||
name
|
||||
rank
|
||||
}
|
||||
reviews(sort: SCORE_DESC, perPage: 3) {
|
||||
nodes {
|
||||
summary
|
||||
user {
|
||||
name
|
||||
avatar {
|
||||
medium
|
||||
large
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
recommendations(sort: RATING_DESC, perPage: 10) {
|
||||
nodes {
|
||||
mediaRecommendation {
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
relations {
|
||||
nodes {
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
externalLinks {
|
||||
url
|
||||
site
|
||||
icon
|
||||
}
|
||||
rankings {
|
||||
rank
|
||||
context
|
||||
}
|
||||
bannerImage
|
||||
episodes
|
||||
}
|
||||
}
|
||||
}
|
||||
31
fastanime/assets/graphql/anilist/queries/character.gql
Normal file
31
fastanime/assets/graphql/anilist/queries/character.gql
Normal file
@@ -0,0 +1,31 @@
|
||||
query ($id: Int, $type: MediaType) {
|
||||
Page {
|
||||
media(id: $id, type: $type) {
|
||||
characters {
|
||||
nodes {
|
||||
name {
|
||||
first
|
||||
middle
|
||||
last
|
||||
full
|
||||
native
|
||||
}
|
||||
image {
|
||||
medium
|
||||
large
|
||||
}
|
||||
description
|
||||
gender
|
||||
dateOfBirth {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
age
|
||||
bloodType
|
||||
favourites
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
fastanime/assets/graphql/anilist/queries/favourite.gql
Normal file
65
fastanime/assets/graphql/anilist/queries/favourite.gql
Normal file
@@ -0,0 +1,65 @@
|
||||
query ($type: MediaType, $page: Int, $perPage: Int) {
|
||||
Page(perPage: $perPage, page: $page) {
|
||||
media(sort: FAVOURITES_DESC, type: $type, genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
episodes
|
||||
description
|
||||
genres
|
||||
synonyms
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
query ($mediaId: Int) {
|
||||
MediaList(mediaId: $mediaId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
11
fastanime/assets/graphql/anilist/queries/logged-in-user.gql
Normal file
11
fastanime/assets/graphql/anilist/queries/logged-in-user.gql
Normal file
@@ -0,0 +1,11 @@
|
||||
query {
|
||||
Viewer {
|
||||
id
|
||||
name
|
||||
bannerImage
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
}
|
||||
}
|
||||
93
fastanime/assets/graphql/anilist/queries/media-list.gql
Normal file
93
fastanime/assets/graphql/anilist/queries/media-list.gql
Normal file
@@ -0,0 +1,93 @@
|
||||
query (
|
||||
$userId: Int
|
||||
$status: MediaListStatus
|
||||
$type: MediaType
|
||||
$page: Int
|
||||
$perPage: Int
|
||||
$sort: [MediaListSort]
|
||||
) {
|
||||
Page(perPage: $perPage, page: $page) {
|
||||
pageInfo {
|
||||
currentPage
|
||||
total
|
||||
}
|
||||
mediaList(userId: $userId, status: $status, type: $type,sort: $sort) {
|
||||
mediaId
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
format
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
episodes
|
||||
genres
|
||||
synonyms
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
favourites
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
description
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
status
|
||||
progress
|
||||
score
|
||||
repeat
|
||||
notes
|
||||
startedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
completedAt {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
60
fastanime/assets/graphql/anilist/queries/media-relations.gql
Normal file
60
fastanime/assets/graphql/anilist/queries/media-relations.gql
Normal file
@@ -0,0 +1,60 @@
|
||||
query ($id: Int) {
|
||||
Media(id: $id) {
|
||||
relations {
|
||||
nodes {
|
||||
id
|
||||
idMal
|
||||
type
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
native
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
description
|
||||
episodes
|
||||
duration
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
genres
|
||||
synonyms
|
||||
averageScore
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
fastanime/assets/graphql/anilist/queries/notifications.gql
Normal file
27
fastanime/assets/graphql/anilist/queries/notifications.gql
Normal file
@@ -0,0 +1,27 @@
|
||||
query {
|
||||
Page(perPage: 5) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
notifications(resetNotificationCount: true, type: AIRING) {
|
||||
... on AiringNotification {
|
||||
id
|
||||
type
|
||||
episode
|
||||
contexts
|
||||
createdAt
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
fastanime/assets/graphql/anilist/queries/popular.gql
Normal file
61
fastanime/assets/graphql/anilist/queries/popular.gql
Normal file
@@ -0,0 +1,61 @@
|
||||
query ($type: MediaType, $page: Int, $perPage: Int) {
|
||||
Page(perPage: $perPage, page: $page) {
|
||||
media(sort: POPULARITY_DESC, type: $type, genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
description
|
||||
episodes
|
||||
genres
|
||||
synonyms
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
query ($type: MediaType, $page: Int, $perPage: Int) {
|
||||
Page(perPage: $perPage, page: $page) {
|
||||
media(
|
||||
sort: UPDATED_AT_DESC
|
||||
type: $type
|
||||
averageScore_greater: 50
|
||||
genre_not_in: ["hentai"]
|
||||
status: RELEASING
|
||||
) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
|
||||
favourites
|
||||
averageScore
|
||||
description
|
||||
genres
|
||||
synonyms
|
||||
episodes
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
fastanime/assets/graphql/anilist/queries/recommended.gql
Normal file
59
fastanime/assets/graphql/anilist/queries/recommended.gql
Normal file
@@ -0,0 +1,59 @@
|
||||
query ($id: Int, $page: Int,$per_page:Int) {
|
||||
Page(perPage: $per_page, page: $page) {
|
||||
recommendations(mediaRecommendationId: $id) {
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
native
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
description
|
||||
episodes
|
||||
duration # Added duration here
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
genres
|
||||
synonyms
|
||||
averageScore
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
fastanime/assets/graphql/anilist/queries/reviews.gql
Normal file
18
fastanime/assets/graphql/anilist/queries/reviews.gql
Normal file
@@ -0,0 +1,18 @@
|
||||
query ($id: Int) {
|
||||
Page {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
reviews(mediaId: $id) {
|
||||
summary
|
||||
user {
|
||||
name
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
61
fastanime/assets/graphql/anilist/queries/score.gql
Normal file
61
fastanime/assets/graphql/anilist/queries/score.gql
Normal file
@@ -0,0 +1,61 @@
|
||||
query ($type: MediaType, $page: Int, $perPage: Int) {
|
||||
Page(perPage: $perPage, page: $page) {
|
||||
media(sort: SCORE_DESC, type: $type, genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
episodes
|
||||
favourites
|
||||
averageScore
|
||||
description
|
||||
genres
|
||||
synonyms
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
fastanime/assets/graphql/anilist/queries/search.gql
Normal file
121
fastanime/assets/graphql/anilist/queries/search.gql
Normal file
@@ -0,0 +1,121 @@
|
||||
query (
|
||||
$query: String
|
||||
$per_page: Int
|
||||
$page: Int
|
||||
$sort: [MediaSort]
|
||||
$id_in: [Int]
|
||||
$genre_in: [String]
|
||||
$genre_not_in: [String]
|
||||
$tag_in: [String]
|
||||
$tag_not_in: [String]
|
||||
$status_in: [MediaStatus]
|
||||
$status: MediaStatus
|
||||
$status_not_in: [MediaStatus]
|
||||
$popularity_greater: Int
|
||||
$popularity_lesser: Int
|
||||
$averageScore_greater: Int
|
||||
$averageScore_lesser: Int
|
||||
$seasonYear: Int
|
||||
$startDate_greater: FuzzyDateInt
|
||||
$startDate_lesser: FuzzyDateInt
|
||||
$startDate: FuzzyDateInt
|
||||
$endDate_greater: FuzzyDateInt
|
||||
$endDate_lesser: FuzzyDateInt
|
||||
$format_in: [MediaFormat]
|
||||
$type: MediaType
|
||||
$season: MediaSeason
|
||||
$on_list: Boolean
|
||||
) {
|
||||
Page(perPage: $per_page, page: $page) {
|
||||
pageInfo {
|
||||
total
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(
|
||||
search: $query
|
||||
id_in: $id_in
|
||||
genre_in: $genre_in
|
||||
genre_not_in: $genre_not_in
|
||||
tag_in: $tag_in
|
||||
tag_not_in: $tag_not_in
|
||||
status_in: $status_in
|
||||
status: $status
|
||||
startDate: $startDate
|
||||
status_not_in: $status_not_in
|
||||
popularity_greater: $popularity_greater
|
||||
popularity_lesser: $popularity_lesser
|
||||
averageScore_greater: $averageScore_greater
|
||||
averageScore_lesser: $averageScore_lesser
|
||||
startDate_greater: $startDate_greater
|
||||
startDate_lesser: $startDate_lesser
|
||||
endDate_greater: $endDate_greater
|
||||
endDate_lesser: $endDate_lesser
|
||||
format_in: $format_in
|
||||
sort: $sort
|
||||
season: $season
|
||||
seasonYear: $seasonYear
|
||||
type: $type
|
||||
onList: $on_list
|
||||
) {
|
||||
id
|
||||
idMal
|
||||
format
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
duration
|
||||
episodes
|
||||
genres
|
||||
synonyms
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
favourites
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
description
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
fastanime/assets/graphql/anilist/queries/trending.gql
Normal file
61
fastanime/assets/graphql/anilist/queries/trending.gql
Normal file
@@ -0,0 +1,61 @@
|
||||
query ($type: MediaType, $page: Int, $perPage: Int) {
|
||||
Page(perPage: $perPage, page: $page) {
|
||||
media(sort: TRENDING_DESC, type: $type, genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
genres
|
||||
synonyms
|
||||
episodes
|
||||
description
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
fastanime/assets/graphql/anilist/queries/upcoming.gql
Normal file
72
fastanime/assets/graphql/anilist/queries/upcoming.gql
Normal file
@@ -0,0 +1,72 @@
|
||||
query ($page: Int, $type: MediaType, $perPage: Int) {
|
||||
Page(perPage: $perPage, page: $page) {
|
||||
pageInfo {
|
||||
total
|
||||
perPage
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(
|
||||
type: $type
|
||||
status: NOT_YET_RELEASED
|
||||
sort: POPULARITY_DESC
|
||||
genre_not_in: ["hentai"]
|
||||
) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
genres
|
||||
synonyms
|
||||
episodes
|
||||
description
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
isAnimationStudio
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
fastanime/assets/graphql/anilist/queries/user-info.gql
Normal file
62
fastanime/assets/graphql/anilist/queries/user-info.gql
Normal file
@@ -0,0 +1,62 @@
|
||||
query ($userId: Int) {
|
||||
User(id: $userId) {
|
||||
name
|
||||
about
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
bannerImage
|
||||
statistics {
|
||||
anime {
|
||||
count
|
||||
minutesWatched
|
||||
episodesWatched
|
||||
genres {
|
||||
count
|
||||
meanScore
|
||||
genre
|
||||
}
|
||||
tags {
|
||||
tag {
|
||||
id
|
||||
}
|
||||
count
|
||||
meanScore
|
||||
}
|
||||
}
|
||||
manga {
|
||||
count
|
||||
meanScore
|
||||
chaptersRead
|
||||
volumesRead
|
||||
tags {
|
||||
count
|
||||
meanScore
|
||||
}
|
||||
genres {
|
||||
count
|
||||
meanScore
|
||||
}
|
||||
}
|
||||
}
|
||||
favourites {
|
||||
anime {
|
||||
nodes {
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
manga {
|
||||
nodes {
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 197 KiB |
17
fastanime/assets/normalizer.json
Normal file
17
fastanime/assets/normalizer.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"allanime": {
|
||||
"1P": "one piece",
|
||||
"Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica",
|
||||
"Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka",
|
||||
"Hazurewaku no \"Joutai Ijou Skill\" de Saikyou ni Natta Ore ga Subete wo Juurin suru made": "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made",
|
||||
"Re:Zero kara Hajimeru Isekai Seikatsu Season 3": "Re:Zero kara Hajimeru Isekai Seikatsu 3rd Season"
|
||||
},
|
||||
"hianime": {
|
||||
"My Star": "Oshi no Ko"
|
||||
},
|
||||
"animepahe": {
|
||||
"Azumanga Daiou The Animation": "Azumanga Daioh",
|
||||
"Mairimashita! Iruma-kun 2nd Season": "Mairimashita! Iruma-kun 2",
|
||||
"Mairimashita! Iruma-kun 3rd Season": "Mairimashita! Iruma-kun 3"
|
||||
}
|
||||
}
|
||||
33
fastanime/assets/scripts/fzf/episode-info.template.sh
Executable file
33
fastanime/assets/scripts/fzf/episode-info.template.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Episode Preview Info Script Template
|
||||
# This script formats and displays episode information in the FZF preview pane.
|
||||
# Some values are injected by python those with '{name}' syntax using .replace()
|
||||
|
||||
|
||||
draw_rule
|
||||
|
||||
echo "{TITLE}"| fold -s -w "$WIDTH"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Duration" "{DURATION}"
|
||||
print_kv "Status" "{STATUS}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Total Episodes" "{EPISODES}"
|
||||
print_kv "Next Episode" "{NEXT_EPISODE}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Progress" "{USER_PROGRESS}"
|
||||
print_kv "List Status" "{USER_STATUS}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Start Date" "{START_DATE}"
|
||||
print_kv "End Date" "{END_DATE}"
|
||||
|
||||
draw_rule
|
||||
|
||||
55
fastanime/assets/scripts/fzf/info.template.sh
Executable file
55
fastanime/assets/scripts/fzf/info.template.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# FastAnime Preview Info Script Template
|
||||
# This script formats and displays the textual information in the FZF preview pane.
|
||||
# Some values are injected by python those with '{name}' syntax using .replace()
|
||||
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Title" "{TITLE}"
|
||||
|
||||
draw_rule
|
||||
|
||||
# Emojis take up double the space
|
||||
score_multiplier=1
|
||||
if ! [ "{SCORE}" = "N/A" ];then
|
||||
score_multiplier=2
|
||||
fi
|
||||
print_kv "Score" "{SCORE}" $score_multiplier
|
||||
|
||||
print_kv "Favourites" "{FAVOURITES}"
|
||||
print_kv "Popularity" "{POPULARITY}"
|
||||
print_kv "Status" "{STATUS}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Episodes" "{EPISODES}"
|
||||
print_kv "Next Episode" "{NEXT_EPISODE}"
|
||||
print_kv "Duration" "{DURATION}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Genres" "{GENRES}"
|
||||
print_kv "Format" "{FORMAT}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "List Status" "{USER_STATUS}"
|
||||
print_kv "Progress" "{USER_PROGRESS}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Start Date" "{START_DATE}"
|
||||
print_kv "End Date" "{END_DATE}"
|
||||
|
||||
draw_rule
|
||||
|
||||
print_kv "Studios" "{STUDIOS}"
|
||||
print_kv "Synonymns" "{SYNONYMNS}"
|
||||
print_kv "Tags" "{TAGS}"
|
||||
|
||||
draw_rule
|
||||
|
||||
# Synopsis
|
||||
echo "{SYNOPSIS}" | fold -s -w "$WIDTH"
|
||||
146
fastanime/assets/scripts/fzf/preview.template.sh
Executable file
146
fastanime/assets/scripts/fzf/preview.template.sh
Executable file
@@ -0,0 +1,146 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# FZF Preview Script Template
|
||||
#
|
||||
# This script is a template. The placeholders in curly braces, like {NAME}
|
||||
# are dynamically filled by python using .replace()
|
||||
|
||||
WIDTH=${FZF_PREVIEW_COLUMNS:-80} # Set a fallback width of 80
|
||||
IMAGE_RENDERER="{IMAGE_RENDERER}"
|
||||
|
||||
generate_sha256() {
|
||||
local input
|
||||
|
||||
# Check if input is passed as an argument or piped
|
||||
if [ -n "$1" ]; then
|
||||
input="$1"
|
||||
else
|
||||
input=$(cat)
|
||||
fi
|
||||
|
||||
if command -v sha256sum &>/dev/null; then
|
||||
echo -n "$input" | sha256sum | awk '{print $1}'
|
||||
elif command -v shasum &>/dev/null; then
|
||||
echo -n "$input" | shasum -a 256 | awk '{print $1}'
|
||||
elif command -v sha256 &>/dev/null; then
|
||||
echo -n "$input" | sha256 | awk '{print $1}'
|
||||
elif command -v openssl &>/dev/null; then
|
||||
echo -n "$input" | openssl dgst -sha256 | awk '{print $2}'
|
||||
else
|
||||
echo -n "$input" | base64 | tr '/+' '_-' | tr -d '\n'
|
||||
fi
|
||||
}
|
||||
|
||||
fzf_preview() {
|
||||
file=$1
|
||||
|
||||
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
|
||||
if [ "$dim" = x ]; then
|
||||
dim=$(stty size </dev/tty | awk "{print \$2 \"x\" \$1}")
|
||||
fi
|
||||
if ! [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$KITTY_WINDOW_ID" ] && [ "$((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES))" -eq "$(stty size </dev/tty | awk "{print \$1}")" ]; then
|
||||
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
|
||||
fi
|
||||
|
||||
if [ "$IMAGE_RENDERER" = "icat" ] && [ -z "$GHOSTTY_BIN_DIR" ]; then
|
||||
if command -v kitten >/dev/null 2>&1; then
|
||||
kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
elif command -v icat >/dev/null 2>&1; then
|
||||
icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
else
|
||||
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
fi
|
||||
|
||||
elif [ -n "$GHOSTTY_BIN_DIR" ]; then
|
||||
if command -v kitten >/dev/null 2>&1; then
|
||||
kitten icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
elif command -v icat >/dev/null 2>&1; then
|
||||
icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed "\$d" | sed "$(printf "\$s/\$/\033[m/")"
|
||||
else
|
||||
chafa -s "$dim" "$file"
|
||||
fi
|
||||
elif command -v chafa >/dev/null 2>&1; then
|
||||
case "$PLATFORM" in
|
||||
android) chafa -s "$dim" "$file" ;;
|
||||
windows) chafa -f sixel -s "$dim" "$file" ;;
|
||||
*) chafa -s "$dim" "$file" ;;
|
||||
esac
|
||||
echo
|
||||
|
||||
elif command -v imgcat >/dev/null; then
|
||||
imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file"
|
||||
|
||||
else
|
||||
echo please install a terminal image viewer
|
||||
echo either icat for kitty terminal and wezterm or imgcat or chafa
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# --- Helper function for printing a key-value pair, aligning the value to the right ---
|
||||
print_kv() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local key_len=${#key}
|
||||
local value_len=${#value}
|
||||
local multiplier="${3:-1}"
|
||||
|
||||
# Correctly calculate padding by accounting for the key, the ": ", and the value.
|
||||
local padding_len=$((WIDTH - key_len - 2 - value_len * multiplier))
|
||||
|
||||
# If the text is too long to fit, just add a single space for separation.
|
||||
if [ "$padding_len" -lt 1 ]; then
|
||||
padding_len=1
|
||||
value=$(echo $value| fold -s -w "$((WIDTH - key_len - 3))")
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
else
|
||||
printf "{C_KEY}%s:{RESET}%*s%s\\n" "$key" "$padding_len" "" " $value"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Draw a rule across the screen ---
|
||||
# TODO: figure out why this method does not work in fzf
|
||||
draw_rule() {
|
||||
local rule
|
||||
# Generate the line of '─' characters, removing the trailing newline `tr` adds.
|
||||
rule=$(printf '%*s' "$WIDTH" | tr ' ' '─' | tr -d '\n')
|
||||
# Print the rule with colors and a single, clean newline.
|
||||
printf "{C_RULE}%s{RESET}\\n" "$rule"
|
||||
}
|
||||
|
||||
|
||||
draw_rule(){
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{C_RULE}─{RESET}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
# Generate the same cache key that the Python worker uses
|
||||
# {PREFIX} is used only on episode previews to make sure they are unique
|
||||
title={}
|
||||
hash=$(generate_sha256 "{PREFIX}$title")
|
||||
|
||||
#
|
||||
# --- Display image if configured and the cached file exists ---
|
||||
#
|
||||
if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "image" ]; then
|
||||
image_file="{IMAGE_CACHE_PATH}{PATH_SEP}$hash.png"
|
||||
if [ -f "$image_file" ]; then
|
||||
fzf_preview "$image_file"
|
||||
else
|
||||
echo "🖼️ Loading image..."
|
||||
fi
|
||||
echo # Add a newline for spacing
|
||||
fi
|
||||
# Display text info if configured and the cached file exists
|
||||
if [ "{PREVIEW_MODE}" = "full" ] || [ "{PREVIEW_MODE}" = "text" ]; then
|
||||
info_file="{INFO_CACHE_PATH}{PATH_SEP}$hash"
|
||||
if [ -f "$info_file" ]; then
|
||||
source "$info_file"
|
||||
else
|
||||
echo "📝 Loading details..."
|
||||
fi
|
||||
fi
|
||||
2
fastanime/libs/fzf/scripts.py → fastanime/assets/scripts/fzf/search.template.sh
Normal file → Executable file
2
fastanime/libs/fzf/scripts.py → fastanime/assets/scripts/fzf/search.template.sh
Normal file → Executable file
@@ -1,4 +1,3 @@
|
||||
FETCH_ANIME_SCRIPT = r"""
|
||||
fetch_anime_for_fzf() {
|
||||
local search_term="$1"
|
||||
if [ -z "$search_term" ]; then exit 0; fi
|
||||
@@ -73,4 +72,3 @@ fetch_anime_details() {
|
||||
"\(.description | gsub("<br><br>"; "\n\n") | gsub("<[^>]*>"; "") | gsub("""; "\""))"
|
||||
'
|
||||
}
|
||||
"""
|
||||
@@ -1,401 +1,3 @@
|
||||
import signal
|
||||
from .cli import cli as run_cli
|
||||
|
||||
import click
|
||||
|
||||
from .. import __version__
|
||||
from ..libs.anime_provider import SERVERS_AVAILABLE, anime_sources
|
||||
from .commands import LazyGroup
|
||||
|
||||
commands = {
|
||||
"search": "search.search",
|
||||
"download": "download.download",
|
||||
"anilist": "anilist.anilist",
|
||||
"config": "config.config",
|
||||
"downloads": "downloads.downloads",
|
||||
"cache": "cache.cache",
|
||||
"completions": "completions.completions",
|
||||
"update": "update.update",
|
||||
"grab": "grab.grab",
|
||||
"serve": "serve.serve",
|
||||
}
|
||||
|
||||
|
||||
# handle keyboard interupt
|
||||
def handle_exit(signum, frame):
|
||||
from click import clear
|
||||
|
||||
from .utils.tools import exit_app
|
||||
|
||||
clear()
|
||||
|
||||
exit_app()
|
||||
|
||||
|
||||
signal.signal(signal.SIGINT, handle_exit)
|
||||
|
||||
|
||||
@click.group(
|
||||
lazy_subcommands=commands,
|
||||
cls=LazyGroup,
|
||||
help="A command line application for streaming anime that provides a complete and featureful interface",
|
||||
short_help="Stream Anime",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# example of syncplay intergration
|
||||
fastanime --sync-play --server sharepoint search -t <anime-title>
|
||||
\b
|
||||
# --- or ---
|
||||
\b
|
||||
# to watch with anilist intergration
|
||||
fastanime --sync-play --server sharepoint anilist
|
||||
\b
|
||||
# downloading dubbed anime
|
||||
fastanime --dub download -t <anime>
|
||||
\b
|
||||
# use icons and fzf for a more elegant ui with preview
|
||||
fastanime --icons --preview --fzf anilist
|
||||
\b
|
||||
# use icons with default ui
|
||||
fastanime --icons --default anilist
|
||||
\b
|
||||
# viewing manga
|
||||
fastanime --manga search -t <manga-title>
|
||||
""",
|
||||
)
|
||||
@click.version_option(__version__, "--version")
|
||||
@click.option("--manga", "-m", help="Enable manga mode", is_flag=True)
|
||||
@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(
|
||||
"-p",
|
||||
"--provider",
|
||||
type=click.Choice(list(anime_sources.keys()), case_sensitive=False),
|
||||
help="Provider of your choice",
|
||||
)
|
||||
@click.option(
|
||||
"-s",
|
||||
"--server",
|
||||
type=click.Choice([*SERVERS_AVAILABLE, "top"]),
|
||||
help="Server of choice",
|
||||
)
|
||||
@click.option(
|
||||
"-f",
|
||||
"--format",
|
||||
type=str,
|
||||
help="yt-dlp format to use",
|
||||
)
|
||||
@click.option(
|
||||
"-c/-no-c",
|
||||
"--continue/--no-continue",
|
||||
"continue_",
|
||||
type=bool,
|
||||
help="Continue from last episode?",
|
||||
)
|
||||
@click.option(
|
||||
"--local-history/--remote-history",
|
||||
type=bool,
|
||||
help="Whether to continue from local history or remote history",
|
||||
)
|
||||
@click.option(
|
||||
"--skip/--no-skip",
|
||||
type=bool,
|
||||
help="Skip opening and ending theme songs?",
|
||||
)
|
||||
@click.option(
|
||||
"-q",
|
||||
"--quality",
|
||||
type=click.Choice(
|
||||
[
|
||||
"360",
|
||||
"480",
|
||||
"720",
|
||||
"1080",
|
||||
]
|
||||
),
|
||||
help="set the quality of the stream",
|
||||
)
|
||||
@click.option(
|
||||
"-t",
|
||||
"--translation-type",
|
||||
type=click.Choice(["dub", "sub"]),
|
||||
help="Anime language[dub/sub]",
|
||||
)
|
||||
@click.option(
|
||||
"-sl",
|
||||
"--sub-lang",
|
||||
help="Set the preferred language for subs",
|
||||
)
|
||||
@click.option(
|
||||
"-A/-no-A",
|
||||
"--auto-next/--no-auto-next",
|
||||
type=bool,
|
||||
help="Auto select next episode?",
|
||||
)
|
||||
@click.option(
|
||||
"-a/-no-a",
|
||||
"--auto-select/--no-auto-select",
|
||||
type=bool,
|
||||
help="Auto select anime title?",
|
||||
)
|
||||
@click.option(
|
||||
"--normalize-titles/--no-normalize-titles",
|
||||
type=bool,
|
||||
help="whether to normalize anime and episode titles given by providers",
|
||||
)
|
||||
@click.option("-d", "--downloads-dir", type=click.Path(), help="Downloads location")
|
||||
@click.option("--fzf", is_flag=True, help="Use fzf for the ui")
|
||||
@click.option("--default", is_flag=True, help="Use the default interface")
|
||||
@click.option("--preview", is_flag=True, help="Show preview when using fzf")
|
||||
@click.option("--no-preview", is_flag=True, help="Dont show preview when using fzf")
|
||||
@click.option(
|
||||
"--icons/--no-icons",
|
||||
type=bool,
|
||||
help="Use icons in the interfaces",
|
||||
)
|
||||
@click.option("--dub", help="Set the translation type to dub", is_flag=True)
|
||||
@click.option("--sub", help="Set the translation type to sub", is_flag=True)
|
||||
@click.option("--rofi", help="Use rofi for the ui", is_flag=True)
|
||||
@click.option("--rofi-theme", help="Rofi theme to use", type=click.Path())
|
||||
@click.option(
|
||||
"--rofi-theme-preview", help="Rofi theme to use for previews", type=click.Path()
|
||||
)
|
||||
@click.option(
|
||||
"--rofi-theme-confirm",
|
||||
help="Rofi theme to use for the confirm prompt",
|
||||
type=click.Path(),
|
||||
)
|
||||
@click.option(
|
||||
"--rofi-theme-input",
|
||||
help="Rofi theme to use for the user input prompt",
|
||||
type=click.Path(),
|
||||
)
|
||||
@click.option(
|
||||
"--use-python-mpv/--use-default-player", help="Whether to use python-mpv", type=bool
|
||||
)
|
||||
@click.option("--sync-play", "-sp", help="Use sync play", is_flag=True)
|
||||
@click.option(
|
||||
"--player",
|
||||
"-P",
|
||||
help="the player to use when streaming",
|
||||
type=click.Choice(["mpv", "vlc"]),
|
||||
)
|
||||
@click.option(
|
||||
"--fresh-requests", is_flag=True, help="Force the requests cache to be updated"
|
||||
)
|
||||
@click.option("--no-config", is_flag=True, help="Don't load the user config")
|
||||
@click.pass_context
|
||||
def run_cli(
|
||||
ctx: click.Context,
|
||||
manga,
|
||||
log,
|
||||
log_file,
|
||||
rich_traceback,
|
||||
provider,
|
||||
server,
|
||||
format,
|
||||
continue_,
|
||||
local_history,
|
||||
skip,
|
||||
translation_type,
|
||||
sub_lang,
|
||||
quality,
|
||||
auto_next,
|
||||
auto_select,
|
||||
normalize_titles,
|
||||
downloads_dir,
|
||||
fzf,
|
||||
default,
|
||||
preview,
|
||||
no_preview,
|
||||
icons,
|
||||
dub,
|
||||
sub,
|
||||
rofi,
|
||||
rofi_theme,
|
||||
rofi_theme_preview,
|
||||
rofi_theme_confirm,
|
||||
rofi_theme_input,
|
||||
use_python_mpv,
|
||||
sync_play,
|
||||
player,
|
||||
fresh_requests,
|
||||
no_config,
|
||||
):
|
||||
import os
|
||||
import sys
|
||||
|
||||
from .config import Config
|
||||
|
||||
ctx.obj = Config(no_config)
|
||||
if (
|
||||
ctx.obj.check_for_updates
|
||||
and ctx.invoked_subcommand != "completions"
|
||||
and "notifier" not in sys.argv
|
||||
):
|
||||
import time
|
||||
|
||||
last_update = ctx.obj.user_data["meta"]["last_updated"]
|
||||
now = time.time()
|
||||
# checks after every 12 hours
|
||||
if (now - last_update) > 43200:
|
||||
ctx.obj.user_data["meta"]["last_updated"] = now
|
||||
ctx.obj._update_user_data()
|
||||
|
||||
from .app_updater import check_for_updates
|
||||
|
||||
print("Checking for updates...", file=sys.stderr)
|
||||
print("So you can enjoy the latest features and bug fixes", file=sys.stderr)
|
||||
print(
|
||||
"You can disable this by setting check_for_updates to False in the config",
|
||||
file=sys.stderr,
|
||||
)
|
||||
is_latest, github_release_data = check_for_updates()
|
||||
if not is_latest:
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.prompt import Confirm
|
||||
|
||||
from .app_updater import 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 Confirm.ask(
|
||||
"A new version of fastanime is available, would you like to update?"
|
||||
):
|
||||
_, release_json = update_app()
|
||||
print("Successfully updated")
|
||||
_print_release(release_json)
|
||||
exit(0)
|
||||
else:
|
||||
print("You are using the latest version of fastanime", file=sys.stderr)
|
||||
|
||||
ctx.obj.manga = manga
|
||||
if log:
|
||||
import logging
|
||||
|
||||
from rich.logging import RichHandler
|
||||
|
||||
FORMAT = "%(message)s"
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG, format=FORMAT, datefmt="[%X]", handlers=[RichHandler()]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("logging has been initialized")
|
||||
elif log_file:
|
||||
import logging
|
||||
|
||||
from ..constants import LOG_FILE_PATH
|
||||
|
||||
format = "%(asctime)s%(levelname)s: %(message)s"
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
filename=LOG_FILE_PATH,
|
||||
format=format,
|
||||
datefmt="[%d/%m/%Y@%H:%M:%S]",
|
||||
filemode="w",
|
||||
)
|
||||
else:
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.CRITICAL)
|
||||
if rich_traceback:
|
||||
from rich.traceback import install
|
||||
|
||||
install()
|
||||
|
||||
if fresh_requests:
|
||||
os.environ["FASTANIME_FRESH_REQUESTS"] = "1"
|
||||
if sync_play:
|
||||
ctx.obj.sync_play = sync_play
|
||||
if provider:
|
||||
ctx.obj.provider = provider
|
||||
if server:
|
||||
ctx.obj.server = server
|
||||
if format:
|
||||
ctx.obj.format = format
|
||||
if sub_lang:
|
||||
ctx.obj.sub_lang = sub_lang
|
||||
if ctx.get_parameter_source("continue_") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.continue_from_history = continue_
|
||||
if ctx.get_parameter_source("player") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.player = player
|
||||
if ctx.get_parameter_source("skip") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.skip = skip
|
||||
if (
|
||||
ctx.get_parameter_source("normalize_titles")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.normalize_titles = normalize_titles
|
||||
|
||||
if quality:
|
||||
ctx.obj.quality = quality
|
||||
if ctx.get_parameter_source("auto_next") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.auto_next = auto_next
|
||||
if ctx.get_parameter_source("icons") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.icons = icons
|
||||
if (
|
||||
ctx.get_parameter_source("local_history")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.preferred_history = "local" if local_history else "remote"
|
||||
if (
|
||||
ctx.get_parameter_source("auto_select")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.auto_select = auto_select
|
||||
if (
|
||||
ctx.get_parameter_source("use_python_mpv")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.use_python_mpv = use_python_mpv
|
||||
if downloads_dir:
|
||||
ctx.obj.downloads_dir = downloads_dir
|
||||
if translation_type:
|
||||
ctx.obj.translation_type = translation_type
|
||||
if default:
|
||||
ctx.obj.use_fzf = False
|
||||
ctx.obj.use_rofi = False
|
||||
if fzf:
|
||||
ctx.obj.use_fzf = True
|
||||
if preview:
|
||||
ctx.obj.preview = True
|
||||
if no_preview:
|
||||
ctx.obj.preview = False
|
||||
if dub:
|
||||
ctx.obj.translation_type = "dub"
|
||||
if sub:
|
||||
ctx.obj.translation_type = "sub"
|
||||
if rofi:
|
||||
ctx.obj.use_fzf = False
|
||||
ctx.obj.use_rofi = True
|
||||
if rofi:
|
||||
from ..libs.rofi import Rofi
|
||||
|
||||
if rofi_theme_preview:
|
||||
ctx.obj.rofi_theme_preview = rofi_theme_preview
|
||||
Rofi.rofi_theme_preview = rofi_theme_preview
|
||||
|
||||
if rofi_theme:
|
||||
ctx.obj.rofi_theme = rofi_theme
|
||||
Rofi.rofi_theme = rofi_theme
|
||||
|
||||
if rofi_theme_input:
|
||||
ctx.obj.rofi_theme_input = rofi_theme_input
|
||||
Rofi.rofi_theme_input = rofi_theme_input
|
||||
|
||||
if rofi_theme_confirm:
|
||||
ctx.obj.rofi_theme_confirm = rofi_theme_confirm
|
||||
Rofi.rofi_theme_confirm = rofi_theme_confirm
|
||||
ctx.obj.set_fastanime_config_environs()
|
||||
__all__ = ["run_cli"]
|
||||
|
||||
101
fastanime/cli/cli.py
Normal file
101
fastanime/cli/cli.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from click.core import ParameterSource
|
||||
|
||||
from ..core.config import AppConfig
|
||||
from ..core.constants import PROJECT_NAME, USER_CONFIG_PATH, __version__
|
||||
from .config import ConfigLoader
|
||||
from .options import options_from_model
|
||||
from .utils.exceptions import setup_exceptions_handler
|
||||
from .utils.lazyloader import LazyGroup
|
||||
from .utils.logging import setup_logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import TypedDict
|
||||
|
||||
from typing_extensions import Unpack
|
||||
|
||||
class Options(TypedDict):
|
||||
no_config: bool | None
|
||||
trace: bool | None
|
||||
log_to_file: bool | None
|
||||
dev: bool | None
|
||||
log: bool | None
|
||||
rich_traceback: bool | None
|
||||
rich_traceback_theme: str
|
||||
|
||||
|
||||
commands = {
|
||||
"config": "config.config",
|
||||
"search": "search.search",
|
||||
"anilist": "anilist.anilist",
|
||||
"download": "download.download",
|
||||
}
|
||||
|
||||
|
||||
@click.group(
|
||||
cls=LazyGroup,
|
||||
root="fastanime.cli.commands",
|
||||
lazy_subcommands=commands,
|
||||
context_settings=dict(auto_envvar_prefix=PROJECT_NAME),
|
||||
)
|
||||
@click.version_option(__version__, "--version")
|
||||
@click.option("--no-config", is_flag=True, help="Don't load the user config file.")
|
||||
@click.option(
|
||||
"--trace", is_flag=True, help="Controls Whether to display tracebacks or not"
|
||||
)
|
||||
@click.option("--dev", is_flag=True, help="Controls Whether the app is in dev mode")
|
||||
@click.option("--log", is_flag=True, help="Controls Whether to log")
|
||||
@click.option("--log-to-file", is_flag=True, help="Controls Whether to log to a file")
|
||||
@click.option(
|
||||
"--rich-traceback",
|
||||
is_flag=True,
|
||||
help="Controls Whether to display a rich traceback",
|
||||
)
|
||||
@click.option(
|
||||
"--rich-traceback-theme",
|
||||
default="github-dark",
|
||||
help="Controls Whether to display a rich traceback",
|
||||
)
|
||||
@options_from_model(AppConfig)
|
||||
@click.pass_context
|
||||
def cli(ctx: click.Context, **options: "Unpack[Options]"):
|
||||
"""
|
||||
The main entry point for the FastAnime CLI.
|
||||
"""
|
||||
setup_logging(options["log"], options["log_to_file"])
|
||||
setup_exceptions_handler(
|
||||
options["trace"],
|
||||
options["dev"],
|
||||
options["rich_traceback"],
|
||||
options["rich_traceback_theme"],
|
||||
)
|
||||
|
||||
loader = ConfigLoader(config_path=USER_CONFIG_PATH)
|
||||
config = AppConfig.model_validate({}) if options["no_config"] else loader.load()
|
||||
|
||||
# update app config with command line parameters
|
||||
for param_name, param_value in ctx.params.items():
|
||||
source = ctx.get_parameter_source(param_name)
|
||||
if (
|
||||
source == ParameterSource.ENVIRONMENT
|
||||
or source == ParameterSource.COMMANDLINE
|
||||
):
|
||||
parameter = None
|
||||
for param in ctx.command.params:
|
||||
if param.name == param_name:
|
||||
parameter = param
|
||||
break
|
||||
if (
|
||||
parameter
|
||||
and hasattr(parameter, "model_name")
|
||||
and hasattr(parameter, "field_name")
|
||||
):
|
||||
model_name = getattr(parameter, "model_name")
|
||||
field_name = getattr(parameter, "field_name")
|
||||
if hasattr(config, model_name):
|
||||
model_instance = getattr(config, model_name)
|
||||
if hasattr(model_instance, field_name):
|
||||
setattr(model_instance, field_name, param_value)
|
||||
ctx.obj = config
|
||||
@@ -1,40 +0,0 @@
|
||||
# in lazy_group.py
|
||||
import importlib
|
||||
|
||||
import click
|
||||
|
||||
|
||||
class LazyGroup(click.Group):
|
||||
def __init__(self, *args, lazy_subcommands=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# lazy_subcommands is a map of the form:
|
||||
#
|
||||
# {command-name} -> {module-name}.{command-object-name}
|
||||
#
|
||||
self.lazy_subcommands = lazy_subcommands or {}
|
||||
|
||||
def list_commands(self, ctx):
|
||||
base = super().list_commands(ctx)
|
||||
lazy = sorted(self.lazy_subcommands.keys())
|
||||
return base + lazy
|
||||
|
||||
def get_command(self, ctx, cmd_name): # pyright:ignore
|
||||
if cmd_name in self.lazy_subcommands:
|
||||
return self._lazy_load(cmd_name)
|
||||
return super().get_command(ctx, cmd_name)
|
||||
|
||||
def _lazy_load(self, cmd_name: str):
|
||||
# lazily loading a command, first get the module name and attribute name
|
||||
import_path: str = self.lazy_subcommands[cmd_name]
|
||||
modname, cmd_object_name = import_path.rsplit(".", 1)
|
||||
# do the import
|
||||
mod = importlib.import_module(f".{modname}", package="fastanime.cli.commands")
|
||||
# get the Command object from that module
|
||||
cmd_object = getattr(mod, cmd_object_name)
|
||||
# check the result to make debugging easier
|
||||
if not isinstance(cmd_object, click.BaseCommand):
|
||||
raise ValueError(
|
||||
f"Lazy loading of {import_path} failed by returning "
|
||||
"a non-command object"
|
||||
)
|
||||
return cmd_object
|
||||
|
||||
@@ -1,128 +1 @@
|
||||
import click
|
||||
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
from .__lazyloader__ import LazyGroup
|
||||
|
||||
commands = {
|
||||
"trending": "trending.trending",
|
||||
"recent": "recent.recent",
|
||||
"search": "search.search",
|
||||
"upcoming": "upcoming.upcoming",
|
||||
"scores": "scores.scores",
|
||||
"popular": "popular.popular",
|
||||
"favourites": "favourites.favourites",
|
||||
"random": "random_anime.random_anime",
|
||||
"login": "login.login",
|
||||
"watching": "watching.watching",
|
||||
"paused": "paused.paused",
|
||||
"rewatching": "rewatching.rewatching",
|
||||
"dropped": "dropped.dropped",
|
||||
"completed": "completed.completed",
|
||||
"planning": "planning.planning",
|
||||
"notifier": "notifier.notifier",
|
||||
"stats": "stats.stats",
|
||||
"download": "download.download",
|
||||
"downloads": "downloads.downloads",
|
||||
}
|
||||
|
||||
|
||||
@click.group(
|
||||
lazy_subcommands=commands,
|
||||
cls=LazyGroup,
|
||||
invoke_without_command=True,
|
||||
help="A beautiful interface that gives you access to a commplete streaming experience",
|
||||
short_help="Access all streaming options",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# ---- search ----
|
||||
\b
|
||||
# get anime with the tag of isekai
|
||||
fastanime anilist search -T isekai
|
||||
\b
|
||||
# get anime of 2024 and sort by popularity
|
||||
# that has already finished airing or is releasing
|
||||
# and is not in your anime lists
|
||||
fastanime anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
|
||||
\b
|
||||
# get anime of 2024 season WINTER
|
||||
fastanime anilist search -y 2024 --season WINTER
|
||||
\b
|
||||
# get anime genre action and tag isekai,magic
|
||||
fastanime anilist search -g Action -T Isekai -T Magic
|
||||
\b
|
||||
# get anime of 2024 thats finished airing
|
||||
fastanime anilist search -y 2024 -S FINISHED
|
||||
\b
|
||||
# get the most favourite anime movies
|
||||
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
|
||||
\b
|
||||
# ---- login ----
|
||||
\b
|
||||
# To sign in just run
|
||||
fastanime anilist login
|
||||
\b
|
||||
# To view your login status
|
||||
fastanime anilist login --status
|
||||
\b
|
||||
# To erase login data
|
||||
fastanime anilist login --erase
|
||||
\b
|
||||
# ---- notifier ----
|
||||
\b
|
||||
# basic form
|
||||
fastanime anilist notifier
|
||||
\b
|
||||
# with logging to stdout
|
||||
fastanime --log anilist notifier
|
||||
\b
|
||||
# with logging to a file. stored in the same place as your config
|
||||
fastanime --log-file anilist notifier
|
||||
""",
|
||||
)
|
||||
@click.option("--resume", is_flag=True, help="Resume from the last session")
|
||||
@click.pass_context
|
||||
def anilist(ctx: click.Context, resume: bool):
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ....anilist import AniList
|
||||
from ....AnimeProvider import AnimeProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
config: Config = ctx.obj
|
||||
config.anime_provider = AnimeProvider(config.provider)
|
||||
if user := ctx.obj.user:
|
||||
AniList.update_login_info(user, user["token"])
|
||||
if ctx.invoked_subcommand is None:
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
if resume:
|
||||
from ...interfaces.anilist_interfaces import (
|
||||
anime_provider_search_results_menu,
|
||||
)
|
||||
|
||||
if not config.user_data["recent_anime"]:
|
||||
click.echo("No recent anime found", err=True, color=True)
|
||||
return
|
||||
fastanime_runtime_state.anilist_results_data = {
|
||||
"data": {"Page": {"media": config.user_data["recent_anime"]}}
|
||||
}
|
||||
|
||||
fastanime_runtime_state.selected_anime_anilist = config.user_data[
|
||||
"recent_anime"
|
||||
][0]
|
||||
fastanime_runtime_state.selected_anime_id_anilist = config.user_data[
|
||||
"recent_anime"
|
||||
][0]["id"]
|
||||
fastanime_runtime_state.selected_anime_title_anilist = (
|
||||
config.user_data["recent_anime"][0]["title"]["romaji"]
|
||||
or config.user_data["recent_anime"][0]["title"]["english"]
|
||||
)
|
||||
anime_provider_search_results_menu(config, fastanime_runtime_state)
|
||||
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import (
|
||||
fastanime_main_menu as anilist_interface,
|
||||
)
|
||||
|
||||
anilist_interface(ctx.obj, fastanime_runtime_state)
|
||||
from .cmd import anilist
|
||||
|
||||
41
fastanime/cli/commands/anilist/cmd.py
Normal file
41
fastanime/cli/commands/anilist/cmd.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import click
|
||||
|
||||
from ...utils.lazyloader import LazyGroup
|
||||
from . import examples
|
||||
|
||||
commands = {
|
||||
# "trending": "trending.trending",
|
||||
# "recent": "recent.recent",
|
||||
# "search": "search.search",
|
||||
# "download": "download.download",
|
||||
# "downloads": "downloads.downloads",
|
||||
"auth": "auth.auth",
|
||||
}
|
||||
|
||||
|
||||
@click.group(
|
||||
cls=LazyGroup,
|
||||
name="anilist",
|
||||
root="fastanime.cli.commands.anilist.commands",
|
||||
invoke_without_command=True,
|
||||
help="A beautiful interface that gives you access to a commplete streaming experience",
|
||||
short_help="Access all streaming options",
|
||||
lazy_subcommands=commands,
|
||||
epilog=examples.main,
|
||||
)
|
||||
@click.option(
|
||||
"--resume", is_flag=True, help="Resume from the last session (Not yet implemented)."
|
||||
)
|
||||
@click.pass_context
|
||||
def anilist(ctx: click.Context, resume: bool):
|
||||
"""
|
||||
The entry point for the 'anilist' command. If no subcommand is invoked,
|
||||
it launches the interactive TUI mode.
|
||||
"""
|
||||
from ...interactive.session import session
|
||||
|
||||
config = ctx.obj
|
||||
|
||||
if ctx.invoked_subcommand is None:
|
||||
session.load_menus_from_folder("")
|
||||
session.run(config, resume=resume)
|
||||
65
fastanime/cli/commands/anilist/commands/auth.py
Normal file
65
fastanime/cli/commands/anilist/commands/auth.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from re import A
|
||||
|
||||
import click
|
||||
|
||||
from .....core.config.model import AppConfig
|
||||
from .....core.constants import ANILIST_AUTH
|
||||
from .....libs.api.factory import create_api_client
|
||||
from .....libs.selectors.selector import create_selector
|
||||
from ....services.auth import AuthService
|
||||
from ....services.feedback import FeedbackService
|
||||
|
||||
|
||||
@click.command(help="Login to your AniList account to enable progress tracking.")
|
||||
@click.option("--status", "-s", is_flag=True, help="Check current login status.")
|
||||
@click.option("--logout", "-l", is_flag=True, help="Log out and erase credentials.")
|
||||
@click.pass_obj
|
||||
def auth(config: AppConfig, status: bool, logout: bool):
|
||||
"""Handles user authentication and credential management."""
|
||||
auth_service = AuthService("anilist")
|
||||
feedback = FeedbackService(config.general.icons)
|
||||
selector = create_selector(config)
|
||||
feedback.clear_console()
|
||||
|
||||
if status:
|
||||
user_data = auth_service.get_auth()
|
||||
if user_data:
|
||||
feedback.info(f"Logged in as: {user_data.user_profile}")
|
||||
else:
|
||||
feedback.error("Not logged in.")
|
||||
return
|
||||
|
||||
if logout:
|
||||
if selector.confirm("Are you sure you want to log out and erase your token?"):
|
||||
auth_service.clear_user_profile()
|
||||
feedback.info("You have been logged out.")
|
||||
return
|
||||
|
||||
if auth_profile := auth_service.get_auth():
|
||||
if not selector.confirm(
|
||||
f"You are already logged in as {auth_profile.user_profile.name}.Would you like to relogin"
|
||||
):
|
||||
return
|
||||
api_client = create_api_client("anilist", config)
|
||||
|
||||
# TODO: stop the printing of opening browser session to stderr
|
||||
click.launch(ANILIST_AUTH)
|
||||
feedback.info("Your browser has been opened to obtain an AniList token.")
|
||||
feedback.info(
|
||||
"After authorizing, copy the token from the address bar and paste it below."
|
||||
)
|
||||
|
||||
token = selector.ask("Enter your AniList Access Token")
|
||||
if not token:
|
||||
feedback.error("Login cancelled.")
|
||||
return
|
||||
|
||||
# Use the API client to validate the token and get profile info
|
||||
profile = api_client.authenticate(token.strip())
|
||||
|
||||
if profile:
|
||||
# If successful, use the manager to save the credentials
|
||||
auth_service.save_user_profile(profile, token)
|
||||
feedback.info(f"Successfully logged in as {profile.name}! ✨")
|
||||
else:
|
||||
feedback.error("Login failed. The token may be invalid or expired.")
|
||||
25
fastanime/cli/commands/anilist/commands/completed.py
Normal file
25
fastanime/cli/commands/anilist/commands/completed.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(help="View anime you completed")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def completed(config: "AppConfig", dump_json: bool):
|
||||
from ..helpers import handle_user_list_command
|
||||
|
||||
handle_user_list_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
status="COMPLETED",
|
||||
list_name="completed"
|
||||
)
|
||||
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.cli.config import Config
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(help="View anime you dropped")
|
||||
@@ -14,15 +14,15 @@ if TYPE_CHECKING:
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def dropped(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
def dropped(config: "AppConfig", dump_json: bool):
|
||||
from ..helpers import handle_user_list_command
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit(1)
|
||||
handle_user_list_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
status="DROPPED",
|
||||
list_name="dropped"
|
||||
)
|
||||
anime_list = AniList.get_anime_list("DROPPED")
|
||||
if not anime_list:
|
||||
exit(1)
|
||||
38
fastanime/cli/commands/anilist/commands/favourites.py
Normal file
38
fastanime/cli/commands/anilist/commands/favourites.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the top 15 most favourited anime from anilist",
|
||||
short_help="View most favourited anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def favourites(config: "AppConfig", dump_json: bool):
|
||||
from fastanime.libs.api.params import MediaSearchParams
|
||||
from ..helpers import handle_media_search_command
|
||||
|
||||
def create_search_params(config):
|
||||
return MediaSearchParams(
|
||||
per_page=config.anilist.per_page or 15,
|
||||
sort=["FAVOURITES_DESC"]
|
||||
)
|
||||
|
||||
handle_media_search_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
task_name="Fetching most favourited anime...",
|
||||
search_params_factory=create_search_params,
|
||||
empty_message="No favourited anime found"
|
||||
)
|
||||
|
||||
exit(1)
|
||||
@@ -2,6 +2,30 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(help="View anime you paused")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def paused(config: "AppConfig", dump_json: bool):
|
||||
from ..helpers import handle_user_list_command
|
||||
|
||||
handle_user_list_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
status="PAUSED",
|
||||
list_name="paused"
|
||||
)t TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
25
fastanime/cli/commands/anilist/commands/planning.py
Normal file
25
fastanime/cli/commands/anilist/commands/planning.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(help="View anime you are planning on watching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def planning(config: "AppConfig", dump_json: bool):
|
||||
from ..helpers import handle_user_list_command
|
||||
|
||||
handle_user_list_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
status="PLANNING",
|
||||
list_name="planning"
|
||||
)
|
||||
38
fastanime/cli/commands/anilist/commands/popular.py
Normal file
38
fastanime/cli/commands/anilist/commands/popular.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the top 15 most popular anime",
|
||||
short_help="View most popular anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def popular(config: "AppConfig", dump_json: bool):
|
||||
from fastanime.libs.api.params import MediaSearchParams
|
||||
from ..helpers import handle_media_search_command
|
||||
|
||||
def create_search_params(config):
|
||||
return MediaSearchParams(
|
||||
per_page=config.anilist.per_page or 15,
|
||||
sort=["POPULARITY_DESC"]
|
||||
)
|
||||
|
||||
handle_media_search_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
task_name="Fetching popular anime...",
|
||||
search_params_factory=create_search_params,
|
||||
empty_message="No popular anime found"
|
||||
)
|
||||
|
||||
exit(1)
|
||||
66
fastanime/cli/commands/anilist/commands/random.py
Normal file
66
fastanime/cli/commands/anilist/commands/random.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Get random anime from anilist based on a range of anilist anime ids that are selected at random",
|
||||
short_help="View random anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def random_anime(config: "AppConfig", dump_json: bool):
|
||||
import json
|
||||
import random
|
||||
|
||||
from fastanime.cli.utils.feedback import create_feedback_manager
|
||||
from fastanime.core.exceptions import FastAnimeError
|
||||
from fastanime.libs.api.factory import create_api_client
|
||||
from fastanime.libs.api.params import MediaSearchParams
|
||||
from rich.progress import Progress
|
||||
|
||||
feedback = create_feedback_manager(config.general.icons)
|
||||
|
||||
try:
|
||||
# Create API client
|
||||
api_client = create_api_client(config.general.media_api, config)
|
||||
|
||||
# Generate random IDs
|
||||
random_ids = random.sample(range(1, 100000), k=50)
|
||||
|
||||
# Search for random anime
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching random anime...", total=None)
|
||||
search_params = MediaSearchParams(id_in=random_ids, per_page=50)
|
||||
search_result = api_client.search_media(search_params)
|
||||
|
||||
if not search_result or not search_result.media:
|
||||
raise FastAnimeError("No random anime found")
|
||||
|
||||
if dump_json:
|
||||
# Use Pydantic's built-in serialization
|
||||
print(json.dumps(search_result.model_dump(), indent=2))
|
||||
else:
|
||||
# Launch interactive session for browsing results
|
||||
from fastanime.cli.interactive.session import session
|
||||
|
||||
feedback.info(
|
||||
f"Found {len(search_result.media)} random anime. Launching interactive mode..."
|
||||
)
|
||||
session.load_menus_from_folder()
|
||||
session.run(config)
|
||||
|
||||
except FastAnimeError as e:
|
||||
feedback.error("Failed to fetch random anime", str(e))
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
feedback.error("Unexpected error occurred", str(e))
|
||||
raise click.Abort()
|
||||
41
fastanime/cli/commands/anilist/commands/recent.py
Normal file
41
fastanime/cli/commands/anilist/commands/recent.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the 15 most recently updated anime from anilist that are currently releasing",
|
||||
short_help="View recently updated anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def recent(config: "AppConfig", dump_json: bool):
|
||||
from fastanime.libs.api.params import MediaSearchParams
|
||||
from ..helpers import handle_media_search_command
|
||||
|
||||
def create_search_params(config):
|
||||
return MediaSearchParams(
|
||||
per_page=config.anilist.per_page or 15,
|
||||
sort=["UPDATED_AT_DESC"],
|
||||
status_in=["RELEASING"]
|
||||
)
|
||||
|
||||
handle_media_search_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
task_name="Fetching recently updated anime...",
|
||||
search_params_factory=create_search_params,
|
||||
empty_message="No recently updated anime found"
|
||||
)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(help="View anime you are rewatching")
|
||||
@@ -14,7 +14,15 @@ if TYPE_CHECKING:
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def rewatching(config: "Config", dump_json):
|
||||
def rewatching(config: "AppConfig", dump_json: bool):
|
||||
from ..helpers import handle_user_list_command
|
||||
|
||||
handle_user_list_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
status="REPEATING",
|
||||
list_name="rewatching"
|
||||
)
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
38
fastanime/cli/commands/anilist/commands/scores.py
Normal file
38
fastanime/cli/commands/anilist/commands/scores.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the 15 most scored anime",
|
||||
short_help="View most scored anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def scores(config: "AppConfig", dump_json: bool):
|
||||
from fastanime.libs.api.params import MediaSearchParams
|
||||
from ..helpers import handle_media_search_command
|
||||
|
||||
def create_search_params(config):
|
||||
return MediaSearchParams(
|
||||
per_page=config.anilist.per_page or 15,
|
||||
sort=["SCORE_DESC"]
|
||||
)
|
||||
|
||||
handle_media_search_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
task_name="Fetching highest scored anime...",
|
||||
search_params_factory=create_search_params,
|
||||
empty_message="No scored anime found"
|
||||
)
|
||||
|
||||
exit(1)
|
||||
150
fastanime/cli/commands/anilist/commands/search.py
Normal file
150
fastanime/cli/commands/anilist/commands/search.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
from fastanime.cli.utils.completions import anime_titles_shell_complete
|
||||
|
||||
from .data import (
|
||||
genres_available,
|
||||
media_formats_available,
|
||||
media_statuses_available,
|
||||
seasons_available,
|
||||
sorts_available,
|
||||
tags_available_list,
|
||||
years_available,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Search for anime using anilists api and get top ~50 results",
|
||||
short_help="Search for anime",
|
||||
)
|
||||
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.option(
|
||||
"--season",
|
||||
help="The season the media was released",
|
||||
type=click.Choice(seasons_available),
|
||||
)
|
||||
@click.option(
|
||||
"--status",
|
||||
"-S",
|
||||
help="The media status of the anime",
|
||||
multiple=True,
|
||||
type=click.Choice(media_statuses_available),
|
||||
)
|
||||
@click.option(
|
||||
"--sort",
|
||||
"-s",
|
||||
help="What to sort the search results on",
|
||||
type=click.Choice(sorts_available),
|
||||
)
|
||||
@click.option(
|
||||
"--genres",
|
||||
"-g",
|
||||
multiple=True,
|
||||
help="the genres to filter by",
|
||||
type=click.Choice(genres_available),
|
||||
)
|
||||
@click.option(
|
||||
"--tags",
|
||||
"-T",
|
||||
multiple=True,
|
||||
help="the tags to filter by",
|
||||
type=click.Choice(tags_available_list),
|
||||
)
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
help="Media format",
|
||||
type=click.Choice(media_formats_available),
|
||||
)
|
||||
@click.option(
|
||||
"--year",
|
||||
"-y",
|
||||
type=click.Choice(years_available),
|
||||
help="the year the media was released",
|
||||
)
|
||||
@click.option(
|
||||
"--on-list/--not-on-list",
|
||||
"-L/-no-L",
|
||||
help="Whether the anime should be in your list or not",
|
||||
type=bool,
|
||||
)
|
||||
@click.pass_obj
|
||||
def search(
|
||||
config: "AppConfig",
|
||||
title: str,
|
||||
dump_json: bool,
|
||||
season: str,
|
||||
status: tuple,
|
||||
sort: str,
|
||||
genres: tuple,
|
||||
tags: tuple,
|
||||
media_format: tuple,
|
||||
year: str,
|
||||
on_list: bool,
|
||||
):
|
||||
import json
|
||||
|
||||
from fastanime.cli.utils.feedback import create_feedback_manager
|
||||
from fastanime.core.exceptions import FastAnimeError
|
||||
from fastanime.libs.api.factory import create_api_client
|
||||
from fastanime.libs.api.params import MediaSearchParams
|
||||
from rich.progress import Progress
|
||||
|
||||
feedback = create_feedback_manager(config.general.icons)
|
||||
|
||||
try:
|
||||
# Create API client
|
||||
api_client = create_api_client(config.general.media_api, config)
|
||||
|
||||
# Build search parameters
|
||||
search_params = MediaSearchParams(
|
||||
query=title,
|
||||
per_page=config.anilist.per_page or 50,
|
||||
sort=[sort] if sort else None,
|
||||
status_in=list(status) if status else None,
|
||||
genre_in=list(genres) if genres else None,
|
||||
tag_in=list(tags) if tags else None,
|
||||
format_in=list(media_format) if media_format else None,
|
||||
season=season,
|
||||
seasonYear=int(year) if year else None,
|
||||
on_list=on_list,
|
||||
)
|
||||
|
||||
# Search for anime
|
||||
with Progress() as progress:
|
||||
progress.add_task("Searching anime...", total=None)
|
||||
search_result = api_client.search_media(search_params)
|
||||
|
||||
if not search_result or not search_result.media:
|
||||
raise FastAnimeError("No anime found matching your search criteria")
|
||||
|
||||
if dump_json:
|
||||
# Use Pydantic's built-in serialization
|
||||
print(json.dumps(search_result.model_dump(), indent=2))
|
||||
else:
|
||||
# Launch interactive session for browsing results
|
||||
from fastanime.cli.interactive.session import session
|
||||
|
||||
feedback.info(
|
||||
f"Found {len(search_result.media)} anime matching your search. Launching interactive mode..."
|
||||
)
|
||||
session.load_menus_from_folder()
|
||||
session.run(config)
|
||||
|
||||
except FastAnimeError as e:
|
||||
feedback.error("Search failed", str(e))
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
feedback.error("Unexpected error occurred", str(e))
|
||||
raise click.Abort()
|
||||
81
fastanime/cli/commands/anilist/commands/stats.py
Normal file
81
fastanime/cli/commands/anilist/commands/stats.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(help="Print out your anilist stats")
|
||||
@click.pass_obj
|
||||
def stats(config: "AppConfig"):
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from fastanime.cli.utils.feedback import create_feedback_manager
|
||||
from fastanime.core.exceptions import FastAnimeError
|
||||
from fastanime.libs.api.factory import create_api_client
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
|
||||
feedback = create_feedback_manager(config.general.icons)
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
# Create API client and ensure authentication
|
||||
api_client = create_api_client(config.general.media_api, config)
|
||||
|
||||
if not api_client.user_profile:
|
||||
feedback.error("Not authenticated", "Please run: fastanime anilist login")
|
||||
raise click.Abort()
|
||||
|
||||
user_profile = api_client.user_profile
|
||||
|
||||
# Check if kitten is available for image display
|
||||
KITTEN_EXECUTABLE = shutil.which("kitten")
|
||||
if not KITTEN_EXECUTABLE:
|
||||
feedback.warning("Kitten not found - profile image will not be displayed")
|
||||
else:
|
||||
# Display profile image using kitten icat
|
||||
if user_profile.avatar_url:
|
||||
console.clear()
|
||||
image_x = int(console.size.width * 0.1)
|
||||
image_y = int(console.size.height * 0.1)
|
||||
img_w = console.size.width // 3
|
||||
img_h = console.size.height // 3
|
||||
|
||||
image_process = subprocess.run(
|
||||
[
|
||||
KITTEN_EXECUTABLE,
|
||||
"icat",
|
||||
"--clear",
|
||||
"--place",
|
||||
f"{img_w}x{img_h}@{image_x}x{image_y}",
|
||||
user_profile.avatar_url,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
if image_process.returncode != 0:
|
||||
feedback.warning("Failed to display profile image")
|
||||
|
||||
# Display user information
|
||||
about_text = getattr(user_profile, "about", "") or "No description available"
|
||||
|
||||
console.print(
|
||||
Panel(
|
||||
Markdown(about_text),
|
||||
title=f"📊 {user_profile.name}'s Profile",
|
||||
)
|
||||
)
|
||||
|
||||
# You can add more stats here if the API provides them
|
||||
feedback.success("User profile displayed successfully")
|
||||
|
||||
except FastAnimeError as e:
|
||||
feedback.error("Failed to fetch user stats", str(e))
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
feedback.error("Unexpected error occurred", str(e))
|
||||
raise click.Abort()
|
||||
36
fastanime/cli/commands/anilist/commands/trending.py
Normal file
36
fastanime/cli/commands/anilist/commands/trending.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the top 15 anime that are currently trending",
|
||||
short_help="Trending anime 🔥🔥🔥",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def trending(config: "AppConfig", dump_json: bool):
|
||||
from fastanime.libs.api.params import MediaSearchParams
|
||||
|
||||
from ..helpers import handle_media_search_command
|
||||
|
||||
def create_search_params(config):
|
||||
return MediaSearchParams(
|
||||
per_page=config.anilist.per_page or 15, sort=["TRENDING_DESC"]
|
||||
)
|
||||
|
||||
handle_media_search_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
task_name="Fetching trending anime...",
|
||||
search_params_factory=create_search_params,
|
||||
empty_message="No trending anime found",
|
||||
)
|
||||
39
fastanime/cli/commands/anilist/commands/upcoming.py
Normal file
39
fastanime/cli/commands/anilist/commands/upcoming.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the 15 most anticipated anime",
|
||||
short_help="View upcoming anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def upcoming(config: "AppConfig", dump_json: bool):
|
||||
from fastanime.libs.api.params import MediaSearchParams
|
||||
from ..helpers import handle_media_search_command
|
||||
|
||||
def create_search_params(config):
|
||||
return MediaSearchParams(
|
||||
per_page=config.anilist.per_page or 15,
|
||||
sort=["POPULARITY_DESC"],
|
||||
status_in=["NOT_YET_RELEASED"]
|
||||
)
|
||||
|
||||
handle_media_search_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
task_name="Fetching upcoming anime...",
|
||||
search_params_factory=create_search_params,
|
||||
empty_message="No upcoming anime found"
|
||||
)
|
||||
|
||||
exit(1)
|
||||
25
fastanime/cli/commands/anilist/commands/watching.py
Normal file
25
fastanime/cli/commands/anilist/commands/watching.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(help="View anime you are watching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def watching(config: "AppConfig", dump_json: bool):
|
||||
from ..helpers import handle_user_list_command
|
||||
|
||||
handle_user_list_command(
|
||||
config=config,
|
||||
dump_json=dump_json,
|
||||
status="CURRENT",
|
||||
list_name="watching"
|
||||
)
|
||||
@@ -1,53 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="View anime you completed")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def completed(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("COMPLETED")
|
||||
if not anime_list or not anime_list[1]:
|
||||
return
|
||||
if not anime_list[0]:
|
||||
return
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
|
||||
fastanime_runtime_state.current_page = 1
|
||||
fastanime_runtime_state.current_data_loader = (
|
||||
lambda config, **kwargs: anilist_interfaces._handle_animelist(
|
||||
config, fastanime_runtime_state, "Completed", **kwargs
|
||||
)
|
||||
)
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
@@ -1,477 +0,0 @@
|
||||
sorts_available = [
|
||||
"ID",
|
||||
"ID_DESC",
|
||||
"TITLE_ROMAJI",
|
||||
"TITLE_ROMAJI_DESC",
|
||||
"TITLE_ENGLISH",
|
||||
"TITLE_ENGLISH_DESC",
|
||||
"TITLE_NATIVE",
|
||||
"TITLE_NATIVE_DESC",
|
||||
"TYPE",
|
||||
"TYPE_DESC",
|
||||
"FORMAT",
|
||||
"FORMAT_DESC",
|
||||
"START_DATE",
|
||||
"START_DATE_DESC",
|
||||
"END_DATE",
|
||||
"END_DATE_DESC",
|
||||
"SCORE",
|
||||
"SCORE_DESC",
|
||||
"POPULARITY",
|
||||
"POPULARITY_DESC",
|
||||
"TRENDING",
|
||||
"TRENDING_DESC",
|
||||
"EPISODES",
|
||||
"EPISODES_DESC",
|
||||
"DURATION",
|
||||
"DURATION_DESC",
|
||||
"STATUS",
|
||||
"STATUS_DESC",
|
||||
"CHAPTERS",
|
||||
"CHAPTERS_DESC",
|
||||
"VOLUMES",
|
||||
"VOLUMES_DESC",
|
||||
"UPDATED_AT",
|
||||
"UPDATED_AT_DESC",
|
||||
"SEARCH_MATCH",
|
||||
"FAVOURITES",
|
||||
"FAVOURITES_DESC",
|
||||
]
|
||||
|
||||
media_statuses_available = [
|
||||
"FINISHED",
|
||||
"RELEASING",
|
||||
"NOT_YET_RELEASED",
|
||||
"CANCELLED",
|
||||
"HIATUS",
|
||||
]
|
||||
seasons_available = ["WINTER", "SPRING", "SUMMER", "FALL"]
|
||||
genres_available = [
|
||||
"Action",
|
||||
"Adventure",
|
||||
"Comedy",
|
||||
"Drama",
|
||||
"Ecchi",
|
||||
"Fantasy",
|
||||
"Horror",
|
||||
"Mahou Shoujo",
|
||||
"Mecha",
|
||||
"Music",
|
||||
"Mystery",
|
||||
"Psychological",
|
||||
"Romance",
|
||||
"Sci-Fi",
|
||||
"Slice of Life",
|
||||
"Sports",
|
||||
"Supernatural",
|
||||
"Thriller",
|
||||
"Hentai",
|
||||
]
|
||||
media_formats_available = [
|
||||
"TV",
|
||||
"TV_SHORT",
|
||||
"MOVIE",
|
||||
"SPECIAL",
|
||||
"OVA",
|
||||
"MUSIC",
|
||||
"NOVEL",
|
||||
"ONE_SHOT",
|
||||
]
|
||||
years_available = [
|
||||
"1900",
|
||||
"1910",
|
||||
"1920",
|
||||
"1930",
|
||||
"1940",
|
||||
"1950",
|
||||
"1960",
|
||||
"1970",
|
||||
"1980",
|
||||
"1990",
|
||||
"2000",
|
||||
"2004",
|
||||
"2005",
|
||||
"2006",
|
||||
"2007",
|
||||
"2008",
|
||||
"2009",
|
||||
"2010",
|
||||
"2011",
|
||||
"2012",
|
||||
"2013",
|
||||
"2014",
|
||||
"2015",
|
||||
"2016",
|
||||
"2017",
|
||||
"2018",
|
||||
"2019",
|
||||
"2020",
|
||||
"2021",
|
||||
"2022",
|
||||
"2023",
|
||||
"2024",
|
||||
"2025",
|
||||
]
|
||||
|
||||
tags_available = {
|
||||
"Cast": ["Polyamorous"],
|
||||
"Cast Main Cast": [
|
||||
"Anti-Hero",
|
||||
"Elderly Protagonist",
|
||||
"Ensemble Cast",
|
||||
"Estranged Family",
|
||||
"Female Protagonist",
|
||||
"Male Protagonist",
|
||||
"Primarily Adult Cast",
|
||||
"Primarily Animal Cast",
|
||||
"Primarily Child Cast",
|
||||
"Primarily Female Cast",
|
||||
"Primarily Male Cast",
|
||||
"Primarily Teen Cast",
|
||||
],
|
||||
"Cast Traits": [
|
||||
"Age Regression",
|
||||
"Agender",
|
||||
"Aliens",
|
||||
"Amnesia",
|
||||
"Angels",
|
||||
"Anthropomorphism",
|
||||
"Aromantic",
|
||||
"Arranged Marriage",
|
||||
"Artificial Intelligence",
|
||||
"Asexual",
|
||||
"Butler",
|
||||
"Centaur",
|
||||
"Chimera",
|
||||
"Chuunibyou",
|
||||
"Clone",
|
||||
"Cosplay",
|
||||
"Cowboys",
|
||||
"Crossdressing",
|
||||
"Cyborg",
|
||||
"Delinquents",
|
||||
"Demons",
|
||||
"Detective",
|
||||
"Dinosaurs",
|
||||
"Disability",
|
||||
"Dissociative Identities",
|
||||
"Dragons",
|
||||
"Dullahan",
|
||||
"Elf",
|
||||
"Fairy",
|
||||
"Femboy",
|
||||
"Ghost",
|
||||
"Goblin",
|
||||
"Gods",
|
||||
"Gyaru",
|
||||
"Hikikomori",
|
||||
"Homeless",
|
||||
"Idol",
|
||||
"Kemonomimi",
|
||||
"Kuudere",
|
||||
"Maids",
|
||||
"Mermaid",
|
||||
"Monster Boy",
|
||||
"Monster Girl",
|
||||
"Nekomimi",
|
||||
"Ninja",
|
||||
"Nudity",
|
||||
"Nun",
|
||||
"Office Lady",
|
||||
"Oiran",
|
||||
"Ojou-sama",
|
||||
"Orphan",
|
||||
"Pirates",
|
||||
"Robots",
|
||||
"Samurai",
|
||||
"Shrine Maiden",
|
||||
"Skeleton",
|
||||
"Succubus",
|
||||
"Tanned Skin",
|
||||
"Teacher",
|
||||
"Tomboy",
|
||||
"Transgender",
|
||||
"Tsundere",
|
||||
"Twins",
|
||||
"Vampire",
|
||||
"Veterinarian",
|
||||
"Vikings",
|
||||
"Villainess",
|
||||
"VTuber",
|
||||
"Werewolf",
|
||||
"Witch",
|
||||
"Yandere",
|
||||
"Zombie",
|
||||
],
|
||||
"Demographic": ["Josei", "Kids", "Seinen", "Shoujo", "Shounen"],
|
||||
"Setting": ["Matriarchy"],
|
||||
"Setting Scene": [
|
||||
"Bar",
|
||||
"Boarding School",
|
||||
"Circus",
|
||||
"Coastal",
|
||||
"College",
|
||||
"Desert",
|
||||
"Dungeon",
|
||||
"Foreign",
|
||||
"Inn",
|
||||
"Konbini",
|
||||
"Natural Disaster",
|
||||
"Office",
|
||||
"Outdoor",
|
||||
"Prison",
|
||||
"Restaurant",
|
||||
"Rural",
|
||||
"School",
|
||||
"School Club",
|
||||
"Snowscape",
|
||||
"Urban",
|
||||
"Work",
|
||||
],
|
||||
"Setting Time": [
|
||||
"Achronological Order",
|
||||
"Anachronism",
|
||||
"Ancient China",
|
||||
"Dystopian",
|
||||
"Historical",
|
||||
"Time Skip",
|
||||
],
|
||||
"Setting Universe": [
|
||||
"Afterlife",
|
||||
"Alternate Universe",
|
||||
"Augmented Reality",
|
||||
"Omegaverse",
|
||||
"Post-Apocalyptic",
|
||||
"Space",
|
||||
"Urban Fantasy",
|
||||
"Virtual World",
|
||||
],
|
||||
"Technical": [
|
||||
"4-koma",
|
||||
"Achromatic",
|
||||
"Advertisement",
|
||||
"Anthology",
|
||||
"CGI",
|
||||
"Episodic",
|
||||
"Flash",
|
||||
"Full CGI",
|
||||
"Full Color",
|
||||
"No Dialogue",
|
||||
"Non-fiction",
|
||||
"POV",
|
||||
"Puppetry",
|
||||
"Rotoscoping",
|
||||
"Stop Motion",
|
||||
],
|
||||
"Theme Action": [
|
||||
"Archery",
|
||||
"Battle Royale",
|
||||
"Espionage",
|
||||
"Fugitive",
|
||||
"Guns",
|
||||
"Martial Arts",
|
||||
"Spearplay",
|
||||
"Swordplay",
|
||||
],
|
||||
"Theme Arts": [
|
||||
"Acting",
|
||||
"Calligraphy",
|
||||
"Classic Literature",
|
||||
"Drawing",
|
||||
"Fashion",
|
||||
"Food",
|
||||
"Makeup",
|
||||
"Photography",
|
||||
"Rakugo",
|
||||
"Writing",
|
||||
],
|
||||
"Theme Arts-Music": [
|
||||
"Band",
|
||||
"Classical Music",
|
||||
"Dancing",
|
||||
"Hip-hop Music",
|
||||
"Jazz Music",
|
||||
"Metal Music",
|
||||
"Musical Theater",
|
||||
"Rock Music",
|
||||
],
|
||||
"Theme Comedy": ["Parody", "Satire", "Slapstick", "Surreal Comedy"],
|
||||
"Theme Drama": [
|
||||
"Bullying",
|
||||
"Class Struggle",
|
||||
"Coming of Age",
|
||||
"Conspiracy",
|
||||
"Eco-Horror",
|
||||
"Fake Relationship",
|
||||
"Kingdom Management",
|
||||
"Rehabilitation",
|
||||
"Revenge",
|
||||
"Suicide",
|
||||
"Tragedy",
|
||||
],
|
||||
"Theme Fantasy": [
|
||||
"Alchemy",
|
||||
"Body Swapping",
|
||||
"Cultivation",
|
||||
"Fairy Tale",
|
||||
"Henshin",
|
||||
"Isekai",
|
||||
"Kaiju",
|
||||
"Magic",
|
||||
"Mythology",
|
||||
"Necromancy",
|
||||
"Shapeshifting",
|
||||
"Steampunk",
|
||||
"Super Power",
|
||||
"Superhero",
|
||||
"Wuxia",
|
||||
"Youkai",
|
||||
],
|
||||
"Theme Game": ["Board Game", "E-Sports", "Video Games"],
|
||||
"Theme Game-Card & Board Game": [
|
||||
"Card Battle",
|
||||
"Go",
|
||||
"Karuta",
|
||||
"Mahjong",
|
||||
"Poker",
|
||||
"Shogi",
|
||||
],
|
||||
"Theme Game-Sport": [
|
||||
"Acrobatics",
|
||||
"Airsoft",
|
||||
"American Football",
|
||||
"Athletics",
|
||||
"Badminton",
|
||||
"Baseball",
|
||||
"Basketball",
|
||||
"Bowling",
|
||||
"Boxing",
|
||||
"Cheerleading",
|
||||
"Cycling",
|
||||
"Fencing",
|
||||
"Fishing",
|
||||
"Fitness",
|
||||
"Football",
|
||||
"Golf",
|
||||
"Handball",
|
||||
"Ice Skating",
|
||||
"Judo",
|
||||
"Lacrosse",
|
||||
"Parkour",
|
||||
"Rugby",
|
||||
"Scuba Diving",
|
||||
"Skateboarding",
|
||||
"Sumo",
|
||||
"Surfing",
|
||||
"Swimming",
|
||||
"Table Tennis",
|
||||
"Tennis",
|
||||
"Volleyball",
|
||||
"Wrestling",
|
||||
],
|
||||
"Theme Other": [
|
||||
"Adoption",
|
||||
"Animals",
|
||||
"Astronomy",
|
||||
"Autobiographical",
|
||||
"Biographical",
|
||||
"Body Horror",
|
||||
"Cannibalism",
|
||||
"Chibi",
|
||||
"Cosmic Horror",
|
||||
"Crime",
|
||||
"Crossover",
|
||||
"Death Game",
|
||||
"Denpa",
|
||||
"Drugs",
|
||||
"Economics",
|
||||
"Educational",
|
||||
"Environmental",
|
||||
"Ero Guro",
|
||||
"Filmmaking",
|
||||
"Found Family",
|
||||
"Gambling",
|
||||
"Gender Bending",
|
||||
"Gore",
|
||||
"Language Barrier",
|
||||
"LGBTQ+ Themes",
|
||||
"Lost Civilization",
|
||||
"Marriage",
|
||||
"Medicine",
|
||||
"Memory Manipulation",
|
||||
"Meta",
|
||||
"Mountaineering",
|
||||
"Noir",
|
||||
"Otaku Culture",
|
||||
"Pandemic",
|
||||
"Philosophy",
|
||||
"Politics",
|
||||
"Proxy Battle",
|
||||
"Psychosexual",
|
||||
"Reincarnation",
|
||||
"Religion",
|
||||
"Royal Affairs",
|
||||
"Slavery",
|
||||
"Software Development",
|
||||
"Survival",
|
||||
"Terrorism",
|
||||
"Torture",
|
||||
"Travel",
|
||||
"War",
|
||||
],
|
||||
"Theme Other-Organisations": [
|
||||
"Assassins",
|
||||
"Criminal Organization",
|
||||
"Cult",
|
||||
"Firefighters",
|
||||
"Gangs",
|
||||
"Mafia",
|
||||
"Military",
|
||||
"Police",
|
||||
"Triads",
|
||||
"Yakuza",
|
||||
],
|
||||
"Theme Other-Vehicle": [
|
||||
"Aviation",
|
||||
"Cars",
|
||||
"Mopeds",
|
||||
"Motorcycles",
|
||||
"Ships",
|
||||
"Tanks",
|
||||
"Trains",
|
||||
],
|
||||
"Theme Romance": [
|
||||
"Age Gap",
|
||||
"Bisexual",
|
||||
"Boys' Love",
|
||||
"Female Harem",
|
||||
"Heterosexual",
|
||||
"Love Triangle",
|
||||
"Male Harem",
|
||||
"Matchmaking",
|
||||
"Mixed Gender Harem",
|
||||
"Teens' Love",
|
||||
"Unrequited Love",
|
||||
"Yuri",
|
||||
],
|
||||
"Theme Sci Fi": [
|
||||
"Cyberpunk",
|
||||
"Space Opera",
|
||||
"Time Loop",
|
||||
"Time Manipulation",
|
||||
"Tokusatsu",
|
||||
],
|
||||
"Theme Sci Fi-Mecha": ["Real Robot", "Super Robot"],
|
||||
"Theme Slice of Life": [
|
||||
"Agriculture",
|
||||
"Cute Boys Doing Cute Things",
|
||||
"Cute Girls Doing Cute Things",
|
||||
"Family Life",
|
||||
"Horticulture",
|
||||
"Iyashikei",
|
||||
"Parenthood",
|
||||
],
|
||||
}
|
||||
tags_available_list = []
|
||||
for tag_category, tags_in_category in tags_available.items():
|
||||
tags_available_list.extend(tags_in_category)
|
||||
@@ -1,358 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ...completion_functions import downloaded_anime_titles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="View and watch your downloads using mpv",
|
||||
short_help="Watch downloads",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
fastanime downloads
|
||||
\b
|
||||
# view individual episodes
|
||||
fastanime downloads --view-episodes
|
||||
# --- or ---
|
||||
fastanime downloads -v
|
||||
\b
|
||||
# to set seek time when using ffmpegthumbnailer for local previews
|
||||
# -1 means random and is the default
|
||||
fastanime downloads --time-to-seek <intRange(-1,100)>
|
||||
# --- or ---
|
||||
fastanime downloads -t <intRange(-1,100)>
|
||||
\b
|
||||
# to watch a specific title
|
||||
# be sure to get the completions for the best experience
|
||||
fastanime downloads --title <title>
|
||||
\b
|
||||
# to get the path to the downloads folder set
|
||||
fastanime downloads --path
|
||||
# useful when you want to use the value for other programs
|
||||
""",
|
||||
)
|
||||
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
|
||||
@click.option(
|
||||
"--title",
|
||||
"-T",
|
||||
shell_complete=downloaded_anime_titles,
|
||||
help="watch a specific title",
|
||||
)
|
||||
@click.option("--view-episodes", "-v", help="View individual episodes", is_flag=True)
|
||||
@click.option(
|
||||
"--ffmpegthumbnailer-seek-time",
|
||||
"--time-to-seek",
|
||||
"-t",
|
||||
type=click.IntRange(-1, 100),
|
||||
help="ffmpegthumbnailer seek time",
|
||||
)
|
||||
@click.pass_obj
|
||||
def downloads(
|
||||
config: "Config", path: bool, title, view_episodes, ffmpegthumbnailer_seek_time
|
||||
):
|
||||
import os
|
||||
|
||||
from ....cli.utils.mpv import run_mpv
|
||||
from ....libs.fzf import fzf
|
||||
from ....libs.rofi import Rofi
|
||||
from ....Utility.utils import sort_by_episode_number
|
||||
from ...utils.tools import exit_app
|
||||
from ...utils.utils import fuzzy_inquirer
|
||||
|
||||
if not ffmpegthumbnailer_seek_time:
|
||||
ffmpegthumbnailer_seek_time = config.ffmpegthumbnailer_seek_time
|
||||
USER_VIDEOS_DIR = config.downloads_dir
|
||||
if path:
|
||||
print(USER_VIDEOS_DIR)
|
||||
return
|
||||
if not os.path.exists(USER_VIDEOS_DIR):
|
||||
print("Downloads directory specified does not exist")
|
||||
return
|
||||
anime_downloads = sorted(
|
||||
os.listdir(USER_VIDEOS_DIR),
|
||||
)
|
||||
anime_downloads.append("Exit")
|
||||
|
||||
def create_thumbnails(video_path, anime_title, downloads_thumbnail_cache_dir):
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
FFMPEG_THUMBNAILER = shutil.which("ffmpegthumbnailer")
|
||||
if not FFMPEG_THUMBNAILER:
|
||||
return
|
||||
|
||||
out = os.path.join(downloads_thumbnail_cache_dir, anime_title)
|
||||
if ffmpegthumbnailer_seek_time == -1:
|
||||
import random
|
||||
|
||||
seektime = str(random.randrange(0, 100))
|
||||
else:
|
||||
seektime = str(ffmpegthumbnailer_seek_time)
|
||||
_ = subprocess.run(
|
||||
[
|
||||
FFMPEG_THUMBNAILER,
|
||||
"-i",
|
||||
video_path,
|
||||
"-o",
|
||||
out,
|
||||
"-s",
|
||||
"0",
|
||||
"-t",
|
||||
seektime,
|
||||
],
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
|
||||
def get_previews_anime(workers=None, bg=True):
|
||||
import concurrent.futures
|
||||
import random
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
from ....constants import APP_CACHE_DIR
|
||||
from ...utils.scripts import bash_functions
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
# 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 anime_downloads:
|
||||
anime_path = os.path.join(USER_VIDEOS_DIR, anime_title)
|
||||
if not os.path.isdir(anime_path):
|
||||
continue
|
||||
playlist = [
|
||||
anime
|
||||
for anime in sorted(
|
||||
os.listdir(anime_path),
|
||||
)
|
||||
if "mp4" in anime
|
||||
]
|
||||
if playlist:
|
||||
# actual link to download image from
|
||||
video_path = os.path.join(anime_path, random.choice(playlist))
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
video_path,
|
||||
anime_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = anime_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
bash_functions,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def get_previews_episodes(anime_playlist_path, workers=None, bg=True):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ....constants import APP_CACHE_DIR
|
||||
from ...utils.scripts import bash_functions
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
import concurrent.futures
|
||||
|
||||
# use concurrency to download the images as fast as possible
|
||||
# anime_playlist_path = os.path.join(USER_VIDEOS_DIR, anime_playlist_path)
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
return
|
||||
anime_episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for episode_title in anime_episodes:
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
|
||||
# actual link to download image from
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
episode_path,
|
||||
episode_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = episode_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
bash_functions,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def stream_episode(
|
||||
anime_playlist_path,
|
||||
):
|
||||
if view_episodes:
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
print(anime_playlist_path, "is not dir")
|
||||
exit_app(1)
|
||||
return
|
||||
episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
downloaded_episodes = [*episodes, "Back"]
|
||||
|
||||
if config.use_fzf:
|
||||
if not config.preview:
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_episodes(anime_playlist_path)
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
episode_title = Rofi.run(downloaded_episodes, "Enter Episode")
|
||||
else:
|
||||
episode_title = fuzzy_inquirer(
|
||||
downloaded_episodes,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
if episode_title == "Back":
|
||||
stream_anime()
|
||||
return
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
if config.sync_play:
|
||||
from ...utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(episode_path)
|
||||
else:
|
||||
run_mpv(
|
||||
episode_path,
|
||||
player=config.player,
|
||||
)
|
||||
stream_episode(anime_playlist_path)
|
||||
|
||||
def stream_anime(title=None):
|
||||
if title:
|
||||
from thefuzz import fuzz
|
||||
|
||||
playlist_name = max(anime_downloads, key=lambda t: fuzz.ratio(title, t))
|
||||
elif config.use_fzf:
|
||||
if not config.preview:
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_anime()
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
playlist_name = Rofi.run(anime_downloads, "Enter Playlist Name")
|
||||
else:
|
||||
playlist_name = fuzzy_inquirer(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
if playlist_name == "Exit":
|
||||
exit_app()
|
||||
return
|
||||
playlist = os.path.join(USER_VIDEOS_DIR, playlist_name)
|
||||
if view_episodes:
|
||||
stream_episode(
|
||||
playlist,
|
||||
)
|
||||
else:
|
||||
if config.sync_play:
|
||||
from ...utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(playlist)
|
||||
else:
|
||||
run_mpv(
|
||||
playlist,
|
||||
player=config.player,
|
||||
)
|
||||
stream_anime()
|
||||
|
||||
stream_anime(title)
|
||||
47
fastanime/cli/commands/anilist/examples.py
Normal file
47
fastanime/cli/commands/anilist/examples.py
Normal file
@@ -0,0 +1,47 @@
|
||||
main = """
|
||||
\b
|
||||
\b\bExamples:
|
||||
# ---- search ----
|
||||
\b
|
||||
# get anime with the tag of isekai
|
||||
fastanime anilist search -T isekai
|
||||
\b
|
||||
# get anime of 2024 and sort by popularity
|
||||
# that has already finished airing or is releasing
|
||||
# and is not in your anime lists
|
||||
fastanime anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
|
||||
\b
|
||||
# get anime of 2024 season WINTER
|
||||
fastanime anilist search -y 2024 --season WINTER
|
||||
\b
|
||||
# get anime genre action and tag isekai,magic
|
||||
fastanime anilist search -g Action -T Isekai -T Magic
|
||||
\b
|
||||
# get anime of 2024 thats finished airing
|
||||
fastanime anilist search -y 2024 -S FINISHED
|
||||
\b
|
||||
# get the most favourite anime movies
|
||||
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
|
||||
\b
|
||||
# ---- login ----
|
||||
\b
|
||||
# To sign in just run
|
||||
fastanime anilist login
|
||||
\b
|
||||
# To view your login status
|
||||
fastanime anilist login --status
|
||||
\b
|
||||
# To erase login data
|
||||
fastanime anilist login --erase
|
||||
\b
|
||||
# ---- notifier ----
|
||||
\b
|
||||
# basic form
|
||||
fastanime anilist notifier
|
||||
\b
|
||||
# with logging to stdout
|
||||
fastanime --log anilist notifier
|
||||
\b
|
||||
# with logging to a file. stored in the same place as your config
|
||||
fastanime --log-file anilist notifier
|
||||
"""
|
||||
@@ -1,37 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the top 15 most favourited anime from anilist",
|
||||
short_help="View most favourited anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def favourites(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
anime_data = AniList.get_most_favourite()
|
||||
if anime_data[0]:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
|
||||
fastanime_runtime_state.current_page = 1
|
||||
fastanime_runtime_state.current_data_loader = AniList.get_most_favourite
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
177
fastanime/cli/commands/anilist/helpers.py
Normal file
177
fastanime/cli/commands/anilist/helpers.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Common helper functions for anilist subcommands.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import click
|
||||
from rich.progress import Progress
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastanime.core.config import AppConfig
|
||||
from fastanime.libs.api.base import BaseApiClient
|
||||
from fastanime.libs.api.types import MediaSearchResult
|
||||
|
||||
|
||||
def get_authenticated_api_client(config: "AppConfig") -> "BaseApiClient":
|
||||
"""
|
||||
Get an authenticated API client or raise an error if not authenticated.
|
||||
|
||||
Args:
|
||||
config: Application configuration
|
||||
|
||||
Returns:
|
||||
Authenticated API client
|
||||
|
||||
Raises:
|
||||
click.Abort: If user is not authenticated
|
||||
"""
|
||||
from fastanime.cli.utils.feedback import create_feedback_manager
|
||||
from fastanime.libs.api.factory import create_api_client
|
||||
|
||||
feedback = create_feedback_manager(config.general.icons)
|
||||
api_client = create_api_client(config.general.media_api, config)
|
||||
|
||||
# Check if user is authenticated by trying to get viewer profile
|
||||
try:
|
||||
user_profile = api_client.get_viewer_profile()
|
||||
if not user_profile:
|
||||
feedback.error("Not authenticated", "Please run: fastanime anilist login")
|
||||
raise click.Abort()
|
||||
except Exception:
|
||||
feedback.error(
|
||||
"Authentication check failed", "Please run: fastanime anilist login"
|
||||
)
|
||||
raise click.Abort()
|
||||
|
||||
return api_client
|
||||
|
||||
|
||||
def handle_media_search_command(
|
||||
config: "AppConfig",
|
||||
dump_json: bool,
|
||||
task_name: str,
|
||||
search_params_factory,
|
||||
empty_message: str,
|
||||
):
|
||||
"""
|
||||
Generic handler for media search commands (trending, popular, recent, etc).
|
||||
|
||||
Args:
|
||||
config: Application configuration
|
||||
dump_json: Whether to output JSON instead of launching interactive mode
|
||||
task_name: Name to display in progress indicator
|
||||
search_params_factory: Function that returns ApiSearchParams
|
||||
empty_message: Message to show when no results found
|
||||
"""
|
||||
from fastanime.cli.utils.feedback import create_feedback_manager
|
||||
from fastanime.core.exceptions import FastAnimeError
|
||||
from fastanime.libs.api.factory import create_api_client
|
||||
|
||||
feedback = create_feedback_manager(config.general.icons)
|
||||
|
||||
try:
|
||||
# Create API client
|
||||
api_client = create_api_client(config.general.media_api, config)
|
||||
|
||||
# Fetch media
|
||||
with Progress() as progress:
|
||||
progress.add_task(task_name, total=None)
|
||||
search_params = search_params_factory(config)
|
||||
search_result = api_client.search_media(search_params)
|
||||
|
||||
if not search_result or not search_result.media:
|
||||
raise FastAnimeError(empty_message)
|
||||
|
||||
if dump_json:
|
||||
# Use Pydantic's built-in serialization
|
||||
print(json.dumps(search_result.model_dump(), indent=2))
|
||||
else:
|
||||
# Launch interactive session for browsing results
|
||||
from fastanime.cli.interactive.session import session
|
||||
|
||||
feedback.info(
|
||||
f"Found {len(search_result.media)} anime. Launching interactive mode..."
|
||||
)
|
||||
session.load_menus_from_folder()
|
||||
session.run(config)
|
||||
|
||||
except FastAnimeError as e:
|
||||
feedback.error(f"Failed to fetch {task_name.lower()}", str(e))
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
feedback.error("Unexpected error occurred", str(e))
|
||||
raise click.Abort()
|
||||
|
||||
|
||||
def handle_user_list_command(
|
||||
config: "AppConfig", dump_json: bool, status: str, list_name: str
|
||||
):
|
||||
"""
|
||||
Generic handler for user list commands (watching, completed, planning, etc).
|
||||
|
||||
Args:
|
||||
config: Application configuration
|
||||
dump_json: Whether to output JSON instead of launching interactive mode
|
||||
status: The list status to fetch (CURRENT, COMPLETED, PLANNING, etc)
|
||||
list_name: Human-readable name for the list (e.g., "watching", "completed")
|
||||
"""
|
||||
from fastanime.cli.utils.feedback import create_feedback_manager
|
||||
from fastanime.core.exceptions import FastAnimeError
|
||||
from fastanime.libs.api.params import UserMediaListSearchParams
|
||||
|
||||
feedback = create_feedback_manager(config.general.icons)
|
||||
|
||||
# Validate status parameter
|
||||
valid_statuses = [
|
||||
"CURRENT",
|
||||
"PLANNING",
|
||||
"COMPLETED",
|
||||
"DROPPED",
|
||||
"PAUSED",
|
||||
"REPEATING",
|
||||
]
|
||||
if status not in valid_statuses:
|
||||
feedback.error(
|
||||
f"Invalid status: {status}", f"Valid statuses are: {valid_statuses}"
|
||||
)
|
||||
raise click.Abort()
|
||||
|
||||
try:
|
||||
# Get authenticated API client
|
||||
api_client = get_authenticated_api_client(config)
|
||||
|
||||
# Fetch user's anime list
|
||||
with Progress() as progress:
|
||||
progress.add_task(f"Fetching your {list_name} list...", total=None)
|
||||
list_params = UserMediaListSearchParams(
|
||||
status=status, # type: ignore # We validated it above
|
||||
page=1,
|
||||
per_page=config.anilist.per_page or 50,
|
||||
)
|
||||
user_list = api_client.search_media_list(list_params)
|
||||
|
||||
if not user_list or not user_list.media:
|
||||
feedback.info(f"You have no anime in your {list_name} list")
|
||||
return
|
||||
|
||||
if dump_json:
|
||||
# Use Pydantic's built-in serialization
|
||||
print(json.dumps(user_list.model_dump(), indent=2))
|
||||
else:
|
||||
# Launch interactive session for browsing results
|
||||
from fastanime.cli.interactive.session import session
|
||||
|
||||
feedback.info(
|
||||
f"Found {len(user_list.media)} anime in your {list_name} list. Launching interactive mode..."
|
||||
)
|
||||
session.load_menus_from_folder()
|
||||
session.run(config)
|
||||
|
||||
except FastAnimeError as e:
|
||||
feedback.error(f"Failed to fetch {list_name} list", str(e))
|
||||
raise click.Abort()
|
||||
except Exception as e:
|
||||
feedback.error("Unexpected error occurred", str(e))
|
||||
raise click.Abort()
|
||||
@@ -1,76 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="Login to your anilist account")
|
||||
@click.option("--status", "-s", help="Whether you are logged in or not", is_flag=True)
|
||||
@click.option("--erase", "-e", help="Erase your login details", is_flag=True)
|
||||
@click.pass_obj
|
||||
def login(config: "Config", status, erase):
|
||||
from os import path
|
||||
from sys import exit
|
||||
|
||||
from rich import print
|
||||
from rich.prompt import Confirm, Prompt
|
||||
|
||||
from ....constants import S_PLATFORM
|
||||
|
||||
if status:
|
||||
is_logged_in = True if config.user else False
|
||||
message = (
|
||||
"You are logged in :smile:"
|
||||
if is_logged_in
|
||||
else "You aren't logged in :cry:"
|
||||
)
|
||||
print(message)
|
||||
print(config.user)
|
||||
exit(0)
|
||||
elif erase:
|
||||
if Confirm.ask(
|
||||
"Are you sure you want to erase your login status", default=False
|
||||
):
|
||||
config.update_user({})
|
||||
print("Success")
|
||||
exit(0)
|
||||
else:
|
||||
exit(1)
|
||||
else:
|
||||
from click import launch
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if config.user:
|
||||
print("Already logged in :confused:")
|
||||
if not Confirm.ask("or would you like to reloggin", default=True):
|
||||
exit(0)
|
||||
# ---- new loggin -----
|
||||
print(
|
||||
f"A browser session will be opened ( [link]{config.fastanime_anilist_app_login_url}[/link] )",
|
||||
)
|
||||
token = ""
|
||||
if S_PLATFORM.startswith("darwin"):
|
||||
anilist_key_file_path = path.expanduser("~") + "/Downloads/anilist_key.txt"
|
||||
launch(config.fastanime_anilist_app_login_url, wait=False)
|
||||
Prompt.ask(
|
||||
"MacOS detected.\nPress any key once the token provided has been pasted into "
|
||||
+ anilist_key_file_path
|
||||
)
|
||||
with open(anilist_key_file_path, "r") as key_file:
|
||||
token = key_file.read().strip()
|
||||
else:
|
||||
launch(config.fastanime_anilist_app_login_url, wait=False)
|
||||
token = Prompt.ask("Enter token")
|
||||
user = AniList.login_user(token)
|
||||
if not user:
|
||||
print("Sth went wrong", user)
|
||||
exit(1)
|
||||
return
|
||||
user["token"] = token
|
||||
config.update_user(user)
|
||||
print("Successfully saved credentials")
|
||||
print(user)
|
||||
exit(0)
|
||||
@@ -1,130 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="Check for notifications on anime you currently watching")
|
||||
@click.pass_obj
|
||||
def notifier(config: "Config"):
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from sys import exit
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
from plyer import notification
|
||||
except ImportError:
|
||||
print("Please install plyer to use this command")
|
||||
exit(1)
|
||||
|
||||
from ....anilist import AniList
|
||||
from ....constants import APP_CACHE_DIR, APP_DATA_DIR, APP_NAME, ICON_PATH, PLATFORM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
notified = os.path.join(APP_DATA_DIR, "last_notification.json")
|
||||
anime_image_path = os.path.join(APP_CACHE_DIR, "notification_image")
|
||||
notification_duration = config.notification_duration
|
||||
notification_image_path = ""
|
||||
|
||||
if not config.user:
|
||||
print("Not Authenticated")
|
||||
print("Run the following to get started: fastanime anilist login")
|
||||
exit(1)
|
||||
run = True
|
||||
# WARNING: Mess around with this value at your own risk
|
||||
timeout = 2 # time is in minutes
|
||||
if os.path.exists(notified):
|
||||
with open(notified, "r") as f:
|
||||
past_notifications = json.load(f)
|
||||
else:
|
||||
past_notifications = {}
|
||||
with open(notified, "w") as f:
|
||||
json.dump(past_notifications, f)
|
||||
|
||||
while run:
|
||||
try:
|
||||
logger.info("checking for notifications")
|
||||
result = AniList.get_notification()
|
||||
if not result[0]:
|
||||
logger.warning(
|
||||
"Something went wrong this could mean anilist is down or you have lost internet connection"
|
||||
)
|
||||
logger.info("sleeping...")
|
||||
time.sleep(timeout * 60)
|
||||
continue
|
||||
data = result[1]
|
||||
if not data:
|
||||
logger.warning(
|
||||
"Something went wrong this could mean anilist is down or you have lost internet connection"
|
||||
)
|
||||
logger.info("sleeping...")
|
||||
time.sleep(timeout * 60)
|
||||
continue
|
||||
|
||||
notifications = data["data"]["Page"]["notifications"]
|
||||
if not notifications:
|
||||
logger.info("Nothing to notify")
|
||||
else:
|
||||
for notification_ in notifications:
|
||||
anime_episode = notification_["episode"]
|
||||
anime_title = notification_["media"]["title"][
|
||||
config.preferred_language
|
||||
]
|
||||
title = f"{anime_title} Episode {anime_episode} just aired"
|
||||
# pyright:ignore
|
||||
message = "Be sure to watch so you are not left out of the loop."
|
||||
# message = str(textwrap.wrap(message, width=50))
|
||||
|
||||
id = notification_["media"]["id"]
|
||||
if past_notifications.get(str(id)) == notification_["episode"]:
|
||||
logger.info(
|
||||
f"skipping id={id} title={anime_title} episode={anime_episode} already notified"
|
||||
)
|
||||
|
||||
else:
|
||||
# windows only supports ico,
|
||||
# and you still ask why linux
|
||||
if PLATFORM != "Windows":
|
||||
image_link = notification_["media"]["coverImage"]["medium"]
|
||||
logger.info("Downloading image...")
|
||||
|
||||
resp = requests.get(image_link)
|
||||
if resp.status_code == 200:
|
||||
with open(anime_image_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
notification_image_path = anime_image_path
|
||||
else:
|
||||
logger.warn(
|
||||
f"Failed to get image response_status={resp.status_code} response_content={resp.content}"
|
||||
)
|
||||
notification_image_path = ICON_PATH
|
||||
else:
|
||||
notification_image_path = ICON_PATH
|
||||
|
||||
past_notifications[f"{id}"] = notification_["episode"]
|
||||
with open(notified, "w") as f:
|
||||
json.dump(past_notifications, f)
|
||||
logger.info(message)
|
||||
notification.notify( # pyright:ignore
|
||||
title=title,
|
||||
message=message,
|
||||
app_name=APP_NAME,
|
||||
app_icon=notification_image_path,
|
||||
hints={
|
||||
"image-path": notification_image_path,
|
||||
"desktop-entry": f"{APP_NAME}.desktop",
|
||||
},
|
||||
timeout=notification_duration,
|
||||
)
|
||||
time.sleep(30)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
logger.info("sleeping...")
|
||||
time.sleep(timeout * 60)
|
||||
@@ -1,53 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="View anime you are planning on watching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def planning(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("PLANNING")
|
||||
if not anime_list:
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
|
||||
fastanime_runtime_state.current_page = 1
|
||||
fastanime_runtime_state.current_data_loader = (
|
||||
lambda config, **kwargs: anilist_interfaces._handle_animelist(
|
||||
config, fastanime_runtime_state, "Planned", **kwargs
|
||||
)
|
||||
)
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
@@ -1,36 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the top 15 most popular anime", short_help="View most popular anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def popular(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
anime_data = AniList.get_most_popular()
|
||||
if anime_data[0]:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
|
||||
fastanime_runtime_state.current_page = 1
|
||||
fastanime_runtime_state.current_data_loader = AniList.get_most_popular
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,39 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Get random anime from anilist based on a range of anilist anime ids that are selected at random",
|
||||
short_help="View random anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def random_anime(config, dump_json):
|
||||
import random
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
random_anime = range(1, 100000)
|
||||
|
||||
random_anime = random.sample(random_anime, k=50)
|
||||
|
||||
anime_data = AniList.search(id_in=list(random_anime))
|
||||
|
||||
if anime_data[0]:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
exit(1)
|
||||
@@ -1,39 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the 15 most recently updated anime from anilist that are currently releasing",
|
||||
short_help="View recently updated anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def recent(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
anime_data = AniList.get_most_recently_updated()
|
||||
if anime_data[0]:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
|
||||
fastanime_runtime_state.current_page = 1
|
||||
fastanime_runtime_state.current_data_loader = (
|
||||
AniList.get_most_recently_updated
|
||||
)
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,36 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the 15 most scored anime", short_help="View most scored anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def scores(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
anime_data = AniList.get_most_scored()
|
||||
if anime_data[0]:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
|
||||
fastanime_runtime_state.current_page = 1
|
||||
fastanime_runtime_state.current_data_loader = AniList.get_most_scored
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,135 +0,0 @@
|
||||
import click
|
||||
|
||||
from ...completion_functions import anime_titles_shell_complete
|
||||
from .data import (
|
||||
genres_available,
|
||||
media_formats_available,
|
||||
media_statuses_available,
|
||||
seasons_available,
|
||||
sorts_available,
|
||||
tags_available_list,
|
||||
years_available,
|
||||
)
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Search for anime using anilists api and get top ~50 results",
|
||||
short_help="Search for anime",
|
||||
)
|
||||
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.option(
|
||||
"--season",
|
||||
help="The season the media was released",
|
||||
type=click.Choice(seasons_available),
|
||||
)
|
||||
@click.option(
|
||||
"--status",
|
||||
"-S",
|
||||
help="The media status of the anime",
|
||||
multiple=True,
|
||||
type=click.Choice(media_statuses_available),
|
||||
)
|
||||
@click.option(
|
||||
"--sort",
|
||||
"-s",
|
||||
help="What to sort the search results on",
|
||||
type=click.Choice(sorts_available),
|
||||
)
|
||||
@click.option(
|
||||
"--genres",
|
||||
"-g",
|
||||
multiple=True,
|
||||
help="the genres to filter by",
|
||||
type=click.Choice(genres_available),
|
||||
)
|
||||
@click.option(
|
||||
"--tags",
|
||||
"-T",
|
||||
multiple=True,
|
||||
help="the tags to filter by",
|
||||
type=click.Choice(tags_available_list),
|
||||
)
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
help="Media format",
|
||||
type=click.Choice(media_formats_available),
|
||||
)
|
||||
@click.option(
|
||||
"--year",
|
||||
"-y",
|
||||
type=click.Choice(years_available),
|
||||
help="the year the media was released",
|
||||
)
|
||||
@click.option(
|
||||
"--on-list/--not-on-list",
|
||||
"-L/-no-L",
|
||||
help="Whether the anime should be in your list or not",
|
||||
type=bool,
|
||||
)
|
||||
@click.pass_obj
|
||||
def search(
|
||||
config,
|
||||
title,
|
||||
dump_json,
|
||||
season,
|
||||
status,
|
||||
sort,
|
||||
genres,
|
||||
tags,
|
||||
media_format,
|
||||
year,
|
||||
on_list,
|
||||
):
|
||||
from ....anilist import AniList
|
||||
|
||||
success, search_results = AniList.search(
|
||||
query=title,
|
||||
sort=sort,
|
||||
status_in=list(status),
|
||||
genre_in=list(genres),
|
||||
season=season,
|
||||
tag_in=list(tags),
|
||||
seasonYear=year,
|
||||
format_in=list(media_format),
|
||||
on_list=on_list,
|
||||
)
|
||||
if success:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(search_results))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
|
||||
fastanime_runtime_state.current_page = 1
|
||||
fastanime_runtime_state.current_data_loader = (
|
||||
lambda page=1, **kwargs: AniList.search(
|
||||
query=title,
|
||||
sort=sort,
|
||||
status_in=list(status),
|
||||
genre_in=list(genres),
|
||||
season=season,
|
||||
tag_in=list(tags),
|
||||
seasonYear=year,
|
||||
format_in=list(media_format),
|
||||
on_list=on_list,
|
||||
page=page,
|
||||
)
|
||||
)
|
||||
fastanime_runtime_state.anilist_results_data = search_results
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,63 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="Print out your anilist stats")
|
||||
@click.pass_obj
|
||||
def stats(
|
||||
config: "Config",
|
||||
):
|
||||
import shutil
|
||||
import subprocess
|
||||
from sys import exit
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
user_data = AniList.get_user_info()
|
||||
if not user_data[0] or not user_data[1]:
|
||||
print("Failed to get user info")
|
||||
print(user_data[1])
|
||||
exit(1)
|
||||
|
||||
KITTEN_EXECUTABLE = shutil.which("kitten")
|
||||
if not KITTEN_EXECUTABLE:
|
||||
print("Kitten not found")
|
||||
exit(1)
|
||||
|
||||
image_url = user_data[1]["data"]["User"]["avatar"]["medium"]
|
||||
user_name = user_data[1]["data"]["User"]["name"]
|
||||
about = user_data[1]["data"]["User"]["about"] or ""
|
||||
console.clear()
|
||||
image_x = int(console.size.width * 0.1)
|
||||
image_y = int(console.size.height * 0.1)
|
||||
img_w = console.size.width // 3
|
||||
img_h = console.size.height // 3
|
||||
image_process = subprocess.run(
|
||||
[
|
||||
KITTEN_EXECUTABLE,
|
||||
"icat",
|
||||
"--clear",
|
||||
"--place",
|
||||
f"{img_w}x{img_h}@{image_x}x{image_y}",
|
||||
image_url,
|
||||
],
|
||||
)
|
||||
if not image_process.returncode == 0:
|
||||
print("failed to get image from icat")
|
||||
exit(1)
|
||||
console.print(
|
||||
Panel(
|
||||
Markdown(about),
|
||||
title=user_name,
|
||||
)
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the top 15 anime that are currently trending",
|
||||
short_help="Trending anime 🔥🔥🔥",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def trending(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
success, data = AniList.get_trending()
|
||||
if success:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(data))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
|
||||
fastanime_runtime_state.current_page = 1
|
||||
fastanime_runtime_state.current_data_loader = AniList.get_trending
|
||||
fastanime_runtime_state.anilist_results_data = data
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,36 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Fetch the 15 most anticipated anime", short_help="View upcoming anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def upcoming(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
|
||||
success, data = AniList.get_upcoming_anime()
|
||||
if success:
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(data))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
|
||||
fastanime_runtime_state.current_page = 1
|
||||
fastanime_runtime_state.current_data_loader = AniList.get_upcoming_anime
|
||||
fastanime_runtime_state.anilist_results_data = data
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
@@ -1,53 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="View anime you are watching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def watching(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("CURRENT")
|
||||
if not anime_list:
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
|
||||
fastanime_runtime_state.current_page = 1
|
||||
fastanime_runtime_state.current_data_loader = (
|
||||
lambda config, **kwargs: anilist_interfaces._handle_animelist(
|
||||
config, fastanime_runtime_state, "Watching", **kwargs
|
||||
)
|
||||
)
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
@@ -1,56 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Helper command to manage cache",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# delete everything in the cache dir
|
||||
fastanime cache --clean
|
||||
\b
|
||||
# print the path to the cache dir and exit
|
||||
fastanime cache --path
|
||||
\b
|
||||
# print the current size of the cache dir and exit
|
||||
fastanime cache --size
|
||||
\b
|
||||
# open the cache dir and exit
|
||||
fastanime cache
|
||||
""",
|
||||
)
|
||||
@click.option("--clean", help="Clean the cache dir", is_flag=True)
|
||||
@click.option("--path", help="The path to the cache dir", is_flag=True)
|
||||
@click.option("--size", help="The size of the cache dir", is_flag=True)
|
||||
def cache(clean, path, size):
|
||||
from ...constants import APP_CACHE_DIR
|
||||
|
||||
if path:
|
||||
print(APP_CACHE_DIR)
|
||||
elif clean:
|
||||
import shutil
|
||||
|
||||
from rich.prompt import Confirm
|
||||
|
||||
if Confirm.ask(
|
||||
f"Are you sure you want to clean the following path: {APP_CACHE_DIR};(NOTE: !!The action is irreversible and will clean your cache!!)",
|
||||
default=False,
|
||||
):
|
||||
print("Cleaning...")
|
||||
shutil.rmtree(APP_CACHE_DIR)
|
||||
print("Successfully removed: ", APP_CACHE_DIR)
|
||||
elif size:
|
||||
import os
|
||||
|
||||
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: ", format_bytes_to_human(total_size))
|
||||
else:
|
||||
import click
|
||||
|
||||
click.launch(APP_CACHE_DIR)
|
||||
@@ -37,7 +37,7 @@ def completions(fish, zsh, bash):
|
||||
current_shell = None
|
||||
else:
|
||||
current_shell = None
|
||||
if fish or current_shell == "fish" and not zsh and not bash:
|
||||
if fish or (current_shell == "fish" and not zsh and not bash):
|
||||
print(
|
||||
"""
|
||||
function _fastanime_completion;
|
||||
@@ -59,7 +59,7 @@ end;
|
||||
complete --no-files --command fastanime --arguments "(_fastanime_completion)";
|
||||
"""
|
||||
)
|
||||
elif zsh or current_shell == "zsh" and not bash:
|
||||
elif zsh or (current_shell == "zsh" and not bash):
|
||||
print(
|
||||
"""
|
||||
#compdef fastanime
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
from ...core.config import AppConfig
|
||||
|
||||
|
||||
@click.command(
|
||||
@@ -15,6 +12,9 @@ if TYPE_CHECKING:
|
||||
# Edit your config in your default editor
|
||||
# NB: If it opens vim or vi exit with `:q`
|
||||
fastanime config
|
||||
\b
|
||||
# Start the interactive configuration wizard
|
||||
fastanime config --interactive
|
||||
\b
|
||||
# get the path of the config file
|
||||
fastanime config --path
|
||||
@@ -33,6 +33,12 @@ if TYPE_CHECKING:
|
||||
@click.option(
|
||||
"--view", "-v", help="View the current contents of your config", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--view-json",
|
||||
"-vj",
|
||||
help="View the current contents of your config in json format",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--desktop-entry",
|
||||
"-d",
|
||||
@@ -45,76 +51,114 @@ if TYPE_CHECKING:
|
||||
help="Persist all the config options passed to fastanime to your config file",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--interactive",
|
||||
"-i",
|
||||
is_flag=True,
|
||||
help="Start the interactive configuration wizard.",
|
||||
)
|
||||
@click.pass_obj
|
||||
def config(user_config: "Config", path, view, desktop_entry, update):
|
||||
import sys
|
||||
|
||||
from rich import print
|
||||
|
||||
from ... import __version__
|
||||
from ...constants import APP_NAME, ICON_PATH, S_PLATFORM, USER_CONFIG_PATH
|
||||
def config(
|
||||
user_config: AppConfig, path, view, view_json, desktop_entry, update, interactive
|
||||
):
|
||||
from ...core.constants import USER_CONFIG_PATH
|
||||
from ..config.generate import generate_config_ini_from_app_model
|
||||
from ..config.editor import InteractiveConfigEditor
|
||||
|
||||
if path:
|
||||
print(USER_CONFIG_PATH)
|
||||
elif view:
|
||||
print(user_config)
|
||||
from rich.console import Console
|
||||
from rich.syntax import Syntax
|
||||
|
||||
console = Console()
|
||||
config_ini = generate_config_ini_from_app_model(user_config)
|
||||
syntax = Syntax(
|
||||
config_ini,
|
||||
"ini",
|
||||
theme=user_config.general.pygment_style,
|
||||
line_numbers=True,
|
||||
word_wrap=True,
|
||||
)
|
||||
console.print(syntax)
|
||||
elif view_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(user_config.model_dump(mode="json")))
|
||||
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 = 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:
|
||||
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)
|
||||
_generate_desktop_entry()
|
||||
elif interactive:
|
||||
editor = InteractiveConfigEditor(current_config=user_config)
|
||||
new_config = editor.run()
|
||||
with open(USER_CONFIG_PATH, "w", encoding="utf-8") as file:
|
||||
file.write(generate_config_ini_from_app_model(new_config))
|
||||
click.echo(f"Configuration saved successfully to {USER_CONFIG_PATH}")
|
||||
elif update:
|
||||
with open(USER_CONFIG_PATH, "w", encoding="utf-8") as file:
|
||||
file.write(user_config.__str__())
|
||||
file.write(generate_config_ini_from_app_model(user_config))
|
||||
print("update successfull")
|
||||
else:
|
||||
click.edit(filename=USER_CONFIG_PATH)
|
||||
click.edit(filename=str(USER_CONFIG_PATH))
|
||||
|
||||
|
||||
def _generate_desktop_entry():
|
||||
"""
|
||||
Generates a desktop entry for FastAnime.
|
||||
"""
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
from rich import print
|
||||
from rich.prompt import Confirm
|
||||
|
||||
from ...core.constants import (
|
||||
ICON_PATH,
|
||||
PLATFORM,
|
||||
PROJECT_NAME,
|
||||
USER_APPLICATIONS,
|
||||
__version__,
|
||||
)
|
||||
|
||||
EXECUTABLE = shutil.which("fastanime")
|
||||
if EXECUTABLE:
|
||||
cmds = f"{EXECUTABLE} --rofi anilist"
|
||||
else:
|
||||
cmds = f"{sys.executable} -m fastanime --rofi anilist"
|
||||
|
||||
# TODO: Get funs of the other platforms to complete this lol
|
||||
if 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 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:
|
||||
desktop_entry = dedent(
|
||||
f"""
|
||||
[Desktop Entry]
|
||||
Name={PROJECT_NAME}
|
||||
Type=Application
|
||||
version={__version__}
|
||||
Path={Path().home()}
|
||||
Comment=Watch anime from your terminal
|
||||
Terminal=false
|
||||
Icon={ICON_PATH}
|
||||
Exec={cmds}
|
||||
Categories=Entertainment
|
||||
"""
|
||||
)
|
||||
desktop_entry_path = USER_APPLICATIONS / f"{PROJECT_NAME}.desktop"
|
||||
if desktop_entry_path.exists():
|
||||
if not Confirm.ask(
|
||||
f"The file already exists {desktop_entry_path}; or would you like to rewrite it",
|
||||
default=False,
|
||||
):
|
||||
return
|
||||
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()}")
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import downloaded_anime_titles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="View and watch your downloads using mpv",
|
||||
short_help="Watch downloads",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
fastanime downloads
|
||||
\b
|
||||
# view individual episodes
|
||||
fastanime downloads --view-episodes
|
||||
# --- or ---
|
||||
fastanime downloads -v
|
||||
\b
|
||||
# to set seek time when using ffmpegthumbnailer for local previews
|
||||
# -1 means random and is the default
|
||||
fastanime downloads --time-to-seek <intRange(-1,100)>
|
||||
# --- or ---
|
||||
fastanime downloads -t <intRange(-1,100)>
|
||||
\b
|
||||
# to watch a specific title
|
||||
# be sure to get the completions for the best experience
|
||||
fastanime downloads --title <title>
|
||||
\b
|
||||
# to get the path to the downloads folder set
|
||||
fastanime downloads --path
|
||||
# useful when you want to use the value for other programs
|
||||
""",
|
||||
)
|
||||
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
|
||||
@click.option(
|
||||
"--title",
|
||||
"-T",
|
||||
shell_complete=downloaded_anime_titles,
|
||||
help="watch a specific title",
|
||||
)
|
||||
@click.option("--view-episodes", "-v", help="View individual episodes", is_flag=True)
|
||||
@click.option(
|
||||
"--ffmpegthumbnailer-seek-time",
|
||||
"--time-to-seek",
|
||||
"-t",
|
||||
type=click.IntRange(-1, 100),
|
||||
help="ffmpegthumbnailer seek time",
|
||||
)
|
||||
@click.pass_obj
|
||||
def downloads(
|
||||
config: "Config", path: bool, title, view_episodes, ffmpegthumbnailer_seek_time
|
||||
):
|
||||
import os
|
||||
|
||||
from ...cli.utils.mpv import run_mpv
|
||||
from ...libs.fzf import fzf
|
||||
from ...libs.rofi import Rofi
|
||||
from ...Utility.utils import sort_by_episode_number
|
||||
from ..utils.tools import exit_app
|
||||
from ..utils.utils import fuzzy_inquirer
|
||||
|
||||
if not ffmpegthumbnailer_seek_time:
|
||||
ffmpegthumbnailer_seek_time = config.ffmpegthumbnailer_seek_time
|
||||
USER_VIDEOS_DIR = config.downloads_dir
|
||||
if path:
|
||||
print(USER_VIDEOS_DIR)
|
||||
return
|
||||
if not os.path.exists(USER_VIDEOS_DIR):
|
||||
print("Downloads directory specified does not exist")
|
||||
return
|
||||
anime_downloads = sorted(
|
||||
os.listdir(USER_VIDEOS_DIR),
|
||||
)
|
||||
anime_downloads.append("Exit")
|
||||
|
||||
def create_thumbnails(video_path, anime_title, downloads_thumbnail_cache_dir):
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
FFMPEG_THUMBNAILER = shutil.which("ffmpegthumbnailer")
|
||||
if not FFMPEG_THUMBNAILER:
|
||||
return
|
||||
|
||||
out = os.path.join(downloads_thumbnail_cache_dir, anime_title)
|
||||
if ffmpegthumbnailer_seek_time == -1:
|
||||
import random
|
||||
|
||||
seektime = str(random.randrange(0, 100))
|
||||
else:
|
||||
seektime = str(ffmpegthumbnailer_seek_time)
|
||||
_ = subprocess.run(
|
||||
[
|
||||
FFMPEG_THUMBNAILER,
|
||||
"-i",
|
||||
video_path,
|
||||
"-o",
|
||||
out,
|
||||
"-s",
|
||||
"0",
|
||||
"-t",
|
||||
seektime,
|
||||
],
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
|
||||
def get_previews_anime(workers=None, bg=True):
|
||||
import concurrent.futures
|
||||
import random
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
from ...constants import APP_CACHE_DIR
|
||||
from ..utils.scripts import bash_functions
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
# 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 anime_downloads:
|
||||
anime_path = os.path.join(USER_VIDEOS_DIR, anime_title)
|
||||
if not os.path.isdir(anime_path):
|
||||
continue
|
||||
playlist = [
|
||||
anime
|
||||
for anime in sorted(
|
||||
os.listdir(anime_path),
|
||||
)
|
||||
if "mp4" in anime
|
||||
]
|
||||
if playlist:
|
||||
# actual link to download image from
|
||||
video_path = os.path.join(anime_path, random.choice(playlist))
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
video_path,
|
||||
anime_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = anime_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
bash_functions,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def get_previews_episodes(anime_playlist_path, workers=None, bg=True):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ...constants import APP_CACHE_DIR
|
||||
from ..utils.scripts import bash_functions
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
import concurrent.futures
|
||||
|
||||
# use concurrency to download the images as fast as possible
|
||||
# anime_playlist_path = os.path.join(USER_VIDEOS_DIR, anime_playlist_path)
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
return
|
||||
anime_episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for episode_title in anime_episodes:
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
|
||||
# actual link to download image from
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
episode_path,
|
||||
episode_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = episode_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
bash_functions,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def stream_episode(
|
||||
anime_playlist_path,
|
||||
):
|
||||
if view_episodes:
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
print(anime_playlist_path, "is not dir")
|
||||
exit_app(1)
|
||||
return
|
||||
episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
downloaded_episodes = [*episodes, "Back"]
|
||||
|
||||
if config.use_fzf:
|
||||
if not config.preview:
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_episodes(anime_playlist_path)
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
episode_title = Rofi.run(downloaded_episodes, "Enter Episode")
|
||||
else:
|
||||
episode_title = fuzzy_inquirer(
|
||||
downloaded_episodes,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
if episode_title == "Back":
|
||||
stream_anime()
|
||||
return
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(episode_path)
|
||||
else:
|
||||
run_mpv(
|
||||
episode_path,
|
||||
player=config.player,
|
||||
)
|
||||
stream_episode(anime_playlist_path)
|
||||
|
||||
def stream_anime(title=None):
|
||||
if title:
|
||||
from thefuzz import fuzz
|
||||
|
||||
playlist_name = max(anime_downloads, key=lambda t: fuzz.ratio(title, t))
|
||||
elif config.use_fzf:
|
||||
if not config.preview:
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_anime()
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
playlist_name = Rofi.run(anime_downloads, "Enter Playlist Name")
|
||||
else:
|
||||
playlist_name = fuzzy_inquirer(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
if playlist_name == "Exit":
|
||||
exit_app()
|
||||
return
|
||||
playlist = os.path.join(USER_VIDEOS_DIR, playlist_name)
|
||||
if view_episodes:
|
||||
stream_episode(
|
||||
playlist,
|
||||
)
|
||||
else:
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(playlist)
|
||||
else:
|
||||
run_mpv(
|
||||
playlist,
|
||||
player=config.player,
|
||||
)
|
||||
stream_anime()
|
||||
|
||||
stream_anime(title)
|
||||
70
fastanime/cli/commands/examples.py
Normal file
70
fastanime/cli/commands/examples.py
Normal file
@@ -0,0 +1,70 @@
|
||||
download = """
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Download all available episodes
|
||||
# multiple titles can be specified with -t option
|
||||
fastanime download -t <anime-title> -t <anime-title>
|
||||
# -- or --
|
||||
fastanime download -t <anime-title> -t <anime-title> -r ':'
|
||||
\b
|
||||
# download latest episode for the two anime titles
|
||||
# the number can be any no of latest episodes but a minus sign
|
||||
# must be present
|
||||
fastanime download -t <anime-title> -t <anime-title> -r '-1'
|
||||
\b
|
||||
# latest 5
|
||||
fastanime download -t <anime-title> -t <anime-title> -r '-5'
|
||||
\b
|
||||
# Download specific episode range
|
||||
# be sure to observe the range Syntax
|
||||
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
|
||||
\b
|
||||
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>'
|
||||
\b
|
||||
fastanime download -t <anime-title> -r '<episodes-start>:'
|
||||
\b
|
||||
fastanime download -t <anime-title> -r ':<episodes-end>'
|
||||
\b
|
||||
# download specific episode
|
||||
# remember python indexing starts at 0
|
||||
fastanime download -t <anime-title> -r '<episode-1>:<episode>'
|
||||
\b
|
||||
# merge subtitles with ffmpeg to mkv format; hianime tends to give subs as separate files
|
||||
# and dont prompt for anything
|
||||
# eg existing file in destination instead remove
|
||||
# and clean
|
||||
# ie remove original files (sub file and vid file)
|
||||
# only keep merged files
|
||||
fastanime download -t <anime-title> --merge --clean --no-prompt
|
||||
\b
|
||||
# EOF is used since -t always expects a title
|
||||
# you can supply anime titles from file or -t at the same time
|
||||
# from stdin
|
||||
echo -e "<anime-title>\\n<anime-title>\\n<anime-title>" | fastanime download -t "EOF" -r <range> -f -
|
||||
\b
|
||||
# from file
|
||||
fastanime download -t "EOF" -r <range> -f <file-path>
|
||||
"""
|
||||
search = """
|
||||
\b
|
||||
\b\bExamples:
|
||||
# basic form where you will still be prompted for the episode number
|
||||
# multiple titles can be specified with the -t option
|
||||
fastanime search -t <anime-title> -t <anime-title>
|
||||
\b
|
||||
# binge all episodes with this command
|
||||
fastanime search -t <anime-title> -r ':'
|
||||
\b
|
||||
# watch latest episode
|
||||
fastanime search -t <anime-title> -r '-1'
|
||||
\b
|
||||
# binge a specific episode range with this command
|
||||
# be sure to observe the range Syntax
|
||||
fastanime search -t <anime-title> -r '<start>:<stop>'
|
||||
\b
|
||||
fastanime search -t <anime-title> -r '<start>:<stop>:<step>'
|
||||
\b
|
||||
fastanime search -t <anime-title> -r '<start>:'
|
||||
\b
|
||||
fastanime search -t <anime-title> -r ':<end>'
|
||||
"""
|
||||
@@ -1,239 +0,0 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import anime_titles_shell_complete
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Helper command to get streams for anime to use externally in a non-python application",
|
||||
short_help="Print anime streams to standard out",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# --- print anime info + episode streams ---
|
||||
\b
|
||||
# multiple titles can be specified with the -t option
|
||||
fastanime grab -t <anime-title> -t <anime-title>
|
||||
# -- or --
|
||||
# print all available episodes
|
||||
fastanime grab -t <anime-title> -r ':'
|
||||
\b
|
||||
# print the latest episode
|
||||
fastanime grab -t <anime-title> -r '-1'
|
||||
\b
|
||||
# print a specific episode range
|
||||
# be sure to observe the range Syntax
|
||||
fastanime grab -t <anime-title> -r '<start>:<stop>'
|
||||
\b
|
||||
fastanime grab -t <anime-title> -r '<start>:<stop>:<step>'
|
||||
\b
|
||||
fastanime grab -t <anime-title> -r '<start>:'
|
||||
\b
|
||||
fastanime grab -t <anime-title> -r ':<end>'
|
||||
\b
|
||||
# --- grab options ---
|
||||
\b
|
||||
# print search results only
|
||||
fastanime grab -t <anime-title> -r <range> --search-results-only
|
||||
\b
|
||||
# print anime info only
|
||||
fastanime grab -t <anime-title> -r <range> --anime-info-only
|
||||
\b
|
||||
# print episode streams only
|
||||
fastanime grab -t <anime-title> -r <range> --episode-streams-only
|
||||
""",
|
||||
)
|
||||
@click.option(
|
||||
"--anime-titles",
|
||||
"--anime_title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to download (start-end)",
|
||||
)
|
||||
@click.option(
|
||||
"--search-results-only",
|
||||
"-s",
|
||||
help="print only the search results to stdout",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--anime-info-only", "-i", help="print only selected anime title info", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--episode-streams-only",
|
||||
"-e",
|
||||
help="print only selected anime episodes streams of given range",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.pass_obj
|
||||
def grab(
|
||||
config: "Config",
|
||||
anime_titles: tuple,
|
||||
episode_range,
|
||||
search_results_only,
|
||||
anime_info_only,
|
||||
episode_streams_only,
|
||||
):
|
||||
import json
|
||||
from logging import getLogger
|
||||
from sys import exit
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
logger = getLogger(__name__)
|
||||
if config.manga:
|
||||
manga_title = anime_titles[0]
|
||||
from ...MangaProvider import MangaProvider
|
||||
|
||||
manga_provider = MangaProvider()
|
||||
search_data = manga_provider.search_for_manga(manga_title)
|
||||
if not search_data:
|
||||
exit(1)
|
||||
if search_results_only:
|
||||
print(json.dumps(search_data))
|
||||
exit(0)
|
||||
search_results = search_data["results"]
|
||||
if not search_results:
|
||||
logger.error("no results for your search")
|
||||
exit(1)
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result for search_result in search_results
|
||||
}
|
||||
|
||||
search_result_anime_title = max(
|
||||
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_titles[0])
|
||||
)
|
||||
manga_info = manga_provider.get_manga(
|
||||
search_results_[search_result_anime_title]["id"]
|
||||
)
|
||||
if not manga_info:
|
||||
return
|
||||
if anime_info_only:
|
||||
print(json.dumps(manga_info))
|
||||
exit(0)
|
||||
|
||||
chapter_info = manga_provider.get_chapter_thumbnails(
|
||||
manga_info["id"], str(episode_range)
|
||||
)
|
||||
if not chapter_info:
|
||||
exit(1)
|
||||
print(json.dumps(chapter_info))
|
||||
|
||||
else:
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
|
||||
grabbed_animes = []
|
||||
for anime_title in anime_titles:
|
||||
# ---- search for anime ----
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, translation_type=config.translation_type
|
||||
)
|
||||
if not search_results:
|
||||
exit(1)
|
||||
if search_results_only:
|
||||
# grab only search results skipping all lines after this
|
||||
grabbed_animes.append(search_results)
|
||||
continue
|
||||
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
logger.error("no results for your search")
|
||||
exit(1)
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result
|
||||
for search_result in search_results
|
||||
}
|
||||
|
||||
search_result_anime_title = max(
|
||||
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
|
||||
)
|
||||
|
||||
# ---- fetch anime ----
|
||||
anime = anime_provider.get_anime(
|
||||
search_results_[search_result_anime_title]["id"]
|
||||
)
|
||||
if not anime:
|
||||
exit(1)
|
||||
if anime_info_only:
|
||||
# grab only the anime data skipping all lines after this
|
||||
grabbed_animes.append(anime)
|
||||
continue
|
||||
episodes = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
|
||||
# where the magic happens
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end)
|
||||
]
|
||||
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
|
||||
else:
|
||||
episodes_range = sorted(episodes, key=float)
|
||||
|
||||
if not episode_streams_only:
|
||||
grabbed_anime = dict(anime)
|
||||
grabbed_anime["requested_episodes"] = episodes_range
|
||||
grabbed_anime["translation_type"] = config.translation_type
|
||||
grabbed_anime["episodes_streams"] = {}
|
||||
else:
|
||||
grabbed_anime = {}
|
||||
|
||||
# lets download em
|
||||
for episode in episodes_range:
|
||||
if episode not in episodes:
|
||||
continue
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime["id"], episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
continue
|
||||
episode_streams = {server["server"]: server for server in streams}
|
||||
|
||||
if episode_streams_only:
|
||||
grabbed_anime[episode] = episode_streams
|
||||
else:
|
||||
grabbed_anime["episodes_streams"][ # pyright:ignore
|
||||
episode
|
||||
] = episode_streams
|
||||
|
||||
# grab the full data for single title and appen to final result or episode streams
|
||||
grabbed_animes.append(grabbed_anime)
|
||||
|
||||
# print out the final result either {} or [] depending if more than one title os requested
|
||||
if len(grabbed_animes) == 1:
|
||||
print(json.dumps(grabbed_animes[0]))
|
||||
else:
|
||||
print(json.dumps(grabbed_animes))
|
||||
@@ -2,42 +2,32 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import anime_titles_shell_complete
|
||||
from ...core.config import AppConfig
|
||||
from ...core.exceptions import FastAnimeError
|
||||
from ..utils.completions import anime_titles_shell_complete
|
||||
from . import examples
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...cli.config import Config
|
||||
from typing import TypedDict
|
||||
|
||||
from typing_extensions import Unpack
|
||||
|
||||
from ...libs.providers.anime.base import BaseAnimeProvider
|
||||
from ...libs.providers.anime.types import Anime
|
||||
from ...libs.selectors.base import BaseSelector
|
||||
|
||||
class Options(TypedDict):
|
||||
anime_title: list[str]
|
||||
episode_range: str | None
|
||||
|
||||
|
||||
@click.command(
|
||||
help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.",
|
||||
short_help="Binge anime",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# basic form where you will still be prompted for the episode number
|
||||
# multiple titles can be specified with the -t option
|
||||
fastanime search -t <anime-title> -t <anime-title>
|
||||
\b
|
||||
# binge all episodes with this command
|
||||
fastanime search -t <anime-title> -r ':'
|
||||
\b
|
||||
# watch latest episode
|
||||
fastanime search -t <anime-title> -r '-1'
|
||||
\b
|
||||
# binge a specific episode range with this command
|
||||
# be sure to observe the range Syntax
|
||||
fastanime search -t <anime-title> -r '<start>:<stop>'
|
||||
\b
|
||||
fastanime search -t <anime-title> -r '<start>:<stop>:<step>'
|
||||
\b
|
||||
fastanime search -t <anime-title> -r '<start>:'
|
||||
\b
|
||||
fastanime search -t <anime-title> -r ':<end>'
|
||||
""",
|
||||
epilog=examples.search,
|
||||
)
|
||||
@click.option(
|
||||
"--anime-titles",
|
||||
"--anime_title",
|
||||
"--anime-title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
@@ -50,343 +40,159 @@ if TYPE_CHECKING:
|
||||
help="A range of episodes to binge (start-end)",
|
||||
)
|
||||
@click.pass_obj
|
||||
def search(config: "Config", anime_titles: str, episode_range: str):
|
||||
from click import clear
|
||||
def search(config: AppConfig, **options: "Unpack[Options]"):
|
||||
from rich import print
|
||||
from rich.progress import Progress
|
||||
from thefuzz import fuzz
|
||||
|
||||
from ...libs.fzf import fzf
|
||||
from ...libs.rofi import Rofi
|
||||
from ..utils.tools import exit_app
|
||||
from ..utils.utils import fuzzy_inquirer
|
||||
from ...core.exceptions import FastAnimeError
|
||||
from ...libs.providers.anime.params import (
|
||||
AnimeParams,
|
||||
SearchParams,
|
||||
)
|
||||
from ...libs.providers.anime.provider import create_provider
|
||||
from ...libs.selectors.selector import create_selector
|
||||
|
||||
if config.manga:
|
||||
from InquirerPy.prompts.number import NumberPrompt
|
||||
from yt_dlp.utils import sanitize_filename
|
||||
provider = create_provider(config.general.provider)
|
||||
selector = create_selector(config)
|
||||
|
||||
from ...MangaProvider import MangaProvider
|
||||
anime_titles = options["anime_title"]
|
||||
print(f"[green bold]Streaming:[/] {anime_titles}")
|
||||
for anime_title in anime_titles:
|
||||
# ---- search for anime ----
|
||||
print(f"[green bold]Searching for:[/] {anime_title}")
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Search Results...", total=None)
|
||||
search_results = provider.search(
|
||||
SearchParams(
|
||||
query=anime_title, translation_type=config.stream.translation_type
|
||||
)
|
||||
)
|
||||
if not search_results:
|
||||
raise FastAnimeError("No results were found matching your query")
|
||||
|
||||
from ..utils.feh import feh_manga_viewer
|
||||
from ..utils.icat import icat_manga_viewer
|
||||
|
||||
manga_title = anime_titles[0]
|
||||
|
||||
manga_provider = MangaProvider()
|
||||
search_data = manga_provider.search_for_manga(manga_title)
|
||||
if not search_data:
|
||||
print("No search results")
|
||||
exit(1)
|
||||
|
||||
search_results = search_data["results"]
|
||||
|
||||
search_results_ = {
|
||||
sanitize_filename(search_result["title"]): search_result
|
||||
for search_result in search_results
|
||||
_search_results = {
|
||||
search_result.title: search_result
|
||||
for search_result in search_results.results
|
||||
}
|
||||
|
||||
if config.auto_select:
|
||||
search_result_manga_title = max(
|
||||
search_results_.keys(),
|
||||
key=lambda title: fuzz.ratio(title, manga_title),
|
||||
)
|
||||
print("[cyan]Auto Selecting:[/] ", search_result_manga_title)
|
||||
selected_anime_title = selector.choose(
|
||||
"Select Anime", list(_search_results.keys())
|
||||
)
|
||||
if not selected_anime_title:
|
||||
raise FastAnimeError("No title selected")
|
||||
anime_result = _search_results[selected_anime_title]
|
||||
|
||||
# ---- fetch selected anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Anime...", total=None)
|
||||
anime = provider.get(AnimeParams(id=anime_result.id, query=anime_title))
|
||||
|
||||
if not anime:
|
||||
raise FastAnimeError(f"Failed to fetch anime {anime_result.title}")
|
||||
episodes_range = []
|
||||
episodes: list[str] = sorted(
|
||||
getattr(anime.episodes, config.stream.translation_type), key=float
|
||||
)
|
||||
if options["episode_range"]:
|
||||
if ":" in options["episode_range"]:
|
||||
ep_range_tuple = options["episode_range"].split(":")
|
||||
if len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
|
||||
elif len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(options["episode_range"]) :]
|
||||
|
||||
episodes_range = iter(episodes_range)
|
||||
|
||||
for episode in episodes_range:
|
||||
stream_anime(config, provider, selector, anime, episode, anime_title)
|
||||
else:
|
||||
choices = list(search_results_.keys())
|
||||
preview = None
|
||||
if config.preview:
|
||||
from ..interfaces.utils import get_fzf_manga_preview
|
||||
episode = selector.choose(
|
||||
"Select Episode",
|
||||
getattr(anime.episodes, config.stream.translation_type),
|
||||
)
|
||||
if not episode:
|
||||
raise FastAnimeError("No episode selected")
|
||||
stream_anime(config, provider, selector, anime, episode, anime_title)
|
||||
|
||||
preview = get_fzf_manga_preview(search_results)
|
||||
if config.use_fzf:
|
||||
search_result_manga_title = fzf.run(
|
||||
choices, "Please Select title", preview=preview
|
||||
)
|
||||
elif config.use_rofi:
|
||||
search_result_manga_title = Rofi.run(choices, "Please Select Title")
|
||||
else:
|
||||
search_result_manga_title = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select Title",
|
||||
)
|
||||
|
||||
anilist_id = search_results_[search_result_manga_title]["id"]
|
||||
manga_info = manga_provider.get_manga(anilist_id)
|
||||
if not manga_info:
|
||||
print("No manga info")
|
||||
exit(1)
|
||||
def stream_anime(
|
||||
config: AppConfig,
|
||||
provider: "BaseAnimeProvider",
|
||||
selector: "BaseSelector",
|
||||
anime: "Anime",
|
||||
episode: str,
|
||||
anime_title: str,
|
||||
):
|
||||
from rich import print
|
||||
from rich.progress import Progress
|
||||
|
||||
anilist_helper = None
|
||||
if config.user:
|
||||
from ...anilist import AniList
|
||||
from ...libs.players.params import PlayerParams
|
||||
from ...libs.players.player import create_player
|
||||
from ...libs.providers.anime.params import EpisodeStreamsParams
|
||||
|
||||
AniList.login_user(config.user["token"])
|
||||
anilist_helper = AniList
|
||||
player = create_player(config)
|
||||
|
||||
def _manga_viewer():
|
||||
chapter_number = NumberPrompt("Select a chapter number").execute()
|
||||
chapter_info = manga_provider.get_chapter_thumbnails(
|
||||
manga_info["id"], str(chapter_number)
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Episode Streams...", total=None)
|
||||
streams = provider.episode_streams(
|
||||
EpisodeStreamsParams(
|
||||
anime_id=anime.id,
|
||||
query=anime_title,
|
||||
episode=episode,
|
||||
translation_type=config.stream.translation_type,
|
||||
)
|
||||
)
|
||||
if not streams:
|
||||
raise FastAnimeError(
|
||||
f"Failed to get streams for anime: {anime.title}, episode: {episode}"
|
||||
)
|
||||
|
||||
if not chapter_info:
|
||||
print("No chapter info")
|
||||
input("Enter to retry...")
|
||||
_manga_viewer()
|
||||
return
|
||||
print(
|
||||
f"[purple bold]Now Reading: [/] {search_result_manga_title} [cyan bold]Chapter:[/] {chapter_info['title']}"
|
||||
)
|
||||
if config.manga_viewer == "feh":
|
||||
feh_manga_viewer(chapter_info["thumbnails"], str(chapter_info["title"]))
|
||||
elif config.manga_viewer == "icat":
|
||||
icat_manga_viewer(
|
||||
chapter_info["thumbnails"], str(chapter_info["title"])
|
||||
if config.stream.server == "TOP":
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching top server...", total=None)
|
||||
server = next(streams, None)
|
||||
if not server:
|
||||
raise FastAnimeError(
|
||||
f"Failed to get server for anime: {anime.title}, episode: {episode}"
|
||||
)
|
||||
if anilist_helper:
|
||||
anilist_helper.update_anime_list(
|
||||
{"mediaId": anilist_id, "progress": chapter_number}
|
||||
)
|
||||
_manga_viewer()
|
||||
|
||||
_manga_viewer()
|
||||
else:
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
from ...libs.anime_provider.types import Anime
|
||||
from ...Utility.data import anime_normalizer
|
||||
from ..utils.mpv import run_mpv
|
||||
from ..utils.utils import filter_by_quality, move_preferred_subtitle_lang_to_top
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
anilist_anime_info = None
|
||||
|
||||
print(f"[green bold]Streaming:[/] {anime_titles}")
|
||||
for anime_title in anime_titles:
|
||||
# ---- search for anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Search Results...", total=None)
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, config.translation_type
|
||||
)
|
||||
if not search_results:
|
||||
print("Search results not found")
|
||||
input("Enter to retry")
|
||||
search(config, anime_title, episode_range)
|
||||
return
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
print("Anime not found :cry:")
|
||||
exit_app()
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result
|
||||
for search_result in search_results
|
||||
}
|
||||
|
||||
if config.auto_select:
|
||||
search_result_manga_title = max(
|
||||
search_results_.keys(),
|
||||
key=lambda title: fuzz.ratio(
|
||||
anime_normalizer.get(title, title), anime_title
|
||||
),
|
||||
)
|
||||
print("[cyan]Auto Selecting:[/] ", search_result_manga_title)
|
||||
|
||||
else:
|
||||
choices = list(search_results_.keys())
|
||||
if config.use_fzf:
|
||||
search_result_manga_title = fzf.run(
|
||||
choices, "Please Select title", "FastAnime"
|
||||
)
|
||||
elif config.use_rofi:
|
||||
search_result_manga_title = Rofi.run(choices, "Please Select Title")
|
||||
else:
|
||||
search_result_manga_title = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select Title",
|
||||
)
|
||||
|
||||
# ---- fetch selected anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Anime...", total=None)
|
||||
anime: Anime | None = anime_provider.get_anime(
|
||||
search_results_[search_result_manga_title]["id"]
|
||||
)
|
||||
|
||||
if not anime:
|
||||
print("Sth went wring anime no found")
|
||||
input("Enter to continue...")
|
||||
search(config, anime_title, episode_range)
|
||||
return
|
||||
episodes_range = []
|
||||
episodes: list[str] = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
|
||||
elif len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
|
||||
episodes_range = iter(episodes_range)
|
||||
|
||||
if config.normalize_titles:
|
||||
from ...libs.common.mini_anilist import get_basic_anime_info_by_title
|
||||
|
||||
anilist_anime_info = get_basic_anime_info_by_title(anime["title"])
|
||||
|
||||
def stream_anime(anime: "Anime"):
|
||||
clear()
|
||||
episode = None
|
||||
|
||||
if episodes_range:
|
||||
try:
|
||||
episode = next(episodes_range) # pyright:ignore
|
||||
print(
|
||||
f"[cyan]Auto selecting:[/] {search_result_manga_title} [cyan]Episode:[/] {episode}"
|
||||
)
|
||||
except StopIteration:
|
||||
print("[green]Completed binge sequence[/]:smile:")
|
||||
return
|
||||
|
||||
if not episode or episode not in episodes:
|
||||
choices = [*episodes, "end"]
|
||||
if config.use_fzf:
|
||||
episode = fzf.run(
|
||||
choices,
|
||||
"Select an episode",
|
||||
header=search_result_manga_title,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
episode = Rofi.run(choices, "Select an episode")
|
||||
else:
|
||||
episode = fuzzy_inquirer(
|
||||
choices,
|
||||
"Select episode",
|
||||
)
|
||||
if episode == "end":
|
||||
return
|
||||
|
||||
# ---- fetch streams ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Episode Streams...", total=None)
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime["id"], episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
print("Failed to get streams")
|
||||
return
|
||||
|
||||
try:
|
||||
# ---- fetch servers ----
|
||||
if config.server == "top":
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching top server...", total=None)
|
||||
server = next(streams, None)
|
||||
if not server:
|
||||
print("Sth went wrong when fetching the episode")
|
||||
input("Enter to continue")
|
||||
stream_anime(anime)
|
||||
return
|
||||
stream_link = filter_by_quality(config.quality, server["links"])
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
input("Enter to continue")
|
||||
stream_anime(anime)
|
||||
return
|
||||
link = stream_link["link"]
|
||||
subtitles = server["subtitles"]
|
||||
stream_headers = server["headers"]
|
||||
episode_title = server["episode_title"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching servers", total=None)
|
||||
# prompt for server selection
|
||||
servers = {server["server"]: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.server in servers_names:
|
||||
server = config.server
|
||||
else:
|
||||
if config.use_fzf:
|
||||
server = fzf.run(servers_names, "Select an link")
|
||||
elif config.use_rofi:
|
||||
server = Rofi.run(servers_names, "Select an link")
|
||||
else:
|
||||
server = fuzzy_inquirer(
|
||||
servers_names,
|
||||
"Select link",
|
||||
)
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, servers[server]["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
input("Enter to continue")
|
||||
stream_anime(anime)
|
||||
return
|
||||
link = stream_link["link"]
|
||||
stream_headers = servers[server]["headers"]
|
||||
subtitles = servers[server]["subtitles"]
|
||||
episode_title = servers[server]["episode_title"]
|
||||
|
||||
selected_anime_title = search_result_manga_title
|
||||
if anilist_anime_info:
|
||||
selected_anime_title = (
|
||||
anilist_anime_info["title"][config.preferred_language]
|
||||
or anilist_anime_info["title"]["romaji"]
|
||||
or anilist_anime_info["title"]["english"]
|
||||
)
|
||||
import re
|
||||
|
||||
for episode_detail in anilist_anime_info["episodes"]:
|
||||
if re.match(f"Episode {episode} ", episode_detail["title"]):
|
||||
episode_title = episode_detail["title"]
|
||||
break
|
||||
print(
|
||||
f"[purple]Now Playing:[/] {selected_anime_title} Episode {episode}"
|
||||
)
|
||||
subtitles = move_preferred_subtitle_lang_to_top(
|
||||
subtitles, config.sub_lang
|
||||
)
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(
|
||||
link,
|
||||
episode_title,
|
||||
headers=stream_headers,
|
||||
subtitles=subtitles,
|
||||
)
|
||||
else:
|
||||
run_mpv(
|
||||
link,
|
||||
episode_title,
|
||||
headers=stream_headers,
|
||||
subtitles=subtitles,
|
||||
player=config.player,
|
||||
)
|
||||
except IndexError as e:
|
||||
print(e)
|
||||
input("Enter to continue")
|
||||
stream_anime(anime)
|
||||
|
||||
stream_anime(anime)
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching servers", total=None)
|
||||
servers = {server.name: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.stream.server in servers_names:
|
||||
server = servers[config.stream.server.value]
|
||||
else:
|
||||
server_name = selector.choose("Select Server", servers_names)
|
||||
if not server_name:
|
||||
raise FastAnimeError("Server not selected")
|
||||
server = servers[server_name]
|
||||
stream_link = server.links[0].link
|
||||
if not stream_link:
|
||||
raise FastAnimeError(
|
||||
f"Failed to get stream link for anime: {anime.title}, episode: {episode}"
|
||||
)
|
||||
print(f"[green bold]Now Streaming:[/] {anime.title} Episode: {episode}")
|
||||
player.play(
|
||||
PlayerParams(
|
||||
url=stream_link,
|
||||
title=f"{anime.title}; Episode {episode}",
|
||||
subtitles=[sub.url for sub in server.subtitles],
|
||||
headers=server.headers,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Command that automates the starting of the builtin fastanime server",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# default
|
||||
fastanime serve
|
||||
|
||||
# specify host and port
|
||||
fastanime serve --host 127.0.0.1 --port 8080
|
||||
""",
|
||||
)
|
||||
@click.option("--host", "-H", help="Specify the host to run the server on")
|
||||
@click.option("--port", "-p", help="Specify the port to run the server on")
|
||||
def serve(host, port):
|
||||
import os
|
||||
import sys
|
||||
|
||||
from ...constants import APP_DIR
|
||||
|
||||
args = [sys.executable, "-m", "fastapi", "run"]
|
||||
if host:
|
||||
args.extend(["--host", host])
|
||||
|
||||
if port:
|
||||
args.extend(["--port", port])
|
||||
args.append(os.path.join(APP_DIR, "api"))
|
||||
os.execv(sys.executable, args)
|
||||
@@ -1,55 +0,0 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Helper command to update fastanime to latest",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# update fastanime to latest
|
||||
fastanime update
|
||||
\b
|
||||
# check for latest release
|
||||
fastanime update --check
|
||||
|
||||
# Force an update regardless of the current version
|
||||
fastanime update --force
|
||||
""",
|
||||
)
|
||||
@click.option("--check", "-c", help="Check for the latest release", is_flag=True)
|
||||
@click.option("--force", "-c", help="Force update", is_flag=True)
|
||||
def update(check, force):
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from ... import __version__
|
||||
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_latest, github_release_data = check_for_updates()
|
||||
if not is_latest:
|
||||
print(
|
||||
f"You are running an older version ({__version__}) of fastanime please update to get the latest features"
|
||||
)
|
||||
_print_release(github_release_data)
|
||||
else:
|
||||
print(f"You are running the latest version ({__version__}) of fastanime")
|
||||
_print_release(github_release_data)
|
||||
else:
|
||||
success, github_release_data = update_app(force)
|
||||
_print_release(github_release_data)
|
||||
if success:
|
||||
print("Successfully updated")
|
||||
else:
|
||||
print("failed to update")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user