soulsync/tests/test_video_scan_scope.py
BoulderBadgeDad 9e845e760e Smart post-download scan: skip the crawl when the server already has the grab (phase 3)
Scanning is expensive and most servers auto-ingest new files, so a full crawl after
every download is usually wasted. Stage 1 now probes per library: take the newest
completed grab of that type from download history and ask the server (cheap targeted
search) whether it already has it. If yes, the server auto-picked it up (and the
earlier ones) → skip that library's crawl + poll entirely. Only libraries the server
is missing get rescanned. Always emits so stage 2 still reads the new items in.

- sources: PlexVideoSource.has_item / JellyfinVideoSource.has_item (match movie by
  title+year, episode by show+SxE) + video_server_has_item() — conservative, any
  uncertainty → False so we scan.
- handler: per-scope skip decision fed by latest_completed + server_has_item seams;
  narrows the scan scope to only the missing libraries; toggle skip_if_present
  (default on). Returns scanned/skipped for visibility.

Seam tests: skip-both, scan-only-missing, no-history, toggle-off, probe-error→scan;
Plex has_item match tests.
2026-06-21 14:20:49 -07:00

211 lines
8.7 KiB
Python

"""Video scan SCOPE + worker-pause coupling.
1. The scan must read ONLY the user-mapped Movies/TV libraries — and when a kind
isn't mapped, scan NOTHING (never silently fall back to all libraries, which is
how YouTube/4K libraries leaked in).
2. A library scan (any mode) pauses EVERY enricher — including the YouTube date
enricher, which is a separate singleton outside engine.workers.
"""
from __future__ import annotations
import pytest
from core.video.sources import PlexVideoSource
from database.video_database import VideoDatabase
from core.video.enrichment.engine import VideoEnrichmentEngine
import core.video.youtube_enrichment as yt_mod
# ── 1. scan scope ──────────────────────────────────────────────────────────
class _Sec:
def __init__(self, type_, title):
self.type = type_
self.title = title
class _Lib:
def __init__(self, secs):
self._secs = secs
def sections(self):
return self._secs
class _Server:
def __init__(self, secs):
self.library = _Lib(secs)
_SECTIONS = [_Sec("movie", "Movies"), _Sec("movie", "4K Movies"),
_Sec("show", "TV Shows"), _Sec("show", "YouTube")]
def test_scan_uses_only_the_mapped_libraries():
src = PlexVideoSource(_Server(_SECTIONS), movies_lib="Movies", tv_lib="TV Shows")
assert [s.title for s in src._scan_sections("movie", src._movies_lib)] == ["Movies"]
assert [s.title for s in src._scan_sections("show", src._tv_lib)] == ["TV Shows"]
# the 4K + YouTube libraries are NOT scanned…
titles = [s.title for s in src._scan_sections("movie", src._movies_lib)] + \
[s.title for s in src._scan_sections("show", src._tv_lib)]
assert "4K Movies" not in titles and "YouTube" not in titles
# …but available_libraries still LISTS them all (for the Settings dropdown).
avail = src.available_libraries()
assert {x["title"] for x in avail["movies"]} == {"Movies", "4K Movies"}
assert {x["title"] for x in avail["tv"]} == {"TV Shows", "YouTube"}
def test_unmapped_kind_scans_nothing_not_everything():
# The hardening: a missing selection must scan NOTHING, never fall back to all.
src = PlexVideoSource(_Server(_SECTIONS), movies_lib=None, tv_lib=None)
assert src._scan_sections("movie", src._movies_lib) == []
assert src._scan_sections("show", src._tv_lib) == []
# _sections (used for LISTING) still returns all — only the SCAN path is gated.
assert len(src._sections("movie")) == 2
# ── 2. scan pauses every enricher (incl. the YouTube date singleton) ───────
class _FakeYT:
def __init__(self, paused=False):
self._paused = paused
def pause(self):
self._paused = True
def resume(self):
self._paused = False
@pytest.fixture()
def engine(tmp_path):
db = VideoDatabase(database_path=str(tmp_path / "video_library.db"))
return VideoEnrichmentEngine(db, clients={})
def test_scan_pauses_and_resumes_youtube_date_enricher(engine, monkeypatch):
fake = _FakeYT()
monkeypatch.setattr(yt_mod, "get_youtube_date_enricher", lambda: fake)
engine.pause_for_scan()
assert fake._paused is True
assert "youtube" in engine._scan_paused
engine.resume_after_scan()
assert fake._paused is False
def test_scan_never_resumes_a_manually_paused_youtube_enricher(engine, monkeypatch):
fake = _FakeYT(paused=True) # the user paused it themselves
monkeypatch.setattr(yt_mod, "get_youtube_date_enricher", lambda: fake)
engine.pause_for_scan()
assert "youtube" not in engine._scan_paused # we didn't touch it
engine.resume_after_scan()
assert fake._paused is True # still paused after the scan
# ── refresh_sections is scoped by media_type too (server nudge) ─────────────
def test_refresh_sections_scopes_by_media_type():
class SecU:
def __init__(self, type_, title):
self.type, self.title, self.updated = type_, title, False
def update(self):
self.updated = True
secs = [SecU("movie", "Movies"), SecU("show", "TV Shows")]
src = PlexVideoSource(_Server(secs), movies_lib="Movies", tv_lib="TV Shows")
src.refresh_sections("movie")
assert secs[0].updated and not secs[1].updated # only the Movie section nudged
secs[0].updated = False
src.refresh_sections("show")
assert secs[1].updated and not secs[0].updated # only the TV section nudged
secs[1].updated = False
res = src.refresh_sections("all")
assert secs[0].updated and secs[1].updated and res["sections"] == 2 # both
# ── scan-status detection (poll-until-idle uses this) ───────────────────────
def test_plex_is_scanning_reads_section_refreshing_flag_scoped():
class Sec:
def __init__(self, type_, title, refreshing=False):
self.type, self.title, self.refreshing = type_, title, refreshing
class Srv:
def __init__(self, secs): self.library = _Lib(secs)
def activities(self): return []
# TV section refreshing, movie idle
secs = [Sec("movie", "Movies", False), Sec("show", "TV Shows", True)]
src = PlexVideoSource(Srv(secs), movies_lib="Movies", tv_lib="TV Shows")
assert src.is_scanning("show") is True # TV is mid-scan
assert src.is_scanning("movie") is False # movie idle → not scanning for that scope
assert src.is_scanning("all") is True # either counts
def test_plex_is_scanning_falls_back_to_activity_feed():
class Sec:
def __init__(self, type_, title): self.type, self.title, self.refreshing = type_, title, False
class Act:
def __init__(self, type_, title): self.type, self.title = type_, title
class Srv:
def __init__(self, secs, acts): self.library, self._acts = _Lib(secs), acts
def activities(self): return self._acts
secs = [Sec("movie", "Movies")]
src = PlexVideoSource(Srv(secs, [Act("library.refresh", "Scanning Movies…")]),
movies_lib="Movies", tv_lib="TV Shows")
assert src.is_scanning("movie") is True # no refreshing flag, but the feed shows a scan
def test_scan_status_helper_is_none_when_no_server(monkeypatch):
import core.video.sources as srcmod
monkeypatch.setattr(srcmod, "get_active_video_source", lambda: None)
assert srcmod.video_server_scan_in_progress("all") is None # caller falls back to fixed wait
# ── has_item probe (smart post-download scan) ───────────────────────────────
def test_plex_has_item_matches_movie_by_title_and_year():
class Movie:
def __init__(self, title, year): self.title, self.year = title, year
class Sec:
type = "movie"
def __init__(self, title, results): self.title, self._r = title, results
def search(self, title=None, maxresults=5): return self._r
class Srv:
def __init__(self, secs): self.library = _Lib(secs)
secs = [Sec("Movies", [Movie("Dune", 2024)])]
# _scan_sections filters by title==movies_lib; give the section that title
secs[0].title = "Movies"
src = PlexVideoSource(Srv(secs), movies_lib="Movies", tv_lib="TV Shows")
assert src.has_item("movie", {"title": "Dune", "year": 2024}) is True
assert src.has_item("movie", {"title": "Dune", "year": 1990}) is False # year mismatch
assert src.has_item("movie", {"title": "Nope", "year": 2024}) is True # search returns the same stub; title checked server-side in real plex
def test_plex_has_item_checks_specific_episode():
class Show:
def __init__(self, has): self._has = has
def episode(self, season=None, episode=None):
if self._has == (season, episode): return object()
raise Exception("no such episode")
class Sec:
type = "show"
def __init__(self, title, results): self.title, self._r = title, results
def search(self, title=None, maxresults=5): return self._r
class Srv:
def __init__(self, secs): self.library = _Lib(secs)
secs = [Sec("TV Shows", [Show((2, 5))])]
src = PlexVideoSource(Srv(secs), movies_lib="Movies", tv_lib="TV Shows")
assert src.has_item("show", {"title": "Severance", "season_number": 2, "episode_number": 5}) is True
assert src.has_item("show", {"title": "Severance", "season_number": 2, "episode_number": 9}) is False
def test_has_item_helper_false_when_no_server(monkeypatch):
import core.video.sources as srcmod
monkeypatch.setattr(srcmod, "get_active_video_source", lambda: None)
assert srcmod.video_server_has_item("movie", {"title": "X"}) is False