aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 16:43:18 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 16:43:18 +0200
commit16bc07acf053d7d14f8182f5523da1d179154a15 (patch)
tree90247aa516702a330f4f8840b96b0bee4b4ba8d1
parent6c2f232e889f0b883b13aa7d60567548d6cc46f1 (diff)
downloadmeshbay-16bc07acf053d7d14f8182f5523da1d179154a15.tar.gz
docs: plan the Music group app (musicbay.md)
Proposal only, not implemented. Same plug-in mechanism as Videos (apps.md), but no new streaming path — a track is downloaded and decrypted like any other file, not transcoded/remuxed like a film. Metadata: local tags first (mutagen), MusicBrainz/Cover Art Archive as node-side fallback enrichment, no API key needed (unlike TMDB) — just a rate-limited, self-identifying client. Player state (queue, shuffle, repeat) moves up into the group-page shell so playback survives a tab switch, mirroring how the video/preview modal is already shell-owned.
-rw-r--r--CLAUDE.md2
-rw-r--r--docs/apps.md10
-rw-r--r--docs/musicbay.md414
3 files changed, 420 insertions, 6 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index b4b5242..7de4abf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -703,7 +703,7 @@ SFR residential Fedora 44 → meshbay.org OVH VPS:
| WebRTC signaling (hub) | `meshbay_hub.api.signaling` | Phase 9.2 — SDP/ICE relay |
| Browser transport client | `static/transport.js` | Phase 9.4 — WebRTC DataChannel |
| Web SPA | `static/app.js` | Phase 9.6 — Preact + preact-router. Routing and every page except a group's — see `docs/apps.md` for the group UI's file layout (2026-08-23 split) |
-| Group applications (adding one) | `docs/apps.md` | Chat/Files/Videos today (see `docs/mediacenter.md` for Videos: poster grid, TMDB metadata, season tabs, manual match correction), Music/Photos planned. Props contract, enablement mechanism, checklist |
+| Group applications (adding one) | `docs/apps.md` | Chat/Files/Videos today (see `docs/mediacenter.md` for Videos: poster grid, TMDB metadata, season tabs, manual match correction), Music planned (see `docs/musicbay.md`: album grid, MusicBrainz metadata, no new streaming path, persistent shell-level player), Photos planned. Props contract, enablement mechanism, checklist |
| File download (large) | `static/file-utils.js` | `downloadEntry`/`_openDownloadTarget`, File System Access API (`showSaveFilePicker`) — stream to disk |
| Background tasks (node) | `meshbay_node.transport.webrtc_server` | `_spawn()` — the only way to start one; a bare `ensure_future` can be collected |
| Stream handover (node) | `meshbay_node.transport.webrtc_server` | `_replace_stream` + `shutdown_tasks` — one viewer, one film, and the slot comes back when they leave |
diff --git a/docs/apps.md b/docs/apps.md
index a032f56..a68a6e6 100644
--- a/docs/apps.md
+++ b/docs/apps.md
@@ -190,11 +190,11 @@ the only node-side touches, and both are allow-lists, not new wire messages.
app can reuse the same node-side machinery (thumbnail cache, chunk-path
delivery) without re-deciding any of this.
- **Videos, Music, Photos themselves.** Videos is now built (`video-app.js`,
- `docs/mediacenter.md`). Music and Photos remain deliberately out of scope
- — see `meshbay-draft-v6.md` §2.7. The infrastructure in this document was
- proven end-to-end first with Chat/Files, then with Videos; Music/Photos
- are additive from here, and can reuse Videos' thumbnail/chunk-delivery
- machinery rather than re-deciding it.
+ `docs/mediacenter.md`). Music is **planned, not built** — see
+ `docs/musicbay.md` — and reuses Videos' node-side thumbnail/chunk-delivery
+ machinery, with no new streaming path (a track is small enough to
+ download-then-play, unlike a film). Photos remains deliberately out of
+ scope — see `meshbay-draft-v6.md` §2.7.
- **The offline/loopback settings path.** `member_upload` can be toggled two
ways: over a live MNP connection, or (Electron only) via the node's local
HTTP API when MNP isn't connected (`platform.node.call('PUT', .../member-
diff --git a/docs/musicbay.md b/docs/musicbay.md
new file mode 100644
index 0000000..2524948
--- /dev/null
+++ b/docs/musicbay.md
@@ -0,0 +1,414 @@
+# MeshBay — Music application (plan)
+
+> Status: **proposal**, not implemented. 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.
+
+### 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.
+
+### 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.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 |
+
+## 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.