soulsync/tests/test_video_scanner.py
BoulderBadgeDad 04fb19c80c video scan: three modes (full refresh / incremental / deep)
Mirrors the music model (full_refresh vs smart incremental, plus deep_scan):
- incremental: only recently-added items from the server (Plex addedAt:desc /
  Jellyfin DateCreated, capped); upsert; no prune.
- full: every item; upsert all (refresh metadata + add new); no prune.
- deep: every item; upsert; prune what the server no longer has (empty-scan
  safety preserved).
scanner.request_scan/scan_sync take mode; /api/video/scan/request reads
{mode} from the body (default full); adapters take incremental=. Tests cover
deep-prunes / full-doesn't / empty-deep-safety / incremental-requests-recent.
2026-06-13 23:28:57 -07:00

96 lines
3.8 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_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_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):
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_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}"