aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
diff options
context:
space:
mode:
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.js284
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 */ }
+ }
+});