diff options
Diffstat (limited to 'docs/musicbay.md')
| -rw-r--r-- | docs/musicbay.md | 502 |
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. |