import logging from datetime import datetime from typing import List, Optional from ..types import ( AiringSchedule, MediaImage, MediaItem, MediaSearchResult, MediaTag, MediaTitle, MediaTrailer, PageInfo, Studio, UserListStatus, UserProfile, ) from .types import ( AnilistBaseMediaDataSchema, AnilistCurrentlyLoggedInUser, AnilistDataSchema, AnilistImage, AnilistMediaList, AnilistMediaLists, AnilistMediaNextAiringEpisode, AnilistMediaTag, AnilistMediaTitle, AnilistMediaTrailer, AnilistPageInfo, AnilistStudioNodes, AnilistViewerData, ) logger = logging.getLogger(__name__) def _to_generic_media_title(anilist_title: AnilistMediaTitle) -> MediaTitle: """Maps an AniList title object to a generic MediaTitle.""" return MediaTitle( romaji=anilist_title.get("romaji"), english=anilist_title.get("english"), native=anilist_title.get("native"), ) def _to_generic_media_image(anilist_image: AnilistImage) -> MediaImage: """Maps an AniList image object to a generic MediaImage.""" return MediaImage( medium=anilist_image.get("medium"), large=anilist_image["large"], extra_large=anilist_image.get("extraLarge"), ) def _to_generic_media_trailer( anilist_trailer: Optional[AnilistMediaTrailer], ) -> Optional[MediaTrailer]: """Maps an AniList trailer object to a generic MediaTrailer.""" if not anilist_trailer or not anilist_trailer.get("id"): return None return MediaTrailer( id=anilist_trailer["id"], site=anilist_trailer.get("site"), thumbnail_url=anilist_trailer.get("thumbnail"), ) def _to_generic_airing_schedule( anilist_schedule: AnilistMediaNextAiringEpisode, ) -> Optional[AiringSchedule]: """Maps an AniList nextAiringEpisode object to a generic AiringSchedule.""" if not anilist_schedule: return return AiringSchedule( airing_at=datetime.fromtimestamp(anilist_schedule["airingAt"]) if anilist_schedule.get("airingAt") else None, episode=anilist_schedule.get("episode", 0), ) def _to_generic_studios(anilist_studios: AnilistStudioNodes) -> List[Studio]: """Maps AniList studio nodes to a list of generic Studio objects.""" return [ Studio( name=s["name"], favourites=s["favourites"], is_animation_studio=s["isAnimationStudio"], ) for s in anilist_studios["nodes"] ] def _to_generic_tags(anilist_tags: list[AnilistMediaTag]) -> List[MediaTag]: """Maps a list of AniList tags to generic MediaTag objects.""" return [ MediaTag(name=t["name"], rank=t.get("rank")) for t in anilist_tags if t.get("name") ] def _to_generic_user_status( anilist_media: AnilistBaseMediaDataSchema, anilist_list_entry: Optional[AnilistMediaList], ) -> Optional[UserListStatus]: """Maps an AniList mediaListEntry to a generic UserListStatus.""" if anilist_list_entry: return UserListStatus( status=anilist_list_entry["status"], progress=anilist_list_entry["progress"], score=anilist_list_entry["score"], repeat=anilist_list_entry["repeat"], notes=anilist_list_entry["notes"], start_date=datetime( anilist_list_entry["startDate"]["year"], anilist_list_entry["startDate"]["month"], anilist_list_entry["startDate"]["day"], ), completed_at=datetime( anilist_list_entry["completedAt"]["year"], anilist_list_entry["completedAt"]["month"], anilist_list_entry["completedAt"]["day"], ), created_at=anilist_list_entry["createdAt"], ) else: if not anilist_media["mediaListEntry"]: return return UserListStatus( id=anilist_media["mediaListEntry"]["id"], status=anilist_media["mediaListEntry"]["status"], progress=anilist_media["mediaListEntry"]["progress"], ) def _to_generic_media_item( data: AnilistBaseMediaDataSchema, media_list: AnilistMediaList | None = None ) -> MediaItem: """Maps a single AniList media schema to a generic MediaItem.""" return MediaItem( id=data["id"], id_mal=data.get("idMal"), type=data.get("type", "ANIME"), title=_to_generic_media_title(data["title"]), status=data["status"], format=data.get("format"), cover_image=_to_generic_media_image(data["coverImage"]), banner_image=data.get("bannerImage"), trailer=_to_generic_media_trailer(data["trailer"]), description=data.get("description"), episodes=data.get("episodes"), duration=data.get("duration"), genres=data.get("genres", []), tags=_to_generic_tags(data.get("tags")), studios=_to_generic_studios(data.get("studios")), synonyms=data.get("synonyms", []), average_score=data.get("averageScore"), popularity=data.get("popularity"), favourites=data.get("favourites"), next_airing=_to_generic_airing_schedule(data.get("nextAiringEpisode")), user_status=_to_generic_user_status(data, media_list), ) def _to_generic_page_info(data: AnilistPageInfo) -> PageInfo: """Maps an AniList page info object to a generic PageInfo.""" return PageInfo( total=data.get("total", 0), current_page=data.get("currentPage", 1), has_next_page=data.get("hasNextPage", False), per_page=data.get("perPage", 0), ) def to_generic_search_result( data: AnilistDataSchema, user_media_list: List[AnilistMediaList] | None = None ) -> Optional[MediaSearchResult]: """ Top-level mapper to convert a raw AniList search/list API response into a generic MediaSearchResult object. """ page_data = data["data"]["Page"] raw_media_list = page_data["media"] if user_media_list: media_items: List[MediaItem] = [ _to_generic_media_item(item, user_media_list_item) for item, user_media_list_item in zip(raw_media_list, user_media_list) ] else: media_items: List[MediaItem] = [ _to_generic_media_item(item) for item in raw_media_list ] page_info = _to_generic_page_info(page_data["pageInfo"]) return MediaSearchResult(page_info=page_info, media=media_items) def to_generic_user_list_result(data: AnilistMediaLists) -> Optional[MediaSearchResult]: """ Mapper for user list queries where media data is nested inside a 'mediaList' key. """ page_data = data["data"]["Page"] # Extract media objects from the 'mediaList' array media_list_items = page_data["mediaList"] raw_media_list = [item["media"] for item in media_list_items] # Now that we have a standard list of media, we can reuse the main search result mapper return to_generic_search_result( {"data": {"Page": {"media": raw_media_list}}}, # pyright:ignore media_list_items, ) def to_generic_user_profile(data: AnilistViewerData) -> Optional[UserProfile]: """Maps a raw AniList viewer response to a generic UserProfile.""" viewer_data: Optional[AnilistCurrentlyLoggedInUser] = data["data"]["Viewer"] return UserProfile( id=viewer_data["id"], name=viewer_data["name"], avatar_url=viewer_data["avatar"]["large"], banner_url=viewer_data["bannerImage"], ) # TODO: complete this def to_generic_relations(data: dict) -> Optional[List[MediaItem]]: """Maps the 'relations' part of an API response.""" nodes = data["data"].get("Media", {}).get("relations", {}).get("nodes", []) return [_to_generic_media_item(node) for node in nodes if node] def to_generic_recommendations(data: dict) -> Optional[List[MediaItem]]: """Maps the 'recommendations' part of an API response.""" recs = ( data.get("data", {}) .get("Media", {}) .get("recommendations", {}) .get("nodes", []) ) return [ _to_generic_media_item(rec.get("mediaRecommendation")) for rec in recs if rec.get("mediaRecommendation") ]