Merge branch 'roadmap-to-3.0'

This commit is contained in:
Benexl
2025-07-24 14:35:00 +03:00
285 changed files with 14628 additions and 11359 deletions

31
.github/copilot-instructions.md vendored Normal file
View 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
View File

@@ -20,7 +20,6 @@ build/
develop-eggs/
dist/
bin/
downloads/
eggs/
.eggs/
lib/

7
fa
View File

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

View File

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

View File

@@ -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]
"""

View File

@@ -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"

View File

@@ -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()

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
from .libs.anilist.api import AniListApi
AniList = AniListApi()

View File

@@ -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
)

View File

@@ -0,0 +1,6 @@
███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗
██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝
█████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░
██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░
██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗
╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝

View 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

View File

@@ -0,0 +1,7 @@
query ($showId: String!) {
show(_id: $showId) {
_id
name
availableEpisodesDetail
}
}

View File

@@ -0,0 +1,15 @@
query (
$showId: String!
$translationType: VaildTranslationTypeEnumType!
$episodeString: String!
) {
episode(
showId: $showId
translationType: $translationType
episodeString: $episodeString
) {
episodeString
sourceUrls
notes
}
}

View 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
}
}
}

View File

@@ -0,0 +1,5 @@
mutation ($id: Int) {
DeleteMediaListEntry(id: $id) {
deleted
}
}

View File

@@ -0,0 +1,5 @@
mutation {
UpdateUser {
unreadNotificationCount
}
}

View 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
}
}
}

View 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
}
}
}
}
}

View 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
}
}
}

View 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
}
}
}
}
}

View 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
}
}
}
}

View File

@@ -0,0 +1,5 @@
query ($mediaId: Int) {
MediaList(mediaId: $mediaId) {
id
}
}

View File

@@ -0,0 +1,11 @@
query {
Viewer {
id
name
bannerImage
avatar {
large
medium
}
}
}

View 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
}
}
}

View 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
}
}
}
}
}

View 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
}
}
}
}
}
}

View 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
}
}
}
}

View File

@@ -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
}
}
}
}

View 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
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
query ($id: Int) {
Page {
pageInfo {
total
}
reviews(mediaId: $id) {
summary
user {
name
avatar {
large
medium
}
}
body
}
}
}

View 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
}
}
}
}

View 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
}
}
}
}

View 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
}
}
}
}

View 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
}
}
}
}

View 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
}
}
}
}
}
}

View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 197 KiB

View 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"
}
}

View 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

View 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"

View 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

View 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("&quot;"; "\""))"
'
}
"""

View File

@@ -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
View 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

View File

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

View File

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

View 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)

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

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

View File

@@ -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)

View 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)

View File

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

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

View 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)

View 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()

View 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)

View File

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

View 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)

View 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()

View 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()

View 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",
)

View 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)

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View 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
"""

View File

@@ -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)

View 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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

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

View File

@@ -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()}")

View File

@@ -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)

View 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>'
"""

View File

@@ -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))

View File

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

View File

@@ -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)

View File

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