aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 17:39:41 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 17:39:41 +0200
commit8c780b9928db8145b7fe18ecd137384ccfe25d8f (patch)
tree5cfc06eec0fdf9bb7fe0a14f45e3ef803bb86a20 /packages/meshbay-hub/src/meshbay_hub/static/transport.js
parent941d1a135dd7b03834576855e8e9fdaa24c4e406 (diff)
downloadmeshbay-8c780b9928db8145b7fe18ecd137384ccfe25d8f.tar.gz
feat(hub): Music app client — album grid, flat list, persistent player
Implements the client half of docs/musicbay.md against MNP 0.8: - music-app.js: album grid (grouped by artist -> album, from index-time artist/album fields) or flat folder view, per-group localStorage toggle like Videos. MusicBrainz (music_meta_req) is only looked up when a track has no embedded cover at all — well-tagged files never trigger a network call, unlike Videos where TMDB is unconditional. Reuses video-app.js's MediaThumb/LazyTile (now exported) rather than duplicating the chunk-path thumbnail decode + virtualization. - music-player.js: the persistent player bar — queue, shuffle (Fisher- Yates, keeps the current track in place when toggled), repeat (off/ all/one), volume (localStorage), prev/next, a one-track prefetch cache. No MSE, no node-side streaming: a track is downloaded and decrypted once via file-utils.js's pipelinedDownload, same chunk pipeline Files already uses, then played from a blob URL. - group-page.js: owns musicQueue/musicbrainzConfig state and renders MusicPlayerBar outside the tab-switched area — deliberately, so playback survives navigating to Chat/Files, the same reasoning the video/preview modals are shell-owned rather than app-owned. - apps.js: registers "music". transport.js: fetchMusicMeta (keyed by path, same reordering-hazard fix as fetchMediaMeta), setMusicbrainzConfig/setMusicbrainzEnabled (signed ops, mirroring TMDB's), and the three new ack handlers. group-settings.js: a MusicBrainz settings section (contact string, per-group toggle) — the existing Applications checklist already picks up "music" for free, per apps.md's own claim. - icon.js: music/pause/skip-next/skip-prev/shuffle/repeat/volume, drawn in the same stroked style as the existing set. - i18n: group.tab_music, the music.* and settings_node.musicbrainz_* keys, translated (not just copied) across all ten locales, Polish carrying full one/few/many/other plural forms for music.n_tracks. - webapp.py's _ASSETS, test_hook_ordering.py's STATIC_FILES and test_transport_contracts.py's SPLIT_FILES gain the two new files. Full suite (common + hub + node): 1116 passed, no regressions. `npm run sync-ui` in meshbay-client confirmed both files copied. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js83
1 files changed, 83 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 763e279..e0e4a64 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -109,6 +109,8 @@ class MeshBayTransport {
set onTmdbConfig(fn) { this._onTmdbConfig = fn; }
set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; }
set onVideoRoot(fn) { this._onVideoRoot = fn; }
+ set onMusicbrainzConfig(fn) { this._onMusicbrainzConfig = fn; }
+ set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; }
set onIndexProgress(fn) { this._onIndexProgress = fn; }
get sessionKeys() { return this._sessionKeys; }
@@ -603,6 +605,63 @@ class MeshBayTransport {
return msg;
}
+ /**
+ * MusicBrainz metadata for one track's path (Music app, docs/musicbay.md
+ * §4.3) — 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. `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(path) {
+ const msg = await this._sendAndWait({ type: 'music_meta_req', v: '0.8', path });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Set/clear the node-wide MusicBrainz contact string — the User-Agent
+ * identity MusicBrainz's usage policy asks for, not a credential (there
+ * is none, docs/musicbay.md §3.1). Signed like setTmdbConfig: this turns
+ * on outbound third-party network traffic the operator has to agree to.
+ * `contact: ''` explicitly clears it (reverting to "no calls at all");
+ * omit it (undefined/null) to leave whatever is stored unchanged.
+ */
+ async setMusicbrainzConfig(contact, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'musicbrainz_config', v: '0.8',
+ contact: contact === undefined ? null : contact,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ // Must match the node's subject byte-for-byte (webrtc_server.py
+ // _do_musicbrainz_config) — not a secret like tmdb_config's token,
+ // but still kept out of the audit log as free text: only whether one
+ // was supplied travels in the subject.
+ const subject = `contact_configured=${contact ? 'yes' : 'no'}`;
+ return this._authorizeAdminOp(msg, 'musicbrainz_config', subject, signFn);
+ }
+ 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.
+ */
+ 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 webrtc_server.py _do_musicbrainz_enabled byte-for-byte.
+ const subject = enabled ? 'True' : 'False';
+ return this._authorizeAdminOp(msg, 'musicbrainz_enabled', subject, signFn);
+ }
+ return msg;
+ }
+
async fetchStreamSegment(fileId, segmentIndex, segmentDuration) {
const msg = await this._sendAndWait({
type: 'stream_seg',
@@ -1320,6 +1379,9 @@ class MeshBayTransport {
? `chunk:${obj.file_id}:${obj.chunk_index}`
: obj.type === 'ping' ? `ping:${obj.token}`
: obj.type === 'media_meta_req' ? `media_meta:${obj.path}`
+ // Same reordering hazard as media_meta_req: an album grid fires
+ // one music_meta_req per visible tile, several at a time.
+ : obj.type === 'music_meta_req' ? `music_meta:${obj.path}`
// Same reordering hazard as media_meta_req: a season-tab bar or a
// search box can have more than one of these in flight at once.
: obj.type === 'season_meta_req' ? `season_meta:${obj.tmdb_id}:${obj.season}`
@@ -1458,6 +1520,17 @@ class MeshBayTransport {
this._onVideoRoot(msg.path || '');
}
+ // Node-wide, like tmdb_config_ack above — no token equivalent to hide,
+ // only whether a contact string is configured (docs/musicbay.md §3.2).
+ if (msg.type === 'musicbrainz_config_ack' && this._onMusicbrainzConfig) {
+ this._onMusicbrainzConfig({ contactConfigured: Boolean(msg.contact_configured) });
+ }
+
+ // Per-group, like tmdb_enabled_ack above.
+ if (msg.type === 'musicbrainz_enabled_ack' && this._onMusicbrainzEnabled) {
+ this._onMusicbrainzEnabled(Boolean(msg.enabled));
+ }
+
// The operator's node is scanning — never the entries themselves, just
// enough to animate a presence dot. Pushed periodically while it runs,
// plus once more on the transition back to idle (daemon.py
@@ -1560,6 +1633,16 @@ class MeshBayTransport {
// Same reasoning as media_meta_resp: keyed, not arrival-order, and
// "nobody's waiting any more" must not fall through either.
+ if (msg.type === 'music_meta_resp') {
+ const key = `music_meta:${msg.path}`;
+ 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') {
const key = `season_meta:${msg.tmdb_id}:${msg.season}`;
for (const [, handler] of this._pending) {