diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/apps.md | 10 | ||||
| -rw-r--r-- | docs/musicbay.md | 414 |
2 files changed, 419 insertions, 5 deletions
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. |