Files
kino-app/backend/models.py
T
Myron Blair 3d2b7a4122 Wire Requests approval into Radarr/Sonarr with live download status
Admin can now Approve a pending request as Movie or TV, which looks up
the title via Radarr/Sonarr's own lookup API, adds the best match with
search-on-add enabled, and tracks the resulting radarr_id/sonarr_id on
the request. A live status line (searching/queued/downloading/downloaded,
polled every 15s) shows next to each approved request.
2026-07-26 20:55:22 -05:00

404 lines
10 KiB
Python

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 RequestApprove(BaseModel):
content_type: str # "movie" | "tv"
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" # pending | approved | fulfilled | rejected
content_type: Optional[str] = None # movie | tv, set once approved
radarr_id: Optional[int] = None
sonarr_id: Optional[int] = None
matched_title: Optional[str] = None
matched_year: Optional[int] = None
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