aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-17 13:39:23 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-17 13:39:23 +0200
commitad4ca3229002997934ccb5b2eaeb553c13b8888f (patch)
treea680f9e5d3ba43a84236b84f73e0b71eaf916db4 /packages/meshbay-hub/src/meshbay_hub/static/transport.js
parent3e6d514663a5df1be3b2f0286c5f67f669d9c1d6 (diff)
downloadmeshbay-ad4ca3229002997934ccb5b2eaeb553c13b8888f.tar.gz
feat: embedded subtitles in the video player (MNP 3.3)
MSE decodes no in-band text track, so a subtitle cannot ride inside the fragmented MP4 the player is fed. The node extracts one track whole, converts it to WebVTT and caches it under its own hash; the client pulls that blob through the ordinary file_req/chunk path and hangs a <track> on the video element — the same indirection as a TMDB poster or an audio transcode, which is what makes a film's subtitles extracted once in the life of the file rather than once per viewing. Whole-file also makes the cues absolute, so a seek and an audio-language change both leave the track untouched. **The ordinal counts every subtitle stream, including the ones never listed.** Only text codecs are offered: a bitmap track (PGS, VOBSUB — about a fifth of a real library) has no path to WebVTT without OCR, and one extracted anyway yields a header with no cues, which is a menu entry that shows nothing and reports no error. Numbering the survivors of that filter would give a PGS/SRT/SRT file the ordinals 0 and 1 for its text tracks and `-map 0:s:0` would then extract the PGS — the same trap `AudioTrack.ordinal` exists for, one level deeper. A fixture whose first subtitle stream cannot be decoded pins it, and the handler checks membership of the probed list, never a range. Additive and MINOR: the selector is drawn from `subtitle_tracks` in the node's own `stream_init` and from no version number, so `subtitle_req` is never sent to a peer that would not answer it. The floor stays at 3.0. Also here: a failed extraction never touches playback, a superseded reply cannot install its blob over a newer choice, and `_languageName` is shared with the audio labels — lifted by both label harnesses, since a lift that names one function stops covering the rule the moment logic moves out of it. Tests: 9 node (tracks told apart by the words in the extracted cues, not by tags), 10 client. Full suite green: 1545 node/common, 1252 hub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js37
1 files changed, 37 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 262b00e..63c71ff 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -1607,6 +1607,25 @@ class MeshBayTransport {
}
/**
+ * 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) {
+ const msg = await this._sendAndWait(
+ { type: 'subtitle_req', v: '0.9', file_id: fileId, track }, 90000);
+ 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/musicbay.md §3.2/§6). Signed like setTmdbEnabled.
*/
@@ -2968,6 +2987,12 @@ class MeshBayTransport {
// Same reordering hazard as media_meta_req: the player prefetches
// the next track while the current one may still be transcoding.
: obj.type === 'audio_transcode_req' ? `audio_transcode:${obj.file_id}`
+ // The track ordinal is part of the key, not just the file id: a
+ // viewer who opens the menu and picks a second language before the
+ // first extraction has answered has two of these in flight for the
+ // same film, and the one that arrives first is not necessarily the
+ // one that was asked for first.
+ : obj.type === 'subtitle_req' ? `subtitle:${obj.file_id}:${obj.track}`
// Two-step admin-op flow (_authorizeAdminOp) — see ADMIN_OP_TYPES'
// own comment for the race this closes. The initial request and
// the admin_response that follows it are keyed the same way
@@ -3399,6 +3424,18 @@ class MeshBayTransport {
return;
}
+ // Keyed on file *and* track — see the `subtitle_req` key above. The node
+ // echoes `track` back for exactly this: without it a reply could only be
+ // matched to the film, and the two tracks of one film are precisely the
+ // pair that can be in flight together.
+ if (msg.type === 'subtitle_resp') {
+ const key = `subtitle:${msg.file_id}:${msg.track}`;
+ for (const [, handler] of this._pending) {
+ if (handler._key === key) { handler.resolve(msg); return; }
+ }
+ 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') {