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:
parent
13e03a624c
commit
093e14bd5d
3 changed files with 221 additions and 0 deletions
|
|
@ -20,6 +20,7 @@ import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
@ -46,6 +47,38 @@ def _sort_title(title) -> str:
|
||||||
return _ARTICLE_RE.sub("", (title or "").strip()).lower()
|
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:
|
class VideoDatabase:
|
||||||
"""Connection + schema manager for the isolated video library DB."""
|
"""Connection + schema manager for the isolated video library DB."""
|
||||||
|
|
||||||
|
|
@ -90,6 +123,7 @@ class VideoDatabase:
|
||||||
conn = self._get_connection()
|
conn = self._get_connection()
|
||||||
try:
|
try:
|
||||||
conn.executescript(schema)
|
conn.executescript(schema)
|
||||||
|
self._ensure_columns(conn)
|
||||||
conn.execute(f"PRAGMA user_version = {int(SCHEMA_VERSION)}")
|
conn.execute(f"PRAGMA user_version = {int(SCHEMA_VERSION)}")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -103,6 +137,132 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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
|
@property
|
||||||
def schema_version(self) -> int:
|
def schema_version(self) -> int:
|
||||||
conn = self._get_connection()
|
conn = self._get_connection()
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ CREATE TABLE IF NOT EXISTS movies (
|
||||||
server_id TEXT, -- media server native id (Plex ratingKey / Jellyfin Item Id)
|
server_id TEXT, -- media server native id (Plex ratingKey / Jellyfin Item Id)
|
||||||
tmdb_id INTEGER, -- not unique: same film can sit in >1 library
|
tmdb_id INTEGER, -- not unique: same film can sit in >1 library
|
||||||
imdb_id TEXT,
|
imdb_id TEXT,
|
||||||
|
tmdb_match_status TEXT, -- enrichment: NULL=pending | matched | not_found | error
|
||||||
|
tmdb_last_attempted TEXT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
sort_title TEXT,
|
sort_title TEXT,
|
||||||
year INTEGER,
|
year INTEGER,
|
||||||
|
|
@ -100,6 +102,10 @@ CREATE TABLE IF NOT EXISTS shows (
|
||||||
tvdb_id INTEGER, -- not unique (same series can sit in >1 library)
|
tvdb_id INTEGER, -- not unique (same series can sit in >1 library)
|
||||||
tmdb_id INTEGER,
|
tmdb_id INTEGER,
|
||||||
imdb_id TEXT,
|
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,
|
title TEXT NOT NULL,
|
||||||
sort_title TEXT,
|
sort_title TEXT,
|
||||||
year INTEGER,
|
year INTEGER,
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,61 @@ def test_query_library_shows_status_and_counts(db):
|
||||||
assert (owned["episode_count"], owned["owned_count"]) == (1, 1)
|
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 ───────────────────────
|
# ── isolation: the video DB imports nothing from music ───────────────────────
|
||||||
|
|
||||||
def test_video_db_module_imports_nothing_from_music():
|
def test_video_db_module_imports_nothing_from_music():
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue