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.
This commit is contained in:
BoulderBadgeDad 2026-06-14 11:18:46 -07:00
parent 13e03a624c
commit 093e14bd5d
3 changed files with 221 additions and 0 deletions

View file

@ -20,6 +20,7 @@ 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
@ -46,6 +47,38 @@ 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."""
@ -90,6 +123,7 @@ class VideoDatabase:
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(
@ -103,6 +137,132 @@ class VideoDatabase:
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()

View file

@ -65,6 +65,8 @@ CREATE TABLE IF NOT EXISTS movies (
server_id TEXT, -- media server native id (Plex ratingKey / Jellyfin Item Id)
tmdb_id INTEGER, -- not unique: same film can sit in >1 library
imdb_id TEXT,
tmdb_match_status TEXT, -- enrichment: NULL=pending | matched | not_found | error
tmdb_last_attempted TEXT,
title TEXT NOT NULL,
sort_title TEXT,
year INTEGER,
@ -100,6 +102,10 @@ CREATE TABLE IF NOT EXISTS shows (
tvdb_id INTEGER, -- not unique (same series can sit in >1 library)
tmdb_id INTEGER,
imdb_id TEXT,
tmdb_match_status TEXT, -- enrichment match state per source
tmdb_last_attempted TEXT,
tvdb_match_status TEXT,
tvdb_last_attempted TEXT,
title TEXT NOT NULL,
sort_title TEXT,
year INTEGER,

View file

@ -322,6 +322,61 @@ def test_query_library_shows_status_and_counts(db):
assert (owned["episode_count"], owned["owned_count"]) == (1, 1)
# ── enrichment plumbing ───────────────────────────────────────────────────────
def test_enrichment_columns_present(db):
with db.connect() as c:
mcols = {r[1] for r in c.execute("PRAGMA table_info(movies)").fetchall()}
scols = {r[1] for r in c.execute("PRAGMA table_info(shows)").fetchall()}
assert {"tmdb_match_status", "tmdb_last_attempted"} <= mcols
assert {"tmdb_match_status", "tvdb_match_status", "tvdb_last_attempted"} <= scols
def test_ensure_columns_is_idempotent(db):
# Running the migration again on an already-migrated DB must not error.
with db.connect() as c:
db._ensure_columns(c)
c.commit()
assert db.enrichment_breakdown("tmdb")["movie"]["pending"] == 0
def test_enrichment_next_pending_then_none_when_fresh(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "A"})
db.upsert_movie("plex", {"server_id": "m2", "title": "B", "tmdb_id": 5})
nxt = db.enrichment_next("tmdb")
assert nxt and nxt["kind"] == "movie"
db.enrichment_apply("tmdb", "movie", nxt["id"], matched=True, external_id=1)
nxt2 = db.enrichment_next("tmdb")
assert nxt2 and nxt2["id"] != nxt["id"]
db.enrichment_apply("tmdb", "movie", nxt2["id"], matched=False)
# both attempted; not_found is fresh (<30d) so nothing is due
assert db.enrichment_next("tmdb") is None
def test_enrichment_apply_matched_sets_id_status_and_metadata(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "A"})
db.enrichment_apply("tmdb", "movie", mid, matched=True, external_id=27205,
metadata={"overview": "O", "backdrop_url": "/b.jpg", "imdb_id": "tt1",
"bogus_col": "x"})
with db.connect() as c:
row = c.execute("SELECT tmdb_id, tmdb_match_status, overview, backdrop_url, imdb_id "
"FROM movies WHERE id=?", (mid,)).fetchone()
assert (row["tmdb_id"], row["tmdb_match_status"]) == (27205, "matched")
assert (row["overview"], row["backdrop_url"], row["imdb_id"]) == ("O", "/b.jpg", "tt1")
def test_enrichment_breakdown_unmatched_retry(db):
a = db.upsert_movie("plex", {"server_id": "m1", "title": "A"})
b = db.upsert_movie("plex", {"server_id": "m2", "title": "B"})
db.enrichment_apply("tmdb", "movie", a, matched=True, external_id=1)
db.enrichment_apply("tmdb", "movie", b, matched=False)
assert db.enrichment_breakdown("tmdb")["movie"] == {"matched": 1, "not_found": 1, "pending": 0}
un = db.enrichment_unmatched("tmdb", "movie", status="not_found")
assert [i["title"] for i in un["items"]] == ["B"] and un["total"] == 1
assert db.enrichment_retry("tmdb", "movie", scope="failed") == 1
assert db.enrichment_breakdown("tmdb")["movie"]["pending"] == 1
# ── isolation: the video DB imports nothing from music ───────────────────────
def test_video_db_module_imports_nothing_from_music():