Video twin of music's 'Auto-Deep Scan Library', split in two because Movies and TV
are separate libraries — scanning the TV library must not pull in new movies and
vice-versa.
- scanner: add a media_type param ('all'|'movie'|'show', friendly aliases) that
gates the movies vs shows passes (and their pruning), plus an in_progress busy
guard so the singleton scanner can't be stomped by an overlapping run.
- video_scan_library handler: thread media_type through, skip cleanly when the
scanner is busy, and name only the scanned library in the summary.
- two system automations (owned_by=video, weekly deep scan, staggered start delays):
'Auto-Deep Scan Movie Library' + 'Auto-Deep Scan TV Library'. Distinct action
types (video_deep_scan_movies / _tv) because the seeder keys on action_type; both
reuse the one handler, scoped via action_config.
- builder block gains a Library selector (Movies+TV / Movies / TV) so custom scans
can scope too; card label/icon maps cover the video action types.
Seam tests for scanner scope + busy guard, handler scope + skip, registration set.
312 lines
12 KiB
Python
312 lines
12 KiB
Python
"""Seam tests for the video library scanner (experimental branch).
|
|
|
|
The scanner is server-agnostic: it consumes a media source that yields
|
|
normalized dicts. We drive it with a fake source so the scan/prune/error logic
|
|
is fully tested without a live Plex/Jellyfin. Also guards that core/video/
|
|
imports nothing from the music database.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from database.video_database import VideoDatabase
|
|
from core.video.scanner import VideoLibraryScanner
|
|
|
|
|
|
class FakeSource:
|
|
server_name = "plex"
|
|
|
|
def __init__(self, movies, shows):
|
|
self._movies, self._shows = movies, shows
|
|
self.incremental_calls = []
|
|
|
|
def iter_movies(self, incremental=False):
|
|
self.incremental_calls.append(("movies", incremental))
|
|
return iter(self._movies)
|
|
|
|
def iter_shows(self, incremental=False):
|
|
self.incremental_calls.append(("shows", incremental))
|
|
return iter(self._shows)
|
|
|
|
|
|
@pytest.fixture()
|
|
def db(tmp_path):
|
|
return VideoDatabase(database_path=str(tmp_path / "video_library.db"))
|
|
|
|
|
|
def test_scan_sync_populates_library(db):
|
|
movies = [{"server_id": "m1", "title": "A", "file": {"relative_path": "a.mkv", "size_bytes": 5}}]
|
|
shows = [{"server_id": "s1", "title": "Show", "seasons": [
|
|
{"season_number": 1, "episodes": [
|
|
{"episode_number": 1, "title": "E1", "file": {"relative_path": "e1.mkv"}}]}]}]
|
|
st = VideoLibraryScanner(db).scan_sync(lambda: FakeSource(movies, shows))
|
|
assert st["state"] == "done"
|
|
assert (st["movies"], st["shows"], st["episodes"]) == (1, 1, 1)
|
|
lib = db.dashboard_stats()["library"]
|
|
assert (lib["movies"], lib["shows"], lib["episodes"]) == (1, 1, 1)
|
|
|
|
|
|
def test_scan_pauses_and_resumes_enrichment_workers(db):
|
|
events = []
|
|
scanner = VideoLibraryScanner(
|
|
db, pause_workers=lambda: events.append("pause"),
|
|
resume_workers=lambda: events.append("resume"))
|
|
scanner.scan_sync(lambda: FakeSource([{"server_id": "m1", "title": "A"}], []))
|
|
assert events == ["pause", "resume"] # paused first, resumed after
|
|
|
|
|
|
def test_scan_resumes_enrichment_even_on_error(db):
|
|
events = []
|
|
scanner = VideoLibraryScanner(
|
|
db, pause_workers=lambda: events.append("pause"),
|
|
resume_workers=lambda: events.append("resume"))
|
|
|
|
def boom():
|
|
raise RuntimeError("server blew up")
|
|
|
|
scanner.scan_sync(boom) # source_factory raises
|
|
assert scanner.get_status()["state"] == "error"
|
|
assert events == ["pause", "resume"] # resumed despite the failure
|
|
|
|
|
|
def test_scan_resumes_enrichment_on_cancel(db):
|
|
events = []
|
|
scanner = VideoLibraryScanner(
|
|
db, pause_workers=lambda: events.append("pause"),
|
|
resume_workers=lambda: events.append("resume"))
|
|
|
|
class S:
|
|
server_name = "plex"
|
|
def counts(self, incremental=False):
|
|
return {"movies": 5, "shows": 0}
|
|
def iter_movies(self, incremental=False):
|
|
for i in range(5):
|
|
scanner._cancel = True # cancel mid-iteration
|
|
yield {"server_id": "m%d" % i, "title": "M%d" % i}
|
|
def iter_shows(self, incremental=False):
|
|
return iter([])
|
|
|
|
scanner.scan_sync(lambda: S())
|
|
assert scanner.get_status()["state"] == "cancelled"
|
|
assert events == ["pause", "resume"] # resumed after cancel too
|
|
|
|
|
|
def test_scanner_with_no_hooks_does_not_pause(db):
|
|
# Default construction (as in tests / when no engine wired) must be inert.
|
|
scanner = VideoLibraryScanner(db)
|
|
st = scanner.scan_sync(lambda: FakeSource([{"server_id": "m1", "title": "A"}], []))
|
|
assert st["state"] == "done" # scans fine without hooks
|
|
|
|
|
|
def test_deep_scan_prunes_removed_items(db):
|
|
scanner = VideoLibraryScanner(db)
|
|
scanner.scan_sync(lambda: FakeSource(
|
|
[{"server_id": "m1", "title": "A"}, {"server_id": "m2", "title": "B"}], []), mode="deep")
|
|
assert db.dashboard_stats()["library"]["movies"] == 2
|
|
scanner.scan_sync(lambda: FakeSource([{"server_id": "m1", "title": "A"}], []), mode="deep")
|
|
assert db.dashboard_stats()["library"]["movies"] == 1
|
|
|
|
|
|
def test_deep_scan_shows_cleanup_phase_during_prune(db):
|
|
# On a deep scan the bar hits 100% before the (slow) prune runs — the scanner
|
|
# must surface a "cleaning up" phase so the UI doesn't look stuck at 100%.
|
|
scanner = VideoLibraryScanner(db)
|
|
scanner.scan_sync(lambda: FakeSource(
|
|
[{"server_id": "m1", "title": "A"}, {"server_id": "m2", "title": "B"}], []), mode="deep")
|
|
# Spy the phase at the exact moment prune is called on the next deep scan.
|
|
seen = {}
|
|
real_prune = db.prune_missing
|
|
|
|
def spy(table, server, ids):
|
|
seen[table] = scanner.get_status().get("phase")
|
|
return real_prune(table, server, ids)
|
|
|
|
db.prune_missing = spy
|
|
scanner.scan_sync(lambda: FakeSource([{"server_id": "m1", "title": "A"}], []), mode="deep")
|
|
assert seen.get("movies") == "cleaning up removed movies"
|
|
|
|
|
|
def test_full_refresh_does_not_prune(db):
|
|
# 'full' refreshes/adds but never removes — only 'deep' prunes.
|
|
scanner = VideoLibraryScanner(db)
|
|
scanner.scan_sync(lambda: FakeSource(
|
|
[{"server_id": "m1", "title": "A"}, {"server_id": "m2", "title": "B"}], []), mode="deep")
|
|
scanner.scan_sync(lambda: FakeSource([{"server_id": "m1", "title": "A"}], []), mode="full")
|
|
assert db.dashboard_stats()["library"]["movies"] == 2 # m2 NOT pruned
|
|
|
|
|
|
def test_empty_deep_scan_does_not_wipe_library(db):
|
|
# Safety: a deep scan that returns nothing (transient failure) must NOT prune.
|
|
scanner = VideoLibraryScanner(db)
|
|
scanner.scan_sync(lambda: FakeSource([{"server_id": "m1", "title": "A"}], []), mode="deep")
|
|
scanner.scan_sync(lambda: FakeSource([], []), mode="deep")
|
|
assert db.dashboard_stats()["library"]["movies"] == 1
|
|
|
|
|
|
def test_incremental_mode_requests_incremental_from_source(db):
|
|
# Populate past the small-library fallback so incremental stays incremental.
|
|
for i in range(60):
|
|
db.upsert_movie("plex", {"server_id": "p%d" % i, "title": str(i)})
|
|
src = FakeSource([{"server_id": "m1", "title": "A"}], [])
|
|
VideoLibraryScanner(db).scan_sync(lambda: src, mode="incremental")
|
|
assert ("movies", True) in src.incremental_calls
|
|
assert ("shows", True) in src.incremental_calls
|
|
|
|
|
|
def test_scan_sync_no_source_reports_error(db):
|
|
st = VideoLibraryScanner(db).scan_sync(lambda: None)
|
|
assert st["state"] == "error" and "error" in st
|
|
|
|
|
|
def test_scan_reports_percent_from_counts(db):
|
|
class S:
|
|
server_name = "plex"
|
|
def counts(self, incremental=False):
|
|
return {"movies": 4, "shows": 0}
|
|
def iter_movies(self, incremental=False):
|
|
return iter([{"server_id": "m%d" % i, "title": str(i)} for i in range(4)])
|
|
def iter_shows(self, incremental=False):
|
|
return iter([])
|
|
st = VideoLibraryScanner(db).scan_sync(lambda: S())
|
|
assert st["state"] == "done"
|
|
assert st["percent"] == 100
|
|
|
|
|
|
def test_scan_cancel_stops_midway(db):
|
|
scanner = VideoLibraryScanner(db)
|
|
|
|
def gen():
|
|
yield {"server_id": "m1", "title": "A"}
|
|
scanner.cancel() # request stop after the first item
|
|
yield {"server_id": "m2", "title": "B"}
|
|
|
|
class S:
|
|
server_name = "plex"
|
|
def counts(self, incremental=False):
|
|
return {"movies": 2, "shows": 0}
|
|
def iter_movies(self, incremental=False):
|
|
return gen()
|
|
def iter_shows(self, incremental=False):
|
|
return iter([])
|
|
|
|
st = scanner.scan_sync(lambda: S())
|
|
assert st["state"] == "cancelled"
|
|
assert db.dashboard_stats()["library"]["movies"] == 1 # only the first was saved
|
|
|
|
|
|
def test_incremental_skips_known_and_early_stops(db):
|
|
# Past the small-library fallback, with everything already known.
|
|
for i in range(60):
|
|
db.upsert_movie("plex", {"server_id": "k%d" % i, "title": "K%d" % i})
|
|
new_item = {"server_id": "new1", "title": "New"}
|
|
known = [{"server_id": "k%d" % i, "title": "K%d" % i} for i in range(60)]
|
|
|
|
class S:
|
|
server_name = "plex"
|
|
def counts(self, incremental=False):
|
|
return {"movies": 61, "shows": 0}
|
|
def iter_movies(self, incremental=False):
|
|
assert incremental is True # NOT fallen back (library big enough)
|
|
return iter([new_item] + known) # one new, then a long run of known
|
|
def iter_shows(self, incremental=False):
|
|
return iter([])
|
|
|
|
st = VideoLibraryScanner(db).scan_sync(lambda: S(), mode="incremental")
|
|
assert st["state"] == "done"
|
|
assert st["movies"] == 1 # only the new one; known skipped
|
|
assert db.table_count("movies") == 61
|
|
|
|
|
|
def test_incremental_falls_back_to_full_on_small_library(db):
|
|
captured = {}
|
|
|
|
class S:
|
|
server_name = "plex"
|
|
def counts(self, incremental=False):
|
|
return {"movies": 3, "shows": 0}
|
|
def iter_movies(self, incremental=False):
|
|
captured["incremental"] = incremental
|
|
return iter([{"server_id": "m%d" % i, "title": str(i)} for i in range(3)])
|
|
def iter_shows(self, incremental=False):
|
|
return iter([])
|
|
|
|
st = VideoLibraryScanner(db).scan_sync(lambda: S(), mode="incremental")
|
|
assert captured["incremental"] is False # empty DB (<50) -> full pass
|
|
assert st["movies"] == 3
|
|
|
|
|
|
def test_parse_plex_guids():
|
|
from core.video.sources import _parse_plex_guids
|
|
|
|
class _G:
|
|
def __init__(self, gid): self.id = gid
|
|
|
|
class _Obj:
|
|
def __init__(self, guids): self.guids = guids
|
|
|
|
got = _parse_plex_guids(_Obj([_G("imdb://tt1375666"), _G("tmdb://27205"), _G("tvdb://121361")]))
|
|
assert got == {"tmdb_id": 27205, "imdb_id": "tt1375666", "tvdb_id": 121361}
|
|
assert _parse_plex_guids(_Obj([])) == {"tmdb_id": None, "imdb_id": None, "tvdb_id": None}
|
|
|
|
|
|
def test_parse_jellyfin_providers():
|
|
from core.video.sources import _parse_jf_providers
|
|
got = _parse_jf_providers({"ProviderIds": {"Imdb": "tt123", "Tmdb": "27205", "Tvdb": "121361"}})
|
|
assert got == {"imdb_id": "tt123", "tmdb_id": 27205, "tvdb_id": 121361}
|
|
assert _parse_jf_providers({}) == {"imdb_id": None, "tmdb_id": None, "tvdb_id": None}
|
|
|
|
|
|
def test_core_video_imports_nothing_from_music():
|
|
base = Path(__file__).resolve().parent.parent / "core" / "video"
|
|
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}"
|
|
|
|
|
|
# ── media_type scope: Movies and TV are independent libraries ───────────────
|
|
|
|
_MOVIES = [{"server_id": "m1", "title": "A", "file": {"relative_path": "a.mkv", "size_bytes": 5}}]
|
|
_SHOWS = [{"server_id": "s1", "title": "Show", "seasons": [
|
|
{"season_number": 1, "episodes": [
|
|
{"episode_number": 1, "title": "E1", "file": {"relative_path": "e1.mkv"}}]}]}]
|
|
|
|
|
|
def test_movie_media_type_scans_only_movies(db):
|
|
src = FakeSource(_MOVIES, _SHOWS)
|
|
st = VideoLibraryScanner(db).scan_sync(lambda: src, "full", "movie")
|
|
assert st["state"] == "done"
|
|
assert (st["movies"], st["shows"], st["episodes"]) == (1, 0, 0)
|
|
assert [k for k, _ in src.incremental_calls] == ["movies"] # shows iterator never touched
|
|
lib = db.dashboard_stats()["library"]
|
|
assert (lib["movies"], lib["shows"]) == (1, 0)
|
|
|
|
|
|
def test_tv_media_type_scans_only_shows(db):
|
|
# 'tv' is a friendly alias normalised to 'show'
|
|
src = FakeSource(_MOVIES, _SHOWS)
|
|
st = VideoLibraryScanner(db).scan_sync(lambda: src, "full", "tv")
|
|
assert (st["movies"], st["shows"], st["episodes"]) == (0, 1, 1)
|
|
assert [k for k, _ in src.incremental_calls] == ["shows"] # movies iterator never touched
|
|
lib = db.dashboard_stats()["library"]
|
|
assert (lib["movies"], lib["shows"]) == (0, 1)
|
|
|
|
|
|
def test_all_media_type_scans_both(db):
|
|
src = FakeSource(_MOVIES, _SHOWS)
|
|
st = VideoLibraryScanner(db).scan_sync(lambda: src, "full", "all")
|
|
assert (st["movies"], st["shows"], st["episodes"]) == (1, 1, 1)
|
|
assert {k for k, _ in src.incremental_calls} == {"movies", "shows"}
|
|
|
|
|
|
def test_concurrent_scan_reports_in_progress_without_running(db):
|
|
scanner = VideoLibraryScanner(db)
|
|
scanner._status = {"state": "scanning"} # a scan is already running
|
|
src = FakeSource(_MOVIES, _SHOWS)
|
|
st = scanner.scan_sync(lambda: src, "full", "movie")
|
|
assert st["state"] == "in_progress"
|
|
assert src.incremental_calls == [] # nothing scanned — didn't stomp the live run
|