soulsync/tests/test_video_enrichment.py
BoulderBadgeDad bb1074cc60 video enrichment: add a GLOBAL 'Retry all failed' (all workers, all kinds)
Alongside the per-worker 'Retry all failed', the worker modal now has a topbar
'Retry all failed' that re-queues every failed/not_found item across ALL workers and
kinds in one click — one-shot recovery after an API outage left lots errored.

- db.retry_all_failed() derives the full service+kind set from the same _ENRICH /
  _BACKFILL maps the workers use (tmdb/tvdb + omdb + fanart/opensubtitles/trakt/
  tvmaze/anilist/wikidata + ryd/sponsorblock/dearrow), loops enrichment_retry, returns
  the total re-queued. POST /api/video/enrichment/retry-all-failed.
- Topbar button (amber, text) → calls it, toasts the count, refreshes the modal.

DB test (resets across matcher + backfill + youtube service, deterministic count) +
frontend wiring test. ruff + node --check clean.
2026-06-20 14:15:18 -07:00

1333 lines
64 KiB
Python

"""Seam tests for the video enrichment workers (experimental branch).
The worker's loop/queue/status logic is driven by a FAKE client so it's tested
without hitting TMDB/TVDB. Also guards that the enrichment package imports
nothing from the music side.
"""
from __future__ import annotations
import sys
import types
from pathlib import Path
import pytest
from database.video_database import VideoDatabase
from core.video.enrichment.worker import VideoEnrichmentWorker
from core.video.enrichment.engine import VideoEnrichmentEngine
from core.video.enrichment.clients import TMDBClient, TVDBClient, OMDBClient
class FakeClient:
enabled = True
def __init__(self, result):
self._result = result
self.calls = []
def match(self, kind, title, year, known_id=None):
self.calls.append((kind, title, year, known_id))
return self._result
@pytest.fixture()
def db(tmp_path):
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
def test_worker_process_one_matches(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "year": 2021})
client = FakeClient({"id": 438631, "metadata": {"overview": "O", "backdrop_url": "/b.jpg"}})
w = VideoEnrichmentWorker(db, "tmdb", client)
assert w.process_one() is True
assert client.calls == [("movie", "Dune", 2021, None)] # no server id → search
with db.connect() as c:
row = c.execute("SELECT tmdb_id, tmdb_match_status, overview FROM movies").fetchone()
assert (row["tmdb_id"], row["tmdb_match_status"], row["overview"]) == (438631, "matched", "O")
assert w.stats["matched"] == 1
def test_worker_enriches_by_server_id_instead_of_searching(db):
# The server already gave us tmdb_id during the scan → the worker must pass
# it through (enrich BY ID, no title re-search).
db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "year": 2021, "tmdb_id": 438631})
client = FakeClient({"id": 438631, "metadata": {"overview": "O"}})
w = VideoEnrichmentWorker(db, "tmdb", client)
assert w.process_one() is True
assert client.calls == [("movie", "Dune", 2021, 438631)] # known_id forwarded
assert db.enrichment_next("tmdb") is None # nothing left pending
def test_enrichment_next_surfaces_known_id(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 99})
nxt = db.enrichment_next("tmdb")
assert nxt["known_id"] == 99 and nxt["kind"] == "movie"
def test_tmdb_client_with_known_id_skips_the_search(monkeypatch):
# With a known id, the client must hit /movie/<id> directly and never the
# /search endpoint (no chance of a wrong title-search match).
urls = []
class _Resp:
def json(self):
return {"overview": "by-id overview", "backdrop_path": "/b.jpg"}
fake = types.SimpleNamespace(get=lambda url, **kw: (urls.append(url), _Resp())[1])
monkeypatch.setitem(sys.modules, "requests", fake)
res = TMDBClient("KEY").match("movie", "Whatever", 2021, known_id=438631)
assert res["id"] == 438631
assert any("/movie/438631" in u for u in urls)
assert not any("/search/" in u for u in urls)
def test_tmdb_pulls_full_metadata(monkeypatch):
# Everything TMDB offers comes from the one detail call.
class _Resp:
def __init__(self, b): self._b = b
def raise_for_status(self): pass
def json(self): return self._b
detail = {"overview": "O", "tagline": "Fear", "vote_average": 8.4, "runtime": 155,
"genres": [{"name": "Sci-Fi"}, {"name": "Drama"}], "status": "Released",
"backdrop_path": "/b.jpg", "external_ids": {"imdb_id": "tt1"}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
m = TMDBClient("KEY").match("movie", "Dune", 2021, known_id=438631)["metadata"]
assert m["tagline"] == "Fear" and m["rating"] == 8.4 and m["runtime_minutes"] == 155
assert m["genres"] == ["Sci-Fi", "Drama"] and m["status"] == "Released" and m["imdb_id"] == "tt1"
def test_refresh_show_art_backfills_seasons_even_when_already_matched(db):
# The exact failing case: a MATCHED show (won't re-run via the queue) still
# has no season posters. Lazy refresh fetches + caches them anyway.
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 1396,
"seasons": [{"season_number": 1, "episodes": [{"episode_number": 1}]}]})
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1396) # already matched
assert db.show_detail(sid)["seasons"][0]["has_poster"] is False
class C:
enabled = True
def match(self, kind, title, year, known_id=None):
assert known_id == 1396
return {"id": 1396, "metadata": {"seasons": [
{"season_number": 1, "poster_url": "https://img/s1.jpg"}]}}
def season_episodes(self, tv, sn): return None
eng = VideoEnrichmentEngine(db, {"tmdb": C()})
assert eng.refresh_show_art(sid)["ok"] is True
assert db.show_detail(sid)["seasons"][0]["has_poster"] is True
def test_refresh_show_art_needs_tmdb_configured(db):
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []})
class Off:
enabled = False
def match(self, *a, **k): return None
res = VideoEnrichmentEngine(db, {"tmdb": Off()}).refresh_show_art(sid)
assert res["ok"] is False and res["reason"] == "tmdb_not_configured"
def test_episode_sync_next_only_matched_unsynced(db):
a = db.upsert_show_tree("plex", {"server_id": "s1", "title": "A", "tmdb_id": 1, "seasons": []})
db.upsert_show_tree("plex", {"server_id": "s2", "title": "B", "seasons": []}) # no tmdb_id
assert db.episode_sync_next()["id"] == a # a is matched + unsynced; b skipped
db.mark_episodes_synced(a)
assert db.episode_sync_next() is None and db.episode_sync_pending_count() == 0
def test_worker_background_episode_sync_pulls_full_list(db):
# A show matched BEFORE the cascade feature: tmdb_id set, only owned episodes,
# episodes_synced still 0. With the match queue clear, the worker syncs it.
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 1396, "seasons": [
{"season_number": 1, "episodes": [{"episode_number": 1, "file": {"relative_path": "e1.mkv"}}]}]})
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1396)
assert db.episode_sync_pending_count() == 1
class C:
enabled = True
def match(self, kind, title, year, known_id=None):
return {"id": 1396, "metadata": {"seasons": [{"season_number": 1, "poster_url": None}]}}
def season_episodes(self, tv, sn):
return {"episodes": [{"episode_number": 1}, {"episode_number": 2}, {"episode_number": 3}]}
w = VideoEnrichmentWorker(db, "tmdb", C())
assert w.process_one() is True # no match pending → episode sync runs
s1 = db.show_detail(sid)["seasons"][0]
assert (s1["episode_total"], s1["episode_owned"]) == (3, 1) # full list now
assert db.episode_sync_pending_count() == 0
def test_detail_backfill_next_only_matched_unsynced(db):
# Matched items (have tmdb_id) that haven't had details backfilled are queued;
# un-matched ones are skipped. Shows + movies are independent queues.
a = db.upsert_show_tree("plex", {"server_id": "s1", "title": "A", "tmdb_id": 1, "seasons": []})
db.upsert_show_tree("plex", {"server_id": "s2", "title": "B", "seasons": []}) # no tmdb_id → skip
nx = db.detail_backfill_next("show")
assert nx and nx["id"] == a and nx["kind"] == "show" and nx["tmdb_id"] == 1
db.mark_details_synced("show", a)
assert db.detail_backfill_next("show") is None
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "M", "tmdb_id": 9})
assert db.detail_backfill_next("movie")["id"] == mid
db.mark_details_synced("movie", mid)
assert db.detail_backfill_next("movie") is None
assert db.detail_backfill_pending_count() == 0
def test_worker_detail_backfill_fills_status(db):
# The real bug: a show pre-matched by the server (tmdb_id set) + already
# episode-synced, but `status` never captured (server doesn't supply it, the
# matcher skips matched rows). With the match + episode queues clear, the worker
# re-fetches details and gap-fills `status` — what the airing-watchlist needs.
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 1396, "seasons": []})
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1396)
db.mark_episodes_synced(sid) # episode queue clear → detail backfill runs
assert db.detail_backfill_pending_count() == 1
client = FakeClient({"id": 1396, "metadata": {"status": "Returning Series", "network": "HBO"}})
w = VideoEnrichmentWorker(db, "tmdb", client)
assert w.process_one() is True # no match/episode work → detail backfill
assert client.calls == [("show", "S", None, 1396)] # enrich BY id, no re-search
with db.connect() as c:
row = c.execute("SELECT status, details_synced FROM shows WHERE id=?", (sid,)).fetchone()
assert row["status"] == "Returning Series"
assert row["details_synced"] == 1
assert db.detail_backfill_pending_count() == 0 # done → not re-picked
def test_detail_backfill_is_tmdb_only(db):
# The queue is keyed on tmdb_id, so only the TMDB worker may run it — the TVDB
# worker would feed a TMDB id to TVDB (404s) and double-process. It must no-op.
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 7, "seasons": []})
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=7)
db.mark_episodes_synced(sid)
client = FakeClient({"id": 7, "metadata": {"status": "Returning Series"}})
tvdb = VideoEnrichmentWorker(db, "tvdb", client)
assert tvdb._detail_backfill_one() is False
assert client.calls == [] # never called the client
assert db.detail_backfill_pending_count() == 1 # still pending (untouched)
def test_detail_backfill_marks_done_even_when_status_absent(db):
# If TMDB returns no status, we still mark it attempted so it isn't re-fetched
# forever (bounded: one attempt per item).
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 5, "seasons": []})
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=5)
db.mark_episodes_synced(sid)
w = VideoEnrichmentWorker(db, "tmdb", FakeClient({"id": 5, "metadata": {}}))
assert w.process_one() is True
assert db.detail_backfill_pending_count() == 0
def test_show_match_info(db):
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "year": 2019,
"tmdb_id": 1396, "seasons": []})
assert db.show_match_info(sid) == {"title": "S", "year": 2019, "tmdb_id": 1396}
assert db.show_match_info(999999) is None
class _Resp:
def __init__(self, b): self._b = b
def raise_for_status(self): pass
def json(self): return self._b
def test_tmdb_search_parses_multi(monkeypatch):
body = {"results": [
{"media_type": "movie", "id": 1, "title": "Dune", "release_date": "2021-10-22",
"poster_path": "/d.jpg", "vote_average": 8.0},
{"media_type": "tv", "id": 2, "name": "Loki", "first_air_date": "2021-06-09", "poster_path": "/l.jpg"},
{"media_type": "person", "id": 3, "name": "Zendaya", "profile_path": "/z.jpg",
"known_for_department": "Acting",
"known_for": [{"title": "Dune"}, {"name": "Euphoria"}]},
{"media_type": "company", "id": 9, "name": "ignore me"}]}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
res = TMDBClient("KEY").search("d")
kinds = [r["kind"] for r in res]
assert kinds == ["movie", "show", "person"] # company dropped
assert res[0] == {"kind": "movie", "tmdb_id": 1, "title": "Dune", "year": "2021",
"overview": None, "rating": 8.0,
"poster": "https://image.tmdb.org/t/p/w300/d.jpg"}
assert res[2]["known_for"] == "Dune, Euphoria"
def test_tmdb_full_detail_movie(monkeypatch):
detail = {"id": 1, "title": "Dune", "overview": "O", "release_date": "2021-10-22",
"runtime": 155, "vote_average": 8.0, "tagline": "Fear is the mind-killer",
"poster_path": "/p.jpg", "backdrop_path": "/b.jpg",
"genres": [{"name": "Sci-Fi"}], "production_companies": [{"name": "Legendary"}],
"external_ids": {"imdb_id": "tt1160419"},
"credits": {"cast": [{"name": "Timothée", "id": 11, "character": "Paul", "profile_path": "/t.jpg"}],
"crew": [{"name": "Denis", "id": 12, "job": "Director"}]},
"images": {"logos": [{"iso_639_1": "en", "file_path": "/logo.png"}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
d = TMDBClient("KEY").full_detail("movie", 1)
assert d["title"] == "Dune" and d["year"] == "2021" and d["studio"] == "Legendary"
assert d["poster_url"] == "https://image.tmdb.org/t/p/original/p.jpg"
assert d["cast"][0] == {"name": "Timothée", "character": "Paul",
"photo": "https://image.tmdb.org/t/p/w185/t.jpg", "tmdb_id": 11}
assert d["imdb_id"] == "tt1160419" and d["logo"].endswith("/logo.png")
assert "videos" not in d["_extras"] and "similar" not in d["_extras"] # none in this body
assert d["_extras"]["cast_full"][0]["character"] == "Paul" # full cast parsed
def test_engine_tmdb_detail_redirects_when_owned(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 77})
class Tmdb:
enabled = True
def full_detail(self, kind, tid, region="US"): raise AssertionError("must not fetch an owned title")
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
assert eng.tmdb_detail("movie", 77) == {"redirect": {"source": "library", "kind": "movie", "id": mid}}
def test_engine_tmdb_detail_assembles_show(db):
class Tmdb:
enabled = True
def full_detail(self, kind, tid, region="US"):
return {"kind": "show", "tmdb_id": tid, "title": "Loki", "imdb_id": None,
"poster_url": "http://p", "backdrop_url": None, "cast": [], "crew": [],
"_extras": {"similar": [{"title": "X", "tmdb_id": 5, "kind": "show"}]},
"_seasons": [{"season_number": 1, "title": "Season 1",
"poster_url": "http://s1", "episode_count": 6}]}
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
d = eng.tmdb_detail("show", 84958)
assert d["source"] == "tmdb" and d["owned"] is False and d["id"] == 84958
assert d["has_poster"] is True and d["has_backdrop"] is False
assert d["similar"][0]["tmdb_id"] == 5
s = d["seasons"][0]
assert s["episode_total"] == 6 and s["episode_owned"] == 0 and s["episodes"] == []
assert d["season_count"] == 1 and d["episode_total"] == 6
def test_engine_search_annotates_library(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
class Tmdb:
enabled = True
def search(self, q):
return [{"kind": "movie", "tmdb_id": 1, "title": "Owned"},
{"kind": "movie", "tmdb_id": 2, "title": "Not owned"},
{"kind": "person", "tmdb_id": 3, "title": "Someone"}]
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
res = eng.search("own")
assert res[0]["library_id"] == mid
assert res[1]["library_id"] is None
assert "library_id" not in res[2] # people aren't library-matched
def test_person_detail_caches_tmdb_call(db):
calls = []
class Tmdb:
enabled = True
def person(self, tid): calls.append(tid); return {"tmdb_id": tid, "name": "P", "credits": []}
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
eng.person_detail(7); eng.person_detail(7)
assert calls == [7] # second view served from cache
def test_tmdb_season_caches_tmdb_call(db):
calls = []
class Tmdb:
enabled = True
def season_episodes(self, tv, sn): calls.append((tv, sn)); return {"overview": "S", "episodes": []}
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
eng.tmdb_season(9, 1); eng.tmdb_season(9, 1)
assert calls == [(9, 1)]
def test_engine_trending_annotates_library(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
class Tmdb:
enabled = True
def trending(self):
return [{"kind": "movie", "tmdb_id": 1, "title": "Owned"},
{"kind": "show", "tmdb_id": 2, "title": "Hot show"}]
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
res = eng.trending()
assert res[0]["library_id"] == mid and res[1]["library_id"] is None
def test_tmdb_trending_parses(monkeypatch):
body = {"results": [
{"media_type": "movie", "id": 1, "title": "A", "release_date": "2024-01-01", "poster_path": "/a.jpg"},
{"media_type": "tv", "id": 2, "name": "B", "first_air_date": "2023-05-05"},
{"media_type": "person", "id": 3, "name": "C"}]} # people excluded from the rail
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
res = TMDBClient("KEY").trending()
assert [r["kind"] for r in res] == ["movie", "show"]
assert res[0]["poster"] == "https://image.tmdb.org/t/p/w300/a.jpg"
def test_tmdb_person_credits_carry_department(monkeypatch):
body = {"id": 5, "name": "X", "known_for_department": "Acting",
"combined_credits": {
"cast": [{"id": 1, "media_type": "movie", "title": "M", "character": "Hero",
"release_date": "2020-01-01", "popularity": 9}],
"crew": [{"id": 2, "media_type": "movie", "title": "D", "job": "Director",
"department": "Directing", "release_date": "2019-01-01", "popularity": 3}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
p = TMDBClient("KEY").person(5)
by = {c["title"]: c for c in p["credits"]}
assert by["M"]["department"] == "Acting" and by["M"]["role"] == "Hero"
assert by["D"]["department"] == "Directing" and by["D"]["role"] == "Director"
def test_engine_person_detail_annotates_credits(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
class Tmdb:
enabled = True
def person(self, tid):
return {"tmdb_id": tid, "name": "P", "credits": [
{"kind": "movie", "tmdb_id": 1, "title": "Owned"},
{"kind": "show", "tmdb_id": 9, "title": "Other"}]}
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
p = eng.person_detail(55)
assert p["credits"][0]["library_id"] == mid
assert p["credits"][1]["library_id"] is None
def test_library_id_for_tmdb(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "M", "tmdb_id": 500})
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 600, "seasons": []})
assert db.library_id_for_tmdb("movie", 500) == mid
assert db.library_id_for_tmdb("show", 600) == sid
assert db.library_id_for_tmdb("movie", 999) is None
assert db.library_id_for_tmdb("bogus", 500) is None
assert db.library_id_for_tmdb("movie", None) is None
def test_omdb_worker_processes_ratings_queue(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"})
db.upsert_movie("plex", {"server_id": "m2", "title": "B"}) # no imdb_id → skipped
class Omdb:
enabled = True
def ratings(self, imdb_id):
assert imdb_id == "tt1"
return {"imdb_rating": 8.0, "rt_rating": 90, "metacritic": 77}
w = VideoEnrichmentWorker(db, "omdb", Omdb())
assert w.is_ratings is True # ratings mode, not a matcher
assert w.process_one() is True
with db.connect() as c:
r = c.execute("SELECT imdb_rating, ratings_synced FROM movies WHERE server_id='m1'").fetchone()
assert r["imdb_rating"] == 8.0 and r["ratings_synced"] == 1
assert db.ratings_next() is None # B has no imdb_id → nothing left
assert w.process_one() is False
def test_omdb_worker_pauses_on_bad_key_without_burning_items(db):
# A rejected key affects every title — the worker must pause (not churn the
# whole library) and must NOT mark items synced, so they retry once fixed.
from core.video.enrichment.clients import OMDbAuthError
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"})
class Omdb:
enabled = True
def ratings(self, imdb_id): raise OMDbAuthError("401")
w = VideoEnrichmentWorker(db, "omdb", Omdb())
assert w.process_one() is False # backed off, didn't process
assert w.paused is True and w.note # paused itself with a reason
assert db.ratings_next() is not None # item NOT burned to 'synced'
def test_omdb_worker_cools_down_on_daily_limit_not_hard_pause(db):
# 'Request limit reached!' is the free-tier daily quota — cool down + auto
# resume (don't hard-pause), so a big library spreads across days.
from core.video.enrichment.clients import OMDbAuthError
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"})
class Omdb:
enabled = True
def ratings(self, imdb_id): raise OMDbAuthError("Request limit reached!")
w = VideoEnrichmentWorker(db, "omdb", Omdb())
assert w.process_one() is False
assert w.paused is False # NOT a hard pause (auto-resumes)
assert w._cooldown_until > 0 # cooling down instead
assert w.get_stats()["cooldown"] is True and w.get_stats()["paused"] is True
assert db.ratings_next() is not None # item not burned to synced
def test_omdb_worker_keeps_item_on_transient_error(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"})
class Omdb:
enabled = True
def ratings(self, imdb_id): raise RuntimeError("network blip")
w = VideoEnrichmentWorker(db, "omdb", Omdb())
assert w.process_one() is False
assert db.ratings_next() is not None # not marked synced → retried later
assert w.stats["errors"] == 1 and w.paused is False # one blip doesn't pause
def test_omdb_test_surfaces_real_error(monkeypatch):
# The Test button should show OMDb's actual reason, not just "HTTP 401".
class _R:
status_code = 401
def json(self): return {"Response": "False", "Error": "Request limit reached!"}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _R()))
ok, msg = OMDBClient("KEY").test()
assert ok is False and "Request limit reached!" in msg
class _R2(_R):
def json(self): return {"Response": "False", "Error": "Invalid API key!"}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _R2()))
ok2, msg2 = OMDBClient("KEY").test()
assert ok2 is False and "activation" in msg2.lower() # nudges toward the email link
def test_omdb_ratings_raises_on_invalid_key(monkeypatch):
from core.video.enrichment.clients import OMDbAuthError
class _R:
status_code = 401
text = ""
def raise_for_status(self): raise AssertionError("should short-circuit on 401")
def json(self): return {}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _R()))
with pytest.raises(OMDbAuthError):
OMDBClient("BADKEY").ratings("tt0111161")
def test_omdb_breakdown_is_ratings_coverage(db):
a = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "imdb_id": "tt1"}) # pending
db.upsert_movie("plex", {"server_id": "m2", "title": "B", "imdb_id": "tt2"})
db.apply_ratings("movie", a, {"imdb_rating": 8.0}) # one rated
bd = db.enrichment_breakdown("omdb")
assert bd["movie"]["matched"] == 1 and bd["movie"]["pending"] == 1
assert "show" in bd
def test_omdb_ratings_parse(monkeypatch):
class _Resp:
status_code = 200
def raise_for_status(self): pass
def json(self):
return {"Response": "True", "imdbRating": "8.4", "Metascore": "74",
"Ratings": [{"Source": "Rotten Tomatoes", "Value": "95%"},
{"Source": "Internet Movie Database", "Value": "8.4/10"}]}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp()))
r = OMDBClient("KEY").ratings("tt1375666")
assert r == {"imdb_rating": 8.4, "rt_rating": 95, "metacritic": 74}
def test_refresh_show_art_backfills_omdb_ratings(db):
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 1396,
"imdb_id": "tt0903747", "seasons": []})
class Tmdb:
enabled = True
def match(self, *a, **k): return {"id": 1396, "metadata": {}}
def season_episodes(self, *a, **k): return None
class Omdb:
enabled = True
def ratings(self, imdb_id):
assert imdb_id == "tt0903747"
return {"imdb_rating": 9.5, "rt_rating": 96, "metacritic": 90}
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()}, ratings_client=Omdb())
assert eng.refresh_show_art(sid)["ok"] is True
d = db.show_detail(sid)
assert (d["imdb_rating"], d["rt_rating"], d["metacritic"]) == (9.5, 96, 90)
def test_refresh_movie_art_backfills_cast_and_genres(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "tmdb_id": 438631})
class C:
enabled = True
def match(self, kind, title, year, known_id=None):
assert kind == "movie" and known_id == 438631
return {"id": 438631, "metadata": {"genres": ["Sci-Fi"],
"cast": [{"name": "Timothee", "tmdb_id": 1}]}}
assert VideoEnrichmentEngine(db, {"tmdb": C()}).refresh_movie_art(mid)["ok"] is True
d = db.movie_detail(mid)
assert d["genres"] == ["Sci-Fi"] and d["cast"][0]["name"] == "Timothee"
def test_movie_match_info(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "year": 2021, "tmdb_id": 438631})
assert db.movie_match_info(mid) == {"title": "Dune", "year": 2021, "tmdb_id": 438631}
assert db.movie_match_info(999999) is None
def test_enrichment_next_priority_pins_kind_first(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "M"})
db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []})
assert db.enrichment_next("tmdb")["kind"] == "movie" # default: movie first
assert db.enrichment_next("tmdb", priority="show")["kind"] == "show" # pinned
assert db.enrichment_next("tmdb", priority="movie")["kind"] == "movie"
def test_worker_respects_global_priority_setting(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "M"})
db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []})
db.set_setting("enrichment_priority", "show")
client = FakeClient({"id": 1, "metadata": {}})
VideoEnrichmentWorker(db, "tmdb", client).process_one()
assert client.calls[0][0] == "show" # processed shows first
def test_show_worker_cascades_episode_backfill(db):
# A matched show backfills its episodes' art via the client's season_episodes
# cascade (episodes ride along with their show — no separate queue).
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [
{"season_number": 1, "episodes": [{"episode_number": 1}, {"episode_number": 2}]}]})
class CascadeClient:
enabled = True
def match(self, kind, title, year, known_id=None):
return {"id": 1396, "metadata": {}}
def season_episodes(self, tv_id, snum):
assert tv_id == 1396
return {"overview": "S%d" % snum, "episodes": [
{"episode_number": 1, "still_url": "/e1.jpg", "overview": "O1", "rating": 8.0}]}
w = VideoEnrichmentWorker(db, "tmdb", CascadeClient())
assert w.process_one() is True
eps = {e["episode_number"]: e for e in db.show_detail(sid)["seasons"][0]["episodes"]}
assert eps[1]["has_still"] is True and eps[2]["has_still"] is False # cascade filled E1
def test_get_stats_excludes_episode_coverage_from_pending(db):
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": [
{"season_number": 1, "episodes": [{"episode_number": 1}]}]}) # episode has no still
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1) # show done
stats = VideoEnrichmentWorker(db, "tmdb", FakeClient(None)).get_stats()
assert "episode" in stats["breakdown"] # manager sees episode coverage
assert stats["stats"]["pending"] == 0 # but it doesn't block "Complete"
def test_tmdb_extras_parse(monkeypatch):
class _Resp:
def __init__(self, b): self._b = b
def raise_for_status(self): pass
def json(self): return self._b
detail = {
"videos": {"results": [
{"site": "YouTube", "type": "Teaser", "key": "tease"},
{"site": "YouTube", "type": "Trailer", "key": "trail"}]},
"watch/providers": {"results": {"US": {"link": "http://w", "flatrate": [
{"provider_name": "Netflix", "logo_path": "/n.jpg"}]}}},
"similar": {"results": [{"id": 5, "title": "Other", "poster_path": "/o.jpg"}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
ex = TMDBClient("KEY").extras("movie", 438631)
assert ex["trailer"]["key"] == "trail" # Trailer beats Teaser
assert ex["providers"][0] == {"name": "Netflix", "logo": "https://image.tmdb.org/t/p/original/n.jpg"}
assert ex["providers_link"] == "http://w"
assert ex["similar"][0]["title"] == "Other" and ex["similar"][0]["kind"] == "movie"
def _no_server_config(monkeypatch):
"""Stub the shared config_manager so no media-server watch link is added
(keeps these tests independent of the dev machine's Plex/Jellyfin config)."""
import config.settings as cs
class CM:
def get_plex_config(self): return {}
def get_jellyfin_config(self): return {}
monkeypatch.setattr(cs, "config_manager", CM())
def test_tmdb_extras_recommendations_and_collection(monkeypatch):
detail = {
"recommendations": {"results": [
{"id": 7, "title": "Rec", "media_type": "movie", "poster_path": "/r.jpg"}]},
"belongs_to_collection": {"id": 99, "name": "Saga Collection", "poster_path": "/c.jpg"}}
collection = {"parts": [
{"id": 2, "title": "Second", "release_date": "2003-01-01"},
{"id": 1, "title": "First", "release_date": "2001-01-01", "poster_path": "/1.jpg"}]}
def fake_get(url, **k):
return _Resp(collection if "/collection/" in url else detail)
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=fake_get))
ex = TMDBClient("KEY").extras("movie", 1)
assert ex["recommendations"][0]["tmdb_id"] == 7 and ex["recommendations"][0]["kind"] == "movie"
assert ex["collection"]["name"] == "Saga Collection"
assert [c["title"] for c in ex["collection"]["items"]] == ["First", "Second"] # release order
def test_tmdb_extras_gallery_videos_keywords_facts(monkeypatch):
detail = {
"budget": 63000000, "revenue": 463517383, "original_language": "en",
"production_countries": [{"name": "United States"}],
"images": {"backdrops": [{"file_path": "/b.jpg"}], "posters": [{"file_path": "/p.jpg"}]},
"videos": {"results": [
{"site": "YouTube", "type": "Featurette", "key": "f1", "name": "Making of"},
{"site": "YouTube", "type": "Trailer", "key": "t1", "name": "Trailer"}]},
"keywords": {"keywords": [{"name": "hacker"}, {"name": "dystopia"}]},
"credits": {"cast": [{"id": 1, "name": "Keanu", "character": "Neo", "profile_path": "/k.jpg"}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
ex = TMDBClient("KEY").extras("movie", 603)
assert ex["facts"]["budget"] == 63000000 and ex["facts"]["countries"] == ["United States"]
assert ex["gallery"]["backdrops"][0]["thumb"].endswith("/w780/b.jpg")
assert ex["gallery"]["backdrops"][0]["full"].endswith("/original/b.jpg")
assert [v["type"] for v in ex["videos"]] == ["Trailer", "Featurette"] # trailer ordered first
assert ex["keywords"] == ["hacker", "dystopia"]
assert ex["cast_full"][0]["character"] == "Neo"
def test_tmdb_extras_featured_review(monkeypatch):
detail = {"reviews": {"results": [
{"author": "Roger", "content": "A masterpiece.", "author_details": {"rating": 9},
"created_at": "2021-10-22T00:00:00.000Z"}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
ex = TMDBClient("KEY").extras("movie", 1)
assert ex["review"] == {"author": "Roger", "content": "A masterpiece.",
"rating": 9, "created": "2021-10-22"}
def test_tmdb_extras_tv_full_cast_episode_counts(monkeypatch):
detail = {"aggregate_credits": {"cast": [
{"id": 1, "name": "Actor", "total_episode_count": 42, "roles": [{"character": "Lead"}]}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
ex = TMDBClient("KEY").extras("show", 1399)
c = ex["cast_full"][0]
assert c["character"] == "Lead" and c["episode_count"] == 42
def test_item_extras_caches_tmdb_call(db, monkeypatch):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 603})
import config.settings as cs
class CM:
def get_plex_config(self): return {}
def get_jellyfin_config(self): return {}
monkeypatch.setattr(cs, "config_manager", CM())
calls = []
class Tmdb:
enabled = True
def extras(self, kind, tid, region="US"): calls.append(tid); return {"keywords": ["x"]}
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
a = eng.item_extras("movie", mid)
b = eng.item_extras("movie", mid)
assert a == b == {"keywords": ["x"]}
assert calls == [603] # second view served from cache
def test_tmdb_extras_tv_next_episode(monkeypatch):
detail = {"next_episode_to_air": {"season_number": 3, "episode_number": 4,
"name": "Finale", "air_date": "2026-12-25"}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
ex = TMDBClient("KEY").extras("show", 5)
assert ex["next_episode"] == {"season_number": 3, "episode_number": 4, "name": "Finale",
"air_date": "2026-12-25", "overview": None}
def test_item_extras_needs_tmdb_and_id(db, monkeypatch):
_no_server_config(monkeypatch)
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) # no tmdb_id
class C:
enabled = True
def extras(self, kind, tid, region="US"): return {"trailer": {"key": "x"}}
eng = VideoEnrichmentEngine(db, {"tmdb": C()})
assert eng.item_extras("show", sid) == {} # no tmdb_id → no call
sid2 = db.upsert_show_tree("plex", {"server_id": "s2", "title": "T", "tmdb_id": 1, "seasons": []})
assert eng.item_extras("show", sid2) == {"trailer": {"key": "x"}}
def test_item_extras_adds_jellyfin_watch_link(db, monkeypatch):
mid = db.upsert_movie("jellyfin", {"server_id": "abc123", "title": "Owned"})
import config.settings as cs
class CM:
def get_jellyfin_config(self): return {"base_url": "http://jelly:8096/"}
def get_plex_config(self): return {}
monkeypatch.setattr(cs, "config_manager", CM())
ex = VideoEnrichmentEngine(db, {}).item_extras("movie", mid) # no tmdb worker needed
assert ex["server"] == {"server": "Jellyfin",
"url": "http://jelly:8096/web/index.html#!/details?id=abc123"}
def test_item_extras_adds_plex_watch_link(db, monkeypatch):
mid = db.upsert_movie("plex", {"server_id": "555", "title": "Owned"})
import config.settings as cs
class CM:
def get_plex_config(self): return {"base_url": "http://plex:32400", "token": "T"}
def get_jellyfin_config(self): return {}
monkeypatch.setattr(cs, "config_manager", CM())
class _R:
text = ""
def json(self): return {"MediaContainer": {"machineIdentifier": "MID123"}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _R()))
ex = VideoEnrichmentEngine(db, {}).item_extras("movie", mid)
assert ex["server"]["server"] == "Plex"
assert "app.plex.tv" in ex["server"]["url"] and "MID123" in ex["server"]["url"]
assert "%2Flibrary%2Fmetadata%2F555" in ex["server"]["url"] # url-encoded item key
def test_item_extras_no_server_link_when_unowned(db, monkeypatch):
# A wishlist-style row with no server id → no watch link.
import config.settings as cs
class CM:
def get_plex_config(self): return {"base_url": "http://plex:32400", "token": "T"}
def get_jellyfin_config(self): return {}
monkeypatch.setattr(cs, "config_manager", CM())
with db.connect() as c:
c.execute("INSERT INTO movies (title, server_source, server_id) VALUES ('W', NULL, NULL)")
c.commit()
rid = c.execute("SELECT id FROM movies WHERE title='W'").fetchone()["id"]
assert "server" not in VideoEnrichmentEngine(db, {}).item_extras("movie", rid)
def test_tmdb_episode_detail_parses_guests(monkeypatch):
body = {"still_path": "/s.jpg", "vote_average": 8.4, "overview": "O", "runtime": 52,
"air_date": "2024-01-01",
"credits": {}, "guest_stars": [
{"id": 5, "name": "Guest", "character": "Villain", "profile_path": "/g.jpg"},
{"id": 6, "name": "NoPic"}]}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
d = TMDBClient("KEY").episode_detail(1399, 1, 1)
assert d["still_url"] == "https://image.tmdb.org/t/p/original/s.jpg" and d["rating"] == 8.4
assert d["guest_stars"][0] == {"name": "Guest", "character": "Villain", "tmdb_id": 5,
"photo": "https://image.tmdb.org/t/p/w185/g.jpg"}
assert d["guest_stars"][1]["photo"] is None
def test_tmdb_season_episodes_parses(monkeypatch):
class _Resp:
def __init__(self, b): self._b = b
def raise_for_status(self): pass
def json(self): return self._b
body = {"overview": "Season 1", "episodes": [
{"episode_number": 1, "still_path": "/a.jpg", "overview": "O", "vote_average": 8.1},
{"episode_number": 2, "still_path": None, "overview": "P", "vote_average": 0}]}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
res = TMDBClient("KEY").season_episodes(1396, 1)
assert res["overview"] == "Season 1" and len(res["episodes"]) == 2
assert res["episodes"][0]["still_url"] == "https://image.tmdb.org/t/p/original/a.jpg"
assert "still_url" not in res["episodes"][1] # no still_path → omitted
def test_tmdb_parses_cast_and_crew(monkeypatch):
class _Resp:
def __init__(self, b): self._b = b
def raise_for_status(self): pass
def json(self): return self._b
detail = {"overview": "O", "external_ids": {}, "created_by": [{"id": 9, "name": "The Creator"}],
"credits": {
"cast": [{"id": 1, "name": "Lead", "character": "Hero", "profile_path": "/p.jpg"},
{"id": 2, "name": "Support", "character": "Sidekick"}],
"crew": [{"id": 3, "name": "Dir", "job": "Director"},
{"id": 4, "name": "Edit", "job": "Editor"}]}} # Editor filtered out
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
m = TMDBClient("KEY").match("show", "S", 2020, known_id=1396)["metadata"]
assert [c["name"] for c in m["cast"]] == ["Lead", "Support"]
assert m["cast"][0]["photo_url"] == "https://image.tmdb.org/t/p/w185/p.jpg"
jobs = {(c["name"], c["job"]) for c in m["crew"]}
assert ("Dir", "Director") in jobs and ("The Creator", "Creator") in jobs
assert not any(c["name"] == "Edit" for c in m["crew"]) # non-headline job dropped
def test_tmdb_picks_english_clearlogo(monkeypatch):
class _Resp:
def __init__(self, b): self._b = b
def raise_for_status(self): pass
def json(self): return self._b
detail = {"overview": "O", "external_ids": {}, "images": {"logos": [
{"iso_639_1": "de", "file_path": "/de.png"},
{"iso_639_1": "en", "file_path": "/en.png"}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
m = TMDBClient("KEY").match("movie", "M", 2020, known_id=1)["metadata"]
assert m["logo_url"] == "https://image.tmdb.org/t/p/w500/en.png" # English preferred
def test_tmdb_show_returns_season_posters(monkeypatch):
class _Resp:
def __init__(self, b): self._b = b
def raise_for_status(self): pass
def json(self): return self._b
detail = {"overview": "O", "external_ids": {}, "seasons": [
{"season_number": 1, "poster_path": "/a.jpg"},
{"season_number": 2, "poster_path": None}]}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
m = TMDBClient("KEY").match("show", "Show", 2020, known_id=1396)["metadata"]
# The FULL season list now comes back (poster None where TMDB has none) so the
# episode cascade can represent missing seasons too.
assert m["seasons"] == [
{"season_number": 1, "poster_url": "https://image.tmdb.org/t/p/original/a.jpg"},
{"season_number": 2, "poster_url": None}]
def test_tmdb_client_raises_on_rate_limit(monkeypatch):
# A 429 must raise (→ worker records 'error'), not silently return no-match.
class _Resp429:
status_code = 429
def raise_for_status(self):
raise RuntimeError("429 Too Many Requests")
def json(self):
return {}
fake = types.SimpleNamespace(get=lambda url, **kw: _Resp429())
monkeypatch.setitem(sys.modules, "requests", fake)
with pytest.raises(Exception):
TMDBClient("KEY").match("movie", "Dune", 2021)
def test_tvdb_client_refreshes_expired_token(monkeypatch):
# Cached token returns 401 → the client must re-login once and retry, not fail.
logins = []
state = {"calls": 0}
class _Resp:
def __init__(self, code, body):
self.status_code = code
self._body = body
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError("HTTP " + str(self.status_code))
def json(self):
return self._body
def fake_post(url, **kw):
logins.append(url)
return _Resp(200, {"data": {"token": "tok%d" % len(logins)}})
def fake_get(url, **kw):
state["calls"] += 1
if state["calls"] == 1:
return _Resp(401, {}) # stale token rejected
return _Resp(200, {"data": [{"tvdb_id": 77, "overview": "O"}]})
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=fake_get, post=fake_post))
res = TVDBClient("KEY").match("show", "Some Show", 2020)
assert res["id"] == 77
assert len(logins) == 2 # initial login + one refresh after the 401
def test_worker_logs_match_progress(db, caplog):
# The worker must log each match at INFO (under soulsync.*) so progress is
# visible in app.log like the music workers — otherwise it looks dead.
db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "year": 2021})
w = VideoEnrichmentWorker(db, "tmdb", FakeClient({"id": 438631, "metadata": {}}))
with caplog.at_level("INFO", logger="soulsync.video_enrichment.worker"):
w.process_one()
assert any("Matched movie 'Dune'" in r.message and "TMDB ID: 438631" in r.message
for r in caplog.records)
def test_worker_process_one_not_found(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "X"})
w = VideoEnrichmentWorker(db, "tmdb", FakeClient(None))
assert w.process_one() is True
with db.connect() as c:
assert c.execute("SELECT tmdb_match_status FROM movies").fetchone()["tmdb_match_status"] == "not_found"
assert w.stats["not_found"] == 1
def test_worker_process_one_no_items_returns_false(db):
assert VideoEnrichmentWorker(db, "tmdb", FakeClient(None)).process_one() is False
def test_worker_match_exception_marks_error_not_notfound(db):
# A failed CALL must be recorded as 'error' (a transient failure), NOT
# 'not_found' — otherwise a network blip permanently logs the item as "no
# match" and it won't retry for retry_days. Mirrors the music workers.
db.upsert_movie("plex", {"server_id": "m1", "title": "X"})
class Boom:
enabled = True
def match(self, *a, **k): raise RuntimeError("api down")
w = VideoEnrichmentWorker(db, "tmdb", Boom())
assert w.process_one() is True # doesn't crash the loop
assert w.stats["errors"] == 1
with db.connect() as c:
assert c.execute("SELECT tmdb_match_status FROM movies").fetchone()["tmdb_match_status"] == "error"
def test_errored_item_is_retried_after_retry_days(db):
# An 'error' row is re-queued by enrichment_next once it's older than the
# retry window (just like 'not_found'), so transient failures recover.
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "X"})
db.enrichment_apply("tmdb", "movie", mid, matched=False, error=True)
# Just attempted → still inside the retry window → not yet due.
assert db.enrichment_next("tmdb", retry_days=30) is None
# Backdate the attempt → now older than the window → re-queued for retry.
with db.connect() as c:
c.execute("UPDATE movies SET tmdb_last_attempted='2000-01-01 00:00:00' WHERE id=?", (mid,))
c.commit()
again = db.enrichment_next("tmdb", retry_days=30)
assert again is not None and again["id"] == mid
def test_worker_get_stats_shape(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "X"})
s = VideoEnrichmentWorker(db, "tmdb", FakeClient(None)).get_stats()
assert {"enabled", "running", "paused", "idle", "current_item", "stats",
"progress", "breakdown"} <= set(s)
assert s["enabled"] is True
assert s["stats"]["pending"] == 1
def test_worker_disabled_without_key(db):
class NoKey:
enabled = False
w = VideoEnrichmentWorker(db, "tmdb", NoKey())
assert w.enabled is False
assert w.get_stats()["enabled"] is False
def test_engine_builds_and_lists_workers(db):
eng = VideoEnrichmentEngine(db, {"tmdb": FakeClient(None), "tvdb": FakeClient(None)})
ids = {s["id"] for s in eng.services()}
assert {"tmdb", "tvdb"} <= ids # the matcher workers
# The backfill workers (artwork / subtitles / no-key YouTube extras) are always
# registered alongside, so the manager/API can drive them too.
assert {"ryd", "sponsorblock", "fanart", "opensubtitles"} <= ids
assert eng.worker("tmdb") is not None and eng.worker("nope") is None
def test_pause_persists_to_db_and_resume_clears_it(db):
w = VideoEnrichmentWorker(db, "tmdb", FakeClient(None))
w.pause()
assert db.get_setting("tmdb_paused") == "1"
w.resume()
assert db.get_setting("tmdb_paused") == "0"
def test_paused_state_survives_a_fresh_worker(db):
VideoEnrichmentWorker(db, "tmdb", FakeClient(None)).pause()
# A brand-new worker (as if after restart) restores the saved pause.
fresh = VideoEnrichmentWorker(db, "tmdb", FakeClient(None))
assert fresh.paused is False # not restored until asked
fresh.restore_paused()
assert fresh.paused is True
def test_engine_restores_paused_workers_on_build(db):
db.set_setting("tvdb_paused", "1") # tvdb was paused before "restart"
eng = VideoEnrichmentEngine(db, {"tmdb": FakeClient(None), "tvdb": FakeClient(None)})
assert eng.worker("tvdb").paused is True
assert eng.worker("tmdb").paused is False
def test_scan_pause_resumes_only_what_it_paused(db):
eng = VideoEnrichmentEngine(db, {"tmdb": FakeClient(None), "tvdb": FakeClient(None)})
# User manually paused tvdb (persisted); tmdb is running.
eng.worker("tvdb").pause()
assert eng.worker("tvdb").paused and not eng.worker("tmdb").paused
paused = eng.pause_for_scan()
assert "tmdb" in paused and "tvdb" not in paused # only running ones (tvdb was user-paused)
assert eng.worker("tmdb").paused and eng.worker("tvdb").paused
eng.resume_after_scan()
assert not eng.worker("tmdb").paused # we paused it → resumed
assert eng.worker("tvdb").paused # user's pause left alone
def test_scan_pause_does_not_persist(db):
eng = VideoEnrichmentEngine(db, {"tmdb": FakeClient(None)})
eng.pause_for_scan()
# Transient: the durable flag is untouched, so a restart mid-scan won't
# restore the worker as "paused".
assert (db.get_setting("tmdb_paused") or "") != "1"
eng.resume_after_scan()
assert (db.get_setting("tmdb_paused") or "") != "1"
def test_enrichment_package_imports_nothing_from_music():
base = Path(__file__).resolve().parent.parent / "core" / "video" / "enrichment"
for py in base.glob("*.py"):
for line in py.read_text(encoding="utf-8").splitlines():
s = line.strip()
if s.startswith("import ") or s.startswith("from "):
assert "music" not in s.lower(), f"{py.name}: music import leaked: {s!r}"
# ── Discover (browse TMDB by curated list / genre / year / decade) ────────────
def test_tmdb_curated_parses_with_forced_kind(monkeypatch):
# A canned TV list → every row forced to kind 'show' (no media_type in body),
# carrying the backdrop the Discover hero needs.
captured = {}
body = {"results": [
{"id": 7, "name": "Show", "first_air_date": "2022-03-03", "poster_path": "/p.jpg",
"backdrop_path": "/b.jpg", "vote_average": 8.1, "overview": "O"}]}
def fake_get(url, **kw):
captured["url"] = url
return _Resp(body)
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=fake_get))
res = TMDBClient("KEY").curated("popular_shows")
assert "/tv/popular" in captured["url"]
assert res[0]["kind"] == "show" and res[0]["title"] == "Show" and res[0]["year"] == "2022"
assert res[0]["backdrop"] == "https://image.tmdb.org/t/p/w780/b.jpg"
assert res[0]["poster"] == "https://image.tmdb.org/t/p/w300/p.jpg"
def test_tmdb_curated_unknown_key_returns_empty():
# An unrecognised list key never hits the network.
assert TMDBClient("KEY").curated("not_a_real_list") == []
def test_tmdb_discover_builds_filtered_params(monkeypatch):
# genre + decade → with_genres + a date *range*; sort passes through.
captured = {}
body = {"results": [
{"id": 1, "title": "A", "release_date": "2015-01-01", "poster_path": "/a.jpg",
"backdrop_path": "/b.jpg", "vote_average": 7.5, "overview": "O"}]}
def fake_get(url, **kw):
captured["url"] = url
captured["params"] = kw.get("params")
return _Resp(body)
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=fake_get))
res = TMDBClient("KEY").discover("movie", genre=28, decade=2010, sort_by="vote_average.desc")
assert "/discover/movie" in captured["url"]
p = captured["params"]
assert p["with_genres"] == 28 and p["sort_by"] == "vote_average.desc"
assert p["primary_release_date.gte"] == "2010-01-01" and p["primary_release_date.lte"] == "2019-12-31"
assert res[0] == {"kind": "movie", "tmdb_id": 1, "title": "A", "year": "2015",
"rating": 7.5, "overview": "O",
"poster": "https://image.tmdb.org/t/p/w300/a.jpg",
"backdrop": "https://image.tmdb.org/t/p/w780/b.jpg"}
def test_tmdb_discover_tv_uses_first_air_date_year(monkeypatch):
# The TV side filters on first_air_date_year, not primary_release_year.
captured = {}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(
get=lambda u, **k: (captured.update(url=u, params=k.get("params")), _Resp({"results": []}))[1]))
TMDBClient("KEY").discover("show", year=2020)
assert "/discover/tv" in captured["url"]
assert captured["params"]["first_air_date_year"] == 2020
assert "primary_release_year" not in captured["params"]
def test_tmdb_genres_parses(monkeypatch):
body = {"genres": [{"id": 28, "name": "Action"}, {"id": 12, "name": "Adventure"},
{"name": "NoId"}]} # rows without an id are dropped
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
assert TMDBClient("KEY").genres("movie") == [{"id": 28, "name": "Action"},
{"id": 12, "name": "Adventure"}]
def test_engine_discover_curated_annotates_and_caches(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
calls = []
class Tmdb:
enabled = True
def curated(self, key, page=1):
calls.append((key, page))
return [{"kind": "movie", "tmdb_id": 1, "title": "Owned"},
{"kind": "movie", "tmdb_id": 2, "title": "Nope"}]
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
res = eng.discover_curated("popular_movies")
assert res[0]["library_id"] == mid and res[1]["library_id"] is None
eng.discover_curated("popular_movies")
assert calls == [("popular_movies", 1)] # second call served from cache
def test_engine_discover_filter_normalizes_kind_and_passes_filters(db):
captured = {}
class Tmdb:
enabled = True
def discover(self, kind, *, genre=None, year=None, decade=None, providers=None,
sort_by="popularity.desc", page=1, region="US"):
captured.update(kind=kind, genre=genre, decade=decade, providers=providers, sort_by=sort_by)
return [{"kind": "movie", "tmdb_id": 9, "title": "X"}]
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
res = eng.discover_filter("bogus", genre=28, decade=2010, providers=8, sort_by="vote_average.desc")
assert captured["kind"] == "movie" # unknown kind normalised
assert captured["genre"] == 28 and captured["decade"] == 2010 and captured["providers"] == 8
assert captured["sort_by"] == "vote_average.desc"
assert res[0]["library_id"] is None # not owned → None
def test_tmdb_discover_provider_sets_watch_region(monkeypatch):
captured = {}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(
get=lambda u, **k: (captured.update(params=k.get("params")), _Resp({"results": []}))[1]))
TMDBClient("KEY").discover("movie", providers=8, region="GB")
p = captured["params"]
assert p["with_watch_providers"] == 8 and p["watch_region"] == "GB"
assert p["with_watch_monetization_types"] == "flatrate"
def test_engine_genre_list_caches(db):
calls = []
class Tmdb:
enabled = True
def genres(self, kind):
calls.append(kind)
return [{"id": 28, "name": "Action"}]
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
assert eng.genre_list("movie")[0]["name"] == "Action"
eng.genre_list("movie")
assert calls == ["movie"] # long-cached → one call
def test_engine_discover_disabled_worker_returns_empty(db):
class Off:
enabled = False
eng = VideoEnrichmentEngine(db, {"tmdb": Off()})
assert eng.discover_curated("popular_movies") == []
assert eng.discover_filter("movie", genre=1) == []
assert eng.genre_list("movie") == []
def test_engine_discover_swallows_client_errors(db):
# A TMDB blip must yield an empty shelf, never crash the page.
class Boom:
enabled = True
def curated(self, key, page=1): raise RuntimeError("tmdb down")
def discover(self, *a, **k): raise RuntimeError("tmdb down")
def genres(self, kind): raise RuntimeError("tmdb down")
eng = VideoEnrichmentEngine(db, {"tmdb": Boom()})
assert eng.discover_curated("popular_movies") == []
assert eng.discover_filter("movie", genre=1) == []
assert eng.genre_list("movie") == []
def test_library_ids_for_tmdb_batched(db):
m1 = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 10})
m2 = db.upsert_movie("plex", {"server_id": "m2", "title": "B", "tmdb_id": 20})
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 30, "seasons": []})
assert db.library_ids_for_tmdb("movie", [10, 20, 99]) == {10: m1, 20: m2} # 99 not owned
assert db.library_ids_for_tmdb("show", [30, 31]) == {30: sid}
assert db.library_ids_for_tmdb("movie", []) == {}
assert db.library_ids_for_tmdb("bogus", [10]) == {}
def test_library_ids_for_tmdb_scopes_by_server(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 10})
db.upsert_movie("jellyfin", {"server_id": "j1", "title": "A", "tmdb_id": 11})
got = db.library_ids_for_tmdb("movie", [10, 11], server_source="plex")
assert 10 in got and 11 not in got # the other server's copy doesn't count
def test_stamp_owned_is_one_query_per_kind(db, monkeypatch):
# The whole rail must cost one ownership query per kind, not one per item.
db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
calls = {"n": 0}
real = db.library_ids_for_tmdb
def counted(kind, ids, server_source=None):
calls["n"] += 1
return real(kind, ids, server_source)
monkeypatch.setattr(db, "library_ids_for_tmdb", counted)
eng = VideoEnrichmentEngine(db, {})
items = [{"kind": "movie", "tmdb_id": i, "title": "T%d" % i} for i in range(1, 21)]
items.append({"kind": "show", "tmdb_id": 99, "title": "Show"})
eng._stamp_owned(items)
assert calls["n"] == 2 # one movie query + one show query
assert items[0]["library_id"] is not None and items[1]["library_id"] is None
def test_tmdb_recommendations_parses_with_forced_kind(monkeypatch):
body = {"results": [
{"id": 5, "title": "Rec", "release_date": "2019-01-01", "poster_path": "/r.jpg",
"backdrop_path": "/b.jpg", "vote_average": 7.0, "overview": "O"}]}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
res = TMDBClient("KEY").recommendations("movie", 1)
assert res[0]["kind"] == "movie" and res[0]["tmdb_id"] == 5 and res[0]["year"] == "2019"
def test_tmdb_video_trailer_prefers_trailer_over_teaser(monkeypatch):
body = {"results": [
{"site": "YouTube", "type": "Teaser", "key": "teasekey", "name": "Teaser"},
{"site": "Vimeo", "type": "Trailer", "key": "nope"}, # wrong site, ignored
{"site": "YouTube", "type": "Trailer", "key": "trailkey", "name": "Official"}]}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
assert TMDBClient("KEY").video_trailer("movie", 1) == {"key": "trailkey", "name": "Official"}
def test_tmdb_video_trailer_falls_back_to_teaser(monkeypatch):
body = {"results": [{"site": "YouTube", "type": "Teaser", "key": "teasekey", "name": "T"}]}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body)))
assert TMDBClient("KEY").video_trailer("show", 1) == {"key": "teasekey", "name": "T"}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp({"results": []})))
assert TMDBClient("KEY").video_trailer("movie", 1) is None # nothing → None
def test_engine_recommendations_annotates_and_caches(db):
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1})
calls = []
class Tmdb:
enabled = True
def recommendations(self, kind, tmdb_id, page=1):
calls.append((kind, tmdb_id, page))
return [{"kind": "movie", "tmdb_id": 1, "title": "Owned"},
{"kind": "movie", "tmdb_id": 2, "title": "Other"}]
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
res = eng.recommendations("movie", 99)
assert res[0]["library_id"] == mid and res[1]["library_id"] is None
eng.recommendations("movie", 99)
assert calls == [("movie", 99, 1)] # cached on repeat
def test_engine_trailer_caches(db):
calls = []
class Tmdb:
enabled = True
def video_trailer(self, kind, tmdb_id):
calls.append((kind, tmdb_id)); return {"key": "abc", "name": "T"}
eng = VideoEnrichmentEngine(db, {"tmdb": Tmdb()})
assert eng.trailer("movie", 5)["key"] == "abc"
eng.trailer("movie", 5)
assert calls == [("movie", 5)]
def test_engine_trailer_none_when_no_video(db):
class Tmdb:
enabled = True
def video_trailer(self, kind, tmdb_id): return None
assert VideoEnrichmentEngine(db, {"tmdb": Tmdb()}).trailer("movie", 5) is None
def test_random_owned_titles_only_owned_with_tmdb(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1, "file": {"relative_path": "a.mkv"}})
db.upsert_movie("plex", {"server_id": "m2", "title": "NoFile", "tmdb_id": 2}) # not owned
db.upsert_movie("plex", {"server_id": "m3", "title": "NoTmdb", "file": {"relative_path": "c.mkv"}}) # no tmdb
db.upsert_show_tree("plex", {"server_id": "s1", "title": "Show", "tmdb_id": 9, "seasons": []})
seeds = db.random_owned_titles(5, server_source="plex")
by_title = {s["title"]: s for s in seeds}
assert "Owned" in by_title and by_title["Owned"]["tmdb_id"] == 1
assert "Show" in by_title # library shows seed too
assert "NoFile" not in by_title and "NoTmdb" not in by_title
def test_top_owned_genres_orders_by_count(db):
db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 1,
"genres": ["Action", "Drama"], "file": {"relative_path": "a.mkv"}})
db.upsert_movie("plex", {"server_id": "m2", "title": "B", "tmdb_id": 2,
"genres": ["Action"], "file": {"relative_path": "b.mkv"}})
db.upsert_movie("plex", {"server_id": "m3", "title": "C", "tmdb_id": 3,
"genres": ["Comedy"]}) # no file → not owned → excluded
g = db.top_owned_genres("movie", server_source="plex", limit=5)
assert g[0] == "Action" # owned twice → first
assert "Drama" in g and "Comedy" not in g # Comedy movie isn't owned
def test_retry_all_failed_requeues_across_all_services(db):
# Global retry: one failed item in a matcher (tmdb), a backfill (trakt) and a
# YouTube video service (dearrow) — all re-queued in one call. imdb_rating is set
# so OMDb's "unrated" re-queue doesn't add to the count (keeps it deterministic).
conn = db._get_connection()
conn.execute("INSERT INTO movies (server_source, server_id, title, tmdb_match_status, imdb_rating) "
"VALUES ('plex','m1','M','not_found', 7.0)")
conn.execute("INSERT INTO shows (server_source, server_id, title, imdb_id, trakt_status, imdb_rating) "
"VALUES ('plex','s1','S','tt1','error', 8.0)")
conn.execute("INSERT INTO youtube_video_stats (youtube_id, dearrow_status) VALUES ('v1','not_found')")
conn.commit(); conn.close()
assert db.retry_all_failed() == 3
conn = db._get_connection()
assert conn.execute("SELECT tmdb_match_status FROM movies WHERE server_id='m1'").fetchone()[0] is None
assert conn.execute("SELECT trakt_status FROM shows WHERE server_id='s1'").fetchone()[0] is None
assert conn.execute("SELECT dearrow_status FROM youtube_video_stats WHERE youtube_id='v1'").fetchone()[0] is None
conn.close()