Post-download scan: wait until Plex's scan queue is actually idle, not a fixed 2min

A fixed debounce can't fit a big library — 8500 movies + 4500 shows scan sequentially
through Plex's queue and can take 10-20 min, so the old 120s wait read the DB before
Plex finished and fresh downloads showed up late. Now Stage 1 (video_scan_server)
fires the rescan then POLLS the server until its scan queue goes idle, then emits the
done event.

- sources: PlexVideoSource.is_scanning (section.refreshing + activity feed, scoped by
  media_type) and JellyfinVideoSource.is_scanning (scheduled-task state), plus
  video_server_scan_in_progress() returning True/False/None.
- handler: pure wait_for_server_scan(scan_status, sleep, …) — grace, then poll every
  interval until idle or a generous cap; falls back to the fixed wait only when the
  server can't report status (None). debounce_seconds is now that fallback; new
  max_wait_minutes caps the poll.

Seam tests for the poll logic (idle/poll/fallback/cap/lost-status), the handler wiring,
and Plex scan-status detection.
This commit is contained in:
BoulderBadgeDad 2026-06-21 13:44:52 -07:00
parent 5a53ffc8c2
commit a16afd1f9e
5 changed files with 220 additions and 26 deletions

View file

@ -228,14 +228,15 @@ ACTIONS: list[dict] = [
# Post-download chain actions (two stages, like music's scan_library +
# start_database_update). Stage 1 nudges the server; stage 2 reads it in.
{"type": "video_scan_server", "label": "Scan Video Server", "icon": "refresh", "scope": "video",
"description": "Tell the media server to rescan your video sections, then fire 'Video Library Scan Done'", "available": True,
"description": "Tell the media server to rescan your video sections, wait until it actually finishes indexing, then fire 'Video Library Scan Done'", "available": True,
"config_fields": [
{"key": "media_type", "type": "select", "label": "Library",
"options": [{"value": "all", "label": "Movies + TV"},
{"value": "movie", "label": "Movies only"},
{"value": "show", "label": "TV only"}],
"default": "all"},
{"key": "debounce_seconds", "type": "number", "label": "Wait for indexing (sec)", "default": 120, "min": 10}
{"key": "max_wait_minutes", "type": "number", "label": "Max wait for scan (min)", "default": 60, "min": 1},
{"key": "debounce_seconds", "type": "number", "label": "Fallback wait if status unknown (sec)", "default": 120, "min": 10}
]},
{"type": "video_update_database", "label": "Update Video Database", "icon": "database", "scope": "video",
"description": "Read newly-indexed media from the server into SoulSync (incremental)", "available": True,

View file

@ -174,31 +174,81 @@ def auto_video_scan_library(
# ── post-download chain (video twin of music's scan_library → start_database_update) ──
# Split into two stages so the calendar/library reflect a download promptly, mirroring
# the music side: tell the server to rescan, then (after it has had time to index) read
# the new state into video.db. Stage 1 emits 'video_library_scan_completed' on a debounce
# (like music's web_scan_manager time-based completion); stage 2 listens for that.
# the music side: tell the server to rescan, WAIT until its scan queue is actually idle
# (a big library can take 10-20 min — a fixed wait would read too early), then fire
# 'video_library_scan_completed'; stage 2 listens for that and reads the fresh state.
def _default_scan_status(media_type: str = "all"):
"""Production wiring: True/False if the active server is/ isn't scanning, or None
when it can't report (so the caller falls back to a fixed wait)."""
from core.video.sources import video_server_scan_in_progress
return video_server_scan_in_progress(media_type)
def wait_for_server_scan(scan_status, sleep, *, grace_seconds: int = 15,
interval_seconds: int = 10, cap_seconds: int = 3600,
fallback_seconds: int = 120) -> int:
"""Block until the media server's scan queue goes idle, then return ~seconds waited.
``scan_status()`` returns True (scanning), False (idle) or None (can't tell). After
a short grace so the just-triggered scan has time to register as running poll
every ``interval_seconds`` until idle or ``cap_seconds`` (a generous backstop for a
huge library). If the server can't report its state, fall back to a fixed
``fallback_seconds`` wait exactly the old behaviour. ``sleep`` is injected and
elapsed is counted from the sleeps, so tests never actually block."""
grace = max(0, grace_seconds)
if grace:
sleep(grace)
waited = grace
try:
status = scan_status()
except Exception: # noqa: BLE001 - status is best-effort
status = None
if status is None:
extra = max(0, fallback_seconds - waited)
if extra:
sleep(extra)
return waited + extra
while status is True and waited < cap_seconds:
sleep(interval_seconds)
waited += interval_seconds
try:
status = scan_status()
except Exception: # noqa: BLE001
status = None
if status is None: # lost the ability to tell — stop waiting, don't hang
break
return waited
def auto_video_scan_server(
config: Dict[str, Any],
deps: AutomationDeps,
*,
server_refresh: Optional[Callable[[], Dict[str, Any]]] = None,
server_refresh: Optional[Callable[..., Dict[str, Any]]] = None,
sleep: Optional[Callable[[float], None]] = None,
emit: Optional[Callable[[str, Dict[str, Any]], None]] = None,
scan_status: Optional[Callable[..., Any]] = None,
) -> Dict[str, Any]:
"""Stage 1: tell the media server to rescan the video sections, wait a debounce
for it to index, then fire 'video_library_scan_completed' so the DB-update twin
reads the fresh state. (Video twin of music's scan_library.)"""
"""Stage 1: tell the media server to rescan the video sections, WAIT until its scan
queue is idle (polling the server; falls back to a fixed wait if it can't report),
then fire 'video_library_scan_completed' so the DB-update twin reads the fresh state.
(Video twin of music's scan_library.)"""
server_refresh = server_refresh or _default_server_refresh
sleep = sleep or time.sleep
emit = emit or deps.engine.emit
scan_status = scan_status or _default_scan_status
automation_id = config.get('_automation_id')
media_type = config.get('media_type') or 'all'
lib_label = {'movie': 'Movie', 'show': 'TV'}.get(media_type, 'video')
try:
debounce = int(config.get('debounce_seconds') or 120)
fallback = int(config.get('debounce_seconds') or 120)
except (TypeError, ValueError):
debounce = 120
fallback = 120
try:
cap = int(config.get('max_wait_minutes') or 60) * 60
except (TypeError, ValueError):
cap = 3600
try:
deps.update_progress(automation_id, phase=f'Asking media server to rescan {lib_label}', progress=20,
log_line=f'Triggering server-side {lib_label} scan', log_type='info')
@ -207,13 +257,15 @@ def auto_video_scan_server(
deps.update_progress(automation_id,
log_line='Server scan trigger unavailable: ' + str(refresh.get('error') or 'unknown'),
log_type='warning')
deps.update_progress(automation_id, phase=f'Waiting for the server to index ({debounce}s)…', progress=55)
sleep(debounce)
deps.update_progress(automation_id, phase='Waiting for the server to finish indexing…', progress=55)
waited = wait_for_server_scan(lambda: scan_status(media_type), sleep,
fallback_seconds=fallback, cap_seconds=cap)
# Carry the scope so stage 2 (Update DB) reads only the library we rescanned.
emit('video_library_scan_completed',
{'server': refresh.get('server') or '', 'media_type': media_type})
deps.update_progress(automation_id, status='finished', progress=100, phase='Complete',
log_line='Server scan done — updating the database', log_type='success')
log_line=f'Server scan finished (~{waited}s) — updating the database',
log_type='success')
return {'status': 'completed', '_manages_own_progress': True}
except Exception as e: # noqa: BLE001
deps.update_progress(automation_id, status='error', phase='Error', log_line=str(e), log_type='error')

View file

@ -296,6 +296,21 @@ def refresh_video_server_sections(media_type="all"):
return {"ok": False, "error": str(e)}
def video_server_scan_in_progress(media_type="all"):
"""True if the active video server is mid-scan for the given library (or either,
for 'all'); False if idle; None if it can't be determined — no server, or an
adapter that can't report scan state. Callers fall back to a fixed wait on None."""
media_type = normalize_media_type(media_type)
src = get_active_video_source()
if src is None or not hasattr(src, "is_scanning"):
return None
try:
return bool(src.is_scanning(media_type))
except Exception:
logger.debug("video sources: scan-status check failed", exc_info=True)
return None
def list_video_libraries():
"""Discover the active server's video libraries for the mapping UI:
{'server', 'movies': [{'title'}], 'tv': [{'title'}]} or None."""
@ -376,6 +391,26 @@ class PlexVideoSource:
logger.exception("Plex: refresh failed for section %s", getattr(s, "title", "?"))
return {"ok": n > 0, "sections": n}
def is_scanning(self, media_type="all") -> bool:
"""True if any SELECTED video section (scoped by media_type) is currently
being scanned by Plex. Checks the per-section refreshing flag, then the
server activity feed (real-time) mirrors the music PlexClient check."""
sections = []
for kind, name in (("movie", self._movies_lib), ("show", self._tv_lib)):
if media_type != "all" and media_type != kind:
continue
sections.extend(self._scan_sections(kind, name))
for s in sections:
if getattr(s, "refreshing", False):
return True
titles = {(getattr(s, "title", "") or "").lower() for s in sections}
for act in self._server.activities():
if getattr(act, "type", "") in ("library.scan", "library.refresh"):
at = (getattr(act, "title", "") or "").lower()
if any(t and t in at for t in titles):
return True
return False
@staticmethod
def _part_file(obj):
try:
@ -571,6 +606,26 @@ class JellyfinVideoSource:
logger.exception("Jellyfin: refresh failed for view %s", vid)
return {"ok": n > 0, "sections": n}
def is_scanning(self, media_type="all") -> bool:
"""True if Jellyfin's library-scan scheduled task is running. Jellyfin's
scan isn't per-library, so media_type is ignored (any scan counts)."""
import requests
base = (self._c.base_url or "").rstrip("/")
if not base:
return False
try:
r = requests.get(base + "/ScheduledTasks",
headers={"X-Emby-Token": self._c.api_key or ""}, timeout=10)
tasks = r.json() if r.ok else []
except Exception:
return False
for task in tasks or []:
name = (task.get("Name") or "").lower()
if (("scan" in name or "refresh" in name or "library" in name)
and task.get("State") in ("Running", "Cancelling")):
return True
return False
def counts(self, incremental=False) -> dict:
def total(view, itype):
resp = self._req(f"/Users/{self.uid}/Items", {

View file

@ -229,29 +229,74 @@ class TestHandlerNeverRaises:
# ── post-download chain: video_scan_server (stage 1) ───────────────────────
from core.automation.handlers.video_scan_library import ( # noqa: E402
auto_video_scan_server, auto_video_update_database)
auto_video_scan_server, auto_video_update_database, wait_for_server_scan)
class TestWaitForServerScan:
"""Poll the server until its scan queue is idle; fixed wait only as a fallback."""
def test_returns_quickly_when_already_idle(self):
slept = []
waited = wait_for_server_scan(lambda: False, slept.append, grace_seconds=15)
assert waited == 15 and slept == [15] # just the grace, then idle
def test_polls_until_the_scan_finishes(self):
# scanning for three polls, then idle — handles a 10-20 min big-library scan
seq = iter([True, True, True, False])
slept = []
waited = wait_for_server_scan(lambda: next(seq), slept.append,
grace_seconds=15, interval_seconds=10)
assert waited == 15 + 30 # grace + 3 polls
assert slept == [15, 10, 10, 10]
def test_falls_back_to_fixed_wait_when_status_unknown(self):
slept = []
waited = wait_for_server_scan(lambda: None, slept.append,
grace_seconds=15, fallback_seconds=120)
assert waited == 120 and slept == [15, 105] # grace + (fallback - grace)
def test_stops_at_the_cap_on_a_runaway_scan(self):
slept = []
waited = wait_for_server_scan(lambda: True, slept.append,
grace_seconds=0, interval_seconds=10, cap_seconds=50)
assert waited == 50 # never hangs forever
def test_stops_if_status_becomes_unknown_mid_poll(self):
seq = iter([True, None])
slept = []
wait_for_server_scan(lambda: next(seq), slept.append, grace_seconds=0, interval_seconds=10)
assert slept == [10] # one poll, then bail — no hang
class TestScanServerStage:
def test_refreshes_waits_then_emits_scan_done(self):
def test_waits_for_idle_then_emits_scan_done(self):
deps = _RecordingDeps()
events = []
slept = []
# scanning once, then idle
seq = iter([True, False])
r = auto_video_scan_server(
{'_automation_id': 'a', 'debounce_seconds': 90}, deps,
server_refresh=_refresh_ok(), sleep=lambda s: slept.append(s),
{'_automation_id': 'a'}, deps,
server_refresh=_refresh_ok(), sleep=lambda s: None,
scan_status=lambda mt: next(seq),
emit=lambda ev, data: events.append((ev, data)))
assert r['status'] == 'completed'
assert slept == [90] # waited the debounce
assert events == [('video_library_scan_completed', {'server': '', 'media_type': 'all'})]
def test_default_debounce_and_server_unavailable_still_emits(self):
def test_falls_back_to_fixed_wait_when_server_cant_report(self):
slept = []
auto_video_scan_server(
{'_automation_id': 'a', 'debounce_seconds': 90}, _RecordingDeps(),
server_refresh=_refresh_ok(), sleep=slept.append,
scan_status=lambda mt: None, emit=lambda ev, data: None)
assert sum(slept) == 90 # honoured the fallback wait
def test_server_unavailable_still_emits(self):
deps = _RecordingDeps()
events = []
auto_video_scan_server(
{'_automation_id': 'a'}, deps,
server_refresh=lambda media_type=None: {'ok': False, 'error': 'no server'},
sleep=lambda s: None, emit=lambda ev, data: events.append(ev))
sleep=lambda s: None, scan_status=lambda mt: False, emit=lambda ev, data: events.append(ev))
assert events == ['video_library_scan_completed'] # fires even if refresh failed
assert 'warning' in deps.log_types()
@ -260,22 +305,24 @@ class TestScanServerStage:
r = auto_video_scan_server(
{'_automation_id': 'a'}, deps,
server_refresh=lambda media_type=None: (_ for _ in ()).throw(RuntimeError('boom')),
sleep=lambda s: None, emit=lambda *a: None)
sleep=lambda s: None, scan_status=lambda mt: False, emit=lambda *a: None)
assert r['status'] == 'error' and r['error'] == 'boom'
def test_scopes_refresh_and_carries_media_type_on_the_event(self):
def test_scopes_refresh_and_status_and_carries_media_type_on_the_event(self):
seen = {}
events = []
def _refresh(media_type=None):
seen['mt'] = media_type
seen['refresh_mt'] = media_type
return {'ok': True}
auto_video_scan_server(
{'_automation_id': 'a', 'media_type': 'show'}, _RecordingDeps(),
server_refresh=_refresh, sleep=lambda s: None,
scan_status=lambda mt: seen.setdefault('status_mt', mt) and False,
emit=lambda ev, data: events.append((ev, data)))
assert seen['mt'] == 'show' # only TV sections nudged
assert seen['refresh_mt'] == 'show' # only TV sections nudged
assert seen['status_mt'] == 'show' # polled TV scan status
assert events[0][1]['media_type'] == 'show' # stage 2 inherits the scope

View file

@ -126,3 +126,42 @@ def test_refresh_sections_scopes_by_media_type():
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