aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src/meshbay_node/transport
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/transport')
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py163
1 files changed, 163 insertions, 0 deletions
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))