video scan: only FULL resets enrichment; incremental/deep preserve it
A scan wrote server-provided fields straight over the row, so an incremental/deep re-read wiped the TMDB-backfilled 'status' (Plex returns it blank) — clearing the airing watchlist. Now matches the intended model: incremental (add recent) and deep (coverage + prune) PRESERVE enrichment-owned fields the server left blank; only a FULL scan clobbers them (an explicit reset / fresh start). - _resilient_upsert gains preserve_enrichment (default True): on a conflict UPDATE, enrichment-owned columns (per _ENRICH_META_COLS: status/network/ratings/air dates/…) take the server value only when non-blank, else keep what's stored. A real server value still wins. - upsert_movie/upsert_show_tree thread the flag; scanner passes preserve=(mode!='full'). Tests: preserve-on-blank, server-value-wins, full-resets, and the scanner picking the right mode. 88 DB + 18 scanner tests + isolation green.
This commit is contained in:
parent
bdcd929558
commit
21e1944784
3 changed files with 123 additions and 11 deletions
|
|
@ -138,6 +138,10 @@ class VideoLibraryScanner:
|
||||||
server = source.server_name
|
server = source.server_name
|
||||||
incremental = mode == "incremental"
|
incremental = mode == "incremental"
|
||||||
do_prune = mode == "deep"
|
do_prune = mode == "deep"
|
||||||
|
# FULL = a clean reset (clobber enrichment-owned fields). Incremental/deep
|
||||||
|
# PRESERVE them, so a routine re-scan never wipes the TMDB-backfilled
|
||||||
|
# `status` the airing watchlist relies on. (Only an explicit full resets.)
|
||||||
|
preserve = mode != "full"
|
||||||
|
|
||||||
# Incremental on a near-empty library is pointless — fall back to a
|
# Incremental on a near-empty library is pointless — fall back to a
|
||||||
# full pass so the first scan actually populates (music does this).
|
# full pass so the first scan actually populates (music does this).
|
||||||
|
|
@ -178,7 +182,7 @@ class VideoLibraryScanner:
|
||||||
continue
|
continue
|
||||||
consec = 0
|
consec = 0
|
||||||
try:
|
try:
|
||||||
self.db.upsert_movie(server, item)
|
self.db.upsert_movie(server, item, preserve_enrichment=preserve)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("video scan: skipping movie %s", sid)
|
logger.exception("video scan: skipping movie %s", sid)
|
||||||
continue
|
continue
|
||||||
|
|
@ -215,7 +219,7 @@ class VideoLibraryScanner:
|
||||||
continue
|
continue
|
||||||
consec = 0
|
consec = 0
|
||||||
try:
|
try:
|
||||||
self.db.upsert_show_tree(server, show)
|
self.db.upsert_show_tree(server, show, preserve_enrichment=preserve)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("video scan: skipping show %s", sid)
|
logger.exception("video scan: skipping show %s", sid)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1381,19 +1381,34 @@ class VideoDatabase:
|
||||||
(owner_id, gid))
|
(owner_id, gid))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resilient_upsert(conn, table: str, base: dict, id_cols: dict) -> None:
|
def _resilient_upsert(conn, table: str, base: dict, id_cols: dict,
|
||||||
|
preserve_enrichment: bool = True) -> None:
|
||||||
"""INSERT…ON CONFLICT(server_source, server_id) for a movie/show row.
|
"""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
|
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):
|
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
|
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.
|
stored (same film in >1 library) instead of being dropped by the scan.
|
||||||
``base`` holds the always-written cols; ``id_cols`` the droppable ids."""
|
``base`` holds the always-written cols; ``id_cols`` the droppable ids.
|
||||||
|
|
||||||
|
``preserve_enrichment`` (default) keeps enrichment-owned fields (``status``,
|
||||||
|
network, ratings, air dates…) that the SERVER left blank — so an incremental
|
||||||
|
or deep re-scan never wipes the TMDB-backfilled ``status`` (which the airing
|
||||||
|
watchlist depends on). A FULL scan passes False = a clean overwrite / reset."""
|
||||||
|
protect = (_ENRICH_META_COLS.get(table, set()) if preserve_enrichment else set())
|
||||||
|
|
||||||
|
def _set(c):
|
||||||
|
# On a conflict UPDATE, an enrichment-owned column only takes the server
|
||||||
|
# value when it's non-blank; otherwise it keeps what's already stored.
|
||||||
|
if c in protect:
|
||||||
|
return f"{c}=COALESCE(NULLIF(excluded.{c}, ''), {table}.{c})"
|
||||||
|
return f"{c}=excluded.{c}"
|
||||||
|
|
||||||
def run(include_ids):
|
def run(include_ids):
|
||||||
cols = list(base.keys()) + (list(id_cols.keys()) if include_ids else [])
|
cols = list(base.keys()) + (list(id_cols.keys()) if include_ids else [])
|
||||||
vals = list(base.values()) + (list(id_cols.values()) 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")]
|
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"
|
set_clause = ", ".join(_set(c) for c in updates) + ", updated_at=CURRENT_TIMESTAMP"
|
||||||
sql = (f"INSERT INTO {table} ({', '.join(cols)}, updated_at) "
|
sql = (f"INSERT INTO {table} ({', '.join(cols)}, updated_at) "
|
||||||
f"VALUES ({', '.join(['?'] * len(cols))}, CURRENT_TIMESTAMP) "
|
f"VALUES ({', '.join(['?'] * len(cols))}, CURRENT_TIMESTAMP) "
|
||||||
f"ON CONFLICT(server_source, server_id) DO UPDATE SET {set_clause}")
|
f"ON CONFLICT(server_source, server_id) DO UPDATE SET {set_clause}")
|
||||||
|
|
@ -1453,8 +1468,10 @@ class VideoDatabase:
|
||||||
for r in rows if r["department"] == "crew"]
|
for r in rows if r["department"] == "crew"]
|
||||||
return {"cast": cast, "crew": crew}
|
return {"cast": cast, "crew": crew}
|
||||||
|
|
||||||
def upsert_movie(self, server_source: str, item: dict) -> int:
|
def upsert_movie(self, server_source: str, item: dict, preserve_enrichment: bool = True) -> int:
|
||||||
"""Insert/update one movie (keyed on server id) and its file. Returns row id."""
|
"""Insert/update one movie (keyed on server id) and its file. Returns row id.
|
||||||
|
``preserve_enrichment`` keeps enrichment-owned fields the server left blank
|
||||||
|
(default); a FULL scan passes False for a clean reset."""
|
||||||
conn = self._get_connection()
|
conn = self._get_connection()
|
||||||
try:
|
try:
|
||||||
self._resilient_upsert(conn, "movies", {
|
self._resilient_upsert(conn, "movies", {
|
||||||
|
|
@ -1465,7 +1482,8 @@ class VideoDatabase:
|
||||||
"studio": item.get("studio"), "tagline": item.get("tagline"),
|
"studio": item.get("studio"), "tagline": item.get("tagline"),
|
||||||
"rating": item.get("rating"), "rating_critic": item.get("rating_critic"),
|
"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,
|
"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")})
|
}, {"tmdb_id": item.get("tmdb_id"), "imdb_id": item.get("imdb_id")},
|
||||||
|
preserve_enrichment=preserve_enrichment)
|
||||||
movie_id = conn.execute(
|
movie_id = conn.execute(
|
||||||
"SELECT id FROM movies WHERE server_source=? AND server_id=?",
|
"SELECT id FROM movies WHERE server_source=? AND server_id=?",
|
||||||
(server_source, item["server_id"]),
|
(server_source, item["server_id"]),
|
||||||
|
|
@ -1477,10 +1495,12 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def upsert_show_tree(self, server_source: str, item: dict) -> int:
|
def upsert_show_tree(self, server_source: str, item: dict, preserve_enrichment: bool = True) -> int:
|
||||||
"""Insert/update a show with its seasons + episodes (and files) in one
|
"""Insert/update a show with its seasons + episodes (and files) in one
|
||||||
transaction. Episodes/seasons no longer present on the server for this
|
transaction. Episodes/seasons no longer present on the server for this
|
||||||
show are pruned. Returns the show row id."""
|
show are pruned. Returns the show row id. ``preserve_enrichment`` keeps
|
||||||
|
enrichment-owned fields (status etc.) the server left blank (default); a FULL
|
||||||
|
scan passes False for a clean reset."""
|
||||||
conn = self._get_connection()
|
conn = self._get_connection()
|
||||||
try:
|
try:
|
||||||
self._resilient_upsert(conn, "shows", {
|
self._resilient_upsert(conn, "shows", {
|
||||||
|
|
@ -1492,7 +1512,8 @@ class VideoDatabase:
|
||||||
"tagline": item.get("tagline"), "rating": item.get("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"),
|
"first_air_date": item.get("first_air_date"), "last_air_date": item.get("last_air_date"),
|
||||||
"poster_url": item.get("poster_url"),
|
"poster_url": item.get("poster_url"),
|
||||||
}, {"tvdb_id": item.get("tvdb_id"), "tmdb_id": item.get("tmdb_id"), "imdb_id": item.get("imdb_id")})
|
}, {"tvdb_id": item.get("tvdb_id"), "tmdb_id": item.get("tmdb_id"), "imdb_id": item.get("imdb_id")},
|
||||||
|
preserve_enrichment=preserve_enrichment)
|
||||||
show_id = conn.execute(
|
show_id = conn.execute(
|
||||||
"SELECT id FROM shows WHERE server_source=? AND server_id=?",
|
"SELECT id FROM shows WHERE server_source=? AND server_id=?",
|
||||||
(server_source, item["server_id"]),
|
(server_source, item["server_id"]),
|
||||||
|
|
|
||||||
87
tests/test_video_upsert_preserve.py
Normal file
87
tests/test_video_upsert_preserve.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""Scan must not wipe enrichment-owned fields the server leaves blank.
|
||||||
|
|
||||||
|
The media server (Plex) returns a blank `status`, but TMDB enrichment fills it —
|
||||||
|
and the airing watchlist depends on it. A routine re-scan (incremental/deep) must
|
||||||
|
PRESERVE that backfilled status; only a FULL scan (an explicit reset) clobbers it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from database.video_database import VideoDatabase
|
||||||
|
from core.video.scanner import VideoLibraryScanner
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def db(tmp_path):
|
||||||
|
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
|
||||||
|
|
||||||
|
|
||||||
|
def _status(db, show_id):
|
||||||
|
conn = db._get_connection()
|
||||||
|
try:
|
||||||
|
return conn.execute("SELECT status FROM shows WHERE id=?", (show_id,)).fetchone()[0]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_status(db, show_id, status):
|
||||||
|
conn = db._get_connection()
|
||||||
|
conn.execute("UPDATE shows SET status=? WHERE id=?", (status, show_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _show(status=None):
|
||||||
|
return {"server_id": "s1", "title": "S", "tmdb_id": 1, "status": status, "seasons": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_preserve_keeps_enriched_status_when_server_is_blank(db):
|
||||||
|
sid = db.upsert_show_tree("plex", _show())
|
||||||
|
_set_status(db, sid, "Returning Series") # TMDB enrichment fills it
|
||||||
|
db.upsert_show_tree("plex", _show(status=None), preserve_enrichment=True)
|
||||||
|
assert _status(db, sid) == "Returning Series" # re-scan didn't wipe it
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_provided_value_still_wins(db):
|
||||||
|
sid = db.upsert_show_tree("plex", _show())
|
||||||
|
_set_status(db, sid, "Returning Series")
|
||||||
|
db.upsert_show_tree("plex", _show(status="Ended"), preserve_enrichment=True)
|
||||||
|
assert _status(db, sid) == "Ended" # a real server value overwrites
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_scan_resets_enriched_status(db):
|
||||||
|
sid = db.upsert_show_tree("plex", _show())
|
||||||
|
_set_status(db, sid, "Returning Series")
|
||||||
|
db.upsert_show_tree("plex", _show(status=None), preserve_enrichment=False)
|
||||||
|
assert _status(db, sid) is None # full = fresh start, clobbers
|
||||||
|
|
||||||
|
|
||||||
|
class _Src:
|
||||||
|
server_name = "plex"
|
||||||
|
|
||||||
|
def __init__(self, shows):
|
||||||
|
self._shows = shows
|
||||||
|
|
||||||
|
def iter_movies(self, incremental=False):
|
||||||
|
return iter([])
|
||||||
|
|
||||||
|
def iter_shows(self, incremental=False):
|
||||||
|
return iter(self._shows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scanner_modes_pick_the_right_preserve(db):
|
||||||
|
# seed a show + enriched status, then re-scan it via each mode
|
||||||
|
sid = db.upsert_show_tree("plex", _show())
|
||||||
|
_set_status(db, sid, "Returning Series")
|
||||||
|
blank = [{"server_id": "s1", "title": "S", "tmdb_id": 1, "status": None, "seasons": []}]
|
||||||
|
|
||||||
|
VideoLibraryScanner(db).scan_sync(lambda: _Src(blank), mode="incremental")
|
||||||
|
assert _status(db, sid) == "Returning Series" # incremental preserves
|
||||||
|
|
||||||
|
VideoLibraryScanner(db).scan_sync(lambda: _Src(blank), mode="deep")
|
||||||
|
assert _status(db, sid) == "Returning Series" # deep preserves
|
||||||
|
|
||||||
|
VideoLibraryScanner(db).scan_sync(lambda: _Src(blank), mode="full")
|
||||||
|
assert _status(db, sid) is None # full resets
|
||||||
Loading…
Reference in a new issue