summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
blob: f94eb408dea66d240f2ad4558a061995c3a8a4d2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
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 */ }
  }
});