soulsync/database/video_database.py
BoulderBadgeDad 093e14bd5d video enrichment 1a: DB layer (match-status cols + migration + helpers)
Foundation for the video enrichment workers, mirroring music's per-source
columns/queries on video.db.
- Schema: tmdb_match_status/tmdb_last_attempted on movies; tmdb_+tvdb_ on shows.
  Idempotent ALTER-TABLE migration adds them to existing DBs on init.
- VideoDatabase helpers (service+kind -> columns map):
  enrichment_next (pending first, then not_found past retry window),
  enrichment_apply (sets id/status/last_attempted + whitelisted metadata,
  backfill-safe), enrichment_breakdown, enrichment_unmatched (paged), and
  enrichment_retry. Same shape as music's enrichment API so the shared modal can
  drive it. 30 DB tests green.
2026-06-14 11:18:46 -07:00

667 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
sets = [f"{sc}=?", f"{ac}=CURRENT_TIMESTAMP"]
params = ["matched" if matched else "not_found"]
if matched and external_id is not None:
sets.append(f"{idc}=?")
params.append(external_id)
allowed = _ENRICH_META_COLS.get(tbl, set())
for col, val in (metadata or {}).items():
if val is not None and col in allowed:
sets.append(f"{col}=?")
params.append(val)
params.append(item_id)
conn = self._get_connection()
try:
conn.execute(f"UPDATE {tbl} SET {', '.join(sets)} WHERE id=?", 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/<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."""
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 + AZ + 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()