From 6af05abf410bbd038ce7fa6915a659defc509071 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 24 Aug 2026 10:04:46 +0200 Subject: feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/mediacenter.md: a "Videos" group application built on the existing files index rather than a separate catalogue. On the node side, new indexer enrichment (technical probe, filename/season parsing, thumbnail generation) runs per-file once an operator has chosen a video_root for the group, plus a TMDB client for on-demand poster/metadata lookups (never client-side, thumbnails delivered over the existing chunk path). On the hub side, a new video-app.js renders a lazily-mounted poster grid or a thumbnail-only flat list, with TMDB entirely optional per group. Along the way: the global apps registry now drives Settings' default-tab picker instead of a hardcoded list, and the video_root is configured from group Settings (like uploads) rather than from Files, with the node refusing to run any TMDB/thumbnail work until one is set. Fixes several bugs found via live testing against a real library, notably a race between two effects writing the same "image ready" state that could leave a poster grid spinning forever on a same-tab revisit — see mediacenter.md §5.4 for the full account of each one. --- packages/meshbay-node/tests/test_enrich.py | 130 +++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 packages/meshbay-node/tests/test_enrich.py (limited to 'packages/meshbay-node/tests/test_enrich.py') diff --git a/packages/meshbay-node/tests/test_enrich.py b/packages/meshbay-node/tests/test_enrich.py new file mode 100644 index 0000000..cff4d50 --- /dev/null +++ b/packages/meshbay-node/tests/test_enrich.py @@ -0,0 +1,130 @@ +"""Tests for indexer/enrich.py — season/title corroboration and the end-to-end pool.""" + +import asyncio +import shutil +import subprocess +from pathlib import Path + +import pytest + +from meshbay_common.protocol import IndexEntry +from meshbay_node.indexer.enrich import Enricher, _season_from_ancestors, _title_from_siblings +from meshbay_node.media_cache import MediaCache + +_HAVE_FFMPEG = shutil.which("ffmpeg") and shutil.which("ffprobe") + + +# ── pure helpers, no ffmpeg needed ─────────────────────────────────────────── + +def test_season_from_ancestors_finds_season_folder(tmp_path): + folder = tmp_path / "Some Show" / "Season 2" + folder.mkdir(parents=True) + ep = folder / "01 - Episode Title.mkv" + ep.touch() + + assert _season_from_ancestors(ep) == 2 + + +def test_season_from_ancestors_none_when_no_season_folder(tmp_path): + folder = tmp_path / "Movies" + folder.mkdir() + f = folder / "Some Movie 2015.mkv" + f.touch() + + assert _season_from_ancestors(f) is None + + +def test_title_from_siblings_borrows_from_a_titled_sibling(tmp_path): + folder = tmp_path / "Acronym Show" + folder.mkdir() + titled = folder / "Some.Show.Name.S01E01.720p.mkv" + untitled = folder / "S01E02.SUBFRENCH.720p.mkv" + titled.touch() + untitled.touch() + + assert _title_from_siblings(untitled) == "Some Show Name" + + +def test_title_from_siblings_none_when_no_titled_sibling(tmp_path): + folder = tmp_path / "Acronym Show" + folder.mkdir() + (folder / "S01E02.mkv").touch() + + assert _title_from_siblings(folder / "S01E02.mkv") is None + + +# ── end-to-end against a real (tiny, synthetic) video file ────────────────── + +pytestmark_ffmpeg = pytest.mark.skipif(not _HAVE_FFMPEG, reason="ffmpeg/ffprobe not installed") + + +def _make_clip(path: Path) -> None: + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25:duration=1", + "-c:v", "libx264", "-preset", "ultrafast", "-an", str(path)], + check=True, capture_output=True, + ) + + +@pytest.fixture +async def media_cache(tmp_path): + c = MediaCache(db_path=tmp_path / "media_cache.db") + await c.open() + yield c + await c.close() + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_populates_fields_and_stores_thumbnail(tmp_path, media_cache): + clip = tmp_path / "Some.Movie.2015.1080p.mkv" + _make_clip(clip) + entry = IndexEntry(id="fileid1", name=clip.name, path=clip.name, + size=clip.stat().st_size, type="video", added_at=0) + + enricher = Enricher(media_cache) + done = asyncio.get_event_loop().create_future() + + async def on_done(file_id, fields): + done.set_result((file_id, fields)) + + enricher.spawn(entry, clip, on_done) + file_id, fields = await asyncio.wait_for(done, timeout=30) + + assert file_id == "fileid1" + assert fields["width"] == 320 + assert fields["height"] == 240 + assert fields["display_title"] == "Some Movie" + assert fields.get("thumb_hash") + stored = await media_cache.get_thumb(fields["thumb_hash"]) + assert stored is not None and len(stored) > 0 + + +@pytestmark_ffmpeg +@pytest.mark.asyncio +async def test_enricher_handles_episode_with_season_from_folder(tmp_path, media_cache): + # Filename carries only a bare episode number, no SxxExx token — guessit + # confirmed (separately) not to find a season here at all — so the + # season must come from the ancestor folder (§3.4's non-standard case). + folder = tmp_path / "Some Show" / "Saison 3" + folder.mkdir(parents=True) + titled_sibling = folder / "Some.Show.Episode.06.mkv" + titled_sibling.touch() + clip = folder / "Episode.07.720p.mkv" + _make_clip(clip) + entry = IndexEntry(id="fileid2", name=clip.name, path=str(clip.relative_to(tmp_path)), + size=clip.stat().st_size, type="video", added_at=0) + + enricher = Enricher(media_cache) + done = asyncio.get_event_loop().create_future() + + async def on_done(file_id, fields): + done.set_result((file_id, fields)) + + enricher.spawn(entry, clip, on_done) + file_id, fields = await asyncio.wait_for(done, timeout=30) + + assert fields["display_title"] == "Some Show" + assert fields["season"] == 3 + assert fields["episode"] == 7 -- cgit v1.2.3