|
|
"""SoulSync — VIDEO side database (database/video_library.db).
|
|
|
|
|
|
ISOLATION CONTRACT: this module owns a SEPARATE SQLite file from the music
|
|
|
library and imports NOTHING from the music database layer. Music code never
|
|
|
imports this; this never imports music. A migration bug, corruption, or reset
|
|
|
here cannot touch music data, and the two never contend for the same write lock.
|
|
|
|
|
|
Conventions mirror database/music_database.py on purpose (so the two feel the
|
|
|
same operationally) — WAL journal, foreign keys ON, a 30s busy timeout, Row
|
|
|
factory, a once-per-process init guard, and PRAGMA user_version as a schema
|
|
|
backstop — but the implementations are independent.
|
|
|
|
|
|
The schema itself lives alongside this file in video_schema.sql and is executed
|
|
|
verbatim on first init.
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import os
|
|
|
import re
|
|
|
import sqlite3
|
|
|
import threading
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
from pathlib import Path
|
|
|
|
|
|
from utils.logging_config import get_logger
|
|
|
|
|
|
logger = get_logger("video_database")
|
|
|
|
|
|
# Bump when video_schema.sql changes in a way worth recording. Stored in
|
|
|
# PRAGMA user_version as a backstop indicator (nothing gates on it yet).
|
|
|
SCHEMA_VERSION = 7
|
|
|
|
|
|
_DEFAULT_DB_PATH = "database/video_library.db"
|
|
|
_SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql"
|
|
|
|
|
|
# Init runs once per database path per process (same guard style as music).
|
|
|
_init_lock = threading.Lock()
|
|
|
_initialized_paths: set[str] = set()
|
|
|
|
|
|
# Sort/letter key: title without a leading article, lowercased (so "The Matrix"
|
|
|
# files under M, like music's library).
|
|
|
_ARTICLE_RE = re.compile(r"^(the|a|an)\s+", re.IGNORECASE)
|
|
|
|
|
|
|
|
|
def _sort_title(title) -> str:
|
|
|
return _ARTICLE_RE.sub("", (title or "").strip()).lower()
|
|
|
|
|
|
|
|
|
# Enrichment plumbing (parallels music's per-source columns). Maps a service +
|
|
|
# content kind to (table, id_col, match_status_col, last_attempted_col).
|
|
|
_ENRICH = {
|
|
|
"tmdb": {
|
|
|
"movie": ("movies", "tmdb_id", "tmdb_match_status", "tmdb_last_attempted"),
|
|
|
"show": ("shows", "tmdb_id", "tmdb_match_status", "tmdb_last_attempted"),
|
|
|
},
|
|
|
"tvdb": {
|
|
|
"show": ("shows", "tvdb_id", "tvdb_match_status", "tvdb_last_attempted"),
|
|
|
},
|
|
|
}
|
|
|
|
|
|
# Whitelist of metadata columns enrichment may write per table (guards against
|
|
|
# arbitrary keys; backfill semantics applied by the caller).
|
|
|
_ENRICH_META_COLS = {
|
|
|
"movies": {"overview", "backdrop_url", "logo_url", "release_date", "status", "content_rating",
|
|
|
"runtime_minutes", "studio", "tagline", "rating", "rating_critic",
|
|
|
"imdb_id", "tmdb_id"},
|
|
|
"shows": {"overview", "backdrop_url", "logo_url", "status", "network", "content_rating",
|
|
|
"tagline", "rating", "first_air_date", "last_air_date",
|
|
|
"imdb_id", "tmdb_id", "tvdb_id"},
|
|
|
}
|
|
|
|
|
|
# Columns ensured on existing DBs (ALTER TABLE ADD COLUMN; idempotent).
|
|
|
_COLUMN_MIGRATIONS = [
|
|
|
("movies", "tmdb_match_status", "TEXT"),
|
|
|
("movies", "tmdb_last_attempted", "TEXT"),
|
|
|
("shows", "tmdb_match_status", "TEXT"),
|
|
|
("shows", "tmdb_last_attempted", "TEXT"),
|
|
|
("shows", "tvdb_match_status", "TEXT"),
|
|
|
("shows", "tvdb_last_attempted", "TEXT"),
|
|
|
# "capture everything" — richer metadata from the server.
|
|
|
("movies", "tagline", "TEXT"),
|
|
|
("movies", "rating", "REAL"),
|
|
|
("movies", "rating_critic", "REAL"),
|
|
|
("shows", "tagline", "TEXT"),
|
|
|
("shows", "rating", "REAL"),
|
|
|
("shows", "first_air_date", "TEXT"),
|
|
|
("shows", "last_air_date", "TEXT"),
|
|
|
("episodes", "still_url", "TEXT"),
|
|
|
("episodes", "rating", "REAL"),
|
|
|
("movies", "logo_url", "TEXT"),
|
|
|
("shows", "logo_url", "TEXT"),
|
|
|
("shows", "episodes_synced", "INTEGER NOT NULL DEFAULT 0"),
|
|
|
("movies", "imdb_rating", "REAL"), ("movies", "rt_rating", "INTEGER"),
|
|
|
("movies", "metacritic", "INTEGER"),
|
|
|
("shows", "imdb_rating", "REAL"), ("shows", "rt_rating", "INTEGER"),
|
|
|
("shows", "metacritic", "INTEGER"),
|
|
|
("movies", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"),
|
|
|
("shows", "ratings_synced", "INTEGER NOT NULL DEFAULT 0"),
|
|
|
]
|
|
|
|
|
|
|
|
|
class VideoDatabase:
|
|
|
"""Connection + schema manager for the isolated video library DB."""
|
|
|
|
|
|
def __init__(self, database_path: str | None = None):
|
|
|
# Honour the env override (Docker mounts) the same way music does, but
|
|
|
# under a DISTINCT variable so the two databases never collide.
|
|
|
if database_path is None or database_path == _DEFAULT_DB_PATH:
|
|
|
database_path = os.environ.get("VIDEO_DATABASE_PATH", _DEFAULT_DB_PATH)
|
|
|
self.database_path = Path(database_path)
|
|
|
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
self._initialize_once()
|
|
|
|
|
|
# ── connection ──────────────────────────────────────────────────────────
|
|
|
def _get_connection(self) -> sqlite3.Connection:
|
|
|
"""A fresh connection with the standard pragmas applied."""
|
|
|
conn = sqlite3.connect(str(self.database_path), timeout=30.0)
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
|
conn.execute("PRAGMA journal_mode = WAL")
|
|
|
conn.execute("PRAGMA busy_timeout = 30000") # 30s
|
|
|
return conn
|
|
|
|
|
|
def connect(self) -> sqlite3.Connection:
|
|
|
"""Public connection factory — caller owns closing it.
|
|
|
|
|
|
Prefer using ``with db.connect() as conn:`` so commits/rollbacks and
|
|
|
close happen automatically.
|
|
|
"""
|
|
|
return self._get_connection()
|
|
|
|
|
|
# ── init ────────────────────────────────────────────────────────────────
|
|
|
def _initialize_once(self) -> None:
|
|
|
key = str(self.database_path.resolve())
|
|
|
with _init_lock:
|
|
|
if key in _initialized_paths:
|
|
|
return
|
|
|
self._initialize_database()
|
|
|
_initialized_paths.add(key)
|
|
|
|
|
|
def _initialize_database(self) -> None:
|
|
|
schema = _SCHEMA_FILE.read_text(encoding="utf-8")
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
conn.executescript(schema)
|
|
|
self._ensure_columns(conn)
|
|
|
conn.execute(f"PRAGMA user_version = {int(SCHEMA_VERSION)}")
|
|
|
conn.commit()
|
|
|
logger.info(
|
|
|
"Video database ready at %s (schema v%d)",
|
|
|
self.database_path, SCHEMA_VERSION,
|
|
|
)
|
|
|
except Exception:
|
|
|
conn.rollback()
|
|
|
logger.exception("Failed to initialize video database at %s", self.database_path)
|
|
|
raise
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
@staticmethod
|
|
|
def _ensure_columns(conn) -> None:
|
|
|
"""Add any new columns to an existing DB (idempotent ALTER TABLE)."""
|
|
|
for table, col, coltype in _COLUMN_MIGRATIONS:
|
|
|
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
|
|
if col not in cols:
|
|
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {coltype}")
|
|
|
|
|
|
# ── enrichment plumbing (per-source match status, like music) ─────────────
|
|
|
def enrichment_next(self, service: str, retry_days: int = 30, priority=None) -> dict | None:
|
|
|
"""Next item that needs enrichment for a service: pending (never tried)
|
|
|
first, then a not_found item older than retry_days. Returns
|
|
|
{kind, id, title, year, known_id} or None. ``known_id`` is the provider
|
|
|
id the media server already supplied (e.g. tmdb_id/tvdb_id) so the worker
|
|
|
can enrich BY ID instead of re-searching by title.
|
|
|
|
|
|
``priority`` ('movie'/'show') pins a kind to be processed first across the
|
|
|
queue — drives the modal's 'Process first everywhere' control."""
|
|
|
kinds = _ENRICH.get(service)
|
|
|
if not kinds:
|
|
|
return None
|
|
|
items = list(kinds.items())
|
|
|
if priority in kinds:
|
|
|
items.sort(key=lambda kv: 0 if kv[0] == priority else 1)
|
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=retry_days)).strftime("%Y-%m-%d %H:%M:%S")
|
|
|
conn = self._get_connection()
|
|
|
|
|
|
def _row(row, kind, idc):
|
|
|
return {"kind": kind, "id": row["id"], "title": row["title"],
|
|
|
"year": row["year"], "known_id": row[idc]}
|
|
|
|
|
|
try:
|
|
|
for kind, (tbl, idc, sc, _ac) in items:
|
|
|
row = conn.execute(
|
|
|
f"SELECT id, title, year, {idc} FROM {tbl} WHERE {sc} IS NULL ORDER BY id LIMIT 1").fetchone()
|
|
|
if row:
|
|
|
return _row(row, kind, idc)
|
|
|
for kind, (tbl, idc, sc, ac) in items:
|
|
|
row = conn.execute(
|
|
|
f"SELECT id, title, year, {idc} FROM {tbl} "
|
|
|
f"WHERE {sc} IN ('not_found','error') "
|
|
|
f"AND ({ac} IS NULL OR {ac} < ?) ORDER BY {ac} LIMIT 1", (cutoff,)).fetchone()
|
|
|
if row:
|
|
|
return _row(row, kind, idc)
|
|
|
return None
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def enrichment_apply(self, service: str, kind: str, item_id: int, matched: bool,
|
|
|
external_id=None, metadata: dict | None = None,
|
|
|
error: bool = False) -> None:
|
|
|
"""Record a match result: set match_status + last_attempted, the external
|
|
|
id (when matched), and any whitelisted metadata columns.
|
|
|
|
|
|
Status is one of 'matched' / 'not_found' / 'error'. 'error' means the
|
|
|
lookup CALL failed (network/rate-limit/timeout) — distinct from a genuine
|
|
|
'not_found' so a transient blip isn't permanently recorded as "no match"
|
|
|
(mirrors the music workers). Both 'not_found' and 'error' are retried by
|
|
|
enrichment_next after retry_days."""
|
|
|
spec = _ENRICH.get(service, {}).get(kind)
|
|
|
if not spec:
|
|
|
return
|
|
|
tbl, idc, sc, ac = spec
|
|
|
allowed = _ENRICH_META_COLS.get(tbl, set())
|
|
|
status = "matched" if matched else "error" if error else "not_found"
|
|
|
# On legacy DBs tmdb_id/tvdb_id may still carry a UNIQUE index; if a match
|
|
|
# would collide with another row's id we drop the id columns and keep the
|
|
|
# existing (authoritative) id, still recording status + metadata.
|
|
|
id_cols = {"tmdb_id", "tvdb_id", "imdb_id"}
|
|
|
|
|
|
def build(include_ids):
|
|
|
sets = [f"{sc}=?", f"{ac}=CURRENT_TIMESTAMP"]
|
|
|
params = [status]
|
|
|
if matched and external_id is not None and include_ids:
|
|
|
sets.append(f"{idc}=?")
|
|
|
params.append(external_id)
|
|
|
for col, val in (metadata or {}).items():
|
|
|
if val is None or col not in allowed:
|
|
|
continue
|
|
|
if not include_ids and col in id_cols:
|
|
|
continue
|
|
|
# BACKFILL: only fill a column the server left empty — enrichment
|
|
|
# fills gaps, it never clobbers data the media server provided.
|
|
|
sets.append(f"{col}=COALESCE(NULLIF({col}, ''), ?)")
|
|
|
params.append(val)
|
|
|
params.append(item_id)
|
|
|
return f"UPDATE {tbl} SET {', '.join(sets)} WHERE id=?", params
|
|
|
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
sql, params = build(True)
|
|
|
try:
|
|
|
conn.execute(sql, params)
|
|
|
except sqlite3.IntegrityError:
|
|
|
conn.rollback()
|
|
|
sql, params = build(False) # keep existing id, just record status/metadata
|
|
|
conn.execute(sql, params)
|
|
|
# Genres backfill — only when the item has none yet (enrichment fills
|
|
|
# the gap the server didn't). Written to the normalised link tables.
|
|
|
genres = (metadata or {}).get("genres")
|
|
|
link = {"movies": ("movie_genres", "movie_id"),
|
|
|
"shows": ("show_genres", "show_id")}.get(tbl)
|
|
|
if matched and genres and link:
|
|
|
lt, oc = link
|
|
|
has = conn.execute(f"SELECT 1 FROM {lt} WHERE {oc}=? LIMIT 1", (item_id,)).fetchone()
|
|
|
if not has:
|
|
|
self._set_genres(conn, lt, oc, item_id, genres)
|
|
|
# Cast/crew backfill — only when the item has none yet (gap-fill).
|
|
|
cast = (metadata or {}).get("cast")
|
|
|
crew = (metadata or {}).get("crew")
|
|
|
if matched and (cast or crew) and tbl in ("movies", "shows"):
|
|
|
oc = "movie_id" if tbl == "movies" else "show_id"
|
|
|
has = conn.execute(f"SELECT 1 FROM credits WHERE {oc}=? LIMIT 1", (item_id,)).fetchone()
|
|
|
if not has:
|
|
|
self._set_credits(conn, oc, item_id, cast or [], crew or [])
|
|
|
# Per-season poster backfill (TMDB) — fills only seasons the server
|
|
|
# left without art.
|
|
|
seasons_meta = (metadata or {}).get("seasons")
|
|
|
if matched and seasons_meta and tbl == "shows":
|
|
|
for s in seasons_meta:
|
|
|
sn, purl = s.get("season_number"), s.get("poster_url")
|
|
|
if sn is None or not purl:
|
|
|
continue
|
|
|
conn.execute(
|
|
|
"UPDATE seasons SET poster_url=COALESCE(NULLIF(poster_url, ''), ?) "
|
|
|
"WHERE show_id=? AND season_number=?", (purl, item_id, sn))
|
|
|
conn.commit()
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def enrichment_breakdown(self, service: str) -> dict:
|
|
|
if service == "omdb":
|
|
|
return self._ratings_breakdown()
|
|
|
kinds = _ENRICH.get(service, {})
|
|
|
out = {}
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
for kind, (tbl, _idc, sc, _ac) in kinds.items():
|
|
|
out[kind] = {
|
|
|
"matched": conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {sc}='matched'").fetchone()[0],
|
|
|
"not_found": conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {sc}='not_found'").fetchone()[0],
|
|
|
"errors": conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {sc}='error'").fetchone()[0],
|
|
|
"pending": conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {sc} IS NULL").fetchone()[0],
|
|
|
}
|
|
|
# TMDB also cascades episode art (still) backfill from the show worker,
|
|
|
# so the manager sees episode coverage. Not a queue (matched = has a
|
|
|
# still; the rest are "pending" art) — kept out of the idle/pending
|
|
|
# calc by the worker so it never blocks "Complete".
|
|
|
if service == "tmdb":
|
|
|
total = conn.execute("SELECT COUNT(*) FROM episodes").fetchone()[0]
|
|
|
with_still = conn.execute(
|
|
|
"SELECT COUNT(*) FROM episodes WHERE still_url IS NOT NULL AND still_url<>''").fetchone()[0]
|
|
|
out["episode"] = {"matched": with_still, "not_found": 0, "errors": 0,
|
|
|
"pending": total - with_still, "coverage_only": True}
|
|
|
return out
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def show_match_info(self, show_id: int) -> dict | None:
|
|
|
"""Title/year/tmdb_id for one show — for on-demand (lazy) art refresh."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
row = conn.execute("SELECT title, year, tmdb_id FROM shows WHERE id=?",
|
|
|
(show_id,)).fetchone()
|
|
|
return dict(row) if row else None
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def movie_match_info(self, movie_id: int) -> dict | None:
|
|
|
"""Title/year/tmdb_id for one movie — for on-demand (lazy) refresh."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
row = conn.execute("SELECT title, year, tmdb_id FROM movies WHERE id=?",
|
|
|
(movie_id,)).fetchone()
|
|
|
return dict(row) if row else None
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def library_id_for_tmdb(self, kind: str, tmdb_id) -> int | None:
|
|
|
"""The library row id for a TMDB id if it's already owned, else None. Lets
|
|
|
the search → detail flow link owned titles to their real library detail
|
|
|
(instead of the live TMDB view)."""
|
|
|
table = {"movie": "movies", "show": "shows"}.get(kind)
|
|
|
if not table or tmdb_id is None:
|
|
|
return None
|
|
|
try:
|
|
|
tmdb_id = int(tmdb_id)
|
|
|
except (TypeError, ValueError):
|
|
|
return None
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
row = conn.execute(
|
|
|
f"SELECT id FROM {table} WHERE tmdb_id=? LIMIT 1", (tmdb_id,)).fetchone()
|
|
|
return row["id"] if row else None
|
|
|
except sqlite3.Error:
|
|
|
return None
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None:
|
|
|
"""Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced.
|
|
|
Ratings are dynamic, so these overwrite (unlike gap-only metadata)."""
|
|
|
table = {"movie": "movies", "show": "shows"}.get(kind)
|
|
|
cols = {"imdb_rating", "rt_rating", "metacritic"}
|
|
|
sets, params = ["ratings_synced=1"], []
|
|
|
for c, v in (ratings or {}).items():
|
|
|
if c in cols and v is not None:
|
|
|
sets.append(f"{c}=?")
|
|
|
params.append(v)
|
|
|
if not table:
|
|
|
return
|
|
|
params.append(item_id)
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
conn.execute(f"UPDATE {table} SET {', '.join(sets)} WHERE id=?", params)
|
|
|
conn.commit()
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def ratings_next(self) -> dict | None:
|
|
|
"""Next library item that needs OMDb ratings (has an imdb_id, not synced).
|
|
|
Drives the OMDb worker's background pass. Returns {kind, id, title, imdb_id}."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
for kind, tbl in (("movie", "movies"), ("show", "shows")):
|
|
|
row = conn.execute(
|
|
|
f"SELECT id, title, imdb_id FROM {tbl} "
|
|
|
"WHERE imdb_id IS NOT NULL AND imdb_id<>'' AND ratings_synced=0 "
|
|
|
"ORDER BY id LIMIT 1").fetchone()
|
|
|
if row:
|
|
|
return {"kind": kind, "id": row["id"], "title": row["title"], "imdb_id": row["imdb_id"]}
|
|
|
return None
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def mark_ratings_synced(self, kind: str, item_id: int) -> None:
|
|
|
table = {"movie": "movies", "show": "shows"}.get(kind)
|
|
|
if not table:
|
|
|
return
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
conn.execute(f"UPDATE {table} SET ratings_synced=1 WHERE id=?", (item_id,))
|
|
|
conn.commit()
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def mark_episodes_synced(self, show_id: int) -> None:
|
|
|
"""Flag that the show's FULL episode list has been pulled from metadata
|
|
|
(so the lazy on-view refresh doesn't re-cascade every visit)."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
conn.execute("UPDATE shows SET episodes_synced=1 WHERE id=?", (show_id,))
|
|
|
conn.commit()
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def episode_sync_next(self) -> dict | None:
|
|
|
"""A matched show (has tmdb_id) whose FULL episode list hasn't been pulled
|
|
|
yet — for the TMDB worker's background episode-sync pass, so library cards
|
|
|
show real owned/total without the user opening each one."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
row = conn.execute(
|
|
|
"SELECT id, title, year, tmdb_id FROM shows "
|
|
|
"WHERE tmdb_id IS NOT NULL AND episodes_synced=0 ORDER BY id LIMIT 1").fetchone()
|
|
|
return dict(row) if row else None
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def episode_sync_pending_count(self) -> int:
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
return conn.execute(
|
|
|
"SELECT COUNT(*) FROM shows WHERE tmdb_id IS NOT NULL AND episodes_synced=0").fetchone()[0]
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def _ratings_breakdown(self) -> dict:
|
|
|
"""OMDb 'coverage' breakdown: matched = ratings present, pending = has an
|
|
|
imdb_id but not fetched, not_found = fetched but OMDb had no rating."""
|
|
|
conn = self._get_connection()
|
|
|
out = {}
|
|
|
try:
|
|
|
for kind, tbl in (("movie", "movies"), ("show", "shows")):
|
|
|
def c(where):
|
|
|
return conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {where}").fetchone()[0]
|
|
|
out[kind] = {
|
|
|
"matched": c("imdb_rating IS NOT NULL"),
|
|
|
"not_found": c("ratings_synced=1 AND imdb_rating IS NULL AND imdb_id IS NOT NULL"),
|
|
|
"errors": 0,
|
|
|
"pending": c("imdb_id IS NOT NULL AND imdb_id<>'' AND ratings_synced=0"),
|
|
|
}
|
|
|
return out
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def show_season_numbers(self, show_id: int) -> list:
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
return [r["season_number"] for r in conn.execute(
|
|
|
"SELECT season_number FROM seasons WHERE show_id=? ORDER BY season_number",
|
|
|
(show_id,)).fetchall()]
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def backfill_episodes(self, show_id: int, season_number: int, episodes: list,
|
|
|
season_overview: str | None = None, season_poster: str | None = None) -> int:
|
|
|
"""UPSERT a season's episodes from the metadata provider so the show's
|
|
|
FULL episode list is represented — owned episodes (from the server) keep
|
|
|
has_file=1, and episodes the server doesn't have are inserted as MISSING
|
|
|
(has_file=0). Existing rows get gap-only metadata fills (never clobbered);
|
|
|
the season row is created if it didn't exist (a fully-missing season).
|
|
|
Returns the number of episode rows touched."""
|
|
|
conn = self._get_connection()
|
|
|
touched = 0
|
|
|
try:
|
|
|
conn.execute("INSERT OR IGNORE INTO seasons (show_id, season_number) VALUES (?, ?)",
|
|
|
(show_id, season_number))
|
|
|
season_id = conn.execute("SELECT id FROM seasons WHERE show_id=? AND season_number=?",
|
|
|
(show_id, season_number)).fetchone()["id"]
|
|
|
if season_overview or season_poster:
|
|
|
conn.execute("UPDATE seasons SET overview=COALESCE(NULLIF(overview, ''), ?), "
|
|
|
"poster_url=COALESCE(NULLIF(poster_url, ''), ?) WHERE id=?",
|
|
|
(season_overview, season_poster, season_id))
|
|
|
for e in (episodes or []):
|
|
|
en = e.get("episode_number")
|
|
|
if en is None:
|
|
|
continue
|
|
|
row = conn.execute(
|
|
|
"SELECT id FROM episodes WHERE show_id=? AND season_number=? AND episode_number=?",
|
|
|
(show_id, season_number, en)).fetchone()
|
|
|
if row:
|
|
|
sets, params = [], []
|
|
|
for col in ("title", "still_url", "overview", "air_date", "rating", "runtime_minutes"):
|
|
|
if e.get(col) is None:
|
|
|
continue
|
|
|
sets.append(f"{col}=COALESCE(NULLIF({col}, ''), ?)")
|
|
|
params.append(e[col])
|
|
|
if sets:
|
|
|
params += [row["id"]]
|
|
|
conn.execute(f"UPDATE episodes SET {', '.join(sets)} WHERE id=?", params)
|
|
|
touched += 1
|
|
|
else:
|
|
|
conn.execute(
|
|
|
"INSERT INTO episodes (show_id, season_id, season_number, episode_number, title, "
|
|
|
"overview, air_date, runtime_minutes, still_url, rating, has_file) "
|
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)",
|
|
|
(show_id, season_id, season_number, en, e.get("title"), e.get("overview"),
|
|
|
e.get("air_date"), e.get("runtime_minutes"), e.get("still_url"), e.get("rating")))
|
|
|
touched += 1
|
|
|
conn.commit()
|
|
|
return touched
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def enrichment_unmatched(self, service: str, kind: str, status: str = "not_found",
|
|
|
search=None, limit: int = 50, offset: int = 0) -> dict:
|
|
|
if kind == "episode" and service == "tmdb":
|
|
|
return self._episodes_missing_art(search, limit, offset)
|
|
|
if service == "omdb" and kind in ("movie", "show"):
|
|
|
return self._ratings_unmatched(kind, search, limit, offset)
|
|
|
spec = _ENRICH.get(service, {}).get(kind)
|
|
|
if not spec:
|
|
|
return {"items": [], "total": 0}
|
|
|
tbl, _idc, sc, ac = spec
|
|
|
where, params = [], []
|
|
|
if status == "pending":
|
|
|
where.append(f"{sc} IS NULL")
|
|
|
elif status == "unmatched":
|
|
|
where.append(f"({sc} IS NULL OR {sc} IN ('not_found','error'))")
|
|
|
else:
|
|
|
where.append(f"{sc}='not_found'")
|
|
|
if search:
|
|
|
where.append("title LIKE ? COLLATE NOCASE")
|
|
|
params.append("%" + search + "%")
|
|
|
where_sql = " WHERE " + " AND ".join(where)
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
total = conn.execute(f"SELECT COUNT(*) FROM {tbl}{where_sql}", params).fetchone()[0]
|
|
|
rows = conn.execute(
|
|
|
f"SELECT id, title, year, {ac} AS last_attempted, "
|
|
|
f"(poster_url IS NOT NULL AND poster_url<>'') AS has_poster "
|
|
|
f"FROM {tbl}{where_sql} ORDER BY COALESCE(sort_title, title) COLLATE NOCASE "
|
|
|
f"LIMIT ? OFFSET ?", params + [limit, offset]).fetchall()
|
|
|
items = []
|
|
|
for r in rows:
|
|
|
d = dict(r)
|
|
|
d["has_poster"] = bool(d.get("has_poster"))
|
|
|
items.append(d)
|
|
|
return {"items": items, "total": total}
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def _episodes_missing_art(self, search, limit, offset) -> dict:
|
|
|
"""Episodes still lacking a still image (for the manager's Episodes view).
|
|
|
Read-only: episode art is backfilled as a cascade, not a retry queue."""
|
|
|
where = ["(e.still_url IS NULL OR e.still_url='')"]
|
|
|
params: list = []
|
|
|
if search:
|
|
|
where.append("(e.title LIKE ? COLLATE NOCASE OR sh.title LIKE ? COLLATE NOCASE)")
|
|
|
params += ["%" + search + "%", "%" + search + "%"]
|
|
|
where_sql = " WHERE " + " AND ".join(where)
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
total = conn.execute(
|
|
|
f"SELECT COUNT(*) FROM episodes e JOIN shows sh ON sh.id=e.show_id{where_sql}",
|
|
|
params).fetchone()[0]
|
|
|
rows = conn.execute(
|
|
|
"SELECT e.id, sh.title || ' · S' || e.season_number || 'E' || e.episode_number "
|
|
|
"|| COALESCE(' · ' || e.title, '') AS title, e.air_date AS year, "
|
|
|
"0 AS has_poster, NULL AS last_attempted "
|
|
|
f"FROM episodes e JOIN shows sh ON sh.id=e.show_id{where_sql} "
|
|
|
"ORDER BY sh.sort_title, e.season_number, e.episode_number LIMIT ? OFFSET ?",
|
|
|
params + [limit, offset]).fetchall()
|
|
|
return {"items": [dict(r) | {"has_poster": False} for r in rows], "total": total}
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def _ratings_unmatched(self, kind: str, search, limit: int, offset: int) -> dict:
|
|
|
tbl = {"movie": "movies", "show": "shows"}[kind]
|
|
|
where = ["imdb_rating IS NULL", "imdb_id IS NOT NULL", "imdb_id<>''"]
|
|
|
params: list = []
|
|
|
if search:
|
|
|
where.append("title LIKE ? COLLATE NOCASE")
|
|
|
params.append("%" + search + "%")
|
|
|
where_sql = " WHERE " + " AND ".join(where)
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
total = conn.execute(f"SELECT COUNT(*) FROM {tbl}{where_sql}", params).fetchone()[0]
|
|
|
rows = conn.execute(
|
|
|
f"SELECT id, title, year, (poster_url IS NOT NULL AND poster_url<>'') AS has_poster "
|
|
|
f"FROM {tbl}{where_sql} ORDER BY COALESCE(sort_title, title) COLLATE NOCASE "
|
|
|
"LIMIT ? OFFSET ?", params + [limit, offset]).fetchall()
|
|
|
return {"items": [dict(r) | {"has_poster": bool(r["has_poster"])} for r in rows], "total": total}
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def enrichment_retry(self, service: str, kind: str, scope: str = "failed", item_id=None) -> int:
|
|
|
"""Re-queue items by resetting status/last_attempted to NULL."""
|
|
|
if service == "omdb":
|
|
|
tbl = {"movie": "movies", "show": "shows"}.get(kind)
|
|
|
if not tbl:
|
|
|
return 0
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
if scope == "item" and item_id is not None:
|
|
|
cur = conn.execute(f"UPDATE {tbl} SET ratings_synced=0 WHERE id=?", (item_id,))
|
|
|
else:
|
|
|
cur = conn.execute(f"UPDATE {tbl} SET ratings_synced=0 WHERE imdb_rating IS NULL")
|
|
|
conn.commit()
|
|
|
return cur.rowcount
|
|
|
finally:
|
|
|
conn.close()
|
|
|
spec = _ENRICH.get(service, {}).get(kind)
|
|
|
if not spec:
|
|
|
return 0
|
|
|
tbl, _idc, sc, ac = spec
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
if scope == "item" and item_id is not None:
|
|
|
cur = conn.execute(f"UPDATE {tbl} SET {sc}=NULL, {ac}=NULL WHERE id=?", (item_id,))
|
|
|
else:
|
|
|
cur = conn.execute(
|
|
|
f"UPDATE {tbl} SET {sc}=NULL, {ac}=NULL WHERE {sc} IN ('not_found','error')")
|
|
|
conn.commit()
|
|
|
return cur.rowcount
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
@property
|
|
|
def schema_version(self) -> int:
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
return int(conn.execute("PRAGMA user_version").fetchone()[0])
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
# ── video_settings KV (temporary home until the settings.db move) ────────
|
|
|
def get_setting(self, key: str, default=None):
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
row = conn.execute(
|
|
|
"SELECT value FROM video_settings WHERE key = ?", (key,)
|
|
|
).fetchone()
|
|
|
return row["value"] if row is not None else default
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def set_setting(self, key: str, value: str) -> None:
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
conn.execute(
|
|
|
"INSERT INTO video_settings(key, value, updated_at) "
|
|
|
"VALUES (?, ?, CURRENT_TIMESTAMP) "
|
|
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, "
|
|
|
"updated_at = CURRENT_TIMESTAMP",
|
|
|
(key, value),
|
|
|
)
|
|
|
conn.commit()
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
# ── library mapping (which server library is Movies / TV) ─────────────────
|
|
|
def get_library_selection(self, server: str) -> dict:
|
|
|
return {
|
|
|
"movies": self.get_setting(server + ".movies_library"),
|
|
|
"tv": self.get_setting(server + ".tv_library"),
|
|
|
}
|
|
|
|
|
|
def set_library_selection(self, server: str, movies, tv) -> None:
|
|
|
self.set_setting(server + ".movies_library", movies or "")
|
|
|
self.set_setting(server + ".tv_library", tv or "")
|
|
|
|
|
|
# ── dashboard ─────────────────────────────────────────────────────────────
|
|
|
def dashboard_stats(self) -> dict:
|
|
|
"""Live counts for the video dashboard, straight from video.db.
|
|
|
|
|
|
Shape is stable so the frontend can map it directly; with an empty
|
|
|
database every number is a real 0 (not a stub).
|
|
|
"""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
def scalar(sql: str):
|
|
|
return conn.execute(sql).fetchone()[0]
|
|
|
|
|
|
return {
|
|
|
"library": {
|
|
|
"movies": scalar("SELECT COUNT(*) FROM movies"),
|
|
|
"shows": scalar("SELECT COUNT(*) FROM shows"),
|
|
|
"episodes": scalar("SELECT COUNT(*) FROM episodes"),
|
|
|
"size_bytes": scalar("SELECT COALESCE(SUM(size_bytes), 0) FROM media_files"),
|
|
|
},
|
|
|
"downloads": {
|
|
|
"active": scalar(
|
|
|
"SELECT COUNT(*) FROM downloads "
|
|
|
"WHERE status IN ('queued','downloading','importing')"),
|
|
|
"finished": scalar("SELECT COUNT(*) FROM downloads WHERE status = 'completed'"),
|
|
|
"speed_bps": scalar(
|
|
|
"SELECT COALESCE(SUM(download_speed_bps), 0) FROM downloads "
|
|
|
"WHERE status = 'downloading'"),
|
|
|
},
|
|
|
"watchlist": scalar("SELECT COUNT(*) FROM v_watchlist"),
|
|
|
"wishlist": scalar("SELECT COUNT(*) FROM v_wishlist"),
|
|
|
}
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
# ── scan upserts (server is the source of truth) ──────────────────────────
|
|
|
# The scanner passes normalized, server-agnostic dicts (a Plex/Jellyfin
|
|
|
# adapter produces them) so this layer never touches a media-server SDK.
|
|
|
@staticmethod
|
|
|
def _set_media_file(conn, owner_col: str, owner_id: int, file: dict | None) -> None:
|
|
|
"""Replace the media_files row(s) for one owner. owner_col is internal
|
|
|
('movie_id'|'episode_id'|'video_id'), never user input."""
|
|
|
conn.execute(f"DELETE FROM media_files WHERE {owner_col} = ?", (owner_id,))
|
|
|
if not file:
|
|
|
return
|
|
|
conn.execute(
|
|
|
f"INSERT INTO media_files ({owner_col}, relative_path, size_bytes, resolution, "
|
|
|
"video_codec, audio_codec, release_source, quality, runtime_seconds) "
|
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
|
(owner_id,
|
|
|
file.get("relative_path") or file.get("path") or "",
|
|
|
file.get("size_bytes"), file.get("resolution"), file.get("video_codec"),
|
|
|
file.get("audio_codec"), file.get("release_source"), file.get("quality"),
|
|
|
file.get("runtime_seconds")),
|
|
|
)
|
|
|
|
|
|
@staticmethod
|
|
|
def _set_genres(conn, link_table: str, owner_col: str, owner_id: int, names) -> None:
|
|
|
"""Replace the genre links for one owner (normalised; dedup names in the
|
|
|
shared genres table). owner_col/link_table are internal, never user input."""
|
|
|
conn.execute(f"DELETE FROM {link_table} WHERE {owner_col}=?", (owner_id,))
|
|
|
for raw in (names or []):
|
|
|
name = (raw or "").strip()
|
|
|
if not name:
|
|
|
continue
|
|
|
conn.execute("INSERT OR IGNORE INTO genres (name) VALUES (?)", (name,))
|
|
|
gid = conn.execute("SELECT id FROM genres WHERE name=? COLLATE NOCASE", (name,)).fetchone()["id"]
|
|
|
conn.execute(f"INSERT OR IGNORE INTO {link_table} ({owner_col}, genre_id) VALUES (?, ?)",
|
|
|
(owner_id, gid))
|
|
|
|
|
|
@staticmethod
|
|
|
def _resilient_upsert(conn, table: str, base: dict, id_cols: dict) -> None:
|
|
|
"""INSERT…ON CONFLICT(server_source, server_id) for a movie/show row.
|
|
|
|
|
|
Resilient to a LEGACY UNIQUE on tmdb_id/tvdb_id/imdb_id (old DBs created
|
|
|
before those were made non-unique — SQLite can't drop an inline UNIQUE):
|
|
|
on IntegrityError we retry WITHOUT the id columns, so the row is still
|
|
|
stored (same film in >1 library) instead of being dropped by the scan.
|
|
|
``base`` holds the always-written cols; ``id_cols`` the droppable ids."""
|
|
|
def run(include_ids):
|
|
|
cols = list(base.keys()) + (list(id_cols.keys()) if include_ids else [])
|
|
|
vals = list(base.values()) + (list(id_cols.values()) if include_ids else [])
|
|
|
updates = [c for c in cols if c not in ("server_source", "server_id")]
|
|
|
set_clause = ", ".join(f"{c}=excluded.{c}" for c in updates) + ", updated_at=CURRENT_TIMESTAMP"
|
|
|
sql = (f"INSERT INTO {table} ({', '.join(cols)}, updated_at) "
|
|
|
f"VALUES ({', '.join(['?'] * len(cols))}, CURRENT_TIMESTAMP) "
|
|
|
f"ON CONFLICT(server_source, server_id) DO UPDATE SET {set_clause}")
|
|
|
conn.execute(sql, vals)
|
|
|
try:
|
|
|
run(True)
|
|
|
except sqlite3.IntegrityError:
|
|
|
conn.rollback() # legacy UNIQUE on an id — keep the row, drop the id
|
|
|
run(False)
|
|
|
|
|
|
@staticmethod
|
|
|
def _set_credits(conn, owner_col: str, owner_id: int, cast, crew) -> None:
|
|
|
"""Replace the cast+crew for one owner (deduped people in the shared
|
|
|
people table). owner_col is internal ('movie_id'|'show_id')."""
|
|
|
conn.execute(f"DELETE FROM credits WHERE {owner_col}=?", (owner_id,))
|
|
|
|
|
|
def person_id(p):
|
|
|
tid = p.get("tmdb_id")
|
|
|
if tid is not None:
|
|
|
conn.execute("INSERT OR IGNORE INTO people (name, tmdb_id, photo_url) VALUES (?, ?, ?)",
|
|
|
(p["name"], tid, p.get("photo_url")))
|
|
|
row = conn.execute("SELECT id FROM people WHERE tmdb_id=?", (tid,)).fetchone()
|
|
|
else:
|
|
|
conn.execute("INSERT INTO people (name, photo_url) VALUES (?, ?)",
|
|
|
(p["name"], p.get("photo_url")))
|
|
|
row = conn.execute("SELECT last_insert_rowid() AS id").fetchone()
|
|
|
pid = row["id"] if row else None
|
|
|
if pid and p.get("photo_url"):
|
|
|
conn.execute("UPDATE people SET photo_url=COALESCE(NULLIF(photo_url, ''), ?) WHERE id=?",
|
|
|
(p["photo_url"], pid))
|
|
|
return pid
|
|
|
|
|
|
def add(group, department, job_default):
|
|
|
for i, c in enumerate(group or []):
|
|
|
if not c.get("name"):
|
|
|
continue
|
|
|
pid = person_id(c)
|
|
|
if not pid:
|
|
|
continue
|
|
|
conn.execute(
|
|
|
f"INSERT INTO credits (person_id, {owner_col}, department, job, character, sort_order) "
|
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
|
(pid, owner_id, department, c.get("job") or job_default, c.get("character"), i))
|
|
|
add(cast, "cast", "Actor")
|
|
|
add(crew, "crew", None)
|
|
|
|
|
|
@staticmethod
|
|
|
def _credits_for(conn, owner_col: str, owner_id: int, cast_limit: int = 18) -> dict:
|
|
|
rows = conn.execute(
|
|
|
"SELECT p.name, p.photo_url, p.tmdb_id, c.department, c.job, c.character "
|
|
|
f"FROM credits c JOIN people p ON p.id = c.person_id WHERE c.{owner_col}=? "
|
|
|
"ORDER BY c.department, c.sort_order", (owner_id,)).fetchall()
|
|
|
cast = [{"name": r["name"], "character": r["character"], "photo": r["photo_url"],
|
|
|
"tmdb_id": r["tmdb_id"]}
|
|
|
for r in rows if r["department"] == "cast"][:cast_limit]
|
|
|
crew = [{"name": r["name"], "job": r["job"], "tmdb_id": r["tmdb_id"]}
|
|
|
for r in rows if r["department"] == "crew"]
|
|
|
return {"cast": cast, "crew": crew}
|
|
|
|
|
|
def upsert_movie(self, server_source: str, item: dict) -> int:
|
|
|
"""Insert/update one movie (keyed on server id) and its file. Returns row id."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
self._resilient_upsert(conn, "movies", {
|
|
|
"server_source": server_source, "server_id": item["server_id"],
|
|
|
"title": item.get("title"), "sort_title": _sort_title(item.get("title")),
|
|
|
"year": item.get("year"), "overview": item.get("overview"),
|
|
|
"runtime_minutes": item.get("runtime_minutes"), "content_rating": item.get("content_rating"),
|
|
|
"studio": item.get("studio"), "tagline": item.get("tagline"),
|
|
|
"rating": item.get("rating"), "rating_critic": item.get("rating_critic"),
|
|
|
"poster_url": item.get("poster_url"), "has_file": 1 if item.get("file") else 0,
|
|
|
}, {"tmdb_id": item.get("tmdb_id"), "imdb_id": item.get("imdb_id")})
|
|
|
movie_id = conn.execute(
|
|
|
"SELECT id FROM movies WHERE server_source=? AND server_id=?",
|
|
|
(server_source, item["server_id"]),
|
|
|
).fetchone()["id"]
|
|
|
self._set_media_file(conn, "movie_id", movie_id, item.get("file"))
|
|
|
self._set_genres(conn, "movie_genres", "movie_id", movie_id, item.get("genres"))
|
|
|
conn.commit()
|
|
|
return movie_id
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def upsert_show_tree(self, server_source: str, item: dict) -> int:
|
|
|
"""Insert/update a show with its seasons + episodes (and files) in one
|
|
|
transaction. Episodes/seasons no longer present on the server for this
|
|
|
show are pruned. Returns the show row id."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
self._resilient_upsert(conn, "shows", {
|
|
|
"server_source": server_source, "server_id": item["server_id"],
|
|
|
"title": item.get("title"), "sort_title": _sort_title(item.get("title")),
|
|
|
"year": item.get("year"), "overview": item.get("overview"),
|
|
|
"status": item.get("status"), "network": item.get("network"),
|
|
|
"runtime_minutes": item.get("runtime_minutes"), "content_rating": item.get("content_rating"),
|
|
|
"tagline": item.get("tagline"), "rating": item.get("rating"),
|
|
|
"first_air_date": item.get("first_air_date"), "last_air_date": item.get("last_air_date"),
|
|
|
"poster_url": item.get("poster_url"),
|
|
|
}, {"tvdb_id": item.get("tvdb_id"), "tmdb_id": item.get("tmdb_id"), "imdb_id": item.get("imdb_id")})
|
|
|
show_id = conn.execute(
|
|
|
"SELECT id FROM shows WHERE server_source=? AND server_id=?",
|
|
|
(server_source, item["server_id"]),
|
|
|
).fetchone()["id"]
|
|
|
self._set_genres(conn, "show_genres", "show_id", show_id, item.get("genres"))
|
|
|
|
|
|
seen_seasons: set[int] = set()
|
|
|
seen_eps: set[tuple[int, int]] = set()
|
|
|
for season in item.get("seasons", []):
|
|
|
snum = season["season_number"]
|
|
|
seen_seasons.add(snum)
|
|
|
conn.execute(
|
|
|
"INSERT INTO seasons (show_id, server_id, season_number, title, overview, poster_url) "
|
|
|
"VALUES (?, ?, ?, ?, ?, ?) "
|
|
|
"ON CONFLICT(show_id, season_number) DO UPDATE SET "
|
|
|
"server_id=excluded.server_id, title=excluded.title, "
|
|
|
"overview=excluded.overview, poster_url=excluded.poster_url",
|
|
|
(show_id, season.get("server_id"), snum, season.get("title"),
|
|
|
season.get("overview"), season.get("poster_url")),
|
|
|
)
|
|
|
season_id = conn.execute(
|
|
|
"SELECT id FROM seasons WHERE show_id=? AND season_number=?", (show_id, snum)
|
|
|
).fetchone()["id"]
|
|
|
|
|
|
for ep in season.get("episodes", []):
|
|
|
enum = ep.get("episode_number")
|
|
|
if enum is None or snum is None:
|
|
|
continue # can't key an episode without season+episode numbers
|
|
|
seen_eps.add((snum, enum))
|
|
|
conn.execute(
|
|
|
"INSERT INTO episodes (show_id, season_id, server_source, server_id, "
|
|
|
"season_number, episode_number, title, overview, air_date, "
|
|
|
"runtime_minutes, still_url, rating, tvdb_id, has_file) "
|
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
|
"ON CONFLICT(show_id, season_number, episode_number) DO UPDATE SET "
|
|
|
"season_id=excluded.season_id, server_source=excluded.server_source, "
|
|
|
"server_id=excluded.server_id, title=excluded.title, "
|
|
|
"overview=excluded.overview, air_date=excluded.air_date, "
|
|
|
"runtime_minutes=excluded.runtime_minutes, still_url=excluded.still_url, "
|
|
|
"rating=excluded.rating, tvdb_id=excluded.tvdb_id, has_file=excluded.has_file",
|
|
|
(show_id, season_id, server_source, ep.get("server_id"), snum, enum,
|
|
|
ep.get("title"), ep.get("overview"), ep.get("air_date"),
|
|
|
ep.get("runtime_minutes"), ep.get("still_url"), ep.get("rating"),
|
|
|
ep.get("tvdb_id"), 1 if ep.get("file") else 0),
|
|
|
)
|
|
|
ep_id = conn.execute(
|
|
|
"SELECT id FROM episodes WHERE show_id=? AND season_number=? AND episode_number=?",
|
|
|
(show_id, snum, enum),
|
|
|
).fetchone()["id"]
|
|
|
self._set_media_file(conn, "episode_id", ep_id, ep.get("file"))
|
|
|
|
|
|
# Prune only SERVER-originated rows that vanished (server_id set) — the
|
|
|
# full episode/season list now includes enrichment-added MISSING items
|
|
|
# (server_id NULL), which the scan must never remove.
|
|
|
for row in conn.execute(
|
|
|
"SELECT season_number, episode_number FROM episodes "
|
|
|
"WHERE show_id=? AND server_id IS NOT NULL", (show_id,)
|
|
|
).fetchall():
|
|
|
if (row["season_number"], row["episode_number"]) not in seen_eps:
|
|
|
conn.execute(
|
|
|
"DELETE FROM episodes WHERE show_id=? AND season_number=? AND episode_number=?",
|
|
|
(show_id, row["season_number"], row["episode_number"]),
|
|
|
)
|
|
|
for row in conn.execute(
|
|
|
"SELECT season_number FROM seasons WHERE show_id=? AND server_id IS NOT NULL", (show_id,)
|
|
|
).fetchall():
|
|
|
if row["season_number"] not in seen_seasons:
|
|
|
conn.execute("DELETE FROM seasons WHERE show_id=? AND season_number=?",
|
|
|
(show_id, row["season_number"]))
|
|
|
conn.commit()
|
|
|
return show_id
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def server_ids(self, table: str, server_source: str) -> set:
|
|
|
"""All server_ids already stored for a server (for incremental early-stop)."""
|
|
|
if table not in ("movies", "shows"):
|
|
|
return set()
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
return {str(r[0]) for r in conn.execute(
|
|
|
f"SELECT server_id FROM {table} WHERE server_source=?", (server_source,)).fetchall()}
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def table_count(self, table: str) -> int:
|
|
|
if table not in ("movies", "shows", "episodes"):
|
|
|
return 0
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
return conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def prune_missing(self, table: str, server_source: str, seen_ids) -> int:
|
|
|
"""Delete top-level rows for a server that the scan no longer saw.
|
|
|
``table`` is internal ('movies'|'shows'); cascades clean children.
|
|
|
|
|
|
Safety (mirrors music's deep scan): if removal would wipe >50% of a
|
|
|
>100-row library, assume a partial server failure and skip it."""
|
|
|
if table not in ("movies", "shows"):
|
|
|
raise ValueError(f"prune_missing: unexpected table {table!r}")
|
|
|
seen = {str(s) for s in seen_ids}
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
existing = [r["server_id"] for r in conn.execute(
|
|
|
f"SELECT server_id FROM {table} WHERE server_source=?", (server_source,)
|
|
|
).fetchall()]
|
|
|
stale = [sid for sid in existing if str(sid) not in seen]
|
|
|
if len(stale) > len(existing) * 0.5 and len(existing) > 100:
|
|
|
logger.warning(
|
|
|
"Video deep scan: %d/%d %s stale (>50%%) — skipping removal (likely a "
|
|
|
"partial server response)", len(stale), len(existing), table)
|
|
|
return 0
|
|
|
for sid in stale:
|
|
|
conn.execute(f"DELETE FROM {table} WHERE server_source=? AND server_id=?",
|
|
|
(server_source, sid))
|
|
|
conn.commit()
|
|
|
return len(stale)
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
# ── library listing ───────────────────────────────────────────────────────
|
|
|
@staticmethod
|
|
|
def _with_poster_flag(row: dict) -> dict:
|
|
|
# Don't leak the raw server thumb path; just say whether a poster exists
|
|
|
# (the frontend hits /api/video/poster/<kind>/<id> when true).
|
|
|
d = dict(row)
|
|
|
d["has_poster"] = bool(d.pop("poster_url", None))
|
|
|
return d
|
|
|
|
|
|
def list_movies(self) -> list[dict]:
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
rows = conn.execute(
|
|
|
"SELECT id, title, year, poster_url, has_file, monitored "
|
|
|
"FROM movies ORDER BY COALESCE(sort_title, title) COLLATE NOCASE, title"
|
|
|
).fetchall()
|
|
|
return [self._with_poster_flag(r) for r in rows]
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def list_shows(self) -> list[dict]:
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
rows = conn.execute(
|
|
|
"SELECT s.id, s.title, s.year, s.poster_url, s.monitored, "
|
|
|
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id = s.id) AS episode_count, "
|
|
|
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id = s.id AND e.has_file = 1) AS owned_count "
|
|
|
"FROM shows s ORDER BY COALESCE(s.sort_title, s.title) COLLATE NOCASE, s.title"
|
|
|
).fetchall()
|
|
|
return [self._with_poster_flag(r) for r in rows]
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def get_poster_ref(self, kind: str, item_id: int) -> dict | None:
|
|
|
"""Server source/id/poster path for one movie or show, for the poster proxy."""
|
|
|
return self.get_art_ref(kind, item_id, "poster")
|
|
|
|
|
|
def get_art_ref(self, kind: str, item_id: int, art: str = "poster") -> dict | None:
|
|
|
"""Server source/id + artwork path for one movie/show/season, for the image
|
|
|
proxy. ``art`` is 'poster' or 'backdrop'. Returns the path under
|
|
|
'poster_url' so the proxy is artwork-agnostic."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
if kind == "season":
|
|
|
if art != "poster":
|
|
|
return None
|
|
|
# Seasons don't carry server_source — inherit the parent show's.
|
|
|
row = conn.execute(
|
|
|
"SELECT sh.server_source, se.server_id, se.poster_url "
|
|
|
"FROM seasons se JOIN shows sh ON sh.id = se.show_id WHERE se.id=?",
|
|
|
(item_id,)).fetchone()
|
|
|
return dict(row) if row else None
|
|
|
if kind == "episode":
|
|
|
if art != "poster":
|
|
|
return None
|
|
|
# Episode still; episodes carry their own server_source + the still path.
|
|
|
row = conn.execute(
|
|
|
"SELECT server_source, server_id, still_url AS poster_url "
|
|
|
"FROM episodes WHERE id=?", (item_id,)).fetchone()
|
|
|
return dict(row) if row else None
|
|
|
table = {"movie": "movies", "show": "shows"}.get(kind)
|
|
|
col = {"poster": "poster_url", "backdrop": "backdrop_url"}.get(art)
|
|
|
if not table or not col:
|
|
|
return None
|
|
|
row = conn.execute(
|
|
|
f"SELECT server_source, server_id, {col} AS poster_url FROM {table} WHERE id=?",
|
|
|
(item_id,)).fetchone()
|
|
|
return dict(row) if row else None
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
# ── detail payloads (drill-in pages) ──────────────────────────────────────
|
|
|
@staticmethod
|
|
|
def _genres_for(conn, link_table: str, owner_col: str, owner_id: int) -> list:
|
|
|
rows = conn.execute(
|
|
|
f"SELECT g.name FROM {link_table} lt JOIN genres g ON g.id = lt.genre_id "
|
|
|
f"WHERE lt.{owner_col}=? ORDER BY g.name", (owner_id,)).fetchall()
|
|
|
return [r["name"] for r in rows]
|
|
|
|
|
|
def show_detail(self, show_id: int) -> dict | None:
|
|
|
"""Full TV-show detail: the show + its seasons → episodes tree, with
|
|
|
owned/total roll-ups. Drives the (isolated) video show-detail page."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
show = conn.execute("SELECT * FROM shows WHERE id=?", (show_id,)).fetchone()
|
|
|
if not show:
|
|
|
return None
|
|
|
genres = self._genres_for(conn, "show_genres", "show_id", show_id)
|
|
|
credits = self._credits_for(conn, "show_id", show_id)
|
|
|
seasons = conn.execute(
|
|
|
"SELECT id, season_number, title, overview, "
|
|
|
"(poster_url IS NOT NULL AND poster_url<>'') AS has_poster "
|
|
|
"FROM seasons WHERE show_id=? ORDER BY season_number", (show_id,)).fetchall()
|
|
|
eps = conn.execute(
|
|
|
"SELECT id, season_number, episode_number, title, overview, air_date, "
|
|
|
"runtime_minutes, rating, monitored, has_file, "
|
|
|
"(still_url IS NOT NULL AND still_url<>'') AS has_still FROM episodes WHERE show_id=? "
|
|
|
"ORDER BY season_number, episode_number", (show_id,)).fetchall()
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
by_season: dict = {}
|
|
|
for e in eps:
|
|
|
by_season.setdefault(e["season_number"], []).append({
|
|
|
"id": e["id"], "episode_number": e["episode_number"],
|
|
|
"title": e["title"], "overview": e["overview"], "air_date": e["air_date"],
|
|
|
"runtime_minutes": e["runtime_minutes"], "rating": e["rating"],
|
|
|
"has_still": bool(e["has_still"]),
|
|
|
"monitored": bool(e["monitored"]), "owned": bool(e["has_file"]),
|
|
|
})
|
|
|
|
|
|
# Seasons declared in the seasons table, plus any season numbers that only
|
|
|
# exist via episodes (defensive — a show with episodes but no season row).
|
|
|
season_nums = [s["season_number"] for s in seasons]
|
|
|
season_meta = {s["season_number"]: s for s in seasons}
|
|
|
for num in by_season:
|
|
|
if num not in season_meta:
|
|
|
season_nums.append(num)
|
|
|
out_seasons = []
|
|
|
for num in sorted(set(season_nums)):
|
|
|
ep_list = by_season.get(num, [])
|
|
|
owned = sum(1 for e in ep_list if e["owned"])
|
|
|
meta = season_meta.get(num)
|
|
|
out_seasons.append({
|
|
|
"id": meta["id"] if meta else None, # needed for the season poster proxy
|
|
|
"season_number": num,
|
|
|
"title": (meta["title"] if meta else None) or (
|
|
|
"Specials" if num == 0 else "Season %d" % num),
|
|
|
"overview": meta["overview"] if meta else None,
|
|
|
"has_poster": bool(meta["has_poster"]) if meta else False,
|
|
|
"episode_total": len(ep_list),
|
|
|
"episode_owned": owned,
|
|
|
"episodes": ep_list,
|
|
|
})
|
|
|
|
|
|
total = len(eps)
|
|
|
owned_total = sum(1 for e in eps if e["has_file"])
|
|
|
return {
|
|
|
"kind": "show", "id": show["id"], "title": show["title"], "year": show["year"],
|
|
|
"overview": show["overview"], "status": show["status"], "network": show["network"],
|
|
|
"content_rating": show["content_rating"], "runtime_minutes": show["runtime_minutes"],
|
|
|
"tagline": show["tagline"], "rating": show["rating"],
|
|
|
"first_air_date": show["first_air_date"], "last_air_date": show["last_air_date"],
|
|
|
"imdb_rating": show["imdb_rating"], "rt_rating": show["rt_rating"],
|
|
|
"metacritic": show["metacritic"],
|
|
|
"genres": genres, "cast": credits["cast"], "crew": credits["crew"],
|
|
|
"tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"],
|
|
|
"has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]),
|
|
|
"logo": show["logo_url"],
|
|
|
"episodes_synced": bool(show["episodes_synced"]),
|
|
|
"monitored": bool(show["monitored"]),
|
|
|
"season_count": len(out_seasons),
|
|
|
"episode_total": total, "episode_owned": owned_total,
|
|
|
"seasons": out_seasons,
|
|
|
}
|
|
|
|
|
|
def set_monitored(self, kind: str, item_id: int, monitored: bool) -> bool:
|
|
|
"""Toggle the 'follow/watchlist' flag on a movie or show. Returns True if a
|
|
|
row was updated."""
|
|
|
table = {"movie": "movies", "show": "shows"}.get(kind)
|
|
|
if not table:
|
|
|
return False
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
cur = conn.execute(f"UPDATE {table} SET monitored=? WHERE id=?",
|
|
|
(1 if monitored else 0, item_id))
|
|
|
conn.commit()
|
|
|
return cur.rowcount > 0
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
def movie_detail(self, movie_id: int) -> dict | None:
|
|
|
"""Full movie detail: the movie + owned/file info. Drives the (isolated)
|
|
|
video movie-detail page."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
m = conn.execute("SELECT * FROM movies WHERE id=?", (movie_id,)).fetchone()
|
|
|
if not m:
|
|
|
return None
|
|
|
genres = self._genres_for(conn, "movie_genres", "movie_id", movie_id)
|
|
|
credits = self._credits_for(conn, "movie_id", movie_id)
|
|
|
f = conn.execute(
|
|
|
"SELECT resolution, quality, video_codec, audio_codec, size_bytes "
|
|
|
"FROM media_files WHERE movie_id=? ORDER BY size_bytes DESC LIMIT 1",
|
|
|
(movie_id,)).fetchone()
|
|
|
finally:
|
|
|
conn.close()
|
|
|
return {
|
|
|
"kind": "movie", "id": m["id"], "title": m["title"], "year": m["year"],
|
|
|
"overview": m["overview"], "status": m["status"], "studio": m["studio"],
|
|
|
"release_date": m["release_date"], "runtime_minutes": m["runtime_minutes"],
|
|
|
"content_rating": m["content_rating"], "tagline": m["tagline"],
|
|
|
"rating": m["rating"], "rating_critic": m["rating_critic"], "genres": genres,
|
|
|
"imdb_rating": m["imdb_rating"], "rt_rating": m["rt_rating"], "metacritic": m["metacritic"],
|
|
|
"cast": credits["cast"], "crew": credits["crew"],
|
|
|
"tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"],
|
|
|
"has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]),
|
|
|
"logo": m["logo_url"],
|
|
|
"owned": bool(m["has_file"]), "monitored": bool(m["monitored"]),
|
|
|
"file": (dict(f) if f else None),
|
|
|
}
|
|
|
|
|
|
# ── paged/filtered/sorted library query (server-side, like music) ─────────
|
|
|
def query_library(self, kind: str, *, search=None, letter=None, sort="title",
|
|
|
status="all", page=1, limit=75) -> dict:
|
|
|
"""One page of movies/shows with search + A–Z + sort + owned/wanted
|
|
|
filtering done in SQL. Returns {items, pagination:{...}} mirroring the
|
|
|
music library's contract."""
|
|
|
try:
|
|
|
page = max(1, int(page or 1))
|
|
|
limit = max(1, min(500, int(limit or 75)))
|
|
|
except (TypeError, ValueError):
|
|
|
page, limit = 1, 75
|
|
|
is_shows = kind == "shows"
|
|
|
alias = "s" if is_shows else "m"
|
|
|
tbl = "shows" if is_shows else "movies"
|
|
|
|
|
|
where, params = [], []
|
|
|
if search:
|
|
|
where.append(f"{alias}.title LIKE ? COLLATE NOCASE")
|
|
|
params.append("%" + search + "%")
|
|
|
if letter and letter != "all":
|
|
|
col = f"COALESCE({alias}.sort_title, {alias}.title)"
|
|
|
if letter == "#":
|
|
|
where.append(f"substr(UPPER({col}), 1, 1) NOT BETWEEN 'A' AND 'Z'")
|
|
|
else:
|
|
|
where.append(f"{col} LIKE ? COLLATE NOCASE")
|
|
|
params.append(letter + "%")
|
|
|
if not is_shows:
|
|
|
if status == "owned":
|
|
|
where.append("m.has_file = 1")
|
|
|
elif status == "wanted":
|
|
|
where.append("m.has_file = 0")
|
|
|
else:
|
|
|
if status == "owned":
|
|
|
where.append("EXISTS (SELECT 1 FROM episodes e WHERE e.show_id=s.id AND e.has_file=1)")
|
|
|
elif status == "wanted":
|
|
|
where.append("NOT EXISTS (SELECT 1 FROM episodes e WHERE e.show_id=s.id AND e.has_file=1)")
|
|
|
where_sql = (" WHERE " + " AND ".join(where)) if where else ""
|
|
|
|
|
|
title_key = f"COALESCE({alias}.sort_title, {alias}.title) COLLATE NOCASE ASC"
|
|
|
order_sql = {
|
|
|
"title": title_key,
|
|
|
"year": f"{alias}.year DESC, " + title_key,
|
|
|
"added": f"{alias}.added_at DESC",
|
|
|
}.get(sort, title_key)
|
|
|
|
|
|
if is_shows:
|
|
|
select = ("SELECT s.id, s.title, s.year, "
|
|
|
"(s.poster_url IS NOT NULL AND s.poster_url <> '') AS has_poster, "
|
|
|
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id) AS episode_count, "
|
|
|
"(SELECT COUNT(*) FROM episodes e WHERE e.show_id=s.id AND e.has_file=1) AS owned_count "
|
|
|
"FROM shows s")
|
|
|
else:
|
|
|
select = ("SELECT m.id, m.title, m.year, m.has_file, "
|
|
|
"(m.poster_url IS NOT NULL AND m.poster_url <> '') AS has_poster, "
|
|
|
"(SELECT mf.resolution FROM media_files mf WHERE mf.movie_id=m.id LIMIT 1) AS resolution "
|
|
|
"FROM movies m")
|
|
|
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
total = conn.execute(f"SELECT COUNT(*) FROM {tbl} {alias}{where_sql}", params).fetchone()[0]
|
|
|
rows = conn.execute(
|
|
|
f"{select}{where_sql} ORDER BY {order_sql} LIMIT ? OFFSET ?",
|
|
|
params + [limit, (page - 1) * limit]).fetchall()
|
|
|
items = []
|
|
|
for r in rows:
|
|
|
d = dict(r)
|
|
|
d["has_poster"] = bool(d.get("has_poster"))
|
|
|
if not is_shows:
|
|
|
d["has_file"] = bool(d.get("has_file"))
|
|
|
items.append(d)
|
|
|
total_pages = max(1, (total + limit - 1) // limit)
|
|
|
return {"items": items, "pagination": {
|
|
|
"page": page, "total_pages": total_pages, "total_count": total,
|
|
|
"has_prev": page > 1, "has_next": page < total_pages}}
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
# ── health ───────────────────────────────────────────────────────────────
|
|
|
def health_check(self) -> bool:
|
|
|
"""True when the DB opens and passes a quick integrity check."""
|
|
|
conn = self._get_connection()
|
|
|
try:
|
|
|
row = conn.execute("PRAGMA quick_check").fetchone()
|
|
|
return bool(row) and row[0] == "ok"
|
|
|
except Exception:
|
|
|
logger.exception("Video database health check failed")
|
|
|
return False
|
|
|
finally:
|
|
|
conn.close()
|