diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 14:33:20 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 14:33:20 +0200 |
| commit | 0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a (patch) | |
| tree | ba8daf5dcf050d44b7b0e766babbfda8fadac59f /packages/meshbay-node/src/meshbay_node | |
| parent | 6af05abf410bbd038ce7fa6915a659defc509071 (diff) | |
| download | meshbay-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/src/meshbay_node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 39 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/media_cache.py | 35 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/tmdb.py | 22 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 163 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 16 |
5 files changed, 272 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") |