"""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 = 1 _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", "release_date", "status", "content_rating", "runtime_minutes", "studio", "imdb_id", "tmdb_id"}, "shows": {"overview", "backdrop_url", "status", "network", "content_rating", "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"), ] 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) -> 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} or None.""" kinds = _ENRICH.get(service) if not kinds: return None cutoff = (datetime.now(timezone.utc) - timedelta(days=retry_days)).strftime("%Y-%m-%d %H:%M:%S") conn = self._get_connection() try: for kind, (tbl, _idc, sc, _ac) in kinds.items(): row = conn.execute( f"SELECT id, title, year FROM {tbl} WHERE {sc} IS NULL ORDER BY id LIMIT 1").fetchone() if row: return {"kind": kind, "id": row["id"], "title": row["title"], "year": row["year"]} for kind, (tbl, _idc, sc, ac) in kinds.items(): row = conn.execute( f"SELECT id, title, year FROM {tbl} WHERE {sc}='not_found' " f"AND ({ac} IS NULL OR {ac} < ?) ORDER BY {ac} LIMIT 1", (cutoff,)).fetchone() if row: return {"kind": kind, "id": row["id"], "title": row["title"], "year": row["year"]} 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) -> None: """Record a match result: set match_status + last_attempted, the external id (when matched), and any whitelisted metadata columns.""" spec = _ENRICH.get(service, {}).get(kind) if not spec: return tbl, idc, sc, ac = spec allowed = _ENRICH_META_COLS.get(tbl, set()) # 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 = ["matched" if matched else "not_found"] 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 sets.append(f"{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) conn.commit() except sqlite3.IntegrityError: conn.rollback() sql, params = build(False) # keep existing id, just record status/metadata conn.execute(sql, params) conn.commit() finally: conn.close() def enrichment_breakdown(self, service: str) -> dict: 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], "pending": conn.execute(f"SELECT COUNT(*) FROM {tbl} WHERE {sc} IS NULL").fetchone()[0], } return out finally: conn.close() def enrichment_unmatched(self, service: str, kind: str, status: str = "not_found", search=None, limit: int = 50, offset: int = 0) -> dict: 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}='not_found')") 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 enrichment_retry(self, service: str, kind: str, scope: str = "failed", item_id=None) -> int: """Re-queue items by resetting status/last_attempted to NULL.""" 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}='not_found'") 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")), ) 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: conn.execute( "INSERT INTO movies (server_source, server_id, title, sort_title, year, overview, " "runtime_minutes, content_rating, studio, poster_url, tmdb_id, imdb_id, has_file, updated_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) " "ON CONFLICT(server_source, server_id) DO UPDATE SET " "title=excluded.title, sort_title=excluded.sort_title, year=excluded.year, " "overview=excluded.overview, runtime_minutes=excluded.runtime_minutes, " "content_rating=excluded.content_rating, studio=excluded.studio, " "poster_url=excluded.poster_url, tmdb_id=excluded.tmdb_id, imdb_id=excluded.imdb_id, " "has_file=excluded.has_file, updated_at=CURRENT_TIMESTAMP", (server_source, item["server_id"], item.get("title"), _sort_title(item.get("title")), item.get("year"), item.get("overview"), item.get("runtime_minutes"), item.get("content_rating"), item.get("studio"), item.get("poster_url"), item.get("tmdb_id"), item.get("imdb_id"), 1 if item.get("file") else 0), ) 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")) 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: conn.execute( "INSERT INTO shows (server_source, server_id, title, sort_title, year, overview, status, " "network, runtime_minutes, content_rating, poster_url, tvdb_id, tmdb_id, imdb_id, updated_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) " "ON CONFLICT(server_source, server_id) DO UPDATE SET " "title=excluded.title, sort_title=excluded.sort_title, year=excluded.year, " "overview=excluded.overview, status=excluded.status, network=excluded.network, " "runtime_minutes=excluded.runtime_minutes, content_rating=excluded.content_rating, " "poster_url=excluded.poster_url, tvdb_id=excluded.tvdb_id, tmdb_id=excluded.tmdb_id, " "imdb_id=excluded.imdb_id, updated_at=CURRENT_TIMESTAMP", (server_source, item["server_id"], item.get("title"), _sort_title(item.get("title")), item.get("year"), item.get("overview"), item.get("status"), item.get("network"), item.get("runtime_minutes"), item.get("content_rating"), item.get("poster_url"), item.get("tvdb_id"), item.get("tmdb_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"] 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, 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, 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("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 episodes/seasons that vanished from the server for this show. for row in conn.execute( "SELECT season_number, episode_number FROM episodes WHERE show_id=?", (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=?", (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// 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.""" table = {"movie": "movies", "show": "shows"}.get(kind) if not table: return None conn = self._get_connection() try: row = conn.execute( f"SELECT server_source, server_id, poster_url FROM {table} WHERE id=?", (item_id,)).fetchone() return dict(row) if row else None finally: conn.close() # ── 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()