From ad4ca3229002997934ccb5b2eaeb553c13b8888f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 17 Sep 2026 13:39:23 +0200 Subject: feat: embedded subtitles in the video player (MNP 3.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 Claude-Session: https://claude.ai/code/session_01UGY17EPph5LsLzePPXhUVc --- .../src/meshbay_hub/static/transport.js | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js') 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 @@ -1606,6 +1606,25 @@ class MeshBayTransport { 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) { + 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') { -- cgit v1.2.3