From c5585beab3d6adefaa2ef9444946dd3816960a7c Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 24 Aug 2026 15:57:41 +0200 Subject: fix(node,hub): HEVC transcode fallback, live-add progress, per-group TMDB toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs found live testing the Videos app against a real HEVC/EAC3 show, plus a design change requested afterward: - Streaming always did "-c:v copy", which faithfully reports a source's real hev1 codec string but is unplayable in a browser with no HEVC decoder (most Chrome/Linux builds). The node now transcodes to H264 whenever the probed codec is browser-incompatible (media_probe.py's new BROWSER_INCOMPATIBLE_VIDEO_CODECS), with a `transcode_incompatible_video` node.toml opt-out for operators who know their viewers already decode it. - Dropping a whole season into an already-watched folder gave no scanning indicator and no progress bar: IndexProgress was only ever updated by the two bulk scan paths, never by the real-time per-file watchdog path (_schedule_update/_debounce/_update_entry). That path now accounts a "burst" the same way, without double-counting a file rewritten mid-debounce. - A stray literal "0" rendered in the video detail modal when there was no TMDB match (`meta.confidence` is 0, and `0 && x` renders "0" in JSX/htm, not nothing) — `confident` is now a real boolean. - Whether TMDB is used at all moves from a node-wide setting to per-group (OP_TMDB_ENABLED/tmdb_enabled/tmdb_enabled_ack, scoped like OP_VIDEO_ROOT): an operator running a real media-library group alongside test/demo groups on one node wants outbound TMDB traffic for the one that needs it, not all of them. The custom API token and query language stay node-wide, one shared credential/cache (tmdb_config/OP_TMDB_CONFIG, unchanged reasoning). MNP_VERSION 0.6 -> 0.7, additive. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY --- packages/meshbay-node/tests/test_indexer.py | 76 +++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) (limited to 'packages/meshbay-node/tests/test_indexer.py') diff --git a/packages/meshbay-node/tests/test_indexer.py b/packages/meshbay-node/tests/test_indexer.py index 68ccc4c..3eac39e 100644 --- a/packages/meshbay-node/tests/test_indexer.py +++ b/packages/meshbay-node/tests/test_indexer.py @@ -356,6 +356,82 @@ async def test_progress_stops_even_when_hashing_raises(tmp_path, sk_node, gek): "an exception mid-scan must not leave the scanning flag stuck on" +@pytest.mark.asyncio +async def test_realtime_watchdog_add_reports_progress_like_a_bulk_scan(tmp_path, sk_node, gek): + """ + Found live: dropping a whole season into an already-watched folder gave + no scanning indicator and no progress bar at all — only the initial scan + and the periodic reconcile backstop ever touched `progress`, never the + real-time per-file watchdog path (_schedule_update/_debounce/ + _update_entry). Drives that path directly (as _WatchdogHandler would), + without a real filesystem observer, exactly like test_root_availability.py + already does for _update_entry alone. + """ + d = tmp_path / "shared" + d.mkdir() + paths = [] + for i in range(3): + p = d / f"ep{i}.mkv" + p.write_bytes(os.urandom(1024 * (i + 1))) + paths.append(p) + total_size = sum(p.stat().st_size for p in paths) + + indexer = DirectoryIndexer(roots=one_root(d), group_id="g", sk_node=sk_node, + gek=gek, debounce_secs=0.01) + indexer._loop = asyncio.get_running_loop() + assert indexer.progress.scanning is False + + for p in paths: + indexer._schedule_update(p) + # _schedule_update only posts _debounce via call_soon_threadsafe (it is + # written to be called from the watchdog thread) — give the loop one + # turn to actually run the three posted calls before asserting on them. + await asyncio.sleep(0) + + # All three scheduled near-simultaneously (as a burst of watchdog events + # for one `mv` would arrive) — the indicator must flip on immediately, + # before any single file has actually finished hashing. + assert indexer.progress.scanning is True + assert indexer.progress.total_bytes == total_size + assert indexer.progress.scanned_bytes == 0 + + await asyncio.sleep(0.05) # past debounce_secs; lets all three fire and finish + + assert indexer.progress.scanning is False, "must end idle, not stuck scanning" + assert indexer.progress.scanned_bytes == total_size + assert indexer.progress.total_bytes == total_size + assert len(indexer.index.entries) == 3 + + +@pytest.mark.asyncio +async def test_realtime_watchdog_rapid_rewrite_does_not_double_count(tmp_path, sk_node, gek): + """A file rewritten during its own debounce window (on_modified firing + again before the first timer fires) must count its size once, not once + per event — the old timer is cancelled, and its accounting must transfer + to whichever fire() actually runs rather than being counted twice.""" + d = tmp_path / "shared" + d.mkdir() + p = d / "ep0.mkv" + p.write_bytes(os.urandom(2048)) + + indexer = DirectoryIndexer(roots=one_root(d), group_id="g", sk_node=sk_node, + gek=gek, debounce_secs=0.05) + indexer._loop = asyncio.get_running_loop() + + indexer._schedule_update(p) + indexer._schedule_update(p) # re-triggered before the first timer fires + indexer._schedule_update(p) + await asyncio.sleep(0) # let the three posted _debounce calls actually run + + assert indexer.progress.total_bytes == p.stat().st_size, \ + "one file re-triggered must count its size once, not three times" + + await asyncio.sleep(0.1) + + assert indexer.progress.scanning is False + assert indexer.progress.scanned_bytes == p.stat().st_size + + # ── Off-loop directory walks, reconcile backoff ───────────────────────────── @pytest.mark.asyncio -- cgit v1.2.3