feat: switch to pydantic types for api

This commit is contained in:
Benexl
2025-07-14 23:00:20 +03:00
parent 273dd56680
commit b6dd965e49
3 changed files with 58 additions and 168 deletions

View File

@@ -6,17 +6,17 @@ Provides comprehensive data structures for tracking and managing local watch his
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
from ...libs.api.types import MediaItem
logger = logging.getLogger(__name__)
@dataclass
class WatchHistoryEntry:
class WatchHistoryEntry(BaseModel):
"""
Represents a single entry in the watch history.
Contains media information and viewing progress.
@@ -26,104 +26,16 @@ class WatchHistoryEntry:
last_watched_episode: int = 0
watch_progress: float = 0.0 # Progress within the episode (0.0-1.0)
times_watched: int = 1
first_watched: datetime = field(default_factory=datetime.now)
last_watched: datetime = field(default_factory=datetime.now)
first_watched: datetime = Field(default_factory=datetime.now)
last_watched: datetime = Field(default_factory=datetime.now)
status: str = "watching" # watching, completed, dropped, paused
notes: str = ""
def to_dict(self) -> dict:
"""Convert entry to dictionary for JSON serialization."""
return {
"media_item": {
"id": self.media_item.id,
"id_mal": self.media_item.id_mal,
"type": self.media_item.type,
"title": {
"romaji": self.media_item.title.romaji,
"english": self.media_item.title.english,
"native": self.media_item.title.native,
},
"status": self.media_item.status,
"format": self.media_item.format,
"cover_image": {
"large": self.media_item.cover_image.large if self.media_item.cover_image else None,
"medium": self.media_item.cover_image.medium if self.media_item.cover_image else None,
} if self.media_item.cover_image else None,
"banner_image": self.media_item.banner_image,
"description": self.media_item.description,
"episodes": self.media_item.episodes,
"duration": self.media_item.duration,
"genres": self.media_item.genres,
"synonyms": self.media_item.synonyms,
"average_score": self.media_item.average_score,
"popularity": self.media_item.popularity,
"favourites": self.media_item.favourites,
},
"last_watched_episode": self.last_watched_episode,
"watch_progress": self.watch_progress,
"times_watched": self.times_watched,
"first_watched": self.first_watched.isoformat(),
"last_watched": self.last_watched.isoformat(),
"status": self.status,
"notes": self.notes,
}
# With Pydantic, serialization is automatic!
# No need for manual to_dict() and from_dict() methods
# Use: entry.model_dump() and WatchHistoryEntry.model_validate(data)
@classmethod
def from_dict(cls, data: dict) -> "WatchHistoryEntry":
"""Create entry from dictionary."""
from ...libs.api.types import MediaImage, MediaTitle
media_data = data["media_item"]
# Reconstruct MediaTitle
title_data = media_data.get("title", {})
title = MediaTitle(
romaji=title_data.get("romaji"),
english=title_data.get("english"),
native=title_data.get("native"),
)
# Reconstruct MediaImage if present
cover_data = media_data.get("cover_image")
cover_image = None
if cover_data:
cover_image = MediaImage(
large=cover_data.get("large", ""),
medium=cover_data.get("medium"),
)
# Reconstruct MediaItem
media_item = MediaItem(
id=media_data["id"],
id_mal=media_data.get("id_mal"),
type=media_data.get("type", "ANIME"),
title=title,
status=media_data.get("status"),
format=media_data.get("format"),
cover_image=cover_image,
banner_image=media_data.get("banner_image"),
description=media_data.get("description"),
episodes=media_data.get("episodes"),
duration=media_data.get("duration"),
genres=media_data.get("genres", []),
synonyms=media_data.get("synonyms", []),
average_score=media_data.get("average_score"),
popularity=media_data.get("popularity"),
favourites=media_data.get("favourites"),
)
return cls(
media_item=media_item,
last_watched_episode=data.get("last_watched_episode", 0),
watch_progress=data.get("watch_progress", 0.0),
times_watched=data.get("times_watched", 1),
first_watched=datetime.fromisoformat(data.get("first_watched", datetime.now().isoformat())),
last_watched=datetime.fromisoformat(data.get("last_watched", datetime.now().isoformat())),
status=data.get("status", "watching"),
notes=data.get("notes", ""),
)
def update_progress(self, episode: int, progress: float = 0.0, status: str = None):
def update_progress(self, episode: int, progress: float = 0.0, status: Optional[str] = None):
"""Update watch progress for this entry."""
self.last_watched_episode = max(self.last_watched_episode, episode)
self.watch_progress = progress
@@ -169,41 +81,16 @@ class WatchHistoryEntry:
return status_emojis.get(self.status, "")
@dataclass
class WatchHistoryData:
class WatchHistoryData(BaseModel):
"""Complete watch history data container."""
entries: Dict[int, WatchHistoryEntry] = field(default_factory=dict)
last_updated: datetime = field(default_factory=datetime.now)
entries: Dict[int, WatchHistoryEntry] = Field(default_factory=dict)
last_updated: datetime = Field(default_factory=datetime.now)
format_version: str = "1.0"
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization."""
return {
"entries": {str(k): v.to_dict() for k, v in self.entries.items()},
"last_updated": self.last_updated.isoformat(),
"format_version": self.format_version,
}
@classmethod
def from_dict(cls, data: dict) -> "WatchHistoryData":
"""Create from dictionary."""
entries = {}
entries_data = data.get("entries", {})
for media_id_str, entry_data in entries_data.items():
try:
media_id = int(media_id_str)
entry = WatchHistoryEntry.from_dict(entry_data)
entries[media_id] = entry
except (ValueError, KeyError) as e:
logger.warning(f"Skipping invalid watch history entry {media_id_str}: {e}")
return cls(
entries=entries,
last_updated=datetime.fromisoformat(data.get("last_updated", datetime.now().isoformat())),
format_version=data.get("format_version", "1.0"),
)
# With Pydantic, serialization is automatic!
# No need for manual to_dict() and from_dict() methods
# Use: data.model_dump() and WatchHistoryData.model_validate(data)
def add_or_update_entry(self, media_item: MediaItem, episode: int = 0, progress: float = 0.0, status: str = "watching") -> WatchHistoryEntry:
"""Add or update a watch history entry."""

View File

@@ -32,30 +32,39 @@ JIKAN_STATUS_MAP = {
def _to_generic_title(jikan_titles: list[dict]) -> MediaTitle:
"""Extracts titles from Jikan's list of title objects."""
title_obj = MediaTitle()
# Initialize with default values
romaji = None
english = None
native = None
# Jikan's default title is often the romaji one.
# We prioritize specific types if available.
for t in jikan_titles:
type_ = t.get("type")
title_ = t.get("title")
if type_ == "Default":
title_obj.romaji = title_
romaji = title_
elif type_ == "English":
title_obj.english = title_
english = title_
elif type_ == "Japanese":
title_obj.native = title_
return title_obj
native = title_
return MediaTitle(
romaji=romaji,
english=english,
native=native
)
def _to_generic_image(jikan_images: dict) -> MediaImage:
"""Maps Jikan's image structure."""
if not jikan_images:
return MediaImage()
return MediaImage(large="") # Provide empty string as fallback
# Jikan provides different image formats under a 'jpg' key.
jpg_images = jikan_images.get("jpg", {})
return MediaImage(
large=jpg_images.get("large_image_url", ""), # Fallback to empty string
medium=jpg_images.get("image_url"),
large=jpg_images.get("large_image_url"),
)
@@ -71,7 +80,7 @@ def _to_generic_media_item(data: dict) -> MediaItem:
id_mal=data["mal_id"],
title=_to_generic_title(data.get("titles", [])),
cover_image=_to_generic_image(data.get("images", {})),
status=JIKAN_STATUS_MAP.get(data.get("status")),
status=JIKAN_STATUS_MAP.get(data.get("status", ""), None),
episodes=data.get("episodes"),
duration=data.get("duration"),
average_score=score,

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
# --- Generic Enums and Type Aliases ---
MediaType = Literal["ANIME", "MANGA"]
@@ -17,8 +18,12 @@ UserListStatusType = Literal[
# --- Generic Data Models ---
@dataclass(frozen=True)
class MediaImage:
class BaseApiModel(BaseModel):
"""Base model for all API types."""
pass
class MediaImage(BaseApiModel):
"""A generic representation of media imagery URLs."""
large: str
@@ -26,8 +31,7 @@ class MediaImage:
extra_large: Optional[str] = None
@dataclass(frozen=True)
class MediaTitle:
class MediaTitle(BaseApiModel):
"""A generic representation of media titles."""
romaji: Optional[str] = None
@@ -35,8 +39,7 @@ class MediaTitle:
native: Optional[str] = None
@dataclass(frozen=True)
class MediaTrailer:
class MediaTrailer(BaseApiModel):
"""A generic representation of a media trailer."""
id: str
@@ -44,16 +47,14 @@ class MediaTrailer:
thumbnail_url: Optional[str] = None
@dataclass(frozen=True)
class AiringSchedule:
class AiringSchedule(BaseApiModel):
"""A generic representation of the next airing episode."""
episode: int
airing_at: datetime | None = None
@dataclass(frozen=True)
class Studio:
class Studio(BaseApiModel):
"""A generic representation of an animation studio."""
id: int | None = None
@@ -62,24 +63,21 @@ class Studio:
is_animation_studio: bool | None = None
@dataclass(frozen=True)
class MediaTag:
class MediaTag(BaseApiModel):
"""A generic representation of a descriptive tag."""
name: str
rank: Optional[int] = None # Percentage relevance from 0-100
@dataclass(frozen=True)
class StreamingEpisode:
class StreamingEpisode(BaseApiModel):
"""A generic representation of a streaming episode."""
title: str
thumbnail: Optional[str] = None
@dataclass(frozen=True)
class UserListStatus:
class UserListStatus(BaseApiModel):
"""Generic representation of a user's list status for a media item."""
id: int | None = None
@@ -94,8 +92,7 @@ class UserListStatus:
created_at: Optional[str] = None
@dataclass(frozen=True)
class MediaItem:
class MediaItem(BaseApiModel):
"""
The definitive, backend-agnostic representation of a single media item.
This is the primary data model the application will interact with.
@@ -104,7 +101,7 @@ class MediaItem:
id: int
id_mal: Optional[int] = None
type: MediaType = "ANIME"
title: MediaTitle = field(default_factory=MediaTitle)
title: MediaTitle = Field(default_factory=MediaTitle)
status: Optional[str] = None
format: Optional[str] = None # e.g., TV, MOVIE, OVA
@@ -115,10 +112,10 @@ class MediaItem:
description: Optional[str] = None
episodes: Optional[int] = None
duration: Optional[int] = None # In minutes
genres: List[str] = field(default_factory=list)
tags: List[MediaTag] = field(default_factory=list)
studios: List[Studio] = field(default_factory=list)
synonyms: List[str] = field(default_factory=list)
genres: List[str] = Field(default_factory=list)
tags: List[MediaTag] = Field(default_factory=list)
studios: List[Studio] = Field(default_factory=list)
synonyms: List[str] = Field(default_factory=list)
average_score: Optional[float] = None
popularity: Optional[int] = None
@@ -127,14 +124,13 @@ class MediaItem:
next_airing: Optional[AiringSchedule] = None
# streaming episodes
streaming_episodes: List[StreamingEpisode] = field(default_factory=list)
streaming_episodes: List[StreamingEpisode] = Field(default_factory=list)
# user related
user_status: Optional[UserListStatus] = None
@dataclass(frozen=True)
class PageInfo:
class PageInfo(BaseApiModel):
"""Generic pagination information."""
total: int
@@ -143,16 +139,14 @@ class PageInfo:
per_page: int
@dataclass(frozen=True)
class MediaSearchResult:
class MediaSearchResult(BaseApiModel):
"""A generic representation of a page of media search results."""
page_info: PageInfo
media: List[MediaItem] = field(default_factory=list)
media: List[MediaItem] = Field(default_factory=list)
@dataclass(frozen=True)
class UserProfile:
class UserProfile(BaseApiModel):
"""A generic representation of a user's profile."""
id: int