soulsync/tests/test_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

394 lines
20 KiB
Python

"""Seam tests for the isolated video database (experimental branch).
These pin the contract that matters: the schema builds with all tables/views,
the no-polymorphic-id CHECK constraints actually reject bad rows, cascades fire,
the derived Watchlist/Wishlist/Calendar views return the right membership, the
settings KV roundtrips — and, critically, that the video DB layer imports
NOTHING from the music database (isolation).
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from database.video_database import VideoDatabase, SCHEMA_VERSION
_MODULE = Path(__file__).resolve().parent.parent / "database" / "video_database.py"
EXPECTED_TABLES = {
"meta", "root_folders", "quality_profiles", "video_settings",
"movies", "shows", "seasons", "episodes", "channels", "channel_videos",
"media_files", "downloads", "activity",
}
EXPECTED_VIEWS = {"v_watchlist", "v_wishlist", "v_calendar"}
@pytest.fixture()
def db(tmp_path):
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
# ── schema ──────────────────────────────────────────────────────────────────
def test_schema_builds_with_all_tables_and_views(db):
with db.connect() as conn:
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'")}
views = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='view'")}
assert EXPECTED_TABLES <= tables, f"missing tables: {EXPECTED_TABLES - tables}"
assert views == EXPECTED_VIEWS
def test_schema_version_recorded(db):
assert db.schema_version == SCHEMA_VERSION
def test_init_is_idempotent(tmp_path):
path = str(tmp_path / "video_library.db")
VideoDatabase(database_path=path)
# Second construction on the same path must not raise or wipe data.
db2 = VideoDatabase(database_path=path)
assert db2.health_check()
def test_health_check_ok(db):
assert db.health_check() is True
# ── no-polymorphic-id CHECK constraints ──────────────────────────────────────
def test_media_file_requires_exactly_one_owner(db):
with db.connect() as conn:
conn.execute("INSERT INTO movies(id,title) VALUES (1,'M')")
conn.execute("INSERT INTO shows(id,title) VALUES (1,'S')")
conn.execute("INSERT INTO seasons(id,show_id,season_number) VALUES (1,1,1)")
conn.execute("INSERT INTO episodes(id,show_id,season_id,season_number,episode_number) "
"VALUES (1,1,1,1,1)")
# zero owners -> reject
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO media_files(relative_path) VALUES ('x.mkv')")
# two owners -> reject
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO media_files(movie_id,episode_id,relative_path) "
"VALUES (1,1,'x.mkv')")
# exactly one -> ok
conn.execute("INSERT INTO media_files(movie_id,relative_path) VALUES (1,'x.mkv')")
def test_download_requires_exactly_one_target(db):
with db.connect() as conn:
conn.execute("INSERT INTO movies(id,title) VALUES (1,'M')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO downloads(title) VALUES ('no target')")
conn.execute("INSERT INTO downloads(movie_id,title) VALUES (1,'ok')")
def test_download_status_is_constrained(db):
with db.connect() as conn:
conn.execute("INSERT INTO movies(id,title) VALUES (1,'M')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO downloads(movie_id,title,status) VALUES (1,'x','bogus')")
# ── cascades ──────────────────────────────────────────────────────────────────
def test_deleting_show_cascades_to_seasons_and_episodes(db):
with db.connect() as conn:
conn.execute("INSERT INTO shows(id,title) VALUES (1,'S')")
conn.execute("INSERT INTO seasons(id,show_id,season_number) VALUES (1,1,1)")
conn.execute("INSERT INTO episodes(id,show_id,season_id,season_number,episode_number) "
"VALUES (1,1,1,1,1)")
conn.execute("DELETE FROM shows WHERE id=1")
assert conn.execute("SELECT COUNT(*) FROM seasons").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM episodes").fetchone()[0] == 0
# ── derived views ─────────────────────────────────────────────────────────────
def test_watchlist_view_is_monitored_shows_and_channels(db):
with db.connect() as conn:
conn.execute("INSERT INTO shows(id,title,monitored) VALUES (1,'Followed',1)")
conn.execute("INSERT INTO shows(id,title,monitored) VALUES (2,'Unfollowed',0)")
conn.execute("INSERT INTO channels(id,youtube_id,title,monitored) VALUES (1,'yt1','Chan',1)")
rows = {(r["kind"], r["title"]) for r in conn.execute("SELECT kind,title FROM v_watchlist")}
assert rows == {("show", "Followed"), ("channel", "Chan")}
def test_wishlist_view_is_wanted_but_missing(db):
with db.connect() as conn:
# wanted movie (monitored, no file) -> in wishlist
conn.execute("INSERT INTO movies(id,title,monitored,has_file) VALUES (1,'Want',1,0)")
# owned movie -> not in wishlist
conn.execute("INSERT INTO movies(id,title,monitored,has_file) VALUES (2,'Own',1,1)")
# aired, monitored, missing episode -> in wishlist; future episode -> not
conn.execute("INSERT INTO shows(id,title) VALUES (1,'S')")
conn.execute("INSERT INTO seasons(id,show_id,season_number) VALUES (1,1,1)")
conn.execute("INSERT INTO episodes(id,show_id,season_id,season_number,episode_number,"
"monitored,has_file,air_date) VALUES (1,1,1,1,1,1,0,'2000-01-01')")
conn.execute("INSERT INTO episodes(id,show_id,season_id,season_number,episode_number,"
"monitored,has_file,air_date) VALUES (2,1,1,1,2,1,0,'2999-01-01')")
rows = [(r["kind"], r["ref_id"]) for r in conn.execute("SELECT kind,ref_id FROM v_wishlist")]
assert ("movie", 1) in rows # wanted movie present
assert ("movie", 2) not in rows # owned movie absent
assert ("episode", 1) in rows # aired+missing episode present
assert ("episode", 2) not in rows # future episode absent
def test_dashboard_stats_empty_is_all_zero(db):
s = db.dashboard_stats()
assert s["library"] == {"movies": 0, "shows": 0, "episodes": 0, "size_bytes": 0}
assert s["downloads"] == {"active": 0, "finished": 0, "speed_bps": 0}
assert s["watchlist"] == 0 and s["wishlist"] == 0
def test_dashboard_stats_counts_content_and_downloads(db):
with db.connect() as conn:
conn.execute("INSERT INTO movies(id,title,monitored,has_file) VALUES (1,'M',1,0)")
conn.execute("INSERT INTO shows(id,title,monitored) VALUES (1,'S',1)")
conn.execute("INSERT INTO seasons(id,show_id,season_number) VALUES (1,1,1)")
conn.execute("INSERT INTO episodes(id,show_id,season_id,season_number,episode_number) "
"VALUES (1,1,1,1,1)")
conn.execute("INSERT INTO media_files(movie_id,relative_path,size_bytes) VALUES (1,'m.mkv',1000)")
conn.execute("INSERT INTO downloads(movie_id,title,status,download_speed_bps) "
"VALUES (1,'d','downloading',500)")
conn.commit()
s = db.dashboard_stats()
assert s["library"] == {"movies": 1, "shows": 1, "episodes": 1, "size_bytes": 1000}
assert s["downloads"]["active"] == 1 and s["downloads"]["speed_bps"] == 500
assert s["watchlist"] == 1 and s["wishlist"] == 1
def test_library_selection_roundtrip(db):
assert db.get_library_selection("plex") == {"movies": None, "tv": None}
db.set_library_selection("plex", "Movies", "TV Shows")
assert db.get_library_selection("plex") == {"movies": "Movies", "tv": "TV Shows"}
# Per-server keys don't collide.
assert db.get_library_selection("jellyfin") == {"movies": None, "tv": None}
def test_settings_kv_roundtrip(db):
assert db.get_setting("download_dir", "unset") == "unset"
db.set_setting("download_dir", "/data/video")
assert db.get_setting("download_dir") == "/data/video"
db.set_setting("download_dir", "/data/video2") # upsert
assert db.get_setting("download_dir") == "/data/video2"
# ── scan upserts (server source of truth) ────────────────────────────────────
def test_upsert_movie_inserts_updates_and_attaches_file(db):
mid = db.upsert_movie("plex", {
"server_id": "p1", "title": "Dune", "year": 2021,
"file": {"relative_path": "Dune.mkv", "size_bytes": 2000, "resolution": "2160p"}})
with db.connect() as c:
row = c.execute("SELECT title,year,has_file FROM movies WHERE id=?", (mid,)).fetchone()
assert (row["title"], row["year"], row["has_file"]) == ("Dune", 2021, 1)
f = c.execute("SELECT relative_path,size_bytes,resolution FROM media_files WHERE movie_id=?",
(mid,)).fetchone()
assert (f["relative_path"], f["size_bytes"], f["resolution"]) == ("Dune.mkv", 2000, "2160p")
# Re-scan same server id -> same row, updated fields, file replaced (not duplicated).
mid2 = db.upsert_movie("plex", {
"server_id": "p1", "title": "Dune: Part One", "year": 2021,
"file": {"relative_path": "Dune1.mkv", "size_bytes": 3000}})
assert mid2 == mid
with db.connect() as c:
assert c.execute("SELECT title FROM movies WHERE id=?", (mid,)).fetchone()["title"] == "Dune: Part One"
files = c.execute("SELECT relative_path FROM media_files WHERE movie_id=?", (mid,)).fetchall()
assert [r["relative_path"] for r in files] == ["Dune1.mkv"]
assert db.dashboard_stats()["library"]["movies"] == 1
def test_upsert_show_tree_builds_seasons_episodes_and_prunes(db):
item = {"server_id": "s1", "title": "Show", "seasons": [
{"season_number": 1, "server_id": "se1", "episodes": [
{"episode_number": 1, "title": "E1", "air_date": "2020-01-01",
"file": {"relative_path": "e1.mkv", "size_bytes": 10}},
{"episode_number": 2, "title": "E2", "air_date": "2020-01-08"}]}]}
sid = db.upsert_show_tree("plex", item)
with db.connect() as c:
assert c.execute("SELECT COUNT(*) FROM episodes WHERE show_id=?", (sid,)).fetchone()[0] == 2
assert c.execute("SELECT has_file FROM episodes WHERE show_id=? AND episode_number=1",
(sid,)).fetchone()["has_file"] == 1
assert c.execute("SELECT has_file FROM episodes WHERE show_id=? AND episode_number=2",
(sid,)).fetchone()["has_file"] == 0
# Re-scan with E2 removed from the server -> it gets pruned.
item["seasons"][0]["episodes"] = item["seasons"][0]["episodes"][:1]
assert db.upsert_show_tree("plex", item) == sid
with db.connect() as c:
eps = [r["episode_number"] for r in c.execute(
"SELECT episode_number FROM episodes WHERE show_id=?", (sid,)).fetchall()]
assert eps == [1]
def test_upsert_stores_provider_ids(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Inception",
"tmdb_id": 27205, "imdb_id": "tt1375666"})
with db.connect() as c:
row = c.execute("SELECT tmdb_id, imdb_id FROM movies WHERE id=?", (mid,)).fetchone()
assert (row["tmdb_id"], row["imdb_id"]) == (27205, "tt1375666")
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "Show", "tvdb_id": 121361,
"tmdb_id": 1396, "imdb_id": "tt0903747", "seasons": [
{"season_number": 1, "episodes": [{"episode_number": 1, "tvdb_id": 349232}]}]})
with db.connect() as c:
srow = c.execute("SELECT tvdb_id, tmdb_id, imdb_id FROM shows WHERE id=?", (sid,)).fetchone()
erow = c.execute("SELECT tvdb_id FROM episodes WHERE show_id=?", (sid,)).fetchone()
assert (srow["tvdb_id"], srow["tmdb_id"], srow["imdb_id"]) == (121361, 1396, "tt0903747")
assert erow["tvdb_id"] == 349232
def test_prune_missing_skips_when_over_half_would_be_removed(db):
# >100 movies; a scan that "sees" only a couple must NOT wipe the rest
# (mirrors music's deep-scan 50% safety against partial server failures).
for i in range(150):
db.upsert_movie("plex", {"server_id": "m%d" % i, "title": "M%d" % i})
assert db.prune_missing("movies", "plex", {"m0", "m1"}) == 0
assert db.table_count("movies") == 150
def test_upsert_show_tree_skips_episodes_without_number(db):
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [
{"season_number": 1, "episodes": [
{"episode_number": 1, "title": "E1"},
{"episode_number": None, "title": "Unmatched special"}]}]})
with db.connect() as c:
eps = [r["episode_number"] for r in c.execute(
"SELECT episode_number FROM episodes WHERE show_id=?", (sid,)).fetchall()]
assert eps == [1]
def test_prune_missing_removes_unseen_top_level(db):
db.upsert_movie("plex", {"server_id": "a", "title": "A"})
db.upsert_movie("plex", {"server_id": "b", "title": "B"})
assert db.prune_missing("movies", "plex", {"a"}) == 1
with db.connect() as c:
ids = {r["server_id"] for r in c.execute("SELECT server_id FROM movies").fetchall()}
assert ids == {"a"}
def test_list_movies_and_shows(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "Zardoz", "year": 1974})
db.upsert_movie("plex", {"server_id": "m2", "title": "Akira", "year": 1988})
db.upsert_show_tree("plex", {"server_id": "s1", "title": "Show", "seasons": [
{"season_number": 1, "episodes": [
{"episode_number": 1, "file": {"relative_path": "e1.mkv"}},
{"episode_number": 2}]}]})
assert [m["title"] for m in db.list_movies()] == ["Akira", "Zardoz"] # title NOCASE sort
shows = db.list_shows()
assert len(shows) == 1
assert (shows[0]["episode_count"], shows[0]["owned_count"]) == (2, 1)
def test_query_library_search_letter_sort_status_pagination(db):
db.upsert_movie("plex", {"server_id": "1", "title": "The Matrix", "year": 1999,
"file": {"relative_path": "x.mkv", "resolution": "1080p"}})
db.upsert_movie("plex", {"server_id": "2", "title": "Akira", "year": 1988}) # wanted
db.upsert_movie("plex", {"server_id": "3", "title": "Avatar", "year": 2009,
"file": {"relative_path": "y.mkv"}})
# Article-aware sort: "The Matrix" files under M.
res = db.query_library("movies")
assert [i["title"] for i in res["items"]] == ["Akira", "Avatar", "The Matrix"]
assert res["pagination"]["total_count"] == 3
assert [i["title"] for i in db.query_library("movies", letter="m")["items"]] == ["The Matrix"]
assert [i["title"] for i in db.query_library("movies", search="aki")["items"]] == ["Akira"]
assert {i["title"] for i in db.query_library("movies", status="owned")["items"]} == {"The Matrix", "Avatar"}
assert [i["title"] for i in db.query_library("movies", status="wanted")["items"]] == ["Akira"]
assert [i["title"] for i in db.query_library("movies", sort="year")["items"]] == ["Avatar", "The Matrix", "Akira"]
# Resolution badge field comes through.
assert db.query_library("movies", search="matrix")["items"][0]["resolution"] == "1080p"
# Pagination.
p1 = db.query_library("movies", limit=2, page=1)
assert len(p1["items"]) == 2 and p1["pagination"]["total_pages"] == 2 and p1["pagination"]["has_next"]
p2 = db.query_library("movies", limit=2, page=2)
assert len(p2["items"]) == 1 and p2["pagination"]["has_prev"]
def test_query_library_shows_status_and_counts(db):
db.upsert_show_tree("plex", {"server_id": "s1", "title": "Owned Show", "seasons": [
{"season_number": 1, "episodes": [{"episode_number": 1, "file": {"relative_path": "e.mkv"}}]}]})
db.upsert_show_tree("plex", {"server_id": "s2", "title": "Wanted Show", "seasons": [
{"season_number": 1, "episodes": [{"episode_number": 1}]}]})
assert [i["title"] for i in db.query_library("shows", status="owned")["items"]] == ["Owned Show"]
assert [i["title"] for i in db.query_library("shows", status="wanted")["items"]] == ["Wanted Show"]
owned = db.query_library("shows", search="Owned")["items"][0]
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():
src = _MODULE.read_text(encoding="utf-8")
for line in src.splitlines():
stripped = line.strip()
if stripped.startswith("import ") or stripped.startswith("from "):
assert "music" not in stripped.lower(), f"music import leaked in: {stripped!r}"
def test_video_db_uses_distinct_default_path_and_env():
src = _MODULE.read_text(encoding="utf-8")
assert "video_library.db" in src
assert "VIDEO_DATABASE_PATH" in src # distinct env var from music's DATABASE_PATH
assert "music_library.db" not in src