summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 14:33:20 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 14:33:20 +0200
commit0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a (patch)
treeba8daf5dcf050d44b7b0e766babbfda8fadac59f /packages/meshbay-node
parent6af05abf410bbd038ce7fa6915a659defc509071 (diff)
downloadmeshbay-0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a.tar.gz
feat(node,hub): season-specific overviews, manual TMDB match correction, and wizard polish
Two operator-facing fixes for a real 3-season show whose automatic TMDB match was wrong at the show level: per-season overview/air_date tabs in the detail modal (falling back to the show-level text when a season's own is empty), and a "Fix match…" search-and-correct affordance that re-resolves every file sharing the corrected show's display_title. New signed op OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp, tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6. Also: the create-group wizard gets a spinning indexing indicator and an app-selection step, group settings default the TMDB language to the operator's own locale (never as a global default), and a file renamed mid-session now re-triggers title parsing instead of being silently skipped by the enrichment dedup guard. Fixes two bugs found during this work: the search overlay's z-index lost to the base video-overlay class and rendered invisibly, and season_meta's own empty overview didn't fall back to the show-level one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
Diffstat (limited to 'packages/meshbay-node')
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py39
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py35
-rw-r--r--packages/meshbay-node/src/meshbay_node/tmdb.py22
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py163
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py16
-rw-r--r--packages/meshbay-node/tests/test_rename_reenrichment.py163
-rw-r--r--packages/meshbay-node/tests/test_season_and_search_requests.py187
-rw-r--r--packages/meshbay-node/tests/test_tmdb_override_policy.py172
-rw-r--r--packages/meshbay-node/tests/test_wizard_apps_endpoint.py76
9 files changed, 870 insertions, 3 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 65c7da8..bf6bd64 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -938,6 +938,7 @@ class NodeDaemon:
prev = self._last_broadcast_snapshot.get(group_id)
delta = None
+ previous = None
if prev is not None:
prev_version, prev_entries = prev
previous = GroupIndex._snapshot(
@@ -953,6 +954,18 @@ class NodeDaemon:
new_entries = delta.additions if delta is not None else list(idx.entries)
asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries))
+ # A rename/move changes the very filename (or season folder) that
+ # §3.3/§3.4's title-parse read display_title/season/episode from,
+ # but leaves the file's content — and so its id and everything
+ # ffprobe/thumbnailing already found — untouched. Only entries
+ # whose name or path actually differ from the last broadcast get a
+ # fresh pass; an update that is enrichment's own field-fill
+ # (duration/thumb_hash/... landing via _on_enriched below) leaves
+ # name/path alone and must not re-trigger itself forever.
+ if delta is not None and delta.updates and previous is not None:
+ asyncio.ensure_future(
+ self._reenrich_renamed_video_entries(indexer, delta.updates, previous))
+
# Videos app: a file that leaves the index also loses its thumbnail
# and file->tmdb mapping — the "real deletion obligation" docs/
# mediacenter.md §2/§8 calls out explicitly rather than leaving
@@ -1060,6 +1073,32 @@ class NodeDaemon:
return
await self._enrich_new_video_entries(indexer, list(indexer.index.entries))
+ async def _reenrich_renamed_video_entries(
+ self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
+ ) -> None:
+ """
+ Videos app: found live — a French-named episode file, renamed by
+ the operator to match its English-named siblings, kept showing as
+ its own separate poster-grid card (and its own row in Flat list)
+ indefinitely, because `_enriched_attempted` — there specifically to
+ stop enrichment's own field-fill from re-triggering itself forever
+ (see the caller) — also silently blocked the *new* filename from
+ ever being title-parsed at all. `entry.id in self._enriched_attempted`
+ is the same content, so simply discarding it here and re-running
+ the ordinary enrichment path is enough: a fresh ffprobe/thumbnail
+ for an unchanged file is redundant work, not a correctness issue,
+ and renames are rare enough that the redundancy is not worth a
+ separate "title-parse only" code path.
+ """
+ for entry in updates:
+ if entry.type != "video":
+ continue
+ old = previous.get_entry(entry.id)
+ if old is None or (old.name == entry.name and old.path == entry.path):
+ continue
+ self._enriched_attempted.discard(entry.id)
+ await self._enrich_new_video_entries(indexer, updates)
+
async def _on_enriched(self, indexer: DirectoryIndexer, file_id: str, fields: dict) -> None:
"""
Merge enrichment fields into the live index and re-trigger a
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py
index 129927d..6daae3f 100644
--- a/packages/meshbay-node/src/meshbay_node/media_cache.py
+++ b/packages/meshbay-node/src/meshbay_node/media_cache.py
@@ -41,6 +41,13 @@ CREATE TABLE IF NOT EXISTS thumbs (
jpeg BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_thumbs_file ON thumbs(file_id);
+CREATE TABLE IF NOT EXISTS season_meta (
+ tmdb_id TEXT NOT NULL,
+ season INTEGER NOT NULL,
+ json TEXT NOT NULL,
+ fetched_at REAL NOT NULL,
+ PRIMARY KEY (tmdb_id, season)
+);
"""
# TMDB overviews/ratings do drift; a file's own resolved tmdb_id does not
@@ -109,6 +116,34 @@ class MediaCache:
)
await self._db.commit()
+ # ── tmdb id + season number -> season-level metadata json ────────────────
+ #
+ # A show's own overview (tmdb_meta above) is one static field an operator
+ # found does not necessarily describe every season alike (mediacenter.md
+ # §5.4) — this is TMDB's per-season `overview`/`air_date`/`poster_path`,
+ # fetched and cached independently, on the same staleness schedule.
+
+ async def get_season_meta(self, tmdb_id: str, season: int) -> dict | None:
+ async with self._db.execute(
+ "SELECT json, fetched_at FROM season_meta WHERE tmdb_id = ? AND season = ?",
+ (tmdb_id, season),
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ return None
+ raw_json, fetched_at = row
+ if time.time() - fetched_at > TMDB_META_TTL_SECS:
+ return None
+ return json.loads(raw_json)
+
+ async def set_season_meta(self, tmdb_id: str, season: int, meta: dict) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO season_meta (tmdb_id, season, json, fetched_at) "
+ "VALUES (?, ?, ?, ?)",
+ (tmdb_id, season, json.dumps(meta), time.time()),
+ )
+ await self._db.commit()
+
# ── thumbnails ────────────────────────────────────────────────────────────
async def get_thumb(self, thumb_hash: str) -> bytes | None:
diff --git a/packages/meshbay-node/src/meshbay_node/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py
index 5e5ed9f..448a2b9 100644
--- a/packages/meshbay-node/src/meshbay_node/tmdb.py
+++ b/packages/meshbay-node/src/meshbay_node/tmdb.py
@@ -132,8 +132,26 @@ class TmdbClient:
results = (data or {}).get("results", [])
return _best_match(title, results, ("name", "original_name"))
- async def tv_season(self, tmdb_id: str | int, season: int) -> dict | None:
- return await self._get(f"tv/{tmdb_id}/season/{season}", {})
+ async def search_movie_results(self, title: str) -> list[dict]:
+ """
+ The raw candidate list (capped), for an operator correcting a wrong
+ automatic match (§ webrtc_server.py's tmdb_search_req) — unlike
+ `search_movie`, this doesn't collapse to TMDB's own top result: a
+ human picks from several, so several is the point.
+ """
+ data = await self._get("search/movie", {"query": title, "include_adult": "false"})
+ return (data or {}).get("results", [])[:8]
+
+ async def search_tv_results(self, title: str) -> list[dict]:
+ data = await self._get("search/tv", {"query": title})
+ return (data or {}).get("results", [])[:8]
+
+ async def tv_season(self, tmdb_id: str | int, season: int,
+ language: str | None = None) -> dict | None:
+ """`language`, when given, overrides the configured one — same
+ English-fallback use as `movie_details`/`tv_details`."""
+ params = {"language": language} if language else {}
+ return await self._get(f"tv/{tmdb_id}/season/{season}", params)
async def movie_details(self, tmdb_id: str | int, language: str | None = None) -> dict | None:
"""
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 938ce3b..f4d1dee 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -67,6 +67,7 @@ from meshbay_common.adminop import (
OP_SET_SCAN_SETTINGS,
OP_TMDB_CONFIG,
OP_VIDEO_ROOT,
+ OP_TMDB_OVERRIDE,
OP_ROOT_ADD,
OP_ROOT_REMOVE,
OP_GROUP_ATTACH,
@@ -378,6 +379,12 @@ class WebRTCPeerSession:
self._do_video_root(msg)
elif mtype == MNP.MEDIA_META_REQ:
self._spawn(self._do_media_meta_request(msg))
+ elif mtype == MNP.SEASON_META_REQ:
+ self._spawn(self._do_season_meta_request(msg))
+ elif mtype == MNP.TMDB_SEARCH_REQ:
+ self._spawn(self._do_tmdb_search_request(msg))
+ elif mtype == MNP.TMDB_OVERRIDE:
+ self._do_tmdb_override(msg)
elif mtype == MNP.MEMBER_UNPIN:
self._do_member_unpin(msg)
elif mtype == MNP.GEK_ROTATE:
@@ -2452,6 +2459,159 @@ class WebRTCPeerSession:
resp["episode"] = entry.episode
self._send(resp)
+ async def _do_season_meta_request(self, msg: dict) -> None:
+ """
+ Per-season TMDB overview/poster/air_date for a multi-season show —
+ found live: `media_meta_resp`'s one static show-level overview does
+ not necessarily describe every season alike (a season-3-specific
+ promotional summary applied to all three seasons of a show).
+ `tmdb_id` is whatever the client's own prior `media_meta_resp`
+ already resolved — never re-derived from a path here, so this
+ never re-runs a TMDB search of its own.
+ """
+ tmdb_id = msg.get("tmdb_id")
+ season = msg.get("season")
+ if not isinstance(tmdb_id, str) or not tmdb_id or not isinstance(season, int):
+ self._send({"type": "error", "detail": "Missing tmdb_id or season"})
+ return
+ media_cache = self._ctx.get("media_cache")
+ tmdb_client = self._ctx.get("tmdb_client")
+ if media_cache is None or tmdb_client is None:
+ self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
+ "tmdb_id": tmdb_id, "season": season, "confidence": 0})
+ return
+
+ details = await media_cache.get_season_meta(tmdb_id, season)
+ if details is None:
+ fetched = await tmdb_client.tv_season(tmdb_id, season)
+ if fetched is None:
+ self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
+ "tmdb_id": tmdb_id, "season": season, "confidence": 0})
+ return
+ # Same per-field English fallback as _tmdb_build_meta: TMDB
+ # returns "" for an untranslated field rather than falling back
+ # itself.
+ if not fetched.get("overview"):
+ fallback = await tmdb_client.tv_season(tmdb_id, season, language="en-US") or {}
+ fetched = {**fallback, **{k: v for k, v in fetched.items() if v not in (None, "", [])}}
+ await media_cache.set_season_meta(tmdb_id, season, fetched)
+ details = fetched
+
+ poster_thumb_hash = await self._fetch_and_cache_poster(
+ media_cache, tmdb_client, details.get("poster_path"))
+ self._send({
+ "type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
+ "tmdb_id": tmdb_id, "season": season, "confidence": 1.0,
+ "name": details.get("name"),
+ "overview": details.get("overview"),
+ "air_date": details.get("air_date"),
+ "poster_thumb_hash": poster_thumb_hash,
+ })
+
+ async def _do_tmdb_search_request(self, msg: dict) -> None:
+ """
+ Candidate TMDB matches for an operator correcting a wrong automatic
+ match (docs/mediacenter.md, §V-whatever this becomes) — a plain
+ lookup, not a mutation, so unlike `tmdb_override` this needs no
+ admin authority: any member can see what TMDB itself would offer,
+ the same as the automatic search already silently does on their
+ behalf. Only `tmdb_override` actually changes what everyone sees.
+ """
+ query = msg.get("query")
+ media_type = msg.get("media_type")
+ if not isinstance(query, str) or not query.strip() or media_type not in ("movie", "tv"):
+ self._send({"type": "error", "detail": "Missing query or media_type"})
+ return
+ media_cache = self._ctx.get("media_cache")
+ tmdb_client = self._ctx.get("tmdb_client")
+ if media_cache is None or tmdb_client is None:
+ self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION,
+ "query": query, "media_type": media_type, "results": []})
+ return
+
+ raw = (await tmdb_client.search_movie_results(query) if media_type == "movie"
+ else await tmdb_client.search_tv_results(query))
+ results = []
+ for r in raw:
+ poster_thumb_hash = await self._fetch_and_cache_poster(
+ media_cache, tmdb_client, r.get("poster_path"))
+ results.append({
+ "tmdb_id": str(r.get("id")),
+ "title": r.get("title") or r.get("name"),
+ "year": (r.get("release_date") or r.get("first_air_date") or "")[:4],
+ "poster_thumb_hash": poster_thumb_hash,
+ })
+ # media_type echoed back, not just query: a client can fire a "war"
+ # tv search and a "war" movie search close together, and without it
+ # the two responses are indistinguishable for keyed matching
+ # (transport.js's tmdb_search_resp handler).
+ self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION,
+ "query": query, "media_type": media_type, "results": results})
+
+ def _do_tmdb_override(self, msg: dict) -> None:
+ """
+ An operator correcting a wrong automatic TMDB match. Signed like
+ video_root/tmdb_config: it replaces what every member sees for a
+ show/movie, node-wide (media_cache is shared, not per-viewer).
+
+ Applied to every entry sharing the representative file's
+ display_title — the same grouping the poster grid itself uses
+ (§3.4/§V6) — not just the one file the operator happened to be
+ looking at, so the correction actually sticks regardless of which
+ episode a future render picks as representative.
+ """
+ path = msg.get("path")
+ tmdb_id = msg.get("tmdb_id")
+ media_type = msg.get("media_type")
+ if not isinstance(path, str) or not path:
+ self._send({"type": "error", "detail": "Missing path"})
+ return
+ if not isinstance(tmdb_id, str) or not tmdb_id or media_type not in ("movie", "tv"):
+ self._send({"type": "error", "detail": "Missing tmdb_id or media_type"})
+ return
+ ctx = self._group_ctx()
+ entry = ctx["index"].get_entry_by_path(path)
+ if not entry:
+ self._send({"type": "error", "detail": "File not found"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ subject = f"path={path},tmdb_id={tmdb_id},media_type={media_type}"
+ self._issue_admin_challenge(OP_TMDB_OVERRIDE, subject)
+
+ async def _admin_exec_tmdb_override(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ subject = pending["subject"]
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"tmdb_override:{subject}")
+ return
+ fields = dict(part.split("=", 1) for part in subject.split(","))
+ path, tmdb_id, media_type = fields["path"], fields["tmdb_id"], fields["media_type"]
+
+ ctx = self._group_ctx()
+ entry = ctx["index"].get_entry_by_path(path)
+ media_cache = self._ctx.get("media_cache")
+ if entry is None or media_cache is None:
+ self._send({"type": "error", "detail": "File or media cache not available"})
+ return
+ target_title = entry.display_title or entry.name
+ matched = [e for e in ctx["index"].entries
+ if e.type == "video" and (e.display_title or e.name) == target_title]
+ for e in matched:
+ await media_cache.set_file_tmdb(e.id, tmdb_id, media_type)
+ self._audit("tmdb_override", subject)
+
+ notice = {"type": MNP.TMDB_OVERRIDE_ACK, "v": MNP_VERSION, "path": path,
+ "tmdb_id": tmdb_id, "media_type": media_type}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
async def _tmdb_search(self, tmdb_client, entry, is_show: bool):
"""
§3.3's retry ladder: the parsed title first, then a couple of
@@ -3045,6 +3205,9 @@ class WebRTCPeerSession:
elif pending["op"] == OP_VIDEO_ROOT:
self._spawn(
self._admin_exec_video_root(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_TMDB_OVERRIDE:
+ self._spawn(
+ self._admin_exec_tmdb_override(pending, transcript, sig_bytes))
elif pending["op"] == OP_ROOT_ADD:
self._spawn(
self._admin_exec_root_add(pending, transcript, sig_bytes))
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index d5b3f94..18764e8 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -20,7 +20,7 @@ import time
from html import escape
from pathlib import Path
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
+from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query
from fastapi.responses import HTMLResponse, JSONResponse
from meshbay_node import __version__
@@ -361,6 +361,20 @@ def create_ui_app(state: dict) -> FastAPI:
state, group_id, bool(payload.get("allowed", False)),
))
+ # ── Enabled apps (operator only, localhost) ────────────────────────────
+ #
+ # Same loopback shape as member-upload: the Create Group wizard sets this
+ # once, right after creating the group and before the (potentially long)
+ # initial scan, so an operator narrowing this down to just Files+Videos
+ # never briefly has Chat live for other members to notice.
+
+ @app.put("/api/groups/{group_id}/apps")
+ async def set_enabled_apps(group_id: str, payload: dict):
+ apps = payload.get("apps")
+ if not isinstance(apps, list) or not apps:
+ raise HTTPException(400, "apps must be a non-empty list")
+ return await _op(lambda: ops.set_enabled_apps(state, group_id, apps))
+
# ── Scan settings (operator only, localhost) ──────────────────────────
@app.put("/api/groups/{group_id}/scan-settings")
diff --git a/packages/meshbay-node/tests/test_rename_reenrichment.py b/packages/meshbay-node/tests/test_rename_reenrichment.py
new file mode 100644
index 0000000..87e0d1a
--- /dev/null
+++ b/packages/meshbay-node/tests/test_rename_reenrichment.py
@@ -0,0 +1,163 @@
+"""
+Bug found live, 2026-08-24: an episode file first named in French (its
+release folder mixed languages across seasons) was renamed by the operator
+to match its English-named siblings — but kept showing as its own
+separate poster-grid card, and its own row in Flat list, indefinitely.
+
+`_enriched_attempted` (daemon.py) exists so that enrichment's own
+field-fill (duration/thumb_hash/display_title/... landing back via
+`_on_enriched`) does not re-trigger itself forever — but it also silently
+blocked the *new* filename from ever being title-parsed at all, since the
+file's content (and so its id) is unchanged by a rename. A rename/move is
+exactly the case `_reenrich_renamed_video_entries` exists to detect: same
+id, but `name` or `path` differs from the version last broadcast.
+"""
+
+import asyncio
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.crypto import generate_gek
+from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.indexer import DirectoryIndexer
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _free_port() -> int:
+ import socket
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+class _StubRoster:
+ async def video_root(self, group_id):
+ return "shared"
+
+
+class _SpyEnricher:
+ """Records which file ids were actually (re-)scheduled, without
+ needing a real ffmpeg/ffprobe pipeline for this test."""
+
+ def __init__(self):
+ self.spawned = []
+
+ def spawn(self, entry, file_path, on_done):
+ self.spawned.append(entry.id)
+
+ async def _noop():
+ return None
+
+ return asyncio.ensure_future(_noop())
+
+
+async def test_a_renamed_file_gets_re_enriched(tmp_path):
+ group_id = "a" * 32
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ old_path = shared / "la-guerre-des-mondes-s03e02.mkv"
+ old_path.write_bytes(b"not a real video, just needs to be indexed as one")
+
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=29016,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01
+ daemon._enricher = _SpyEnricher()
+ daemon._roster = _StubRoster()
+
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(),
+ # reconcile() below calls this itself — the initial scan doesn't
+ # (see test_startup_scan_enrichment.py), so that first broadcast is
+ # still triggered manually, matching _bg_scan's real sequence.
+ on_change=daemon._on_index_change)
+ await indexer.initial_scan()
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+ entry = next(iter(indexer.index.entries))
+ file_id = entry.id
+ assert daemon._enricher.spawned == [file_id], (
+ "the file must be scheduled for enrichment once, under its original name")
+
+ new_path = shared / "war-of-the-worlds-s03e02.mkv"
+ old_path.rename(new_path)
+ changed = await indexer.reconcile()
+ assert changed, "the rename must actually be picked up by reconcile()"
+ # The re-broadcast is a fire-and-forget task chained behind the
+ # coalescing timer, itself scheduling another fire-and-forget task —
+ # poll rather than guess a single sleep long enough for both hops.
+ for _ in range(30):
+ if len(daemon._enricher.spawned) >= 2:
+ break
+ await asyncio.sleep(0.02)
+
+ renamed_entry = indexer.index.get_entry(file_id)
+ assert renamed_entry is not None
+ assert renamed_entry.name == "war-of-the-worlds-s03e02.mkv"
+ assert daemon._enricher.spawned == [file_id, file_id], (
+ "a rename must re-schedule enrichment for the same file id — "
+ "_enriched_attempted must not permanently block the new filename "
+ "from ever being title-parsed")
+
+
+async def test_an_unrelated_update_does_not_re_trigger_enrichment(tmp_path):
+ """
+ The other half of the same fix: an update whose name/path did *not*
+ change (the ordinary case — enrichment's own field-fill, or the
+ reconcile sweep confirming a file unmodified) must not re-schedule
+ enrichment. Without this, `_on_enriched` merging a file's own results
+ back into the index would count as its own trigger and loop forever.
+ """
+ group_id = "a" * 32
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ path = shared / "movie.mkv"
+ path.write_bytes(b"not a real video, just needs to be indexed as one")
+
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=29017,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01
+ daemon._enricher = _SpyEnricher()
+ daemon._roster = _StubRoster()
+
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(),
+ on_change=daemon._on_index_change)
+ await indexer.initial_scan()
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+ entry = next(iter(indexer.index.entries))
+ assert daemon._enricher.spawned == [entry.id]
+
+ # A reconcile pass that finds nothing changed at all — not even a
+ # rename — must not re-schedule anything.
+ changed = await indexer.reconcile()
+ await asyncio.sleep(0.05)
+ assert not changed
+ assert daemon._enricher.spawned == [entry.id]
diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py
new file mode 100644
index 0000000..8ba55fb
--- /dev/null
+++ b/packages/meshbay-node/tests/test_season_and_search_requests.py
@@ -0,0 +1,187 @@
+"""
+`_do_season_meta_request` (per-season TMDB overview/poster/air_date, for the
+season-tab view — docs/mediacenter.md §5.4's fix for a 3-season show whose
+overview read as season-3-specific for every season) and
+`_do_tmdb_search_request` (raw TMDB candidates for an operator correcting a
+wrong automatic match). Neither is a signed admin op — see each handler's own
+docstring for why — so these tests only exercise the read path, unlike
+test_tmdb_override_policy.py.
+"""
+
+import pytest
+
+from meshbay_common.protocol import MNP
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession:
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client}
+ session.sent = []
+ session._send = session.sent.append
+ return session
+
+
+@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()
+
+
+class FakeTmdbClient:
+ def __init__(self, season_json=None):
+ self.season_json = season_json
+ self.tv_season_calls = []
+ self.movie_search_calls = []
+ self.tv_search_calls = []
+
+ @staticmethod
+ def poster_url(path):
+ return f"https://image.tmdb.org/t/p/w500{path}"
+
+ async def fetch_image(self, url):
+ return b"jpeg-bytes-for-" + url.encode()
+
+ async def tv_season(self, tmdb_id, season, language=None):
+ self.tv_season_calls.append((tmdb_id, season, language))
+ return self.season_json
+
+ async def search_movie_results(self, title):
+ self.movie_search_calls.append(title)
+ return [{"id": 111, "title": title, "release_date": "2019-05-01", "poster_path": "/m.jpg"}]
+
+ async def search_tv_results(self, title):
+ self.tv_search_calls.append(title)
+ return [{"id": 222, "name": title, "first_air_date": "2021-03-01", "poster_path": "/t.jpg"}]
+
+
+# ── season_meta_req ──────────────────────────────────────────────────────────
+
+async def test_season_meta_missing_tmdb_id_is_refused():
+ session = _session()
+ await session._do_season_meta_request({"season": 1})
+ assert session.sent == [{"type": "error", "detail": "Missing tmdb_id or season"}]
+
+
+async def test_season_meta_non_int_season_is_refused():
+ session = _session()
+ await session._do_season_meta_request({"tmdb_id": "42", "season": "1"})
+ assert session.sent == [{"type": "error", "detail": "Missing tmdb_id or season"}]
+
+
+async def test_season_meta_with_no_cache_or_client_reports_zero_confidence():
+ session = _session(media_cache=None, tmdb_client=None)
+ await session._do_season_meta_request({"tmdb_id": "42", "season": 1})
+ assert session.sent == [{
+ "type": MNP.SEASON_META_RESP, "v": session.sent[0]["v"],
+ "tmdb_id": "42", "season": 1, "confidence": 0,
+ }]
+
+
+async def test_season_meta_cache_hit_skips_the_tmdb_call(media_cache):
+ await media_cache.set_season_meta("42", 3, {
+ "name": "Season 3", "overview": "cached overview", "air_date": "2023-01-01",
+ "poster_path": "/cached.jpg",
+ })
+ client = FakeTmdbClient()
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_season_meta_request({"tmdb_id": "42", "season": 3})
+
+ assert client.tv_season_calls == [], "a cached season must not be re-fetched"
+ resp = session.sent[0]
+ assert resp["type"] == MNP.SEASON_META_RESP
+ assert resp["confidence"] == 1.0
+ assert resp["overview"] == "cached overview"
+
+
+async def test_season_meta_cache_miss_fetches_and_caches(media_cache):
+ client = FakeTmdbClient(season_json={
+ "name": "Season 1", "overview": "fresh overview", "air_date": "2020-01-01",
+ "poster_path": "/fresh.jpg",
+ })
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_season_meta_request({"tmdb_id": "7", "season": 1})
+
+ assert client.tv_season_calls == [("7", 1, None)]
+ resp = session.sent[0]
+ assert resp["overview"] == "fresh overview"
+ assert resp["poster_thumb_hash"] is not None
+ cached = await media_cache.get_season_meta("7", 1)
+ assert cached["overview"] == "fresh overview", "a fetched season must be cached for next time"
+
+
+async def test_season_meta_empty_overview_falls_back_to_english(media_cache):
+ async def tv_season(tmdb_id, season, language=None):
+ if language == "en-US":
+ return {"name": "S1", "overview": "English overview", "air_date": "2020-01-01",
+ "poster_path": "/p.jpg"}
+ return {"name": "S1", "overview": "", "air_date": "2020-01-01", "poster_path": "/p.jpg"}
+
+ client = FakeTmdbClient()
+ client.tv_season = tv_season
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_season_meta_request({"tmdb_id": "9", "season": 1})
+
+ assert session.sent[0]["overview"] == "English overview"
+
+
+# ── tmdb_search_req ──────────────────────────────────────────────────────────
+
+async def test_search_missing_query_is_refused():
+ session = _session()
+ await session._do_tmdb_search_request({"media_type": "movie"})
+ assert session.sent == [{"type": "error", "detail": "Missing query or media_type"}]
+
+
+async def test_search_bad_media_type_is_refused():
+ session = _session()
+ await session._do_tmdb_search_request({"query": "war", "media_type": "album"})
+ assert session.sent == [{"type": "error", "detail": "Missing query or media_type"}]
+
+
+async def test_search_with_no_cache_or_client_returns_empty_results():
+ session = _session(media_cache=None, tmdb_client=None)
+ await session._do_tmdb_search_request({"query": "war", "media_type": "tv"})
+ assert session.sent == [{
+ "type": MNP.TMDB_SEARCH_RESP, "v": session.sent[0]["v"],
+ "query": "war", "media_type": "tv", "results": [],
+ }]
+
+
+async def test_search_movie_calls_movie_search_and_echoes_media_type(media_cache):
+ client = FakeTmdbClient()
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "movie"})
+
+ assert client.movie_search_calls == ["War of the Worlds"]
+ assert client.tv_search_calls == []
+ resp = session.sent[0]
+ assert resp["type"] == MNP.TMDB_SEARCH_RESP
+ assert resp["media_type"] == "movie", (
+ "media_type must be echoed back — otherwise a movie search and a tv "
+ "search for the same query are indistinguishable to the client's "
+ "keyed response matching (transport.js tmdb_search_resp handler)")
+ assert resp["results"] == [{
+ "tmdb_id": "111", "title": "War of the Worlds", "year": "2019",
+ "poster_thumb_hash": resp["results"][0]["poster_thumb_hash"],
+ }]
+
+
+async def test_search_tv_calls_tv_search(media_cache):
+ client = FakeTmdbClient()
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "tv"})
+
+ assert client.tv_search_calls == ["War of the Worlds"]
+ assert client.movie_search_calls == []
+ assert session.sent[0]["media_type"] == "tv"
diff --git a/packages/meshbay-node/tests/test_tmdb_override_policy.py b/packages/meshbay-node/tests/test_tmdb_override_policy.py
new file mode 100644
index 0000000..b8fa6f9
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb_override_policy.py
@@ -0,0 +1,172 @@
+"""
+An operator correcting a wrong automatic TMDB match (found live: a real
+show's search consistently matched a season-3-specific promotional TMDB
+entry instead of the show itself). Signed like video_root/tmdb_config —
+it changes what every member sees, node-wide (media_cache is shared, not
+per-viewer) — and, once authorized, applies to every index entry sharing
+the representative file's display_title, the same grouping the poster
+grid itself uses (§3.4/§V6), not just the one file the operator happened
+to be looking at.
+"""
+
+import hashlib
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.adminop import OP_TMDB_OVERRIDE
+from meshbay_common.protocol import IndexEntry, MNP
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession:
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ ctx = {
+ "roots": one_root(shared_root),
+ "index": index,
+ "sk_node": index.sk_node,
+ "node_user_id": operator,
+ }
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = ctx
+ session._group_id = None
+ session._user_id = user_id
+ session._pk_user = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def _entry(path: str, name: str, display_title: str) -> IndexEntry:
+ # A real id is a blake3 content hash; sha256 here is just a stand-in with
+ # the same property that matters for these tests — deterministic and
+ # effectively collision-free across the handful of entries a test builds.
+ # (`hash((path, name)) % 10` was tried here before and is NOT that: it's
+ # randomized per-process by PYTHONHASHSEED and collides constantly across
+ # only 10 possible values, silently dropping entries in GroupIndex's
+ # id-keyed dict.)
+ digest = hashlib.sha256(f"{path}/{name}".encode()).hexdigest()
+ return IndexEntry(
+ id=digest, name=name, path=path,
+ size=1, type="video", added_at=0, display_title=display_title,
+ season=1, episode=1,
+ )
+
+
+# ── Refused before a challenge is even issued ───────────────────────────────
+
+async def test_missing_path_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"tmdb_id": "123", "media_type": "tv"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_missing_tmdb_id_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "Show"))
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"path": "shared", "media_type": "tv"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_unknown_path_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"path": "nope", "tmdb_id": "123", "media_type": "tv"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
+ session = _session(tmp_path, "member-1", operator="the-operator")
+ session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "Show"))
+ session._has_admin_authority = lambda: False
+
+ session._do_tmdb_override({"path": "shared", "tmdb_id": "123", "media_type": "tv"})
+
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_a_valid_request_is_signed(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "War of the Worlds"))
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"path": "shared", "tmdb_id": "2255", "media_type": "tv"})
+
+ assert issued == [(OP_TMDB_OVERRIDE,
+ "path=shared,tmdb_id=2255,media_type=tv")]
+
+
+# ── Applying the override ───────────────────────────────────────────────────
+
+async def test_override_updates_every_entry_sharing_the_display_title(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ index = session._ctx["index"]
+ s1 = _entry("shared/S1", "s01e01.mkv", "War of the Worlds")
+ s2 = _entry("shared/S2", "s02e01.mkv", "War of the Worlds")
+ s3 = _entry("shared/S3", "s03e02.mkv", "War of the Worlds")
+ other_show = _entry("shared/Other", "ep.mkv", "A Different Show")
+ for e in (s1, s2, s3, other_show):
+ index.add_entry(e)
+
+ media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await media_cache.open()
+ try:
+ session._ctx["media_cache"] = media_cache
+ # Signature verification itself is exercised generically elsewhere
+ # (test_roster_pairing.py) — this test is about the policy once a
+ # signature is known good: which entries actually get updated, and
+ # who is told about it.
+ session._verify_admin_sig = lambda transcript, sig: _true()
+ peer = type("Peer", (), {"sent": []})()
+ peer._send = peer.sent.append
+ session._peer_registry = lambda: {"peer-1": peer}
+
+ await session._admin_exec_tmdb_override(
+ {"subject": "path=shared/S1,tmdb_id=999,media_type=tv"},
+ b"transcript", b"sig")
+
+ for e in (s1, s2, s3):
+ assert await media_cache.get_file_tmdb(e.id) == ("999", "tv"), (
+ "every entry sharing the representative file's display_title "
+ "must be corrected, not just the one the operator clicked on")
+ assert await media_cache.get_file_tmdb(other_show.id) is None, (
+ "a different show's own match must be left alone")
+ # The ack is broadcast to other connected peers, never echoed onto
+ # the requester's own `sent` — see the loop in
+ # _admin_exec_tmdb_override, which sends via each peer's own _send.
+ assert [m for m in peer.sent if m.get("type") == MNP.TMDB_OVERRIDE_ACK]
+ finally:
+ await media_cache.close()
+
+
+async def _true():
+ return True
diff --git a/packages/meshbay-node/tests/test_wizard_apps_endpoint.py b/packages/meshbay-node/tests/test_wizard_apps_endpoint.py
new file mode 100644
index 0000000..10ab489
--- /dev/null
+++ b/packages/meshbay-node/tests/test_wizard_apps_endpoint.py
@@ -0,0 +1,76 @@
+"""
+Create Group wizard: choosing which apps a brand-new group offers, before
+the (potentially long) initial scan — see app.js's CreateGroupWizard. This
+is a loopback-only, operator-authenticated endpoint (11.5.3), same shape as
+the existing member-upload one: a thin adapter over `ops.set_enabled_apps`,
+with only the validation `_do_apps_enabled` (the signed MNP front door)
+already does client-side in the wizard, but worth enforcing at this front
+door too since nothing else would.
+"""
+
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from fastapi.testclient import TestClient
+
+from conftest import one_root
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import Roster
+from meshbay_node.ui.app import create_ui_app
+
+pytestmark = pytest.mark.asyncio
+
+
+async def _client(tmp_path: Path):
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ state = {
+ "status": "running",
+ "groups_ctx": {"g" * 32: {"index": index, "roots": one_root(shared)}},
+ "indexes": {"g" * 32: index},
+ "roster": roster,
+ }
+ app = create_ui_app(state)
+ return TestClient(app), roster
+
+
+async def test_narrowing_the_apps_persists_to_the_roster(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put(f"/api/groups/{'g' * 32}/apps", json={"apps": ["files", "video"]})
+ assert resp.status_code == 200, resp.text
+ assert sorted(resp.json()["apps"]) == ["files", "video"]
+ assert sorted(await roster.enabled_apps("g" * 32)) == ["files", "video"]
+ finally:
+ await roster.close()
+
+
+async def test_empty_apps_list_is_refused(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put(f"/api/groups/{'g' * 32}/apps", json={"apps": []})
+ assert resp.status_code == 400
+ finally:
+ await roster.close()
+
+
+async def test_missing_apps_field_is_refused(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put(f"/api/groups/{'g' * 32}/apps", json={})
+ assert resp.status_code == 400
+ finally:
+ await roster.close()
+
+
+async def test_unhosted_group_is_refused(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put("/api/groups/" + "z" * 32 + "/apps", json={"apps": ["files"]})
+ assert resp.status_code == 404
+ finally:
+ await roster.close()