summaryrefslogtreecommitdiffstats
path: root/docs/musicbay.md
diff options
context:
space:
mode:
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.