aboutsummaryrefslogtreecommitdiffstats
path: root/docs/musicbay.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-11 00:19:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-11 00:19:06 +0200
commitf059cb118c556d1f0279350507f74b8a47d5a98a (patch)
tree9a97762a844038a06134b4b7dcead1758477dfc1 /docs/musicbay.md
parentb045ba0010d69360b6a0265eb7c73a07900fe328 (diff)
downloadmeshbay-f059cb118c556d1f0279350507f74b8a47d5a98a.tar.gz
docs: remove the documents MESHBAY_DESIGN.md replaces
Twenty-four files, about 17 000 lines: the two architecture drafts, the three security reviews, eleven design notes, the roadmap, the decisions file, the v1–v4 archive, the deprecated user guide and the stale quickstart. Their content is in MESHBAY_DESIGN.md, and git history holds the originals. The reason to delete rather than keep bannered: a document that is superseded but present still gets read, and a reader cannot always tell which of two accounts of one mechanism is the live one. That was the argument for retiring the user guide rather than repairing it, and it applies to the whole set. What made this safe is the concordance. Roughly 290 comments and docstrings cite these files by section — `musicbay.md §6`, `mediacenter.md §5.5`, `draft-v6 §2.11` — and section 16 maps every one onto its replacement, so not a single comment needs editing to stay followable. It now says plainly that the files are gone and where to recover them, and it gained rows for the three reviews (their findings are section 13), and for the two guides. Four kept documents pointed into the set and were repointed first: `playlists.md` (nine references — it is a live proposal and must not dangle), `WINDOWS-PORT.md`, and CLAUDE.md's example. No dangling reference remains outside section 16. Two files were dropped from the list after checking what they hold. `HTTPS.md` is an operational runbook — Caddy, certificate renewal, DNS, troubleshooting — and MESHBAY_DESIGN.md deliberately covers no operations, so nothing would replace it; the versioned Caddyfile is the config, not the procedure. `cast-smart-tv.md` is the plan for the unbuilt DLNA phase of a feature whose first two phases ship, and section 11.4 summarises it in four lines rather than carrying the SSDP/UPnP work. There is no user guide now, and section 0.1 says so rather than leaving a reader to discover it. Suites green: 2258 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
Diffstat (limited to 'docs/musicbay.md')
-rw-r--r--docs/musicbay.md502
1 files changed, 0 insertions, 502 deletions
diff --git a/docs/musicbay.md b/docs/musicbay.md
deleted file mode 100644
index 0bcff38..0000000
--- a/docs/musicbay.md
+++ /dev/null
@@ -1,502 +0,0 @@
-# MeshBay — Music application (plan)
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the Music application design; its design
-> content now lives in §9.8.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **built** — `music-app.js`, `music-app-settings.js` and the node-side
-> enrichment all shipped; this header said "not implemented" long after they did.
-> Follows the plug-in mechanism in
-> `docs/apps.md` — read that first, and `docs/mediacenter.md` second: Music
-> reuses its node-side pattern (metadata fetched and cached by the node,
-> never by a client) wherever the same shape applies, and this document only
-> states where Music differs and why.
->
-> Follows the project convention: every claim names the adversary it holds
-> against (§7).
-
----
-
-## 0. What was asked, in one paragraph
-
-A group "application" with exactly the same principles as Videos — a view
-over the existing file index, no catalogue, node-side metadata enrichment,
-enable/disable per group on the same signed-op mechanism — but for audio,
-styled after a mainstream streaming-music player rather than a poster grid:
-album art browsing, a persistent transport (play/pause, previous/next within
-the current album or queue, shuffle, repeat), and no cast/DLNA surface (out
-of scope, unlike a possible future addition to Videos).
-
----
-
-## 1. What this design does not reopen
-
-Everything Videos already established stands, and this plan fits inside it:
-
-- **Views over the index, never a catalogue** (`desktop-client-v1.md` §6.10,
- draft-v6 §2.7, `mediacenter.md` §1). A file stays tied to its filesystem
- representation; nothing here adds a second identity for a file, and
- nothing reaches the hub.
-- **The apps plug-in mechanism** (`apps.md`): a new `music-app.js`, one
- registry entry, one node-side `ALLOWED_APPS` entry, i18n keys, the asset
- list, the two file-set tests. Enablement is a per-group, operator-signed
- setting, same shape as `member_upload`/`apps_enabled`.
-- **Group-related server state lives on the node** (E9). Nothing in this
- design puts a row on the hub.
-- **Node-side derived-data caching, never in a shared root**
- (`mediacenter.md` §2, §9). Same reasoning applies unchanged: a cache
- belongs in the node's own `data_dir`, never beside the audio files.
-- **The node already classifies audio** — `indexer.py`'s `MEDIA_EXTENSIONS`
- already has an `"audio"` set (`.mp3`, `.flac`, `.ogg`, `.wav`, `.aac`,
- `.m4a`, `.opus`), and `IndexEntry.duration`/`.thumb_hash`/`.display_title`
- are already generic fields, not video-specific ones, per their own
- docstrings. No MNP change is needed to know a file is a track.
-
-## 2. Where Music genuinely differs from Videos
-
-### 2.1 Metadata mostly already exists in the files themselves
-
-A video file's name is usually all there is to go on; an MP3 collection —
-especially one assembled over many years — routinely carries **ID3 tags**
-(artist, album, title, track/disc number, year, genre) and often an
-**embedded cover image** (the `APIC` frame). Unlike Videos, where `guessit`
-had to do all the work, here the filename parser is the *fallback*, not the
-primary source. Order of trust, each one filling gaps the previous left:
-
-1. **Embedded tags** (ID3v2 for MP3, Vorbis comments for OGG/FLAC, etc.),
- read with `mutagen` (pure Python, MIT, no native extension — same
- licensing shape as `guessit`, trivially addable to
- `packages/meshbay-node/pyproject.toml`). Also the source of an embedded
- cover image when present.
-2. **Filename/folder parsing** for whatever tags don't supply — the existing
- `title_parse.py` pattern extended with a small audio-specific ruleset
- (`Track - Title.mp3`, a leading `NN - `/`NN. ` track-number prefix, the
- parent folder as a fallback album name, the grandparent as a fallback
- artist name for an `Artist/Album/track.mp3` layout).
-3. **MusicBrainz**, node-side, fetched and cached exactly like TMDB — for
- canonical spelling, a missing field, or cover art when the file has none
- embedded. Read the whole of §3 before assuming this needs the same
- credential story as TMDB: it does not.
-
-A quick, honest read of a real personal MP3 library (several thousand
-files, classic P2P-era rips, folder-per-artist/album) during planning showed
-embedded tags present and broadly usable on the large majority of files, with
-gaps concentrated in older VBR rips and remix/compilation folders — exactly
-where the folder-name fallback already has something to say (the folder is
-named after the artist or the release). The library itself is not committed
-anywhere in this repo, the same discipline `mediacenter.md` §3.1 already
-follows for its own test corpus; a real accuracy figure belongs in an
-acceptance step (§8), not asserted here from a first look.
-
-**Amendment (2026-08-24):** an early version of this plan skipped a Videos-
-style scoping root, on the reasoning that tag reads are free/local (unlike
-ffprobe+ffmpeg thumbnailing) so the cost a root protects against was
-smaller. Real use showed the actual problem a root solves isn't cost, it's
-*mixing* — a shared tree with more than one kind of thing under it (a real
-messy library rarely holds only music) puts everything into one
-undifferentiated Music view with no way to scope it down. Music now has
-`audio_root`, symmetric to `video_root` in every respect including gating
-enrichment on it — see §4.3b.
-
-### 2.2 No playback protocol change is needed at all — the big simplification
-
-Videos needed MSE, live ffmpeg remuxing, seek-by-restart, and a whole flow
-control mechanism (`video-player.js`) because a film is gigabytes and has to
-start playing before it is fully downloaded. A track is a few megabytes.
-**Music does not need any of that.** Playback reuses the download/decrypt
-pipeline `Files` and `Chat` attachments already use —
-`pipelinedDownload`/`downloadEntry` in `file-utils.js` — fetching and
-decrypting a whole track into a blob, then handing a `blob:` URL to an
-`<audio>` element. No `stream_req`, no node-side transcode pool, no
-`max_concurrent_streams` slot consumed, nothing added to
-`webrtc_server.py`'s streaming machinery. This is a real, deliberate
-simplification worth stating plainly rather than under-selling: **Music adds
-no new node-side streaming code**, only a metadata/art path shaped like
-Videos' TMDB one (§4).
-
-The one thing worth prefetching, as a UX nicety and not a protocol change:
-while a track plays, silently start the decrypt pipeline for the *next*
-queued track so pressing "next" doesn't visibly wait — client-side only, a
-small in-memory blob cache keyed by file id, evicted once it falls off the
-front of the queue.
-
-**Amendment (2026-08-24), one exception found against a real library:** WMA
-and Musepack (.mpc) tag and cover perfectly well (enrich_audio.py reads
-their real keys directly, since neither has mutagen's generic "easy"
-wrapper), but decode in **no** mainstream browser's `<audio>` element at
-all, no matter how the file is tagged — this is a codec-support gap, not a
-metadata one. For these two extensions only, the player asks the node to
-transcode to AAC/M4A first (`audio_transcode_req`/`_resp`, MNP 0.9), a
-one-shot whole-file conversion (not live-piped like video's fMP4 segments —
-an audio file is small enough that there is nothing to gain from streaming
-it), cached under its own content hash the same way a TMDB poster or a
-MusicBrainz cover is cached, and served back through the ordinary
-file_req/chunk path (`_try_serve_thumbnail`, generalized to slice a
-multi-chunk cached blob rather than assuming everything cached is
-thumbnail-sized). Every other format still plays exactly as described
-above — this is a narrow, extension-gated exception, not a reopening of the
-simplification.
-
-### 2.3 The player is persistent across tabs — a shell-level addition
-
-Closing the Music tab must not stop the music, the same way switching to
-Chat mid-film is not how Videos works today (Videos doesn't have this
-problem because playback only ever happens inside a modal the shell already
-owns). A `<audio>` element tied to the currently active *tab* would pause on
-every navigation, which is wrong for a Spotify/Deezer-style player and is
-explicitly requested (previous/next, shuffle, loop — a session, not a
-per-visit control).
-
-**Decision:** the playing state — the `<audio>` element itself, the current
-queue, track index, shuffle/repeat mode, play/pause — moves up into
-`group-page.js` (the shell), exactly the precedent already set for "the
-video/preview modal" (`apps.md` §1). `music-app.js` reads and controls this
-state through props, the same way `onPreview` already lets any app open the
-shell's preview modal without owning it. A small persistent player bar,
-rendered by the shell below the tab content (visible regardless of which tab
-is active, hidden entirely when nothing is queued), is the concrete UI for
-this — it is the one piece of Music's UI that is *not* inside
-`music-app.js` itself, and that asymmetry should be commented at the call
-site so a future reader doesn't go looking for the play bar in the wrong
-file.
-
-This is a small addition to the shell's responsibilities, not a new trust
-boundary: it is UI state, holds nothing secret, and unmounts cleanly when the
-group is left (leaving a group already tears down the shell entirely).
-
-### 2.4 Transport features requested explicitly
-
-All client-side, no signed op, no node involvement — this is exactly the
-kind of state `mediacenter.md` §4.3 already put in `localStorage` for the
-view-mode toggle, for the same reason (a display/session preference with no
-security or authority implication):
-
-| Feature | Behaviour |
-|---|---|
-| Previous / next within an album | Queue defaults to "every track in the clicked track's folder, in track-number then filename order" — the folder is the album, same principle as Videos treating the folder as the category (`desktop-client-v1.md` §6.10) |
-| Next / previous across the queue | Same queue, not bounded to the album once shuffle or a manual "play all" from an artist view has built a longer one |
-| Shuffle | Fisher-Yates over the current queue, toggled on/off; toggling off restores original (folder) order from the current track forward |
-| Repeat | Three-state cycle: off → repeat-all (queue wraps) → repeat-one (current track loops) |
-| Volume | `localStorage`, per browser, same shape as the video player's resume position |
-| Resume-on-return | Not attempted for v1 (a 3-minute track resuming mid-play across a reload is marginal value); *volume* and *shuffle/repeat mode* persisting is enough. Open item if requested later (§9) |
-
-No cast/DLNA surface — explicitly out of scope per the request, and nothing
-in Videos' cast plans (if any exist elsewhere) is reused here.
-
----
-
-## 3. MusicBrainz: what it needs, and what it does not
-
-Directly relevant since the operator now has a MusicBrainz account and asked
-whether the TMDB pattern applies. **Mostly yes, for the caching/node-side
-architecture — no, for the credential.**
-
-### 3.1 Read lookups need no API key at all
-
-TMDB requires a **Bearer token** issued to a registered application (v4
-auth) for every call, which is why `tmdb.py` resolves a secret before doing
-anything. MusicBrainz's web service (`ws/2`, documented at
-`musicbrainz.org/doc/MusicBrainz_API`) is different: **search and lookup
-calls require no key or account at all.** What it requires instead:
-
-- A **descriptive `User-Agent` header** identifying the application, a
- version, and a contact (an email or a URL) — MusicBrainz's stated policy
- for being allowed to use the service at all, not a secret.
-- **Rate limiting to roughly one request per second** for unauthenticated
- use. This is a courtesy limit enforced by convention and by MusicBrainz's
- own throttling of abusive clients, not a token-bucket the server hands
- out — so it is this node's job to self-limit, not something to "get more
- of" by having an account.
-- **Cover Art Archive** (`coverartarchive.org`), the companion service that
- hosts album art keyed by MusicBrainz release ID, is equally keyless.
-
-So the MusicBrainz account the operator created is **not what supplies the
-credential this design needs** — there isn't one to fetch. What an account
-*is* for:
-
-- **Editing MusicBrainz's own data** (submitting corrections, adding
- releases) — irrelevant here, this design only reads.
-- **AcoustID** (`acoustid.org`), a *separate* service (audio fingerprinting
- — identifying a track from its waveform when it has no useful tags or
- filename at all) that does issue a free API key from its own site, tied to
- an AcoustID account, not the MusicBrainz one. This is the feature to reach
- for if the filename+tag+MusicBrainz-search pipeline (§2.1) still can't
- identify a meaningfully large fraction of a real library once measured —
- deliberately **deferred** (§9) rather than built speculatively, the same
- discipline `mediacenter.md` applied to its own out-of-scope items.
-
-### 3.2 What to configure instead — mirrors `tmdb_config`, minus the secret
-
-Same shape as `mediacenter.md` §5.5's `OP_TMDB_CONFIG`/`tmdb_enabled`, with
-the credential field replaced by a contact string:
-
-- **`musicbrainz_contact`** (node-wide, `group_settings` under the `""`
- sentinel, same precedent as `tmdb_api_token`/`tmdb_language`) — the
- `User-Agent` contact field MusicBrainz's policy asks for. **This must not
- be a literal value committed to source** — same rule already in force for
- `MESHBAY_TMDB_DEFAULT_TOKEN`, and the reason the plan note at the top of
- this document exists: no personal contact address in git. A generic
- project-level placeholder (e.g. a `meshbay.org` contact page, once one
- exists) is the shipped default; an operator can override it with their
- own, the same "operator-supplied beats shipped default" order TMDB's
- token resolution already uses.
-- **`tmdb_language`'s twin**, if wanted — MusicBrainz search doesn't take a
- language parameter the way TMDB does (its data isn't localized per
- request the same way); skip this field rather than inventing a knob
- nothing reads.
-- **`musicbrainz_enabled`**, per group from the start (unlike TMDB, which
- started node-wide and was split per-group later, §5.6 of `mediacenter.md`
- — no reason to repeat that path here now that the lesson is already
- learned).
-- **Rate limiting is the node's own job**, not configuration: a single
- `asyncio.Semaphore(1)` plus a minimum-interval gate in the client (the
- same shape as the bounded worker pools already used for thumbnails/probes,
- just serialized to one at a time with a floor on the interval between
- calls) — enforced regardless of how many members' tiles are requesting
- metadata concurrently, because the 1 req/s budget is shared across the
- whole node, the same reasoning `mediacenter.md` §2 already used to justify
- node-side TMDB caching in the first place (one lookup per unique
- artist/release serves every member indefinitely).
-
-### 3.3 What MusicBrainz actually supplies here
-
-- Canonical artist/release/track names and a `release` MBID, searched by
- (artist, album) or (artist, track) depending on what tags/parsing already
- found.
-- Release date, and enough of a release/recording graph to disambiguate a
- reissue/compilation from the original release when useful — not pursued
- further than what the detail view needs.
-- Cover art, via Cover Art Archive keyed by the resolved release MBID — used
- **only when the file has no embedded art** (§2.1's first tier already
- covers the common case for free, no network call).
-
----
-
-## 4. Protocol and index changes
-
-### 4.1 Reuse first
-
-`IndexEntry.duration` and `.thumb_hash` are already generic fields (their
-docstrings say "for media" / "of thumbnail", not "video"); Music reuses both
-exactly as declared — `duration` from `mutagen`, `thumb_hash` pointing at
-either an embedded or MusicBrainz-fetched cover image, delivered through the
-existing chunk `file_req` path (`mediacenter.md` §5.3's reuse, unchanged).
-`display_title` is reused as the parsed track title.
-
-### 4.2 New fields on `IndexEntry` (additive — MNP MINOR bump, same class of
-change as the video fields were)
-
-```python
-artist: str | None = None # tag or parsed, Music app
-album: str | None = None # tag or parsed, Music app
-track_no: int | None = None # tag or parsed, Music app
-```
-
-`disc_no` and `year` were considered and dropped from v1: neither is needed
-by the two view modes below, and an unused field is exactly the kind of
-thing that silently rots (`desktop-client-v1.md`'s own history has more than
-one example of a declared-but-never-populated field). Add them later,
-additively, if a feature actually needs them.
-
-### 4.3 New request/response: MusicBrainz metadata
-
-Same shape as `media_meta_req`/`resp`, keyed by `path` for the same reason
-(`mediacenter.md` §5.4's concurrent-lookup bug and its fix apply identically
-here — `transport.js`'s reply routing needs the same per-path keying the
-moment more than one tile can be in flight):
-
-```
-music_meta_req { path }
-music_meta_resp { mbid, artist, album, title, release_date,
- cover_thumb_hash, confidence }
-```
-
-No season-tab, no manual-override pair (`tmdb_override`'s equivalent) in
-v1 — deferred (§9), on the reasoning that a wrong MusicBrainz match's blast
-radius is smaller than a wrong TMDB match (wrong cover art or a
-canonicalized spelling, not a wrong show entirely) and tag-derived data
-already covers most files, so building a correction UI before measuring
-whether it's needed would repeat the "designed and not needed" pattern this
-project's own history warns against.
-
-### 4.3b New request/response: audio transcode (amendment, §2.2)
-
-```
-audio_transcode_req { file_id }
-audio_transcode_resp { file_id, hash, size, mime }
-```
-
-`hash` is a cache key, not the file's own id — the client downloads it
-through the ordinary `file_req`/chunk path exactly like a TMDB poster or
-MusicBrainz cover, never a new download mechanism. Sent only for WMA and
-Musepack (`.wma`/`.mpc`); every other format still downloads its own
-`entry.id` directly, unchanged. Shares its concurrency cap with video's
-transcode pool (`MAX_CONCURRENT_TRANSCODES`) rather than getting its own —
-both are real ffmpeg processes on the same node.
-
-### 4.4 `ALLOWED_APPS`
-
-Add `"music"` to the frozenset in `webrtc_server.py`. `DEFAULT_APPS` stays
-`("chat", "files")` — same reasoning as Videos: a brand-new group doesn't
-get an app that can make outbound third-party calls (once MusicBrainz is on)
-for free; the operator opts in explicitly.
-
----
-
-## 5. The two view modes
-
-Same two-mode shape as Videos (`mediacenter.md` §4), same per-device
-`localStorage` toggle (§4.3 there), same default-to-the-richer-mode
-decision:
-
-### 5.1 Mode A — album grid (art + metadata), default
-
-Grid of album covers (one card per distinct `(artist, album)` pair found in
-the index — folder-derived when tags are absent), click through to a
-tracklist (track number, title, duration) with a **Play** / **Play all**
-action that builds the queue and hands off to the shell's player (§2.3). An
-artist-level view groups album cards under an artist heading, with its own
-"play all" that queues every album's tracks in album-then-track order.
-Falls back to a plain folder-name card with no art when there's neither an
-embedded cover nor a MusicBrainz match — the direct analogue of Videos'
-thumbnail-only fallback card.
-
-### 5.2 Mode B — flat, folder-based, no MusicBrainz
-
-Alphabetical list of folders (artists or albums, whichever level a given
-subtree sits at), each expandable to its tracks, no network call, no
-MusicBrainz dependency — keeps working with `musicbrainz_enabled` off,
-exactly like Videos' Mode B keeps working with TMDB off.
-
----
-
-## 6. Node-side implementation, concretely
-
-| Piece | Where | What |
-|---|---|---|
-| Tag/filename parse | `meshbay_node/indexer/title_parse.py`, extended | A small audio ruleset alongside the existing video one — track-number prefix, `Artist/Album/track.mp3` folder fallback |
-| Tag + embedded art extraction | new `meshbay_node/indexer/enrich_audio.py`, sibling to the existing (video) `enrich.py` | `mutagen` read is synchronous I/O — run via `asyncio.to_thread`, never blocking the event loop; through its own bounded worker pool, same discipline as the video enrichment pool (`enrich.py`'s `DEFAULT_MAX_CONCURRENT`), not because `mutagen` shells out (it doesn't — no ffmpeg-style pipe-draining hazard here) but to keep a large library's indexing burst bounded the same way everywhere |
-| MusicBrainz client | new `meshbay_node/musicbrainz.py`, shaped like `tmdb.py` | `httpx`, self-rate-limited to ~1 req/s (§3.2), no bearer token — just the configured `User-Agent` |
-| Cover Art Archive fetch | same client | Unauthenticated image fetch, same shape as `TmdbClient.fetch_image` |
-| Caches | `data_dir/media_cache.db`, same store TMDB already uses, new tables alongside the existing ones | `(content_hash) → mbid` mapping; `(mbid) → metadata json`, TTL (30 days, same default as TMDB, revisit if MusicBrainz data proves to churn less); `(thumb_hash) → jpeg bytes`, pruned on the owning file's index removal — identical lifecycle to the video thumbnail cache |
-| Operator config | `roster.py` `group_settings` | `musicbrainz_contact` (`group_id=""`, node-wide), `musicbrainz_enabled` (per real `group_id`, per §3.2's reasoning for starting there directly) |
-| New adminop | `meshbay_common/adminop.py` | `OP_MUSICBRAINZ_CONFIG` (node-wide contact string), `OP_MUSICBRAINZ_ENABLED` (per-group), same shape as `OP_TMDB_CONFIG`/`OP_TMDB_ENABLED` |
-| `pyproject.toml` | `packages/meshbay-node/pyproject.toml` | add `mutagen>=1.47` |
-
-No hub change, no change to the streaming/transcode machinery at all (§2.2).
-
-## 7. Client-side, per `apps.md`'s checklist
-
-1. `music-app.js` — the album grid / flat list, receiving the standard
- `apps.md` §2 props. Does **not** own the `<audio>` element or queue state
- (§2.3) — those live in `group-page.js` and are threaded through as
- additional props (`playerState`, `enqueue`, `playNow`, `skipNext`,
- `skipPrev`, `toggleShuffle`, `cycleRepeat`), the same "spread, not
- hand-listed" shape `commonProps` already uses.
-2. A small persistent player-bar component, rendered by `group-page.js`
- itself below the tab content — the one piece of this feature that is not
- inside `music-app.js` (§2.3).
-3. Register `{ key: "music", icon, labelKey: "group.tab_music", Component:
- MusicApp }` in `apps.js`.
-4. `ALLOWED_APPS` (§4.4).
-5. `group.tab_music` (and a handful of player-bar strings — play/pause,
- shuffle, repeat) in all ten `static/locales/*.js`; `test_locales.py`
- holds them to the same key set.
-6. `webapp.py`'s `_ASSETS` tuple — add `music-app.js`.
-7. `test_hook_ordering.py` (`STATIC_FILES`) and `test_transport_contracts.py`
- (`SPLIT_FILES`) — add the new file to both.
-8. `npm run sync-ui` in `meshbay-client`, confirmed reported.
-
----
-
-## 8. Security — per adversary
-
-| Claim | Passive hub | Active hub | Malicious node operator | Another member |
-|---|---|---|---|---|
-| MusicBrainz contact string confidentiality | ✅ unreachable | ✅ unreachable | not a secret — it's a courtesy header, not a credential | ✅ never transmitted to members |
-| Metadata/art delivery | ✅ unchanged transport | ✅ unchanged transport | sees it already (holds the plaintext file) | same GEK-proofed MNP channel as files/streaming — no new authorization surface |
-| New outbound traffic (node → MusicBrainz/Cover Art Archive) | — | — | the operator already controls their own node's egress | — |
-| Playback path | — | — | operator already holds the plaintext | unchanged — the same chunk `file_req` path Files already uses, now consumed by an `<audio>` element instead of a save dialog |
-| Stale cache after file deletion | — | — | pruned on the index's own deletion event, same mechanism as the video cache (§6, `mediacenter.md` §2) | — |
-
-**The claim this design supports:** identical to Videos' — no new
-authorization boundary, and (going further than TMDB) **no credential of
-any kind** leaves the node's own config, because MusicBrainz needs none.
-
-**The claim it must not make:** that self-imposed rate limiting is enforced
-by MusicBrainz on this node's behalf — it is a courtesy the node has to
-actually implement (§3.2), not a property the protocol gives for free.
-
----
-
-## 9. Open items — deliberately deferred
-
-| # | Item | Why not now |
-|---|---|---|
-| M1 | AcoustID audio-fingerprint matching for tag-less, unparseable files | Needs its own account/key (§3.1) and only matters once real coverage is measured against the library — building it speculatively repeats a pattern this project's history warns against |
-| M2 | Manual match correction (`tmdb_override`'s analogue) | Smaller blast radius than a wrong TMDB match (§4.3); build if real mismatches turn out to be common enough to need it |
-| M3 | Resume position mid-track | Marginal value for a ~3-minute file; volume/shuffle/repeat persistence (§2.4) covers the useful part of "remembering state" |
-| M4 | Playlists / favorites | A genuinely new category of per-account node state, not covered by anything E9 already enumerates — needs its own design pass (ownership, sync across devices, whether it's node-local or something else) rather than being folded in here |
-| M5 | Gapless playback, crossfade, lyrics, waveform seek preview | Nice-to-haves, no architectural prerequisite from this plan either way |
-| M6 | Photos app | Out of scope of this document, per `apps.md`'s own list — unaffected by anything here |
-
-## 9b. One album, not two, in the cross-group Search view (2026-09-02)
-
-Reported against Videos and true here by construction: an operator hosting two
-groups that share one directory saw every track listed twice inside one album.
-Inside a group it cannot happen (`GroupIndex` is keyed by blake3); the Search
-page concatenates *N* indexes, and that is where the duplication is born.
-
-The fix is `source-merge.js`, applied identically to all three media views —
-fold on the content hash, resolve **one source per unit**. For Music the unit
-is an album, and a track loose enough to carry no artist at all is a unit of
-its own (or it would be dropped from the merge entirely, since
-`groupMusicEntries` never puts it in an album bucket).
-
-**`foldKey` is exported for this**, and the reason is worth keeping. §5.1's
-grouping folds case and `&`/`and` for the *key* while keeping the first-seen
-spelling for display — and which group is seen first is whichever index
-happened to arrive first. Keying a merge unit on the display strings would
-therefore let the chosen source change between page loads. The folded key is
-the one grouping actually uses, and is stable.
-
-The units come from `groupMusicEntries` itself, called on the un-merged list
-purely to learn them — never a second copy of its keys in the Search page.
-
-**The player needed no change.** `music-player.js` resolves a connection from
-`entry.groupId` (§2.3), and a merged entry carries exactly one. The queue built
-in `search-page.js`'s `onPreview` needed none either: it filters by `groupId`
-and is reachable only from the Files explorer, which is deliberately *not*
-merged — there each group is a folder and merging would remove a track from
-one of them.
-
-Design and the adversary it names: `docs/refactoring-search.md`.
-`test_search_media_merge.py` covers the album cases, including a differently-
-cased tag not splitting the unit.
-
-## 10. Acceptance before shipping
-
-1. Tag-coverage measurement against a real local library (not committed —
- same discipline as `mediacenter.md` §3.1): what fraction of files get a
- usable artist/album/title from tags alone, from tags+filename parsing,
- and only then how much MusicBrainz enrichment actually adds — this
- number decides whether M1 (AcoustID) is worth ever building.
-2. A mocked-`httpx`-transport test suite for `musicbrainz.py`, the same
- shape `test_tmdb.py` already uses — no test hits the real service.
-3. Confirm the self-imposed rate limiter actually serializes calls to
- roughly 1/s under concurrent tile requests from several open clients at
- once (a unit test around the semaphore/interval gate, not a live
- MusicBrainz call).
-4. Confirm cache pruning on file deletion fires for the new tables, mirroring
- `test_media_cache.py`'s existing coverage.
-5. Confirm the persistent player survives a tab switch and a group-to-group
- navigation without an audible glitch, and stops cleanly when the group is
- left — this is the one behavior that can't be unit-tested meaningfully
- and needs a real browser check before calling this done, the same
- standing rule `CLAUDE.md` records for anything UI-timing-shaped.