diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-25 11:28:31 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-25 12:07:49 +0200 |
| commit | b7bf11c077bdd165400cf8d80c5cd1ad4248d854 (patch) | |
| tree | e80590cd7b798e21a4ac663fd1e653a66e5f3b34 /packages/meshbay-hub/src/meshbay_hub/static/transport-media.js | |
| parent | 6348698322e5bfcb80d0ef0dcd833ba4e6179640 (diff) | |
| download | meshbay-b7bf11c077bdd165400cf8d80c5cd1ad4248d854.tar.gz | |
refactor(client): split transport.js into classic scripts
transport.js keeps the core (connection, reconnect, leases, dispatch).
Chat, media, admin, upload and device methods move, cut as text, into
transport-*.js scripts that hand a class of their own to extendTransport,
which copies each method onto MeshBayTransport.prototype; the codec,
roster checks, node pins and the rewrap fan-out move as they were. Both
shells load them after transport.js. Every prototype member, class
property and top-level function has the same source text as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport-media.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-media.js | 284 |
1 files changed, 284 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js new file mode 100644 index 0000000..f94eb40 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js @@ -0,0 +1,284 @@ +// What the media apps ask the node for: TMDB and MusicBrainz metadata, audio +// transcoding, subtitles, and the video stream. +// +// Methods of MeshBayTransport, copied onto its prototype by extendTransport +// (transport.js, which the shell loads first). + +extendTransport(class { + /** + * TMDB metadata for one file (Videos app, docs/MESHBAY_DESIGN.md §9.7). + * Keyed by the entry's own `id` (its content hash) — never a path: a + * path names the *folder* a file is in (indexer.py's `_virtual_dir`), so + * two files sharing a folder (any multi-episode season) would resolve to + * whichever entry the node's index happened to return first (found live + * via the Music app's identical bug, 2026-08-25 — see apps/video_meta.py's + * `_do_media_meta_request`). + * `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(fileId) { + const msg = await this._sendAndWait({ type: 'media_meta_req', v: '0.6', file_id: fileId }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * One season's own overview/air_date/poster (docs/MESHBAY_DESIGN.md §9.7'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 + * 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 (apps/ + * video_meta.py's _admin_exec_tmdb_override). Keyed by `fileId`, not a path + * — same reasoning as fetchMediaMeta above. + */ + async overrideTmdbMatch(fileId, tmdbId, mediaType, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_override', v: '0.7', file_id: fileId, tmdb_id: tmdbId, media_type: mediaType, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + const subject = `file_id=${fileId},tmdb_id=${tmdbId},media_type=${mediaType}`; + return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn); + } + return msg; + } + + /** + * Drop one file's cached TMDB match so it re-resolves with the node's + * current matcher (V13) — the one-click alternative to the full + * search-and-pick flow. Signed for the same reason as overrideTmdbMatch. + */ + async rematchTmdbMatch(fileId, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_rematch', v: '0.7', file_id: fileId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'tmdb_rematch', `file_id=${fileId}`, signFn); + } + return msg; + } + + /** + * Set/clear a custom TMDB API token, and/or set the language TMDB is + * queried in (e.g. "fr-FR") — one for the whole node, since both are one + * operator's shared credential/cache, not a per-group concern (see + * setTmdbEnabled below for the per-group on/off switch). Signed like + * setAppsEnabled/updateRoot — an unsigned change would let any + * member alter outbound third-party network traffic the operator never + * agreed to (docs/MESHBAY_DESIGN.md §9.7, §6.5). `token: ''` explicitly clears + * a previously-set custom token; omit it (undefined/null), like + * `language`, to leave whatever is stored unchanged. + */ + async setTmdbConfig(token, language, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_config', v: '0.7', + 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 (apps/video_meta.py + // _do_tmdb_config) — 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 = `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`; + return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn); + } + return msg; + } + + /** + * Whether TMDB lookups run for this group at all — per-group (2026-08-24, + * used to be node-wide): a real media-library group and a test/demo group + * on the same node need not share the decision to spend TMDB quota and + * make outbound requests. Signed like the rest — it decides whether + * this group's members' Videos tab ever makes outbound TMDB traffic. + */ + async setTmdbEnabled(enabled, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_enabled', v: '0.7', enabled: Boolean(enabled), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + // Must match the node's subject byte-for-byte (apps/video_meta.py + // _do_tmdb_enabled): Python's f"{bool}" is "True"/"False", not JS's + // lowercase. + const subject = enabled ? 'True' : 'False'; + return this._authorizeAdminOp(msg, 'tmdb_enabled', subject, signFn); + } + return msg; + } + + /** + * MusicBrainz metadata for one track (Music app, docs/MESHBAY_DESIGN.md §9.8) + * — same shape as fetchMediaMeta, minus a season/episode concept: + * album-level (release), resolved from the track's own artist/album + * fields already in the index. Keyed by the track's own `id` (content + * hash), not a path — a path names the *folder* a track is in, and an + * album is one folder with many tracks in it; three unrelated albums + * shared one folder's track's cover before this fix (found live, + * 2026-08-25). `confidence: 0` means no confident match (or MusicBrainz + * off for this group, or nothing configured) — the caller falls back to + * the embedded/no cover it already had, not an error. + */ + async fetchMusicMeta(fileId) { + const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.9', file_id: fileId }); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Server-side transcode of a Music-app file the browser's own <audio> + * element cannot decode at all (WMA, Musepack) into AAC/M4A. Returns + * `{ hash, size, mime }` — the *cache* hash to pull through the normal + * file_req/chunk path (fetchChunk/pipelinedDownload), not the file's own + * id, the same indirection already used for a TMDB poster or a + * MusicBrainz cover. Cached node-side after the first call, but ffmpeg + * still has to run at least once and a transcode slot can be busy, so + * this gets a longer timeout than the metadata lookups above. + */ + async requestAudioTranscode(fileId) { + const msg = await this._sendAndWait( + { type: 'audio_transcode_req', v: '0.9', file_id: fileId }, 120000); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * One embedded subtitle track, extracted node-side to WebVTT. Returns + * `{ hash, size, mime, track }` — the *cache* hash to pull through the + * ordinary file_req/chunk path, the same indirection as an audio transcode + * or a TMDB poster, and cached node-side under the file's own id so a film + * is extracted once rather than once per viewing. + * + * `track` is the ordinal the node published in `stream_init.subtitle_tracks` + * and is passed back untouched: it counts every subtitle stream in the + * container, including the bitmap ones that are never listed, so it is not + * a position in the list this client received. + */ + async requestSubtitle(fileId, track) { + // Generous because the node's own bound is, and for the same reason: the + // extraction demuxes the whole container, measured at 9.8 s per GB on an + // external disk — 71 s for a 7.3 GB film, and more for a 4K one. The node + // always answers, with a refusal if its own budget runs out, so this is a + // backstop against a peer that has gone silent rather than a deadline for + // the work. It is paid once per film: every later viewing is cached. + const msg = await this._sendAndWait( + { type: 'subtitle_req', v: '0.9', file_id: fileId, track }, 900000); + if (msg.type === 'error') throw new Error(msg.detail); + return msg; + } + + /** + * Whether MusicBrainz lookups run for this group at all — per-group from + * the start (docs/MESHBAY_DESIGN.md §9.8). Signed like setTmdbEnabled. + */ + async setMusicbrainzEnabled(enabled, signFn) { + const msg = await this._sendAndWait({ + type: 'musicbrainz_enabled', v: '0.8', enabled: Boolean(enabled), + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + // Python's f"{bool}" is "True"/"False", not JS's lowercase — must + // match apps/music.py _do_musicbrainz_enabled byte-for-byte. + const subject = enabled ? 'True' : 'False'; + return this._authorizeAdminOp(msg, 'musicbrainz_enabled', subject, signFn); + } + return msg; + } + + /** + * Ask for a video stream, and say how much we can take. + * + * `credits` bounds what is in flight. Without it the node pushes the whole + * film as fast as ffmpeg produces it and the browser holds all of it while + * MediaSource consumes a segment at a time — which is fine for a clip and + * fatal for anything worth streaming. + */ + requestStream(fileId, credits = STREAM_CREDITS, start = 0, audioTrack = null) { + // `start` is a seek: the node retires whatever this session was streaming + // and spawns ffmpeg again from there. Omitted or zero is the film's + // beginning, which is what an 0.1 node understands. + // + // `audioTrack` is omitted entirely unless the caller has one, and the + // caller only has one because a `stream_init` listed the tracks. A node + // too old to enumerate them is therefore never sent a field it would + // ignore — which matters more here than it looks: ignoring it would not + // degrade the stream, it would serve a different language without saying + // so. + const req = { type: 'stream_req', v: '0.1', file_id: fileId, credits, start }; + if (Number.isInteger(audioTrack) && audioTrack >= 0) { + req.audio_track = audioTrack; + } + console.log('[stream] sending stream_req start:', start, 'credits:', credits, + 'audio_track:', req.audio_track ?? '-'); + this._send(req); + } + + /** Room for `n` more segments. */ + grantStreamCredit(n = 1) { + if (!this._connected) return; + console.log('[stream] grant credit:', n); + this._send({ type: 'stream_more', v: '0.1', n }); + } + + /** + * Tell the node what the player sees. + * + * A hang on a phone is unreadable from here: there is no console to open and + * the node's own log shows a stream it is feeding perfectly well. This puts + * the two halves in one file. The node only logs it. + */ + sendStreamDiag(diag) { + if (!this._connected) return; + try { this._send({ type: 'client_diag', v: '0.1', ...diag }); } catch { /* gone */ } + } + + /** + * Nobody is watching any more. + * + * Closing the viewer used to say nothing to the node, which went on + * transcoding and holding one of its two slots until the credit timeout — so + * the next video answered "server busy". + */ + stopStream() { + if (!this._connected) return; + try { this._send({ type: 'stream_stop', v: '0.1' }); } catch { /* gone */ } + } +}); |