feat: improve api types

This commit is contained in:
Benexl
2025-07-23 18:48:57 +03:00
parent 6c30cf808b
commit d78b62fcee
19 changed files with 807 additions and 700 deletions

View File

@@ -18,7 +18,7 @@ from rich.table import Table
from rich.text import Text from rich.text import Text
from ....libs.api.params import UpdateListEntryParams, UserListParams from ....libs.api.params import UpdateListEntryParams, UserListParams
from ....libs.api.types import MediaItem, MediaSearchResult, UserListStatusType from ....libs.api.types import MediaItem, MediaSearchResult, UserListItem
from ...utils.feedback import create_feedback_manager, execute_with_feedback from ...utils.feedback import create_feedback_manager, execute_with_feedback
from ..session import Context, session from ..session import Context, session
from ..state import ControlFlow, MediaApiState, State from ..state import ControlFlow, MediaApiState, State
@@ -373,7 +373,7 @@ def _display_anime_list_details(console: Console, anime: MediaItem, icons: bool)
console.print(panel) console.print(panel)
def _navigate_to_list(ctx: Context, list_status: UserListStatusType) -> State: def _navigate_to_list(ctx: Context, list_status: UserListItem) -> State:
"""Navigate to a specific list view.""" """Navigate to a specific list view."""
return State( return State(
menu_name="ANILIST_LIST_VIEW", data={"list_status": list_status, "page": 1} menu_name="ANILIST_LIST_VIEW", data={"list_status": list_status, "page": 1}

View File

@@ -3,7 +3,12 @@ import random
from typing import Callable, Dict, Tuple from typing import Callable, Dict, Tuple
from ....libs.api.params import ApiSearchParams, UserListParams from ....libs.api.params import ApiSearchParams, UserListParams
from ....libs.api.types import MediaSearchResult, MediaStatus, UserListStatusType from ....libs.api.types import (
MediaSearchResult,
MediaSort,
MediaStatus,
UserMediaListStatus,
)
from ..session import Context, session from ..session import Context, session
from ..state import ControlFlow, MediaApiState, State from ..state import ControlFlow, MediaApiState, State
@@ -28,36 +33,44 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
options: Dict[str, MenuAction] = { options: Dict[str, MenuAction] = {
# --- Search-based Actions --- # --- Search-based Actions ---
f"{'🔥 ' if icons else ''}Trending": _create_media_list_action( f"{'🔥 ' if icons else ''}Trending": _create_media_list_action(
ctx, "TRENDING_DESC" ctx, MediaSort.TRENDING_DESC
), ),
f"{'' if icons else ''}Popular": _create_media_list_action( f"{'' if icons else ''}Popular": _create_media_list_action(
ctx, "POPULARITY_DESC" ctx, MediaSort.POPULARITY_DESC
), ),
f"{'💖 ' if icons else ''}Favourites": _create_media_list_action( f"{'💖 ' if icons else ''}Favourites": _create_media_list_action(
ctx, "FAVOURITES_DESC" ctx, MediaSort.FAVOURITES_DESC
), ),
f"{'💯 ' if icons else ''}Top Scored": _create_media_list_action( f"{'💯 ' if icons else ''}Top Scored": _create_media_list_action(
ctx, "SCORE_DESC" ctx, MediaSort.SCORE_DESC
), ),
f"{'🎬 ' if icons else ''}Upcoming": _create_media_list_action( f"{'🎬 ' if icons else ''}Upcoming": _create_media_list_action(
ctx, "POPULARITY_DESC", "NOT_YET_RELEASED" ctx, MediaSort.POPULARITY_DESC, MediaStatus.NOT_YET_RELEASED
), ),
f"{'🔔 ' if icons else ''}Recently Updated": _create_media_list_action( f"{'🔔 ' if icons else ''}Recently Updated": _create_media_list_action(
ctx, "UPDATED_AT_DESC" ctx, MediaSort.UPDATED_AT_DESC
), ),
# --- special case media list -- # --- special case media list --
f"{'🎲 ' if icons else ''}Random": _create_random_media_list(ctx), f"{'🎲 ' if icons else ''}Random": _create_random_media_list(ctx),
f"{'🔎 ' if icons else ''}Search": _create_search_media_list(ctx), f"{'🔎 ' if icons else ''}Search": _create_search_media_list(ctx),
# --- Authenticated User List Actions --- # --- Authenticated User List Actions ---
f"{'📺 ' if icons else ''}Watching": _create_user_list_action(ctx, "watching"), f"{'📺 ' if icons else ''}Watching": _create_user_list_action(
f"{'📑 ' if icons else ''}Planned": _create_user_list_action(ctx, "planning"), ctx, UserMediaListStatus.WATCHING
f"{'' if icons else ''}Completed": _create_user_list_action( ),
ctx, "completed" f"{'📑 ' if icons else ''}Planned": _create_user_list_action(
ctx, UserMediaListStatus.PLANNING
),
f"{'' if icons else ''}Completed": _create_user_list_action(
ctx, UserMediaListStatus.COMPLETED
),
f"{'⏸️ ' if icons else ''}Paused": _create_user_list_action(
ctx, UserMediaListStatus.PAUSED
),
f"{'🚮 ' if icons else ''}Dropped": _create_user_list_action(
ctx, UserMediaListStatus.DROPPED
), ),
f"{'⏸️ ' if icons else ''}Paused": _create_user_list_action(ctx, "paused"),
f"{'🚮 ' if icons else ''}Dropped": _create_user_list_action(ctx, "dropped"),
f"{'🔁 ' if icons else ''}Rewatching": _create_user_list_action( f"{'🔁 ' if icons else ''}Rewatching": _create_user_list_action(
ctx, "repeating" ctx, UserMediaListStatus.REPEATING
), ),
f"{'🔁 ' if icons else ''}Recent": lambda: ( f"{'🔁 ' if icons else ''}Recent": lambda: (
"RESULTS", "RESULTS",
@@ -123,7 +136,7 @@ def main(ctx: Context, state: State) -> State | ControlFlow:
def _create_media_list_action( def _create_media_list_action(
ctx: Context, sort, status: MediaStatus | None = None ctx: Context, sort: MediaSort, status: MediaStatus | None = None
) -> MenuAction: ) -> MenuAction:
"""A factory to create menu actions for fetching media lists""" """A factory to create menu actions for fetching media lists"""
@@ -163,7 +176,7 @@ def _create_search_media_list(ctx: Context) -> MenuAction:
return action return action
def _create_user_list_action(ctx: Context, status: UserListStatusType) -> MenuAction: def _create_user_list_action(ctx: Context, status: UserMediaListStatus) -> MenuAction:
"""A factory to create menu actions for fetching user lists, handling authentication.""" """A factory to create menu actions for fetching user lists, handling authentication."""
def action(): def action():

View File

@@ -1,6 +1,5 @@
from typing import Callable, Dict from typing import Callable, Dict
import click
from rich.console import Console from rich.console import Console
from ....libs.api.params import UpdateListEntryParams from ....libs.api.params import UpdateListEntryParams
@@ -152,7 +151,7 @@ def _view_info(ctx: Context, state: State) -> MenuAction:
console = Console() console = Console()
title = Text(anime.title.english or anime.title.romaji or "", style="bold cyan") title = Text(anime.title.english or anime.title.romaji or "", style="bold cyan")
description = Text(anime.description or "NO description") description = Text(anime.description or "NO description")
genres = Text(f"Genres: {', '.join(anime.genres)}") genres = Text(f"Genres: {', '.join([v.value for v in anime.genres])}")
panel_content = f"{genres}\n\n{description}" panel_content = f"{genres}\n\n{description}"

View File

@@ -1,5 +1,5 @@
from ....libs.api.params import ApiSearchParams, UserListParams from ....libs.api.params import ApiSearchParams, UserListParams
from ....libs.api.types import MediaItem from ....libs.api.types import MediaItem, MediaStatus, UserMediaListStatus
from ..session import Context, session from ..session import Context, session
from ..state import ControlFlow, MediaApiState, State from ..state import ControlFlow, MediaApiState, State
@@ -96,10 +96,10 @@ def _format_anime_choice(anime: MediaItem, config) -> str:
# Add a visual indicator for new episodes if applicable # Add a visual indicator for new episodes if applicable
if ( if (
anime.status == "RELEASING" anime.status == MediaStatus.RELEASING
and anime.next_airing and anime.next_airing
and anime.user_status and anime.user_status
and anime.user_status.status == "CURRENT" and anime.user_status.status == UserMediaListStatus.WATCHING
): ):
last_aired = anime.next_airing.episode - 1 last_aired = anime.next_airing.episode - 1
unwatched = last_aired - (anime.user_status.progress or 0) unwatched = last_aired - (anime.user_status.progress or 0)

View File

@@ -8,7 +8,7 @@ from ...libs.api.types import (
MediaItem, MediaItem,
MediaSearchResult, MediaSearchResult,
MediaStatus, MediaStatus,
UserListStatusType, UserListItem,
) )
from ...libs.players.types import PlayerResult from ...libs.players.types import PlayerResult
from ...libs.providers.anime.types import Anime, SearchResults, Server from ...libs.providers.anime.types import Anime, SearchResults, Server
@@ -80,7 +80,7 @@ class MediaApiState(BaseModel):
search_results_type: Optional[Literal["MEDIA_LIST", "USER_MEDIA_LIST"]] = None search_results_type: Optional[Literal["MEDIA_LIST", "USER_MEDIA_LIST"]] = None
sort: Optional[str] = None sort: Optional[str] = None
query: Optional[str] = None query: Optional[str] = None
user_media_status: Optional[UserListStatusType] = None user_media_status: Optional[UserListItem] = None
media_status: Optional[MediaStatus] = None media_status: Optional[MediaStatus] = None
anime: Optional[MediaItem] = None anime: Optional[MediaItem] = None

View File

@@ -1,4 +1,5 @@
from collections.abc import Callable from collections.abc import Callable
from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Literal, get_args, get_origin from typing import Any, Literal, get_args, get_origin
@@ -130,6 +131,16 @@ def _get_click_type(field_info: FieldInfo) -> Any:
"""Maps a Pydantic field's type to a corresponding click type.""" """Maps a Pydantic field's type to a corresponding click type."""
field_type = field_info.annotation field_type = field_info.annotation
# check if type is enum
if (
field_type is not None
and isinstance(field_type, type)
and issubclass(field_type, Enum)
):
# Get the string values of the enum members
enum_choices = [member.value for member in field_type]
return click.Choice(enum_choices)
# Check if the type is a Literal # Check if the type is a Literal
if ( if (
field_type is not None field_type is not None

View File

@@ -1,27 +1,33 @@
import logging import logging
from datetime import datetime from datetime import datetime
from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Dict, Literal, Optional from typing import Dict, Literal, Optional
from pydantic import BaseModel, Field, computed_field from pydantic import BaseModel, Field, computed_field
from ....libs.api.types import MediaItem, UserListStatusType from ....libs.api.types import MediaItem, UserMediaListStatus
from ...utils import converters from ...utils import converters
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Type aliases
DownloadStatus = Literal[ class DownloadStatus(Enum):
"not_downloaded", "queued", "downloading", "completed", "failed", "paused" NOT_DOWNLOADED = "not_downloaded"
] QUEUED = "queued"
DOWNLOADING = "downloading"
COMPLETED = "completed"
FAILED = "failed"
PAUSED = "paused"
REGISTRY_VERSION = "1.0" REGISTRY_VERSION = "1.0"
class MediaEpisode(BaseModel): class MediaEpisode(BaseModel):
episode_number: str episode_number: str
# Download tracking download_status: DownloadStatus = DownloadStatus.NOT_DOWNLOADED
download_status: DownloadStatus = "not_downloaded"
file_path: Path file_path: Path
download_date: datetime = Field(default_factory=datetime.now) download_date: datetime = Field(default_factory=datetime.now)
@@ -35,7 +41,7 @@ class MediaRegistryIndexEntry(BaseModel):
media_id: int media_id: int
media_api: Literal["anilist", "NONE", "jikan"] = "NONE" media_api: Literal["anilist", "NONE", "jikan"] = "NONE"
status: UserListStatusType = "watching" status: UserMediaListStatus = UserMediaListStatus.WATCHING
progress: str = "0" progress: str = "0"
last_watch_position: Optional[str] = None last_watch_position: Optional[str] = None
last_watched: datetime = Field(default_factory=datetime.now) last_watched: datetime = Field(default_factory=datetime.now)

View File

@@ -12,7 +12,7 @@ from ....libs.api.types import (
MediaItem, MediaItem,
MediaSearchResult, MediaSearchResult,
PageInfo, PageInfo,
UserListStatusType, UserMediaListStatus,
) )
from .filters import MediaFilter from .filters import MediaFilter
from .models import ( from .models import (
@@ -150,7 +150,7 @@ class MediaRegistryService:
watched: bool = False, watched: bool = False,
media_item: Optional[MediaItem] = None, media_item: Optional[MediaItem] = None,
progress: Optional[str] = None, progress: Optional[str] = None,
status: Optional[UserListStatusType] = None, status: Optional[UserMediaListStatus] = None,
last_watch_position: Optional[str] = None, last_watch_position: Optional[str] = None,
total_duration: Optional[str] = None, total_duration: Optional[str] = None,
score: Optional[float] = None, score: Optional[float] = None,
@@ -171,7 +171,7 @@ class MediaRegistryService:
if status: if status:
index_entry.status = status index_entry.status = status
else: else:
index_entry.status = "watching" index_entry.status = UserMediaListStatus.WATCHING
if last_watch_position: if last_watch_position:
index_entry.last_watch_position = last_watch_position index_entry.last_watch_position = last_watch_position

View File

@@ -4,7 +4,7 @@ from typing import Optional
from ....core.config.model import AppConfig from ....core.config.model import AppConfig
from ....libs.api.base import BaseApiClient from ....libs.api.base import BaseApiClient
from ....libs.api.params import UpdateListEntryParams from ....libs.api.params import UpdateListEntryParams
from ....libs.api.types import MediaItem, UserListStatusType from ....libs.api.types import MediaItem, UserMediaListStatus
from ....libs.players.types import PlayerResult from ....libs.players.types import PlayerResult
from ..registry import MediaRegistryService from ..registry import MediaRegistryService
@@ -48,7 +48,7 @@ class WatchHistoryService:
self, self,
media_item: MediaItem, media_item: MediaItem,
progress: Optional[str] = None, progress: Optional[str] = None,
status: Optional[UserListStatusType] = None, status: Optional[UserMediaListStatus] = None,
score: Optional[float] = None, score: Optional[float] = None,
notes: Optional[str] = None, notes: Optional[str] = None,
): ):

View File

@@ -1,21 +1,16 @@
import concurrent.futures import concurrent.futures
import logging import logging
import os import os
import shutil
from hashlib import sha256 from hashlib import sha256
from io import StringIO
from threading import Thread from threading import Thread
from typing import List from typing import List
import httpx import httpx
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from ...core.config import AppConfig from ...core.config import AppConfig
from ...core.constants import APP_CACHE_DIR, APP_DIR, PLATFORM from ...core.constants import APP_CACHE_DIR, APP_DIR, PLATFORM
from ...core.utils.file import AtomicWriter from ...core.utils.file import AtomicWriter
from ...libs.api.types import MediaItem, StreamingEpisode from ...libs.api.types import MediaItem
from . import ansi, formatters from . import ansi, formatters
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -76,8 +71,8 @@ def _populate_info_template(item: MediaItem, config: AppConfig) -> str:
# plain text # plain text
# #
"TITLE": formatters.shell_safe(item.title.english or item.title.romaji), "TITLE": formatters.shell_safe(item.title.english or item.title.romaji),
"STATUS": formatters.shell_safe(item.status), "STATUS": formatters.shell_safe(item.status.value),
"FORMAT": formatters.shell_safe(item.format), "FORMAT": formatters.shell_safe(item.format.value),
# #
# numerical # numerical
# #
@@ -100,10 +95,10 @@ def _populate_info_template(item: MediaItem, config: AppConfig) -> str:
# list # list
# #
"GENRES": formatters.shell_safe( "GENRES": formatters.shell_safe(
formatters.format_list_with_commas(item.genres) formatters.format_list_with_commas([v.value for v in item.genres])
), ),
"TAGS": formatters.shell_safe( "TAGS": formatters.shell_safe(
formatters.format_list_with_commas([t.name for t in item.tags]) formatters.format_list_with_commas([t.name.value for t in item.tags])
), ),
"STUDIOS": formatters.shell_safe( "STUDIOS": formatters.shell_safe(
formatters.format_list_with_commas([t.name for t in item.studios if t.name]) formatters.format_list_with_commas([t.name for t in item.studios if t.name])
@@ -115,7 +110,9 @@ def _populate_info_template(item: MediaItem, config: AppConfig) -> str:
# user # user
# #
"USER_STATUS": formatters.shell_safe( "USER_STATUS": formatters.shell_safe(
item.user_status.status if item.user_status else "NOT_ON_LIST" item.user_status.status.value
if item.user_status and item.user_status.status
else "NOT_ON_LIST"
), ),
"USER_PROGRESS": formatters.shell_safe( "USER_PROGRESS": formatters.shell_safe(
f"Episode {item.user_status.progress}" if item.user_status else "0" f"Episode {item.user_status.progress}" if item.user_status else "0"

View File

@@ -1,6 +1,6 @@
import os import os
from pathlib import Path from pathlib import Path
from typing import List, Literal from typing import Literal
from pydantic import BaseModel, Field, PrivateAttr, computed_field, field_validator from pydantic import BaseModel, Field, PrivateAttr, computed_field, field_validator
@@ -11,7 +11,7 @@ from ...core.constants import (
ROFI_THEME_MAIN, ROFI_THEME_MAIN,
ROFI_THEME_PREVIEW, ROFI_THEME_PREVIEW,
) )
from ...libs.api.anilist.constants import MEDIA_LIST_SORTS, SORTS_AVAILABLE from ...libs.api.types import MediaSort, UserMediaListSort
from ...libs.providers.anime import PROVIDERS_AVAILABLE, SERVERS_AVAILABLE from ...libs.providers.anime import PROVIDERS_AVAILABLE, SERVERS_AVAILABLE
from ..constants import APP_ASCII_ART from ..constants import APP_ASCII_ART
from . import defaults from . import defaults
@@ -21,12 +21,6 @@ from . import descriptions as desc
class GeneralConfig(BaseModel): class GeneralConfig(BaseModel):
"""Configuration for general application behavior and integrations.""" """Configuration for general application behavior and integrations."""
per_page: int = Field(
default=defaults.ANILIST_PER_PAGE,
gt=0,
le=50,
description=desc.ANILIST_PER_PAGE,
)
pygment_style: str = Field( pygment_style: str = Field(
default=defaults.GENERAL_PYGMENT_STYLE, description=desc.GENERAL_PYGMENT_STYLE default=defaults.GENERAL_PYGMENT_STYLE, description=desc.GENERAL_PYGMENT_STYLE
) )
@@ -311,44 +305,41 @@ class AnilistConfig(OtherConfig):
le=50, le=50,
description=desc.ANILIST_PER_PAGE, description=desc.ANILIST_PER_PAGE,
) )
sort_by: str = Field( sort_by: MediaSort = Field(
default=defaults.ANILIST_SORT_BY, default=MediaSort.SEARCH_MATCH,
description=desc.ANILIST_SORT_BY, description=desc.ANILIST_SORT_BY,
examples=SORTS_AVAILABLE,
) )
media_list_sort_by: str = Field( media_list_sort_by: UserMediaListSort = Field(
default=defaults.ANILIST_MEDIA_LIST_SORT_BY, default=UserMediaListSort.MEDIA_POPULARITY_DESC,
description=desc.ANILIST_MEDIA_LIST_SORT_BY, description=desc.ANILIST_MEDIA_LIST_SORT_BY,
examples=MEDIA_LIST_SORTS,
) )
preferred_language: Literal["english", "romaji"] = Field( preferred_language: Literal["english", "romaji"] = Field(
default=defaults.ANILIST_PREFERRED_LANGUAGE, default=defaults.ANILIST_PREFERRED_LANGUAGE,
description=desc.ANILIST_PREFERRED_LANGUAGE, description=desc.ANILIST_PREFERRED_LANGUAGE,
) )
@field_validator("sort_by")
@classmethod
def validate_sort_by(cls, v: str) -> str:
if v not in SORTS_AVAILABLE:
raise ValueError(
f"'{v}' is not a valid sort option. See documentation for available options."
)
return v
@field_validator("media_list_sort_by")
@classmethod
def validate_media_list_sort_by(cls, v: str) -> str:
if v not in MEDIA_LIST_SORTS:
raise ValueError(
f"'{v}' is not a valid sort option. See documentation for available options."
)
return v
class JikanConfig(OtherConfig): class JikanConfig(OtherConfig):
"""Configuration for the Jikan API (currently none).""" """Configuration for the Jikan API (currently none)."""
pass per_page: int = Field(
default=defaults.ANILIST_PER_PAGE,
gt=0,
le=50,
description=desc.ANILIST_PER_PAGE,
)
sort_by: MediaSort = Field(
default=MediaSort.SEARCH_MATCH,
description=desc.ANILIST_SORT_BY,
)
media_list_sort_by: UserMediaListSort = Field(
default=UserMediaListSort.MEDIA_POPULARITY_DESC,
description=desc.ANILIST_MEDIA_LIST_SORT_BY,
)
preferred_language: Literal["english", "romaji"] = Field(
default=defaults.ANILIST_PREFERRED_LANGUAGE,
description=desc.ANILIST_PREFERRED_LANGUAGE,
)
class DownloadsConfig(OtherConfig): class DownloadsConfig(OtherConfig):

View File

@@ -1,5 +1,6 @@
import logging import logging
from typing import Optional from enum import Enum
from typing import List, Optional
from httpx import Client from httpx import Client
@@ -8,20 +9,20 @@ from ....core.utils.graphql import (
execute_graphql, execute_graphql,
) )
from ..base import ApiSearchParams, BaseApiClient, UpdateListEntryParams, UserListParams from ..base import ApiSearchParams, BaseApiClient, UpdateListEntryParams, UserListParams
from ..types import MediaSearchResult, UserProfile from ..types import MediaSearchResult, UserMediaListStatus, UserProfile
from . import gql, mapper from . import gql, mapper
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ANILIST_ENDPOINT = "https://graphql.anilist.co" ANILIST_ENDPOINT = "https://graphql.anilist.co"
status_map = { user_list_status_map = {
"watching": "CURRENT", UserMediaListStatus.WATCHING: "CURRENT",
"planning": "PLANNING", UserMediaListStatus.PLANNING: "PLANNING",
"completed": "COMPLETED", UserMediaListStatus.COMPLETED: "COMPLETED",
"dropped": "DROPPED", UserMediaListStatus.DROPPED: "DROPPED",
"paused": "PAUSED", UserMediaListStatus.PAUSED: "PAUSED",
"repeating": "REPEATING", UserMediaListStatus.REPEATING: "REPEATING",
} }
# TODO: Just remove and have consistent variable naming between the two # TODO: Just remove and have consistent variable naming between the two
@@ -86,15 +87,40 @@ class AniListApi(BaseApiClient):
def search_media(self, params: ApiSearchParams) -> Optional[MediaSearchResult]: def search_media(self, params: ApiSearchParams) -> Optional[MediaSearchResult]:
variables = { variables = {
search_params_map[k]: v for k, v in params.__dict__.items() if v is not None search_params_map[k]: v
for k, v in params.__dict__.items()
if v is not None and not isinstance(v, Enum)
} }
# handle case where value is an enum
variables.update(
{
search_params_map[k]: v.value
for k, v in params.__dict__.items()
if v is not None and isinstance(v, Enum)
}
)
# handle case where is a list of enums
variables.update(
{
search_params_map[k]: list(map(lambda item: item.value, v))
for k, v in params.__dict__.items()
if v is not None and isinstance(v, list)
}
)
variables["per_page"] = params.per_page or self.config.per_page variables["per_page"] = params.per_page or self.config.per_page
# ignore hentai by default # ignore hentai by default
variables["genre_not_in"] = params.genre_not_in or ["Hentai"] variables["genre_not_in"] = (
list(map(lambda item: item.value, params.genre_not_in))
if params.genre_not_in
else ["Hentai"]
)
# anime by default # anime by default
variables["type"] = params.type or "ANIME" variables["type"] = params.type.value if params.type else "ANIME"
response = execute_graphql( response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.SEARCH_MEDIA, variables ANILIST_ENDPOINT, self.http_client, gql.SEARCH_MEDIA, variables
) )
@@ -108,12 +134,14 @@ class AniListApi(BaseApiClient):
# TODO: use consistent variable naming btw graphql and params # TODO: use consistent variable naming btw graphql and params
# so variables can be dynamically filled # so variables can be dynamically filled
variables = { variables = {
"sort": params.sort or self.config.media_list_sort_by, "sort": params.sort.value
if params.sort
else self.config.media_list_sort_by,
"userId": self.user_profile.id, "userId": self.user_profile.id,
"status": status_map[params.status] if params.status else None, "status": user_list_status_map[params.status] if params.status else None,
"page": params.page, "page": params.page,
"perPage": params.per_page or self.config.per_page, "perPage": params.per_page or self.config.per_page,
"type": params.type or "ANIME", "type": params.type.value if params.type else "ANIME",
} }
response = execute_graphql( response = execute_graphql(
ANILIST_ENDPOINT, self.http_client, gql.GET_USER_MEDIA_LIST, variables ANILIST_ENDPOINT, self.http_client, gql.GET_USER_MEDIA_LIST, variables
@@ -126,7 +154,7 @@ class AniListApi(BaseApiClient):
score_raw = int(params.score * 10) if params.score is not None else None score_raw = int(params.score * 10) if params.score is not None else None
variables = { variables = {
"mediaId": params.media_id, "mediaId": params.media_id,
"status": status_map[params.status] if params.status else None, "status": user_list_status_map[params.status] if params.status else None,
"progress": int(float(params.progress)) if params.progress else None, "progress": int(float(params.progress)) if params.progress else None,
"scoreRaw": score_raw, "scoreRaw": score_raw,
} }

View File

@@ -1,515 +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_LIST_SORTS = [
"MEDIA_ID",
"MEDIA_ID_DESC",
"SCORE",
"SCORE_DESC",
"STATUS",
"STATUS_DESC",
"PROGRESS",
"PROGRESS_DESC",
"PROGRESS_VOLUMES",
"PROGRESS_VOLUMES_DESC",
"REPEAT",
"REPEAT_DESC",
"PRIORITY",
"PRIORITY_DESC",
"STARTED_ON",
"STARTED_ON_DESC",
"FINISHED_ON",
"FINISHED_ON_DESC",
"ADDED_TIME",
"ADDED_TIME_DESC",
"UPDATED_TIME",
"UPDATED_TIME_DESC",
"MEDIA_TITLE_ROMAJI",
"MEDIA_TITLE_ROMAJI_DESC",
"MEDIA_TITLE_ENGLISH",
"MEDIA_TITLE_ENGLISH_DESC",
"MEDIA_TITLE_NATIVE",
"MEDIA_TITLE_NATIVE_DESC",
"MEDIA_POPULARITY",
"MEDIA_POPULARITY_DESC",
"MEDIA_SCORE",
"MEDIA_SCORE_DESC",
"MEDIA_START_DATE",
"MEDIA_START_DATE_DESC",
"MEDIA_RATING",
"MEDIA_RATING_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,6 +1,6 @@
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional from typing import List, Optional
from ....core.utils.formatting import renumber_titles, strip_original_episode_prefix from ....core.utils.formatting import renumber_titles, strip_original_episode_prefix
from ..types import ( from ..types import (
@@ -8,13 +8,15 @@ from ..types import (
MediaImage, MediaImage,
MediaItem, MediaItem,
MediaSearchResult, MediaSearchResult,
MediaTag, MediaStatus,
MediaTagItem,
MediaTitle, MediaTitle,
MediaTrailer, MediaTrailer,
PageInfo, PageInfo,
StreamingEpisode, StreamingEpisode,
Studio, Studio,
UserListStatus, UserListItem,
UserMediaListStatus,
UserProfile, UserProfile,
) )
from .types import ( from .types import (
@@ -25,7 +27,6 @@ from .types import (
AnilistImage, AnilistImage,
AnilistMediaList, AnilistMediaList,
AnilistMediaLists, AnilistMediaLists,
AnilistMediaListStatus,
AnilistMediaNextAiringEpisode, AnilistMediaNextAiringEpisode,
AnilistMediaTag, AnilistMediaTag,
AnilistMediaTitle, AnilistMediaTitle,
@@ -40,13 +41,19 @@ from .types import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
user_list_status_map = {
"CURRENT": UserMediaListStatus.WATCHING,
"PLANNING": UserMediaListStatus.PLANNING,
"COMPLETED": UserMediaListStatus.COMPLETED,
"PAUSED": UserMediaListStatus.PAUSED,
"REPEATING": UserMediaListStatus.REPEATING,
}
status_map = { status_map = {
"CURRENT": "watching", "FINISHED": MediaStatus.FINISHED,
"PLANNING": "planning", "RELEASING": MediaStatus.RELEASING,
"COMPLETED": "completed", "NOT_YET_RELEASED": MediaStatus.NOT_YET_RELEASED,
"DROPPED": "dropped", "CANCELLED": MediaStatus.CANCELLED,
"PAUSED": "paused", "HIATUS": MediaStatus.HIATUS,
"REPEATING": "repeating",
} }
@@ -123,10 +130,10 @@ def _to_generic_studios(anilist_studios: AnilistStudioNodes) -> List[Studio]:
] ]
def _to_generic_tags(anilist_tags: list[AnilistMediaTag]) -> List[MediaTag]: def _to_generic_tags(anilist_tags: list[AnilistMediaTag]) -> List[MediaTagItem]:
"""Maps a list of AniList tags to generic MediaTag objects.""" """Maps a list of AniList tags to generic MediaTag objects."""
return [ return [
MediaTag(name=t["name"], rank=t.get("rank")) MediaTagItem(name=t["name"], rank=t.get("rank"))
for t in anilist_tags for t in anilist_tags
if t.get("name") if t.get("name")
] ]
@@ -200,11 +207,11 @@ def _to_generic_streaming_episodes(
def _to_generic_user_status( def _to_generic_user_status(
anilist_media: AnilistBaseMediaDataSchema, anilist_media: AnilistBaseMediaDataSchema,
anilist_list_entry: Optional[AnilistMediaList], anilist_list_entry: Optional[AnilistMediaList],
) -> Optional[UserListStatus]: ) -> Optional[UserListItem]:
"""Maps an AniList mediaListEntry to a generic UserListStatus.""" """Maps an AniList mediaListEntry to a generic UserListStatus."""
if anilist_list_entry: if anilist_list_entry:
return UserListStatus( return UserListItem(
status=status_map[anilist_list_entry["status"]], # pyright: ignore status=user_list_status_map[anilist_list_entry["status"]],
progress=anilist_list_entry["progress"], progress=anilist_list_entry["progress"],
score=anilist_list_entry["score"], score=anilist_list_entry["score"],
repeat=anilist_list_entry["repeat"], repeat=anilist_list_entry["repeat"],
@@ -218,9 +225,9 @@ def _to_generic_user_status(
if not anilist_media["mediaListEntry"]: if not anilist_media["mediaListEntry"]:
return return
return UserListStatus( return UserListItem(
id=anilist_media["mediaListEntry"]["id"], id=anilist_media["mediaListEntry"]["id"],
status=status_map[anilist_media["mediaListEntry"]["status"]] # pyright: ignore status=user_list_status_map[anilist_media["mediaListEntry"]["status"]]
if anilist_media["mediaListEntry"]["status"] if anilist_media["mediaListEntry"]["status"]
else None, else None,
progress=anilist_media["mediaListEntry"]["progress"], progress=anilist_media["mediaListEntry"]["progress"],
@@ -236,7 +243,7 @@ def _to_generic_media_item(
id_mal=data.get("idMal"), id_mal=data.get("idMal"),
type=data.get("type", "ANIME"), type=data.get("type", "ANIME"),
title=_to_generic_media_title(data["title"]), title=_to_generic_media_title(data["title"]),
status=data["status"], status=status_map[data["status"]],
format=data.get("format"), format=data.get("format"),
cover_image=_to_generic_media_image(data["coverImage"]), cover_image=_to_generic_media_image(data["coverImage"]),
banner_image=data.get("bannerImage"), banner_image=data.get("bannerImage"),

View File

@@ -1,9 +1,3 @@
"""
This module defines the shape of the anilist data that can be received in order to enhance dev experience
"""
# TODO: rename this module to types
from typing import Literal, TypedDict from typing import Literal, TypedDict

View File

@@ -9,12 +9,12 @@ from ..types import (
MediaItem, MediaItem,
MediaSearchResult, MediaSearchResult,
MediaStatus, MediaStatus,
MediaTag, MediaTagItem,
MediaTitle, MediaTitle,
PageInfo, PageInfo,
StreamingEpisode, StreamingEpisode,
Studio, Studio,
UserListStatus, UserListItem,
UserProfile, UserProfile,
) )
@@ -36,7 +36,7 @@ def _to_generic_title(jikan_titles: list[dict]) -> MediaTitle:
romaji = None romaji = None
english = None english = None
native = None native = None
# Jikan's default title is often the romaji one. # Jikan's default title is often the romaji one.
# We prioritize specific types if available. # We prioritize specific types if available.
for t in jikan_titles: for t in jikan_titles:
@@ -48,12 +48,8 @@ def _to_generic_title(jikan_titles: list[dict]) -> MediaTitle:
english = title_ english = title_
elif type_ == "Japanese": elif type_ == "Japanese":
native = title_ native = title_
return MediaTitle( return MediaTitle(romaji=romaji, english=english, native=native)
romaji=romaji,
english=english,
native=native
)
def _to_generic_image(jikan_images: dict) -> MediaImage: def _to_generic_image(jikan_images: dict) -> MediaImage:

View File

@@ -1,7 +1,17 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Literal, Optional, Union from typing import List, Optional, Union
from .types import UserListStatusType from .types import (
MediaFormat,
MediaGenre,
MediaSeason,
MediaSort,
MediaStatus,
MediaTag,
MediaType,
UserMediaListSort,
UserMediaListStatus,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -9,23 +19,23 @@ class ApiSearchParams:
query: Optional[str] = None query: Optional[str] = None
page: int = 1 page: int = 1
per_page: Optional[int] = None per_page: Optional[int] = None
sort: Optional[Union[str, List[str]]] = None sort: Optional[Union[MediaSort, List[MediaSort]]] = None
# IDs # IDs
id_in: Optional[List[int]] = None id_in: Optional[List[int]] = None
# Genres # Genres
genre_in: Optional[List[str]] = None genre_in: Optional[List[MediaGenre]] = None
genre_not_in: Optional[List[str]] = None genre_not_in: Optional[List[MediaGenre]] = None
# Tags # Tags
tag_in: Optional[List[str]] = None tag_in: Optional[List[MediaTag]] = None
tag_not_in: Optional[List[str]] = None tag_not_in: Optional[List[MediaTag]] = None
# Status # Status
status_in: Optional[List[str]] = None # Corresponds to [MediaStatus] status_in: Optional[List[MediaStatus]] = None # Corresponds to [MediaStatus]
status: Optional[str] = None # Corresponds to MediaStatus status: Optional[MediaStatus] = None # Corresponds to MediaStatus
status_not_in: Optional[List[str]] = None # Corresponds to [MediaStatus] status_not_in: Optional[List[MediaStatus]] = None # Corresponds to [MediaStatus]
# Popularity # Popularity
popularity_greater: Optional[int] = None popularity_greater: Optional[int] = None
@@ -37,7 +47,7 @@ class ApiSearchParams:
# Season and Year # Season and Year
seasonYear: Optional[int] = None seasonYear: Optional[int] = None
season: Optional[str] = None season: Optional[MediaSeason] = None
# Start Date (FuzzyDateInt is often an integer representation like YYYYMMDD) # Start Date (FuzzyDateInt is often an integer representation like YYYYMMDD)
startDate_greater: Optional[int] = None startDate_greater: Optional[int] = None
@@ -49,8 +59,8 @@ class ApiSearchParams:
endDate_lesser: Optional[int] = None endDate_lesser: Optional[int] = None
# Format and Type # Format and Type
format_in: Optional[List[str]] = None # Corresponds to [MediaFormat] format_in: Optional[List[MediaFormat]] = None
type: Optional[str] = None # Corresponds to MediaType (e.g., "ANIME", "MANGA") type: Optional[MediaType] = None
# On List # On List
on_list: Optional[bool] = None on_list: Optional[bool] = None
@@ -58,16 +68,16 @@ class ApiSearchParams:
@dataclass(frozen=True) @dataclass(frozen=True)
class UserListParams: class UserListParams:
status: UserListStatusType status: UserMediaListStatus
page: int = 1 page: int = 1
type: Optional[str] = None type: Optional[MediaType] = None
sort: Optional[str] = None sort: Optional[UserMediaListSort] = None
per_page: Optional[int] = None per_page: Optional[int] = None
@dataclass(frozen=True) @dataclass(frozen=True)
class UpdateListEntryParams: class UpdateListEntryParams:
media_id: int media_id: int
status: Optional[UserListStatusType] = None status: Optional[UserMediaListStatus] = None
progress: Optional[str] = None progress: Optional[str] = None
score: Optional[float] = None score: Optional[float] = None

View File

@@ -1,25 +1,474 @@
from datetime import datetime from datetime import datetime
from typing import List, Literal, Optional from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
# --- Generic Enums and Type Aliases ---
MediaType = Literal["ANIME", "MANGA"]
MediaStatus = Literal[
"FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"
]
UserListStatusType = Literal[
"planning", "watching", "completed", "dropped", "paused", "repeating"
]
# --- Generic Data Models ---
# ENUMS
class MediaStatus(Enum):
FINISHED = "FINISHED"
RELEASING = "RELEASING"
NOT_YET_RELEASED = "NOT_YET_RELEASED"
CANCELLED = "CANCELLED"
HIATUS = "HIATUS"
class MediaType(Enum):
ANIME = "ANIME"
MANGA = "MANGA"
class UserMediaListStatus(Enum):
PLANNING = "planning"
WATCHING = "watching"
COMPLETED = "completed"
DROPPED = "dropped"
PAUSED = "paused"
REPEATING = "repeating"
class MediaGenre(Enum):
ACTION = "Action"
ADVENTURE = "Adventure"
COMEDY = "Comedy"
DRAMA = "Drama"
ECCHI = "Ecchi"
FANTASY = "Fantasy"
HORROR = "Horror"
MAHOU_SHOUJO = "Mahou Shoujo"
MECHA = "Mecha"
MUSIC = "Music"
MYSTERY = "Mystery"
PSYCHOLOGICAL = "Psychological"
ROMANCE = "Romance"
SCI_FI = "Sci-Fi"
SLICE_OF_LIFE = "Slice of Life"
SPORTS = "Sports"
SUPERNATURAL = "Supernatural"
THRILLER = "Thriller"
HENTAI = "Hentai"
class MediaFormat(Enum):
TV = "TV"
TV_SHORT = "TV_SHORT"
MOVIE = "MOVIE"
SPECIAL = "SPECIAL"
OVA = "OVA"
ONA = "ONA"
MUSIC = "MUSIC"
NOVEL = "NOVEL"
ONE_SHOT = "ONE_SHOT"
class MediaTag(Enum):
# Cast
POLYAMOROUS = "Polyamorous"
# Cast Main Cast
ANTI_HERO = "Anti-Hero"
ELDERLY_PROTAGONIST = "Elderly Protagonist"
ENSEMBLE_CAST = "Ensemble Cast"
ESTRANGED_FAMILY = "Estranged Family"
FEMALE_PROTAGONIST = "Female Protagonist"
MALE_PROTAGONIST = "Male Protagonist"
PRIMARILY_ADULT_CAST = "Primarily Adult Cast"
PRIMARILY_ANIMAL_CAST = "Primarily Animal Cast"
PRIMARILY_CHILD_CAST = "Primarily Child Cast"
PRIMARILY_FEMALE_CAST = "Primarily Female Cast"
PRIMARILY_MALE_CAST = "Primarily Male Cast"
PRIMARILY_TEEN_CAST = "Primarily Teen Cast"
# Cast Traits
AGE_REGRESSION = "Age Regression"
AGENDER = "Agender"
ALIENS = "Aliens"
AMNESIA = "Amnesia"
ANGELS = "Angels"
ANTHROPOMORPHISM = "Anthropomorphism"
AROMANTIC = "Aromantic"
ARRANGED_MARRIAGE = "Arranged Marriage"
ARTIFICIAL_INTELLIGENCE = "Artificial Intelligence"
ASEXUAL = "Asexual"
BUTLER = "Butler"
CENTAUR = "Centaur"
CHIMERA = "Chimera"
CHUUNIBYOU = "Chuunibyou"
CLONE = "Clone"
COSPLAY = "Cosplay"
COWBOYS = "Cowboys"
CROSSDRESSING = "Crossdressing"
CYBORG = "Cyborg"
DELINQUENTS = "Delinquents"
DEMONS = "Demons"
DETECTIVE = "Detective"
DINOSAURS = "Dinosaurs"
DISABILITY = "Disability"
DISSOCIATIVE_IDENTITIES = "Dissociative Identities"
DRAGONS = "Dragons"
DULLAHAN = "Dullahan"
ELF = "Elf"
EXHIBITIONISM = "Exhibitionism"
FAIRY = "Fairy"
FEMBOY = "Femboy"
GHOST = "Ghost"
GOBLIN = "Goblin"
GODS = "Gods"
GYARU = "Gyaru"
HIKIKOMORI = "Hikikomori"
HOMELESS = "Homeless"
IDOL = "Idol"
INSEKI = "Inseki"
KEMONOMIMI = "Kemonomimi"
KUUDERE = "Kuudere"
MAIDS = "Maids"
MERMAID = "Mermaid"
MONSTER_BOY = "Monster Boy"
MONSTER_GIRL = "Monster Girl"
NEKOMIMI = "Nekomimi"
NINJA = "Ninja"
NUDITY = "Nudity"
NUN = "Nun"
OFFICE_LADY = "Office Lady"
OIRAN = "Oiran"
OJOU_SAMA = "Ojou-sama"
ORPHAN = "Orphan"
PIRATES = "Pirates"
ROBOTS = "Robots"
SAMURAI = "Samurai"
SHRINE_MAIDEN = "Shrine Maiden"
SKELETON = "Skeleton"
SUCCUBUS = "Succubus"
TANNED_SKIN = "Tanned Skin"
TEACHER = "Teacher"
TOMBOY = "Tomboy"
TRANSGENDER = "Transgender"
TSUNDERE = "Tsundere"
TWINS = "Twins"
VAMPIRE = "Vampire"
VETERINARIAN = "Veterinarian"
VIKINGS = "Vikings"
VILLAINESS = "Villainess"
VIRGINITY = "Virginity"
VTUBER = "VTuber"
WEREWOLF = "Werewolf"
WITCH = "Witch"
YANDERE = "Yandere"
ZOMBIE = "Zombie"
YOUKAI = "Youkai" # Added
# Demographic
JOSEI = "Josei"
KIDS = "Kids"
SEINEN = "Seinen"
SHOUJO = "Shoujo"
SHOUNEN = "Shounen"
# Setting
MATRIARCHY = "Matriarchy"
# Setting Scene
BAR = "Bar"
BOARDING_SCHOOL = "Boarding School"
CIRCUS = "Circus"
COASTAL = "Coastal"
COLLEGE = "College"
DESERT = "Desert"
DUNGEON = "Dungeon"
FOREIGN = "Foreign"
INN = "Inn"
KONBINI = "Konbini"
NATURAL_DISASTER = "Natural Disaster"
OFFICE = "Office"
OUTDOOR = "Outdoor"
PRISON = "Prison"
RESTAURANT = "Restaurant"
RURAL = "Rural"
SCHOOL = "School"
SCHOOL_CLUB = "School Club"
SNOWSCAPE = "Snowscape"
URBAN = "Urban"
WORK = "Work"
# Setting Time
ACHRONOLOGICAL_ORDER = "Achronological Order"
ANACHRONISM = "Anachronism"
ANCIENT_CHINA = "Ancient China"
DYSTOPIAN = "Dystopian"
HISTORICAL = "Historical"
TIME_SKIP = "Time Skip"
# Setting Universe
AFTERLIFE = "Afterlife"
ALTERNATE_UNIVERSE = "Alternate Universe"
AUGMENTED_REALITY = "Augmented Reality"
OMEGAVERSE = "Omegaverse"
POST_APOCALYPTIC = "Post-Apocalyptic"
SPACE = "Space"
URBAN_FANTASY = "Urban Fantasy"
VIRTUAL_WORLD = "Virtual World"
# Technical
_4_KOMA = "4-koma"
ACHROMATIC = "Achromatic"
ADVERTISEMENT = "Advertisement"
ANTHOLOGY = "Anthology"
CGI = "CGI"
EPISODIC = "Episodic"
FLASH = "Flash"
FULL_CGI = "Full CGI"
FULL_COLOR = "Full Color"
NO_DIALOGUE = "No Dialogue"
NON_FICTION = "Non-fiction"
POV = "POV"
PUPPETRY = "Puppetry"
ROTOSCOPING = "Rotoscoping"
STOP_MOTION = "Stop Motion"
# Theme Action
ARCHERY = "Archery"
BATTLE_ROYALE = "Battle Royale"
ESPIONAGE = "Espionage"
FUGITIVE = "Fugitive"
GUNS = "Guns"
MARTIAL_ARTS = "Martial Arts"
SPEARPLAY = "Spearplay"
SWORDPLAY = "Swordplay"
# Theme Arts
ACTING = "Acting"
CALLIGRAPHY = "Calligraphy"
CLASSIC_LITERATURE = "Classic Literature"
DRAWING = "Drawing"
FASHION = "Fashion"
FOOD = "Food"
MAKEUP = "Makeup"
PHOTOGRAPHY = "Photography"
RAKUGO = "Rakugo"
WRITING = "Writing"
# Theme Arts-Music
BAND = "Band"
CLASSICAL_MUSIC = "Classical Music"
DANCING = "Dancing"
HIP_HOP_MUSIC = "Hip-hop Music"
JAZZ_MUSIC = "Jazz Music"
METAL_MUSIC = "Metal Music"
MUSICAL_THEATER = "Musical Theater"
ROCK_MUSIC = "Rock Music"
# Theme Comedy
PARODY = "Parody"
SATIRE = "Satire"
SLAPSTICK = "Slapstick"
SURREAL_COMEDY = "Surreal Comedy"
# Theme Drama
BULLYING = "Bullying"
CLASS_STRUGGLE = "Class Struggle"
COMING_OF_AGE = "Coming of Age"
CONSPIRACY = "Conspiracy"
ECO_HORROR = "Eco-Horror"
FAKE_RELATIONSHIP = "Fake Relationship"
KINGDOM_MANAGEMENT = "Kingdom Management"
MASTURBATION = "Masturbation"
PREGNANCY = "Pregnancy"
RAPE = "Rape"
REHABILITATION = "Rehabilitation"
REVENGE = "Revenge"
SUICIDE = "Suicide"
TRAGEDY = "Tragedy"
# Theme Fantasy
ALCHEMY = "Alchemy"
BODY_SWAPPING = "Body Swapping"
CURSES = "Curses"
CULTIVATION = "Cultivation"
EXORCISM = "Exorcism"
FAIRY_TALE = "Fairy Tale"
HENSHIN = "Henshin"
ISEKAI = "Isekai"
KAIJU = "Kaiju"
MAGIC = "Magic"
MYTHOLOGY = "Mythology"
MEDIEVAL = "Medieval"
NECROMANCY = "Necromancy"
SHAPESHIFTING = "Shapeshifting"
STEAMPUNK = "Steampunk"
SUPER_POWER = "Super Power"
SUPERHERO = "Superhero"
WUXIA = "Wuxia"
# Theme Game
BOARD_GAME = "Board Game"
E_SPORTS = "E-Sports"
VIDEO_GAMES = "Video Games"
# Theme Game-Card & Board Game
CARD_BATTLE = "Card Battle"
GO = "Go"
KARUTA = "Karuta"
MAHJONG = "Mahjong"
POKER = "Poker"
SHOGI = "Shogi"
# Theme Game-Sport
ACROBATICS = "Acrobatics"
AIRSOFT = "Airsoft"
AMERICAN_FOOTBALL = "American Football"
ATHLETICS = "Athletics"
BADMINTON = "Badminton"
BASEBALL = "Baseball"
BASKETBALL = "Basketball"
BOWLING = "Bowling"
BOXING = "Boxing"
CHEERLEADING = "Cheerleading"
CYCLING = "Cycling"
FENCING = "Fencing"
FISHING = "Fishing"
FITNESS = "Fitness"
FOOTBALL = "Football"
GOLF = "Golf"
HANDBALL = "Handball"
ICE_SKATING = "Ice Skating"
JUDO = "Judo"
LACROSSE = "Lacrosse"
PARKOUR = "Parkour"
RUGBY = "Rugby"
SCUBA_DIVING = "Scuba Diving"
SKATEBOARDING = "Skateboarding"
SUMO = "Sumo"
SURFING = "Surfing"
SWIMMING = "Swimming"
TABLE_TENNIS = "Table Tennis"
TENNIS = "Tennis"
VOLLEYBALL = "Volleyball"
WRESTLING = "Wrestling"
# Theme Other
ADOPTION = "Adoption"
ANIMALS = "Animals"
ASTRONOMY = "Astronomy"
AUTOBIOGRAPHICAL = "Autobiographical"
BIOGRAPHICAL = "Biographical"
BODY_HORROR = "Body Horror"
BODY_IMAGE = "Body Image"
CANNIBALISM = "Cannibalism"
CHIBI = "Chibi"
COHABITATION = "Cohabitation"
COSMIC_HORROR = "Cosmic Horror"
CREATURE_TAMING = "Creature Taming"
CRIME = "Crime"
CROSSOVER = "Crossover"
DEATH_GAME = "Death Game"
DENPA = "Denpa"
DEFLORATION = "Defloration"
DRUGS = "Drugs"
ECONOMICS = "Economics"
EDUCATIONAL = "Educational"
ENVIRONMENTAL = "Environmental"
ERO_GURO = "Ero Guro"
FILMMAKING = "Filmmaking"
FOUND_FAMILY = "Found Family"
GAMBLING = "Gambling"
GENDER_BENDING = "Gender Bending"
GORE = "Gore"
HYPERSEXUALITY = "Hypersexuality"
LANGUAGE_BARRIER = "Language Barrier"
LARGE_BREASTS = "Large Breasts"
LGBTQ_PLUS_THEMES = "LGBTQ+ Themes"
LOST_CIVILIZATION = "Lost Civilization"
MARRIAGE = "Marriage"
MEDICINE = "Medicine"
MEMORY_MANIPULATION = "Memory Manipulation"
META = "Meta"
MIXED_MEDIA = "Mixed Media"
MOUNTAINEERING = "Mountaineering"
NOIR = "Noir"
OTAKU_CULTURE = "Otaku Culture"
OUTDOOR_ACTIVITIES = "Outdoor Activities"
PANDEMIC = "Pandemic"
PHILOSOPHY = "Philosophy"
POLITICS = "Politics"
PROXY_BATTLE = "Proxy Battle"
PSYCHOSEXUAL = "Psychosexual"
REINCARNATION = "Reincarnation"
RELIGION = "Religion"
RESCUE = "Rescue"
ROYAL_AFFAIRS = "Royal Affairs"
SLAVERY = "Slavery"
SOFTWARE_DEVELOPMENT = "Software Development"
SURVIVAL = "Survival"
TERRORISM = "Terrorism"
THREESOME = "Threesome"
TORTURE = "Torture"
TRAVEL = "Travel"
WAR = "War"
WILDERNESS = "Wilderness"
VORE = "Vore" # Added
# Theme Other-Organisations
ASSASSINS = "Assassins"
CRIMINAL_ORGANIZATION = "Criminal Organization"
CULT = "Cult"
FIREFIGHTERS = "Firefighters"
GANGS = "Gangs"
MAFIA = "Mafia"
MILITARY = "Military"
POLICE = "Police"
TRIADS = "Triads"
YAKUZA = "Yakuza"
# Theme Other-Vehicle
AVIATION = "Aviation"
CARS = "Cars"
MOPEDS = "Mopeds"
MOTORCYCLES = "Motorcycles"
SHIPS = "Ships"
TANKS = "Tanks"
TRAINS = "Trains"
# Theme Romance
AGE_GAP = "Age Gap"
BISEXUAL = "Bisexual"
BOYS_LOVE = "Boys' Love"
FEMALE_HAREM = "Female Harem"
HETEROSEXUAL = "Heterosexual"
INCEST = "Incest"
LOVE_TRIANGLE = "Love Triangle"
MALE_HAREM = "Male Harem"
MATCHMAKING = "Matchmaking"
MIXED_GENDER_HAREM = "Mixed Gender Harem"
PUBLIC_SEX = "Public Sex"
TEENS_LOVE = "Teens' Love"
UNREQUITED_LOVE = "Unrequited Love"
YURI = "Yuri"
# Theme Sci Fi
CYBERPUNK = "Cyberpunk"
SPACE_OPERA = "Space Opera"
TIME_LOOP = "Time Loop"
TIME_MANIPULATION = "Time Manipulation"
TOKUSATSU = "Tokusatsu"
# Theme Sci Fi-Mecha
REAL_ROBOT = "Real Robot"
SUPER_ROBOT = "Super Robot"
# Theme Slice of Life
AGRICULTURE = "Agriculture"
CUTE_BOYS_DOING_CUTE_THINGS = "Cute Boys Doing Cute Things"
CUTE_GIRLS_DOING_CUTE_THINGS = "Cute Girls Doing Cute Things"
FAMILY_LIFE = "Family Life"
HORTICULTURE = "Horticulture"
IYASHIKEI = "Iyashikei"
PARENTHOOD = "Parenthood"
# MODELS
class BaseApiModel(BaseModel): class BaseApiModel(BaseModel):
"""Base model for all API types.""" model_config = ConfigDict(frozen=True)
pass
class MediaImage(BaseApiModel): class MediaImage(BaseApiModel):
@@ -50,22 +499,22 @@ class AiringSchedule(BaseApiModel):
"""A generic representation of the next airing episode.""" """A generic representation of the next airing episode."""
episode: int episode: int
airing_at: datetime | None = None airing_at: Optional[datetime] = None
class Studio(BaseApiModel): class Studio(BaseApiModel):
"""A generic representation of an animation studio.""" """A generic representation of an animation studio."""
id: int | None = None id: Optional[int] = None
name: str | None = None name: Optional[str] = None
favourites: int | None = None favourites: Optional[int] = None
is_animation_studio: bool | None = None is_animation_studio: Optional[bool] = None
class MediaTag(BaseApiModel): class MediaTagItem(BaseApiModel):
"""A generic representation of a descriptive tag.""" """A generic representation of a descriptive tag."""
name: str name: MediaTag
rank: Optional[int] = None # Percentage relevance from 0-100 rank: Optional[int] = None # Percentage relevance from 0-100
@@ -76,12 +525,11 @@ class StreamingEpisode(BaseApiModel):
thumbnail: Optional[str] = None thumbnail: Optional[str] = None
class UserListStatus(BaseApiModel): class UserListItem(BaseApiModel):
"""Generic representation of a user's list status for a media item.""" """Generic representation of a user's list status for a media item."""
id: int | None = None id: Optional[int] = None
status: Optional[UserMediaListStatus] = None
status: Optional[UserListStatusType] = None
progress: Optional[int] = None progress: Optional[int] = None
score: Optional[float] = None score: Optional[float] = None
repeat: Optional[int] = None repeat: Optional[int] = None
@@ -95,9 +543,9 @@ class MediaItem(BaseApiModel):
id: int id: int
title: MediaTitle title: MediaTitle
id_mal: Optional[int] = None id_mal: Optional[int] = None
type: MediaType = "ANIME" type: MediaType = MediaType.ANIME
status: Optional[str] = None status: MediaStatus = MediaStatus.FINISHED
format: Optional[str] = None # e.g., TV, MOVIE, OVA format: MediaFormat = MediaFormat.TV
cover_image: Optional[MediaImage] = None cover_image: Optional[MediaImage] = None
banner_image: Optional[str] = None banner_image: Optional[str] = None
@@ -106,8 +554,8 @@ class MediaItem(BaseApiModel):
description: Optional[str] = None description: Optional[str] = None
episodes: Optional[int] = None episodes: Optional[int] = None
duration: Optional[int] = None # In minutes duration: Optional[int] = None # In minutes
genres: List[str] = Field(default_factory=list) genres: List[MediaGenre] = Field(default_factory=list)
tags: List[MediaTag] = Field(default_factory=list) tags: List[MediaTagItem] = Field(default_factory=list)
studios: List[Studio] = Field(default_factory=list) studios: List[Studio] = Field(default_factory=list)
synonymns: List[str] = Field(default_factory=list) synonymns: List[str] = Field(default_factory=list)
@@ -124,7 +572,7 @@ class MediaItem(BaseApiModel):
streaming_episodes: List[StreamingEpisode] = Field(default_factory=list) streaming_episodes: List[StreamingEpisode] = Field(default_factory=list)
# user related # user related
user_status: Optional[UserListStatus] = None user_status: Optional[UserListItem] = None
class PageInfo(BaseApiModel): class PageInfo(BaseApiModel):
@@ -150,3 +598,126 @@ class UserProfile(BaseApiModel):
name: str name: str
avatar_url: Optional[str] = None avatar_url: Optional[str] = None
banner_url: Optional[str] = None banner_url: Optional[str] = None
# ENUMS
class MediaSort(Enum):
ID = "ID"
ID_DESC = "ID_DESC"
TITLE_ROMAJI = "TITLE_ROMAJI"
TITLE_ROMAJI_DESC = "TITLE_ROMAJI_DESC"
TITLE_ENGLISH = "TITLE_ENGLISH"
TITLE_ENGLISH_DESC = "TITLE_ENGLISH_DESC"
TITLE_NATIVE = "TITLE_NATIVE"
TITLE_NATIVE_DESC = "TITLE_NATIVE_DESC"
TYPE = "TYPE"
TYPE_DESC = "TYPE_DESC"
FORMAT = "FORMAT"
FORMAT_DESC = "FORMAT_DESC"
START_DATE = "START_DATE"
START_DATE_DESC = "START_DATE_DESC"
END_DATE = "END_DATE"
END_DATE_DESC = "END_DATE_DESC"
SCORE = "SCORE"
SCORE_DESC = "SCORE_DESC"
POPULARITY = "POPULARITY"
POPULARITY_DESC = "POPULARITY_DESC"
TRENDING = "TRENDING"
TRENDING_DESC = "TRENDING_DESC"
EPISODES = "EPISODES"
EPISODES_DESC = "EPISODES_DESC"
DURATION = "DURATION"
DURATION_DESC = "DURATION_DESC"
STATUS = "STATUS"
STATUS_DESC = "STATUS_DESC"
CHAPTERS = "CHAPTERS"
CHAPTERS_DESC = "CHAPTERS_DESC"
VOLUMES = "VOLUMES"
VOLUMES_DESC = "VOLUMES_DESC"
UPDATED_AT = "UPDATED_AT"
UPDATED_AT_DESC = "UPDATED_AT_DESC"
SEARCH_MATCH = "SEARCH_MATCH"
FAVOURITES = "FAVOURITES"
FAVOURITES_DESC = "FAVOURITES_DESC"
class UserMediaListSort(Enum):
MEDIA_ID = "MEDIA_ID"
MEDIA_ID_DESC = "MEDIA_ID_DESC"
SCORE = "SCORE"
SCORE_DESC = "SCORE_DESC"
STATUS = "STATUS"
STATUS_DESC = "STATUS_DESC"
PROGRESS = "PROGRESS"
PROGRESS_DESC = "PROGRESS_DESC"
PROGRESS_VOLUMES = "PROGRESS_VOLUMES"
PROGRESS_VOLUMES_DESC = "PROGRESS_VOLUMES_DESC"
REPEAT = "REPEAT"
REPEAT_DESC = "REPEAT_DESC"
PRIORITY = "PRIORITY"
PRIORITY_DESC = "PRIORITY_DESC"
STARTED_ON = "STARTED_ON"
STARTED_ON_DESC = "STARTED_ON_DESC"
FINISHED_ON = "FINISHED_ON"
FINISHED_ON_DESC = "FINISHED_ON_DESC"
ADDED_TIME = "ADDED_TIME"
ADDED_TIME_DESC = "ADDED_TIME_DESC"
UPDATED_TIME = "UPDATED_TIME"
UPDATED_TIME_DESC = "UPDATED_TIME_DESC"
MEDIA_TITLE_ROMAJI = "MEDIA_TITLE_ROMAJI"
MEDIA_TITLE_ROMAJI_DESC = "MEDIA_TITLE_ROMAJI_DESC"
MEDIA_TITLE_ENGLISH = "MEDIA_TITLE_ENGLISH"
MEDIA_TITLE_ENGLISH_DESC = "MEDIA_TITLE_ENGLISH_DESC"
MEDIA_TITLE_NATIVE = "MEDIA_TITLE_NATIVE"
MEDIA_TITLE_NATIVE_DESC = "MEDIA_TITLE_NATIVE_DESC"
MEDIA_POPULARITY = "MEDIA_POPULARITY"
MEDIA_POPULARITY_DESC = "MEDIA_POPULARITY_DESC"
MEDIA_SCORE = "MEDIA_SCORE"
MEDIA_SCORE_DESC = "MEDIA_SCORE_DESC"
MEDIA_START_DATE = "MEDIA_START_DATE"
MEDIA_START_DATE_DESC = "MEDIA_START_DATE_DESC"
MEDIA_RATING = "MEDIA_RATING"
MEDIA_RATING_DESC = "MEDIA_RATING_DESC"
class MediaSeason(Enum):
WINTER = "WINTER"
SPRING = "SPRING"
SUMMER = "SUMMER"
FALL = "FALL"
class MediaYear(Enum):
_1900 = "1900"
_1910 = "1910"
_1920 = "1920"
_1930 = "1930"
_1940 = "1940"
_1950 = "1950"
_1960 = "1960"
_1970 = "1970"
_1980 = "1980"
_1990 = "1990"
_2000 = "2000"
_2004 = "2004"
_2005 = "2005"
_2006 = "2006"
_2007 = "2007"
_2008 = "2008"
_2009 = "2009"
_2010 = "2010"
_2011 = "2011"
_2012 = "2012"
_2013 = "2013"
_2014 = "2014"
_2015 = "2015"
_2016 = "2016"
_2017 = "2017"
_2018 = "2018"
_2019 = "2019"
_2020 = "2020"
_2021 = "2021"
_2022 = "2022"
_2023 = "2023"
_2024 = "2024"
_2025 = "2025"

View File

@@ -1,10 +1,10 @@
from typing import Literal, Optional from typing import Literal, Optional
from pydantic import BaseModel from pydantic import BaseModel, ConfigDict
class BaseAnimeProviderModel(BaseModel): class BaseAnimeProviderModel(BaseModel):
pass model_config = ConfigDict(frozen=True)
class PageInfo(BaseAnimeProviderModel): class PageInfo(BaseAnimeProviderModel):
@@ -35,7 +35,6 @@ class SearchResult(BaseAnimeProviderModel):
class SearchResults(BaseAnimeProviderModel): class SearchResults(BaseAnimeProviderModel):
page_info: PageInfo page_info: PageInfo
results: list[SearchResult] results: list[SearchResult]
model_config = {"frozen": True}
class AnimeEpisodeInfo(BaseAnimeProviderModel): class AnimeEpisodeInfo(BaseAnimeProviderModel):