diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 198 |
1 files changed, 192 insertions, 6 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 4f6b656..b4bfe87 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -106,6 +106,8 @@ class MeshBayTransport { set onIndexDelta(fn) { this._onIndexDelta = fn; } set onUploadPolicy(fn) { this._onUploadPolicy = fn; } set onAppsEnabled(fn) { this._onAppsEnabled = fn; } + set onTmdbConfig(fn) { this._onTmdbConfig = fn; } + set onVideoRoot(fn) { this._onVideoRoot = fn; } set onIndexProgress(fn) { this._onIndexProgress = fn; } get sessionKeys() { return this._sessionKeys; } @@ -465,6 +467,121 @@ class MeshBayTransport { return msg; } + /** + * TMDB metadata for one file (Videos app, docs/mediacenter.md §5.4). + * `path` is root+relpath, exactly what index_sync/index_delta already + * gave this browser — never a raw filesystem path constructed here. + * `confidence: 0` (no tmdb_id, no fields) means no confident match — + * the caller falls back to a thumbnail-only card (§4.1), not an error. + */ + async fetchMediaMeta(path) { + const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.5', path }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * One season's own overview/air_date/poster (docs/mediacenter.md §5.4's + * per-season view) — a show's own tmdb_meta is one static field that does + * not necessarily describe every season alike, found live: a 3-season + * show whose overview read as season-3-specific for every season. + * Keyed like media_meta_req: a season-tab bar can fire a request per tab + * before the previous one lands, and matching by arrival order would hand + * one season's data to a different season's tab whenever two responses + * reordered. + */ + async fetchSeasonMeta(tmdbId, season) { + const msg = await this._sendAndWait({ + type: 'season_meta_req', v: '0.6', tmdb_id: tmdbId, season, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Raw TMDB search candidates for an operator correcting a wrong automatic + * match — unlike fetchMediaMeta, this never collapses to one best guess: + * a human picks from several, so several is the point. Read-only, not an + * admin op: it looks nothing up in this node's own state and changes + * nothing, so it needs no signature (mirrors why media_meta_req isn't + * signed either). + */ + async searchTmdb(mediaType, query) { + const msg = await this._sendAndWait({ + type: 'tmdb_search_req', v: '0.6', media_type: mediaType, query, + }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Correct a wrong automatic TMDB match. Signed like setVideoRoot/ + * setTmdbConfig: it replaces what every member sees for a show/movie, + * node-wide (media_cache is shared, not per-viewer) — an unsigned + * override would let any member vandalize another show's metadata. + * Applies to every file sharing the representative one's display_title, + * not just the file the operator happened to be looking at (webrtc_ + * server.py's _admin_exec_tmdb_override). + */ + async overrideTmdbMatch(path, tmdbId, mediaType, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_override', v: '0.6', path, tmdb_id: tmdbId, media_type: mediaType, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + const subject = `path=${path},tmdb_id=${tmdbId},media_type=${mediaType}`; + return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn); + } + return msg; + } + + /** + * Turn TMDB lookups on/off node-wide, optionally set/clear a custom API + * token, and optionally set the language TMDB is queried in (e.g. + * "fr-FR") — one for the whole node, same reasoning as the token: one + * shared cache, not a per-viewer request. Signed like setAppsEnabled/ + * setMemberUpload — an unsigned toggle would let any member turn on + * outbound third-party network traffic the operator never agreed to + * (docs/mediacenter.md §5.5, §8). `token: ''` explicitly clears a + * previously-set custom token; omit it (undefined/null), like + * `language`, to leave whatever is stored unchanged. + */ + async setTmdbConfig(enabled, token, language, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_config', v: '0.5', enabled: Boolean(enabled), + token: token === undefined ? null : token, + language: language === undefined ? null : language, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + // Must match the node's subject byte-for-byte (webrtc_server.py + // _do_tmdb_config): Python's f"{bool}" is "True"/"False", not JS's + // lowercase — and the token itself is never part of the subject + // (it would end up in the audit log in plaintext), only whether one + // was supplied. The language is not a secret, so it appears as-is. + const subject = `enabled=${enabled ? 'True' : 'False'},` + + `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`; + return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn); + } + return msg; + } + + /** + * Which folder (possibly a subfolder of a shared root) the Videos app + * treats as its entry point for this group. `path: ''` means the whole + * group index. Signed like setAppsEnabled — it decides what every + * member's Videos tab shows. + */ + async setVideoRoot(path, signFn) { + const clean = (path || '').replace(/^\/+|\/+$/g, ''); + const msg = await this._sendAndWait({ type: 'video_root', v: '0.5', path: clean }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'video_root', clean, signFn); + } + return msg; + } + async fetchStreamSegment(fileId, segmentIndex, segmentDuration) { const msg = await this._sendAndWait({ type: 'stream_seg', @@ -1173,9 +1290,20 @@ class MeshBayTransport { // *while* other traffic is in flight, so the fallback below would hand // a pong to whatever was waiting — resolving a history request with a // message that has no messages in it, and emptying the conversation. + // media_meta_req is the same shape as file_req: video-app.js fires + // one per visible poster-grid tile, several at a time — matching by + // arrival order handed one tile's TMDB result to a different tile + // whenever two responses reordered (reproduced live: which of two + // shows got the confident match flipped across reloads). _key: obj.type === 'file_req' ? `chunk:${obj.file_id}:${obj.chunk_index}` - : obj.type === 'ping' ? `ping:${obj.token}` : null, + : obj.type === 'ping' ? `ping:${obj.token}` + : obj.type === 'media_meta_req' ? `media_meta:${obj.path}` + // Same reordering hazard as media_meta_req: a season-tab bar or a + // search box can have more than one of these in flight at once. + : obj.type === 'season_meta_req' ? `season_meta:${obj.tmdb_id}:${obj.season}` + : obj.type === 'tmdb_search_req' ? `tmdb_search:${obj.media_type}:${obj.query}` + : null, resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); }, reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); }, }); @@ -1276,6 +1404,34 @@ class MeshBayTransport { this._onAppsEnabled(msg.apps || []); } + // Node-wide (not per-group) — the operator changed whether TMDB is + // called at all, or supplied/cleared a custom token. `token_customized` + // only says whether one is set, never the token itself. + if (msg.type === 'tmdb_config_ack' && this._onTmdbConfig) { + this._onTmdbConfig({ + enabled: Boolean(msg.enabled), + tokenCustomized: Boolean(msg.token_customized), + language: msg.language || '', + }); + } + + // Same shape: an operator corrected a wrong automatic TMDB match, and + // everyone connected needs to know their poster grid/detail modal for + // this show is now stale — falls through so the operator's own + // admin_response promise resolves on this same message, exactly like + // member_upload_ack/apps_enabled_ack above. + if (msg.type === 'tmdb_override_ack' && this._onTmdbOverride) { + this._onTmdbOverride({ + path: msg.path || '', tmdbId: msg.tmdb_id || '', mediaType: msg.media_type || '', + }); + } + + // Same shape: the operator changed which folder is the Videos app's + // entry point for this group. + if (msg.type === 'video_root_ack' && this._onVideoRoot) { + this._onVideoRoot(msg.path || ''); + } + // The operator's node is scanning — never the entries themselves, just // enough to animate a presence dot. Pushed periodically while it runs, // plus once more on the transition back to idle (daemon.py @@ -1316,11 +1472,11 @@ class MeshBayTransport { return; } - // Incremental update — additions/deletions only, never the whole index. - // Only ever arrives after the full index this browser already has (the - // node's first push to a newly connected peer is always index_sync, see - // daemon.py _broadcast_index_change), so there is always a base to - // apply it to. + // Incremental update — additions/deletions/updates, never the whole + // index. Only ever arrives after the full index this browser already + // has (the node's first push to a newly connected peer is always + // index_sync, see daemon.py _broadcast_index_change), so there is + // always a base to apply it to. if (msg.type === 'index_delta') { if (this._onIndexDelta) this._onIndexDelta(msg); return; @@ -1364,6 +1520,36 @@ class MeshBayTransport { return; } + if (msg.type === 'media_meta_resp') { + const key = `media_meta:${msg.path}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + // Nobody asked for this path any more (tile scrolled out and a fresh + // request superseded it, most likely) — must not fall through to the + // oldest pending request, which would hand a different tile's promise + // a TMDB result for a path it never asked about. + return; + } + + // Same reasoning as media_meta_resp: keyed, not arrival-order, and + // "nobody's waiting any more" must not fall through either. + if (msg.type === 'season_meta_resp') { + const key = `season_meta:${msg.tmdb_id}:${msg.season}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + return; + } + + if (msg.type === 'tmdb_search_resp') { + const key = `tmdb_search:${msg.media_type}:${msg.query}`; + for (const [, handler] of this._pending) { + if (handler._key === key) { handler.resolve(msg); return; } + } + return; + } + // chat_hist_resp answers a `chat_hist` request, but under a different // type string — unlike index_sync, which is asked for and answered under // the same name, so the generic fallback below happens to work for it by |