Mirrors the music flow: a finished download batch → refresh the media server → (after
it indexes) pull the new media into the DB — so a downloaded movie/episode shows as
owned without waiting for the 6h scheduled scan.
Two event-based system automations (owned_by='video'):
- 'Auto-Scan Video After Downloads' (video_batch_complete → video_scan_server)
- 'Auto-Update Video Database After Scan' (video_library_scan_completed → video_update_database)
Pieces:
- core/video/download_events.py: a callback registry (core/video can't import the
engine — isolation). The monitor publishes batch-complete; web_server bridges it to
automation_engine.emit('video_batch_complete', …), like music's web_scan_manager.
- download_monitor: fires the batch-complete event once, when the last in-flight
download finishes (none queued/downloading/searching left).
- video_scan_server handler (stage 1): refresh server, wait a debounce for indexing,
then emit 'video_library_scan_completed' (mirrors music's time-based completion).
- video_update_database handler (stage 2): incremental read (newest-first, stop after
25 consecutive known — same as music).
- blocks: 2 video triggers + 2 video actions (scope='video'); registration; seeds.
kettui: seam tests for both handlers (refresh/wait/emit, incremental read, error paths),
the event registry (idempotent register, isolated failures), and the monitor (fires once
on last completion, never while work remains). 298 automation tests + isolation green.
79 lines
3.3 KiB
Python
79 lines
3.3 KiB
Python
"""Video download → batch-complete event bridge + the monitor detection that fires it.
|
|
|
|
The isolated monitor (core/video) publishes 'batch complete' to a callback registry;
|
|
web_server bridges that to automation_engine.emit('video_batch_complete', …). These
|
|
pin the publish side: the registry, and the monitor firing exactly once when the last
|
|
download finishes (never while work is still in flight).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import core.video.download_monitor as mon
|
|
import core.video.download_events as events
|
|
|
|
|
|
def setup_function(_):
|
|
events._reset_for_tests()
|
|
|
|
|
|
# ── the event registry ─────────────────────────────────────────────────────
|
|
|
|
def test_register_and_notify_fires_callbacks():
|
|
got = []
|
|
events.register_batch_complete_callback(lambda d: got.append(d))
|
|
events.notify_batch_complete({"completed": 3})
|
|
assert got == [{"completed": 3}]
|
|
|
|
|
|
def test_register_is_idempotent_and_one_failure_is_isolated():
|
|
got = []
|
|
cb = lambda d: got.append(d)
|
|
events.register_batch_complete_callback(cb)
|
|
events.register_batch_complete_callback(cb) # dup → ignored
|
|
events.register_batch_complete_callback(lambda d: (_ for _ in ()).throw(RuntimeError("boom")))
|
|
events.notify_batch_complete({}) # must not raise despite the bad cb
|
|
assert got == [{}] # the good cb fired exactly once
|
|
|
|
|
|
# ── the monitor fires it on the last completion ────────────────────────────
|
|
|
|
class _FakeDb:
|
|
def __init__(self, active):
|
|
self._active = list(active)
|
|
|
|
def get_active_video_downloads(self):
|
|
return list(self._active)
|
|
|
|
def update_video_download(self, dl_id, **kw):
|
|
if kw.get("status") in ("completed", "failed", "cancelled"):
|
|
self._active = [d for d in self._active if d["id"] != dl_id]
|
|
|
|
|
|
def _patch(monkeypatch, result):
|
|
monkeypatch.setattr(mon, "list_downloads", lambda: [])
|
|
monkeypatch.setattr(mon, "process_download", lambda dl, *a, **k: dict(result))
|
|
|
|
|
|
def test_tick_fires_batch_complete_when_last_download_finishes(monkeypatch):
|
|
fired = []
|
|
events.register_batch_complete_callback(lambda d: fired.append(d))
|
|
_patch(monkeypatch, {"status": "completed", "progress": 100.0})
|
|
db = _FakeDb([{"id": 1, "status": "downloading", "filename": "a.mkv"}])
|
|
mon._tick(db)
|
|
assert fired == [{"completed": 1}] # one download done, none left → fired once
|
|
|
|
|
|
def test_tick_does_not_fire_while_a_download_is_still_active(monkeypatch):
|
|
fired = []
|
|
events.register_batch_complete_callback(lambda d: fired.append(d))
|
|
# one completes, one is still downloading → batch NOT done yet
|
|
monkeypatch.setattr(mon, "list_downloads", lambda: [])
|
|
|
|
def _proc(dl, *a, **k):
|
|
return {"status": "completed", "progress": 100.0} if dl["id"] == 1 else {"progress": 50.0}
|
|
|
|
monkeypatch.setattr(mon, "process_download", _proc)
|
|
db = _FakeDb([{"id": 1, "status": "downloading", "filename": "a.mkv"},
|
|
{"id": 2, "status": "downloading", "filename": "b.mkv"}])
|
|
mon._tick(db)
|
|
assert fired == [] # 2 still active → no batch-complete
|