aboutsummaryrefslogtreecommitdiffstats
path: root/docs/musicbay.md
blob: 2ca8b23f303d260a49de5a5bc90f4a86e47adbbb (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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
# 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.

**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 |

## 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.