mirror of
https://github.com/myronblair/kino-app
synced 2026-07-27 21:18:43 -05:00
514cbad4ab
- New admin Library Scan page walks the NAS mounts directly for movies, TV, and music not already in Radarr/Sonarr/local storage, and imports them with storage_type=scan (fully integrated: streamable, transcodable, listed in Admin) — Radarr/Sonarr import flows are untouched. - New Music section (nav tab, album-grid browse, per-album track list, persistent bottom player with queue/seek) backed by a Track model and range-request audio streaming endpoint. - MusicBrainz + Cover Art Archive integration (free, no API key) to fill in album art for scanned tracks, paced to their 1 req/sec rate limit. - Mounted the NAS music share into CT104 (new mp2 LXC mountpoint, CIFS read-only) alongside the existing video mount. - Optional Bluetooth/output-device picker on the player bar via the Audio Output Devices API (setSinkId) for already-paired speakers — Chrome/Edge only, since browsers can't pair new BT devices directly.
395 lines
9.7 KiB
Python
395 lines
9.7 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 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
|