from pydantic import BaseModel, Field, ConfigDict from typing import List, Optional from datetime import datetime, timezone import uuid def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() # ---------- Users ---------- class UserCreate(BaseModel): email: str password: str name: str class UserLogin(BaseModel): email: str password: str class PasswordChange(BaseModel): current_password: str new_password: str class UserPublic(BaseModel): model_config = ConfigDict(extra="ignore") id: str email: str name: str is_admin: bool = False created_at: str class AdminUserCreate(BaseModel): email: str password: str name: str is_admin: bool = False class AdminUserUpdate(BaseModel): name: Optional[str] = None is_admin: Optional[bool] = None password: Optional[str] = None # ---------- Profile (1:1 with account) ---------- RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"] class ProfileUpdate(BaseModel): name: Optional[str] = None avatar_color: Optional[str] = None is_kids: Optional[bool] = None max_rating: Optional[str] = None class Profile(BaseModel): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) user_id: str name: str avatar_color: str = "#D9381E" is_kids: bool = False max_rating: str = "NR" created_at: str = Field(default_factory=_now_iso) # ---------- Movies ---------- class MovieBase(BaseModel): title: str description: str = "" year: int = 2024 duration_minutes: int = 0 rating: str = "NR" genres: List[str] = [] cast: List[str] = [] director: str = "" poster_url: str = "" backdrop_url: str = "" video_url: str = "" storage_type: str = "external" # external | local | radarr storage_path: Optional[str] = None featured: bool = False tmdb_id: Optional[int] = None radarr_id: Optional[int] = None hls_path: Optional[str] = None hls_status: Optional[str] = None # pending|running|done|failed class MovieCreate(MovieBase): pass class MovieUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None year: Optional[int] = None duration_minutes: Optional[int] = None rating: Optional[str] = None genres: Optional[List[str]] = None cast: Optional[List[str]] = None director: Optional[str] = None poster_url: Optional[str] = None backdrop_url: Optional[str] = None video_url: Optional[str] = None storage_type: Optional[str] = None storage_path: Optional[str] = None featured: Optional[bool] = None tmdb_id: Optional[int] = None class Movie(MovieBase): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) created_at: str = Field(default_factory=_now_iso) # ---------- Music ---------- class TrackUpdate(BaseModel): title: Optional[str] = None artist: Optional[str] = None album: Optional[str] = None track_number: Optional[int] = None album_art_url: Optional[str] = None class Track(BaseModel): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) title: str artist: str = "Unknown Artist" album: str = "Unknown Album" album_art_url: str = "" track_number: Optional[int] = None duration_seconds: float = 0 storage_type: str = "scan" # scan is currently the only source for tracks storage_path: str created_at: str = Field(default_factory=_now_iso) # ---------- Subtitles ---------- class Subtitle(BaseModel): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) movie_id: str language: str = "en" # ISO code label: str = "English" storage_path: str # filename inside MEDIA_ROOT/subtitles is_default: bool = False created_at: str = Field(default_factory=_now_iso) # ---------- Watchlist / Progress (now profile-scoped) ---------- class WatchlistItem(BaseModel): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) profile_id: str user_id: str movie_id: str added_at: str = Field(default_factory=_now_iso) class ProgressUpsert(BaseModel): movie_id: str position_seconds: float duration_seconds: float class Progress(BaseModel): model_config = ConfigDict(extra="ignore") profile_id: str user_id: str movie_id: str position_seconds: float duration_seconds: float updated_at: str = Field(default_factory=_now_iso) # ---------- Requests ---------- class RequestCreate(BaseModel): title: str year: Optional[int] = None notes: str = "" class RequestUpdate(BaseModel): status: str class MovieRequest(BaseModel): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) user_id: str user_name: str = "" title: str year: Optional[int] = None notes: str = "" status: str = "pending" created_at: str = Field(default_factory=_now_iso) # ---------- App Settings (admin) ---------- class AppSettings(BaseModel): model_config = ConfigDict(extra="ignore") tmdb_api_key: str = "" radarr_url: str = "" radarr_api_key: str = "" sonarr_url: str = "" sonarr_api_key: str = "" opensubs_api_key: str = "" trakt_client_id: str = "" trakt_client_secret: str = "" auto_transcode: str = "off" queue_paused: bool = False class AppSettingsPublic(BaseModel): tmdb_configured: bool = False radarr_configured: bool = False sonarr_configured: bool = False opensubs_configured: bool = False trakt_configured: bool = False tmdb_api_key: str = "" radarr_url: str = "" radarr_api_key: str = "" sonarr_url: str = "" sonarr_api_key: str = "" opensubs_api_key: str = "" trakt_client_id: str = "" trakt_client_secret: str = "" auto_transcode: str = "off" queue_paused: bool = False # ---------- Shows / Episodes (Sonarr) ---------- class ShowBase(BaseModel): title: str description: str = "" year: int = 2024 rating: str = "NR" genres: List[str] = [] cast: List[str] = [] creator: str = "" poster_url: str = "" backdrop_url: str = "" featured: bool = False tvdb_id: Optional[int] = None tmdb_id: Optional[int] = None sonarr_id: Optional[int] = None class Show(ShowBase): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) episode_count: int = 0 season_count: int = 0 created_at: str = Field(default_factory=_now_iso) class ShowCreate(ShowBase): pass class ShowUpdate(BaseModel): title: Optional[str] = None description: Optional[str] = None year: Optional[int] = None rating: Optional[str] = None genres: Optional[List[str]] = None poster_url: Optional[str] = None backdrop_url: Optional[str] = None featured: Optional[bool] = None class EpisodeBase(BaseModel): show_id: str season_number: int episode_number: int title: str description: str = "" duration_minutes: int = 0 air_date: str = "" still_url: str = "" storage_type: str = "external" # local | sonarr | external storage_path: Optional[str] = None video_url: str = "" class Episode(EpisodeBase): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) sonarr_episode_id: Optional[int] = None hidden: bool = False hls_path: Optional[str] = None hls_status: Optional[str] = None created_at: str = Field(default_factory=_now_iso) class EpisodeCreate(EpisodeBase): sonarr_episode_id: Optional[int] = None class EpisodeProgressUpsert(BaseModel): episode_id: str position_seconds: float duration_seconds: float class SonarrSeriesDTO(BaseModel): sonarr_id: int title: str year: Optional[int] = None overview: str = "" poster_url: str = "" tvdb_id: Optional[int] = None tmdb_id: Optional[int] = None season_count: int = 0 episode_file_count: int = 0 # ---------- Trakt ---------- class TraktDeviceCodeResponse(BaseModel): device_code: str user_code: str verification_url: str expires_in: int interval: int class TraktStatus(BaseModel): connected: bool username: str = "" # ---------- OpenSubtitles ---------- class OpenSubsResult(BaseModel): file_id: int language: str label: str = "" release: str = "" fps: float = 0 downloads: int = 0 # ---------- Transcode Queue ---------- class TranscodeJob(BaseModel): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) movie_id: str # generic content id (movie_id OR episode_id, depending on content_type) movie_title: str = "" content_type: str = "movie" # movie | episode quality: str = "abr" status: str = "pending" error: str = "" triggered_by: str = "manual" created_at: str = Field(default_factory=_now_iso) started_at: Optional[str] = None finished_at: Optional[str] = None class QueuePauseToggle(BaseModel): paused: bool # ---------- Auth Tokens ---------- class TokenResponse(BaseModel): access_token: str token_type: str = "bearer" user: UserPublic # ---------- TMDB / Radarr DTOs ---------- class TMDBSearchResult(BaseModel): tmdb_id: int title: str year: Optional[int] = None overview: str = "" poster_url: str = "" backdrop_url: str = "" class RadarrMovieDTO(BaseModel): radarr_id: int title: str year: Optional[int] = None overview: str = "" has_file: bool = False file_path: Optional[str] = None poster_url: str = "" tmdb_id: Optional[int] = None