summaryrefslogtreecommitdiffstats
path: root/packages
Commit message (Collapse)AuthorAgeFilesLines
...
* | fix: on restart, serve a video's cached TMDB match instead of re-searchingChristophe Besson2026-08-293-16/+105
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Root of the 2026-08-29 demo35 storms. `_do_media_meta_request` could not tell "this is a movie" from "this video isn't enriched yet" — both have season/episode None — so during a slow initial scan with a browser on the Videos tab, every show episode requested was run through the *movie* search path with its raw filename as the query (`search/movie?query=Show S01E01 1080p WEB DL ...`), hundreds per second, until TMDB rate-limited and posters stopped loading. Worse, an un-enriched show episode's own valid cached "tv" match was treated as stale (its provisional kind was "movie"), so a file already resolved got re-queried anyway. - While a video is un-enriched (no display_title — enrich.py always sets one), never run a TMDB *search*. Serve the cached match if the content hash has one, honouring the cached kind ("tv"/"movie") rather than the provisional split; otherwise answer confidence 0. - Once enriched, the strict `cached_media_type == media_type` check is unchanged: an enrichment fix that reclassifies a folder movie->tv still drops the stale match and re-resolves. - video-app.js: `useMediaMeta` gains an `enrichSig` dependency (`entry.display_title`) so the client refetches once the enriched fields arrive on an index delta — the fileId is a content hash and never changes, so nothing else would retrigger it. Not caused by the V8-V13 work; it raised the per-file call count so the pre-existing race became a visible storm. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat: V13 — per-card "re-match this one file" (MNP 0.13)Christophe Besson2026-08-2920-5/+288
| | | | | | | | | | | | | | | | | | | | | | | | A one-click alternative to the full "Fix match" search-and-pick flow, and reachable without SSH (`meshbay-node video rematch` clears a whole group). - MNP 0.13: tmdb_rematch / tmdb_rematch_ack (additive — an older node logs "unknown type", the button just does nothing). OP_TMDB_REMATCH, signed like tmdb_override (media_cache is shared node-wide). - media_cache.drop_tmdb_match(file_id): forgets the match AND the override marker — deliberately stronger than clear_file_tmdb, since the operator is explicitly asking for a fresh resolution. - webrtc_server: _do_tmdb_rematch / _admin_exec_tmdb_rematch, dispatch + admin-response routing, broadcasts tmdb_rematch_ack. - transport.js: rematchTmdbMatch(fileId, signFn); 'tmdb_rematch' in the admin-op allowlist; tmdb_rematch_ack handled like tmdb_override_ack. - video-app.js: a "Re-match" button beside "Fix match" in the detail modal (isNodeAdmin), then bumpMediaMetaGeneration(). video.rematch_one key in all ten locales. docs/mediacenter.md §10.1: V8–V13 marked done. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat(hub): V12 — merge movies by TMDB id in the poster gridChristophe Besson2026-08-2912-17/+77
| | | | | | | | | | | | | | | | | Two movie files that TMDB resolves to the same id (the same film at two resolutions, or the same rip in two folders) now collapse to one poster card, mirroring the existing show merge — same keying discipline so an unmerged movie keeps its card and a merge updates props rather than remounting. The detail modal lists the versions (resolution · duration · size), each a Play button, when there is more than one; a single-file movie is unchanged. New `video.versions` key in all ten locales. Known edge, noted in §10.1: "Fix match" on a merged movie corrects only the representative file; the other version un-merges and can be corrected on its own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat(node): V8–V11 — show-branch ladder, year-aware _best_match, wider ↵Christophe Besson2026-08-296-89/+340
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | sequel_variants V8: the TV/show branch of _tmdb_search used the old "first candidate over 0.6 wins" shape. It now shares one _tmdb_ladder helper with the movie branch — score every candidate query, keep the best, fast-path a confident primary hit. A year lifted off the show's folder name (title_parse.year_in, e.g. "Some.Show.2022.S01") rescues a sub-0.6 hit that lands on the exact year. title_parse.clean_query de-dots a folder-derived title without naive_title's extension-stripping trap. V9: _best_match gains an optional `year`. When the top result is not a confident textual hit (ratio < 0.6) and a year was requested, a different result of that exact release year is preferred — TMDB already year-filtered the search, so this is a hard corroboration, not the fuzzy re-rank §3.3 warns against. A confident top hit is never overridden. search_movie/search_tv forward the year. V10: sequel_variants widened — trailing Roman→digit as well as digit→Roman, spelled-out indices (one..twelve / un..douze / ordinals), and a "Part N" / "Chapitre N" wrapper. Still empty for a trailing word that is not an index or a 4-digit year. V11: when the primary hit is already decent (>= 0.6) and there is nothing more specific to try (no alternative_title, no sequel variant — only a punctuation restatement left), the ladder returns without the extra requests. The clean-title common case is back to one call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(hub): update layout test for the fit → fitAndPin renameChristophe Besson2026-08-291-3/+5
| | | | | | | | | | | b6f3139 renamed `fit` to `fitAndPin` in chat-app.js and rebound the resize / orientationchange / visualViewport listeners to it, but test_the_panel_refits_when_the_viewport_changes still grepped for the old name and failed. `fit` itself still exists (fitAndPin wraps it), so test_the_measurement_survives_a_scrolled_page was unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* Merge branch 'fix/videos-tmdb-matching'Christophe Besson2026-08-2925-27/+732
|\
| * fix(node): correct TMDB movie matching, per-file overrides, rematchChristophe Besson2026-08-2925-27/+732
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A batch of wrong poster-grid matches found live on a real library (2026-08-29): a two-volume film's second part matched the first; a numbered sequel matched a same-year making-of documentary; several entries of one franchise matched a single early entry whose localized TMDB title is the franchise name; one matched nothing. One mechanism: _tmdb_search returned the first candidate query whose title-similarity ratio merely cleared 0.6, before alternative_title / the Roman-numeral variant was ever tried. Matching: - title_parse: fold guessit's volume/part number back into display_title so the parts of a multi-part film stay distinct in the query, the card and the override. - _tmdb_search: keep a strong PASS 1 fast path (ratio >= 0.85, one request), otherwise score every candidate query and pick the best. A year-exact rescue lifts a sub-0.6 top hit to the confidence floor only when TMDB's own year-filtered result lands exactly on the filename's year. No local re-ranking of any single result list; no tmdb.py change. Fix match / rematch: - _admin_exec_tmdb_override: a movie override touches its own file only (guessit gives a whole franchise one display_title); a show override still fans out. Corrected files are marked in media_cache.tmdb_override. - media_cache: tmdb_override table; clear_file_tmdb / clear_tmdb_matches drop auto-resolved matches while sparing manual corrections. - ops.rematch_video + `meshbay-node video rematch` (loopback endpoint + CLI verb): re-resolve a group's video matches after a matcher fix. file_tmdb is keyed by content hash and otherwise only pruned on deletion, so nothing dislodged a cached match before. - a rename now drops the stale auto match too (daemon _reenrich_renamed_video_entries). UI: - VideoDetailModal shows the source filename and resolved TMDB id; an unmatched poster gets a badge (3 new video.* i18n keys x 10 locales). So a wrong match can actually be identified before hitting Fix match. docs/mediacenter.md 10.1 records this and the V8-V13 follow-up backlog (show-branch ladder, year-aware _best_match, wider sequel_variants, the 0.6-0.85 extra calls, movie grid merge, per-card rematch). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* | perf(node): filter ICE interfaces to eliminate 5s STUN timeout on ↵Christophe Besson2026-08-293-0/+102
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | VPN/virtual adapters aioice sends STUN binding requests from every IPv4 interface and waits up to 5 seconds for all to complete. On a machine with Tailscale (wt0), the STUN request never gets a response, adding a fixed 5-second penalty to every WebRTC connection — measured at 6 s total (vs 1-2 s without it). Auto-exclude virtual/VPN adapters (tailscale, virbr, docker, veth, podman, cni) and CGNAT-range IPs (100.64.0.0/10). Operator can override with ice_interfaces in node.toml [node] section for explicit control. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* | fix(hub): chat scroll-to-bottom, message loading on group switch, connecting ↵Christophe Besson2026-08-292-29/+119
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | spinner Three regressions fixed in ChatPanel: - Scroll pinning: layout effect now depends on [messages, hasMore] so the "load older" button appearance triggers a re-pin; setHasMore is called before setMessages to avoid an intermediate render without the button; fit() is wrapped in fitAndPin() so panel resizing re-pins the scroll. - Message loading on group switch: fetch effect depends on the status prop instead of transportRef.current?.connected to avoid racing with GroupPage's cleanup; loadedRef resets on fetch failure so a retry works. - Connecting spinner restored in the Chat tab empty state. Adds structural regression tests (test_chat_scroll_bottom.py) that lock the dependency arrays and setState ordering so these invariants break loudly in CI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* | fix(hub): chat kept stale messages when switching groups on the Chat tabChristophe Besson2026-08-291-1/+1
|/ | | | | | | | | App components were keyed by app name only (e.g. 'chat'), so switching groups while staying on the same tab reused the component instance — loadedRef stayed true and messages state was never cleared. Now keyed by app name + groupId, forcing a clean remount on group change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: node workflow redesign — wizard auto-config, reset, MusicBrainz contactChristophe Besson2026-08-2929-552/+474
| | | | | | | | | | | | | | | | | | | | | | | | Wizard (Electron): - Auto-provisions node config (hub URL + username) from logged-in user - node:start handles both cold start and restart of misconfigured daemon - Waits for daemon to reach 'running', auto-links node key on hub - probeNode accepts intermediate states for wizard progress feedback Reset (meshbay-node reset): - Unlinks node key from hub (DELETE /me/node_key, best-effort) - Stops and disables daemon (systemctl --user disable --now) - Erases ~/.config/meshbay, ~/.local/share/meshbay, ~/.local/state/meshbay MusicBrainz contact: - Resolved from owner's hub email instead of per-node roster config - Removed musicbrainz_contact UI and WebRTC handshake field - Removed set_musicbrainz_contact/musicbrainz_contact from roster Node pairing: - Added operator pairing banner on NodePage - Added operator_paired flag to list_groups Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(transport): chat_hist_resp dispatch skipped when older pending entries existChristophe Besson2026-08-281-8/+11
| | | | | | | | | | | chat_hist_resp checked only the single oldest pending entry across all types. When switching tabs, keyed requests (media_meta_req, file_req) from Videos/Music/Photos stayed in _pending and were older than the chat_hist entry, so the response was dropped and the chat appeared empty on return. Now scans for the first chat_hist entry instead. Same fix applied to index_sync dispatch for consistency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(hub): 10s connect timeout in search page, error on unreachable downloadChristophe Besson2026-08-282-9/+35
| | | | | | | | Search-page connections (indexing and pool) now time out after 10s instead of the transport's default 30s, so down nodes are skipped faster. Downloads show a user-facing error when a group is unreachable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): search downloads, photos view, pre-connect optimizationChristophe Besson2026-08-2814-37/+107
| | | | | | | | | | | | | | | | | | Downloads from search: FilesPanel accepts a getTransport callback for cross-group file downloads. Select/Download are available in search; delete and zip are hidden (readOnly). Each download connects to the entry's group via the pool on demand. Photos view: PhotosApp reused in search with the same pattern as Videos/Music — synthetic root, per-entry transport refs, group badges on album cards. Lightbox uses per-entry transport for full-resolution image fetches. Zip download hidden in search context. Pre-connecting: connections to all groups start as soon as indexing finishes, regardless of the active view. Thumbnails cache across pool evictions, so they're ready when the user switches views. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): cross-group search with reuse of existing viewsChristophe Besson2026-08-2819-250/+916
| | | | | | | | | | | | | Search page fetches indexes from all groups, then renders consolidated entries through the existing FilesPanel, VideoApp, and MusicApp components — no reimplemented views. Groups appear as top-level directories in the file browser; video/music entries use a synthetic root with per-entry transport refs for thumbnails and metadata across groups. Music player lifted to app.js with getConnection(groupId) for cross-group playback. Connection pool (max 3, LRU eviction) manages lazy WebRTC connections. All 10 locales updated with search keys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(node): a pinned device can be invited to a further invite-only groupChristophe Besson2026-08-282-6/+123
| | | | | | | | | | | | | | | | | | | | | | | | | | | Regression from device linking (Stage C, 2026-08-18). Once a device is pinned on a node — as a member of one group, or an operator pairing — the `known` fast-path in `_do_join_request` dropped straight into `_join_ok`. For any *other* invite-only group it had no roster row for, that answered `not_authorized_for_group` and stopped there: the client never got `code_required`, so the pairing-code form never appeared and a legitimately invited member could not join. The `known` branch now, when there is no membership for the group being opened: - with a valid code → consumes the invite and admits (as the unknown- device path already does); - with no code but an invite waiting for this user here → `code_required`, so the client prompts; - with no code and nothing inviting them → `not_authorized_for_group`, unchanged, so the H3 guarantee (a hub-invented pin gets no key) holds. Also fixed: the group's own roster row is now consulted first, so an existing member opening their group is never mistaken for a stranger. Tests in test_roster_pairing.py cover all three branches plus the H3 guard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(hub): Node page — "Remove group" only for a group the hub droppedChristophe Besson2026-08-281-5/+12
| | | | | | | | | | | The button detaches a group from this node's config. For a group that still exists on the hub that strands it with no host; deleting such a group is the group Settings tab's job (detach + hub delete together). It now shows only when the group is `stale` — present on the node, gone from the hub — which the page already computes for the "not on hub" badge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(hub): sidebar — status dot on the name line, not between name and @ownerChristophe Besson2026-08-282-7/+26
| | | | | | | | | | | The @owner line turned the sidebar name into a two-line block, and the status dot (a flex sibling, align-items:center) floated to its vertical middle. A group entry is now a flex column: dot+name on one row (.si-head), @owner on a subordinate line indented past the dot. Non-group sidebar links are untouched (.sidebar-item stays a row; only .sidebar-group flips). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(hub): group names unique per owner, shown as name@ownerChristophe Besson2026-08-2812-30/+320
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A group's identity stays its UUID. What changes is that "the name is unique" — until now an unenforced expectation — becomes real, scoped to the owner account, and the owner's username is surfaced so two groups called "photos" on different nodes can be told apart. Hub: - `groups` gains a functional unique index `uq_groups_owner_name` on `(admin_id, lower(name))` (model + migration c3d4e5f6a7b8). The migration pre-flights: if the data already clashes it aborts and lists the offending (admin_id, name) pairs rather than renaming anyone's group. meshbay.org checked clean. - `create_group` trims the name, rejects blank (422) and an owner-scoped case-insensitive clash (409), with an IntegrityError backstop for the race, and returns `owner_username`. - `owner_username` added to `/v1/groups/mine`, `GET /v1/groups` (local rows), `POST /v1/groups/{id}/join`, `GET /v1/admin/groups`. SPA: - new `static/group-name.js` — `<GroupName name owner [inline]>` renders the name with the `@owner` handle on a smaller grey line under it. - used in the sidebar, the group-page header, Explore cards, the Admin groups table, and cross-group Search (via a widened `cacheGroupIndex` carrying the owner). Delete/leave confirmations show `name@owner` inline. - federated Explore rows show `@<source_hub>` instead of an account. Design record and the locked decisions: ~/next/groupnames.md (out of repo). MNP unchanged. Tests: test_group_name_unique.py, test_group_name_migration.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(hub): Explore page says nothing about public groups when they're offChristophe Besson2026-08-2811-2/+20
| | | | | | | | | | When the hub has public groups disabled, /explore showed an empty "Public Groups" screen with a search box and a Create button that do nothing. It now renders a single line — "This hub does not have public groups." — and the server already returns an empty directory regardless. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* chore: release 0.8.00.8Christophe Besson2026-08-286-8/+8
| | | | | | | | | | | | | | | | | Bump the three packages to 0.8.0 (released together) and realign meshbay-common's __version__, which had drifted to 0.7.0 while the pyproject stayed at 0.6.0. Dependency pins updated to meshbay-common>=0.8.0. Protocol versions are independent and unchanged: MNP 0.12, MHP 0.1. The Electron client stays on its own 0.1.0 track (hub MIN_CLIENT_VERSION). Since 0.6/0.7: public-groups admin switch with full server-side enforcement and a group Revoke action; chat link previews (node-side URL unfurl, SSRF- gated); chat composer focus + scroll-to-bottom on tab entry; whole-group "Filter files" search. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(files): make "Filter files" search the whole groupChristophe Besson2026-08-282-6/+29
| | | | | | | | | | | | | | | | | The filter only ever matched files whose folder was exactly the current path, so at the top of a group — where every row is a root, never a loose file — typing in the field did nothing at all. With text in the field the panel now searches every file in the group by name or containing folder, wherever it lives, and hides the folder rows (you are searching, not browsing). Each result shows its folder as a sub-line; clicking it clears the filter and opens that folder. Empty field restores the normal folder-by-folder view unchanged. Also hardened the sort comparators against an entry missing `name`/`type`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(chat): focus the composer when the Chat tab opensChristophe Besson2026-08-281-0/+10
| | | | | | | | | | ChatPanel remounts on every tab switch, so a mount effect is the tab-entry hook. `focus({ preventScroll: true })` so it does not fight the panel-sizing and scroll-to-bottom effects still settling, and so a phone opens the keyboard without the view jumping. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* Merge branch 'feat/chat-link-previews'Christophe Besson2026-08-289-9/+777
|\
| * feat(chat): link previews for pasted URLsChristophe Besson2026-08-289-9/+777
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Paste an http(s) link in a group's chat and it unfurls into an OpenGraph card — title, description, site name, and image — the way WhatsApp/Signal/ Slack do it. The fetch is the node's, never the browser's or the hub's. The browser cannot: a strict img-src/connect-src and CORS block it, and a direct fetch would leak every reader's IP to the linked host on each render. The hub must not touch group content (draft-v6 §2.5). The node already fetches third-party metadata for the Videos and Music apps, over the same authorised path. Flow mirrors media_meta_req: the client sends `link_preview_req {url}`, the node replies `link_preview_resp` with the card fields (or `ok: false`), and any OG image is stored under its blake3 in the existing media_cache thumb store — the client then fetches it via the normal file_req path, exactly like a poster. Nothing durable is added: the card text lives in a bounded in-memory TTL cache on the node (draft-v6 §2.7 — enrichment on demand, the asking device caches), and MNP goes 0.11 → 0.12 (additive: an older node logs "unknown type" and the client shows the bare link). Because the URL is chosen by a *member* and triggers an outbound request from the operator's machine, `linkpreview.safe_url` is an SSRF gate: http(s) only, no credentials, and every resolved address must be globally routable — no loopback, private, link-local, multicast or reserved range, cloud-metadata included. Redirects are followed by hand so each hop is re-checked. Residual, documented in the module: DNS rebinding between the check and connect, closed properly by pinning the checked IP — a follow-up. Also fixes a long-standing chat annoyance the preview cards made worse: opening the Chat tab landed a screen or two above the newest message because the scroll-to-bottom ran before attachment thumbnails and (now) preview cards had loaded and grown the content. A ResizeObserver keeps the view pinned to the bottom through late content growth, and does nothing once the reader scrolls up. Tests: test_linkpreview.py (the SSRF gate and the OpenGraph parse, incl. redirect re-validation and image downscaling) and test_link_preview_request.py (reply shape, the media_cache image round-trip, the result cache). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* | feat(hub): let a hub admin disable public groups instance-wideChristophe Besson2026-08-2821-75/+729
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A new General tab in Administration carries one switch, allow_public_groups, stored in a hub_settings key/value table (runtime-editable, unlike hub.toml). Default is on; an absent row means on, so an upgrade changes nothing. Enforcement is server-side on every hub-mediated path, not just the SPA: - create_group refuses visibility=public (403), staff included - list_public_groups the directory returns nothing (local + federated) - join_group open-joining a public group is refused - group_online_nodes a non-member of a public group is handed no node - signaling.webrtc_offer drops the "node hosts an open group" fallback - federation.export_directory advertises nothing to peer hubs The switch is read live, so flipping it back restores every path. Existing members of a group that predates the switch keep their membership row and their access — this is plan A, not a purge. GET /v1/hub/info exposes the flag (unauthenticated) so the create-group form and the sidebar's "Public groups" link render correctly. Also in the admin Groups tab: a Revoke action beside Suspend. Suspend is the reversible hub flag; Revoke calls POST /v1/admin/revoke, which sets status=revoked and broadcasts a signed revocation every node enforces (denylist + dropped live sessions). It is confirm-guarded and names the group. And a message fix the revoke work surfaced: group_online_nodes, join_group and webrtc_offer answered "Group is suspended" for any non-active status. They now report the real state, so a member of a revoked group is told "Group is revoked" rather than something reversible-sounding. Tests: test_public_groups_toggle.py (10) covers the switch end to end and the five enforcement paths; test_revocation.py gains the status-message assertion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(music): stop sharing one folder's cover across unrelated tracks, and ↵Christophe Besson2026-08-263-1/+99
| | | | | | | | | | | | | | | | | | | | | | | | | | | merge various-artists compilations into one album Two real, confirmed bugs in a large flat music library: - enrich_audio.py's sibling-cover fallback assumed one folder is one release. A large flat "chart ranking" folder mixing dozens of unrelated artists carried several distinct WMP AlbumArt-cache guids (one per original album a track was ripped from), and the fallback picked whichever one WMP had copied to Folder.jpg — attaching one unrelated release's cover to every other track in the folder. Now refuses to pick a cover at all once 2+ distinct guids show up, rather than guess. - music-app.js's groupMusicEntries grouped by artist first, album second, so a various-artists compilation (many genuinely different per-track artists, one shared album tag, no album-artist tag at all — a real ~20-track soundtrack rip has exactly this shape) could never be recognized as one release: every track landed alone in its own artist's bucket and got folded into a singleton pile. Now detects an album key shared across 2+ distinct artist keys and merges those tracks into one compilation card under a "Various" heading instead. Both verified against real, previously-affected files and live in the browser: the shared wrong cover is gone, and the compilation renders as one card with all its tracks in order.
* fix(video): a TMDB override never stored the metadata its chosen id namesChristophe Besson2026-08-262-0/+76
| | | | | | | | | | | | | | | | | | | | | | | Found live: "fix match" appeared to work for two shows but not for a movie whose own automatic search kept landing on the same wrong result. The override only ever recorded the file->tmdb_id mapping — never the metadata that id actually names. _do_media_meta_request's cache check agrees the mapping is fresh (same media_type) but finds nothing under that *new* id in tmdb_meta, since nothing had ever fetched it, and falls through to a brand-new search using the file's own title — reproducing the exact match the override was meant to replace. This stayed invisible for the two shows only because their own title happened to be enough for that fallback search to land on the right answer anyway, entirely independent of whatever the override recorded — never because the override was actually being honored. It surfaced on a movie whose own title search kept landing on the same wrong match regardless. Fetches and stores the real metadata for the chosen tmdb_id up front (via the existing _tmdb_build_meta, which needs only the id — no search result object required), so a later lookup finds the override itself instead of falling through to a search blind to it.
* fix(video): let the toolbar wrap on mobile — no room left for the filter fieldChristophe Besson2026-08-261-0/+7
| | | | | | | | video-app.js's toolbar isn't built from .toolbar-group, so it never got the existing row-per-group mobile treatment. Mode buttons plus the new All/Movies/Series control left nothing for the search field on a phone width. Wraps now, with the filter field taking its own full-width line like .toolbar-group's search field already does elsewhere.
* feat(video): add an All/Movies/Series filter to the toolbarChristophe Besson2026-08-2612-5/+85
| | | | | | | A segmented control to the left of the search box, defaulting to "All". Applied before the text filter — a title match within a type nobody asked to see still isn't shown. Resets to "All" on group change, matching the text filter's own reset.
* fix(video): recognize "S1"/"S2"-style season folders, not just full wordsChristophe Besson2026-08-262-0/+27
| | | | | | | | | | | | | | | | | Found live: a real show organized its first season as "House.of.the.Dragon. S01E01...mkv" inside a folder named "S1" (the show name embedded in every filename, so grouping worked by guessit's own title alone), but its later seasons as "S02E01. Episode's Own Title.mkv" inside "S2"/"S3" — no show name in any filename at all, relying entirely on the folder. Those seasons showed up as loose individual entries instead of grouped under the show: season_from_folder_name only recognized "season"/"saison"/"livre" as full words, so "S2" didn't register as a season folder at all, and the ancestor-based show-grouping fix (previous commits) never triggered for those seasons. A bare "S" + 1-2 digits as the *whole* folder name is now recognized too — anchored to the entire name so it can't match some unrelated folder that merely starts with "s" followed by digits.
* fix(video): fix two remaining bugs in Specials numbering and long-season ↵Christophe Besson2026-08-264-33/+159
| | | | | | | | | | | | | | | | | | | | | | | episodes 1. A season spanning more than one folder (a per-book Bonus folder nested inside every numbered season) got the same synthetic episode numbers handed out again in each folder independently — three unrelated Specials all showing up as "S0E01". _synthetic_episode_number now ranks across the whole show for the target season, not just one file's own folder. 2. guessit reads a bare 3-digit leading episode number as a concatenated season+episode guess rather than a plain episode number — "100" parses as season=1, episode=0, not episode=100, with nothing in its output distinguishing that from a real 2-digit episode. A season-like ancestor already overrides guessit's season (previous commit); this applies the same fix to episode via a direct regex on the leading number, capped at 3 digits so a leading year (4 digits) is never misread the same way. Both confirmed against the real library this whole fix was found on: per-book Bonus features across two books no longer collide, and a real 100th-episode file now resolves to episode 100 instead of 0.
* fix(video): group every episode under the show's own folder, not a per-file ↵Christophe Besson2026-08-264-123/+146
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | guessit title The mechanism itself was wrong, not just the TMDB matching: a bare episode numbering convention with no show name in the filename at all (`001 Episode's Own Title.ext`, no SxxExx, no show prefix — entirely ordinary on its own) makes guessit invent a "title" from whatever text follows the number. That text is the individual episode's own name, and differs for every episode in the folder — trusting it, as the code did, groups nothing together at all: every episode became its own single- episode "show", searched against TMDB by that one-off title alone. Whenever a season-like ancestor folder exists (a numbered season, or Specials/Bonus/Extras -> season 0), its own root folder now names the show unconditionally — never a per-file guessit title, which cannot tell a show's real name from an individual episode's one-off name when the filename carries no reliable ShowName/SxxExx structure. The walk continues past *every* consecutive season-like ancestor, not just the first: a per-season Bonus folder (Show/Season N/Bonus/file.ext) is nested two levels inside the show, both "Bonus" and "Season N" season-like on their own, and stopping at the first would hand back "Season N" as the show's name instead of "Show". Also adds "livre" ("book") to the season-word vocabulary (§3.4) — some shows number their seasons that way (Roman numerals) rather than "season"/"saison". Supersedes _title_from_show_siblings from the previous commit (removed): that fallback assumed a per-file title could still be trusted often enough to be worth borrowing from a sibling season folder — this fix means it never needs trusting in the first place once a season-like ancestor exists. Verified directly against the real library this was found on, not just synthetic tests: every sample file (a numbered episode, a Book/Bonus feature, a Book-root "making of") now resolves to the show's real name.
* fix(video): TMDB match cache never invalidated on movie<->show reclassificationChristophe Besson2026-08-262-2/+51
| | | | | | | | | | | | | | | | The real reason a node restart alone didn't fix already-indexed entries after the previous commit's enrichment change: _do_media_meta_request checked media_cache's file->tmdb mapping (keyed by content hash) and trusted it unconditionally, before ever comparing it against the file's *current* movie/show classification. A file whose season/episode changed on a later scan — exactly what the Specials-folder fix does, for every file it reclassifies from "movie" to "tv" — kept answering with its stale, wrong-kind-of-match forever, since nothing about a reclassification touches this cache or its key. Now falls through to a fresh search whenever the cached media_type disagrees with what the entry resolves to right now, rather than trusting a mapping that predates the file's current classification.
* fix(video): a Specials/Bonus folder's episodes were matched to TMDB as ↵Christophe Besson2026-08-262-10/+201
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | unrelated standalone movies Found live: a "Specials" folder full of one-off-named bonus episodes had every file appear as its own poster, matched against TMDB by its own title, because guessit finds no season/episode grammar at all in a filename with no SxxExx of its own — so the classification (episode vs standalone movie), based solely on that, fell to the movie branch. Real, unrelated films happened to share several of those one-off titles and matched confidently, one per Special, cluttering the Videos view with dozens of wrong posters instead of grouping under the show. An ancestor folder saying this is part of a show — a numbered season, or Specials/Bonus/Extras -> season 0 — is now trusted over the filename having no SxxExx of its own. The show's name can't come from this file's own guessit title (that's the bug) or from siblings in the same Specials folder (every one of them has the same gap) — it's borrowed from the show's ordinary season folders next door, which do carry it in the usual ShowName.SxxExx shape (_title_from_show_siblings). A synthetic, stable episode number (alphabetical rank among the folder's video files) stands in for the real one nothing in a Specials folder provides. Generic, not specific to a folder literally named "Specials": the same fallback fires for any season-like ancestor folder whose files lack per-file episode grammar, numbered seasons included (covered by a dedicated test). Also hardens _title_from_siblings to require the sibling's own episode number too, not just a title — otherwise it would borrow one Special's one-off title as if it were representative, on a folder like this one. Existing already-indexed entries will need a rescan (restart the node) to be re-enriched under this logic — nothing re-derives them on its own.
* feat(music): network-adaptive prefetch depth, opt-in keep-screen-on toggleChristophe Besson2026-08-2614-14/+133
| | | | | | | | | | | | | | | | | Prefetch depth (music-player.js): 5 tracks ahead on Wi-Fi, 3 on cellular — more runway through a screen-lock network gap when the connection is cheap and fast, less when it's metered. navigator.connection is Chromium-only; Firefox/Safari (where it's undefined) get the same conservative tier as an unrecognized connection type, never assumed fast. MAX_CACHED_BLOBS raised to 6 to hold the largest case (current + 5). Keep-screen-on-during-audio (new user preference, off by default): a Settings toggle, backed by a new whitelisted key on /v1/users/me/preferences (music_keep_screen_on). music-player.js holds a Screen Wake Lock only while a track is playing and only when the user has opted in — unlike the video player's unconditional lock, this must not fight the ordinary expectation (matching Spotify/Deezer) that the phone locks on its own while listening.
* fix(transport): reconnect used a fresh handshake token but a stale signaling oneChristophe Besson2026-08-261-0/+9
| | | | | | | | | | | | | | | | Found live: every reconnect attempt failed "Signaling failed: 401 Invalid or expired token", looping for 4+ minutes with no chance of ever succeeding. connect() takes one token but uses it in two places — the handshake sent to the node, and the Authorization header on the signaling POST to the hub — and only the constructor's original `this._accessToken` was ever used for the latter. onNeedToken correctly fetches a fresh token for each reconnect attempt, but it only ever reached the handshake; the signaling call kept sending whatever token the transport was constructed with, no matter how many minutes had passed or how many attempts fetched a new one. connect() now updates this._accessToken on every call, reconnects included, so both places use the same current token.
* feat(music): prefetch 2 tracks ahead instead of 1Christophe Besson2026-08-261-4/+16
| | | | | | | | | | Complements the WebRTC auto-reconnect: a track already sitting in blobCacheRef needs no live connection to play, so whatever was fetched before a screen lock started plays through it regardless of what the transport is doing during the lock. One track of runway was often shorter than the lock itself; two buys more of it. MAX_CACHED_BLOBS (3) already covers the currently-playing track plus these two, so no cache-size change needed.
* fix(transport): wake a backing-off reconnect on visibilitychange, fix ↵Christophe Besson2026-08-261-1/+39
| | | | | | | | | | | | | | | | | | | | | | | | | listener leak Confirmed live by trace: during a screen lock, every reconnect attempt failed with "Failed to fetch" (the browser grants no network access to a locked/backgrounded tab, no code can change that) — expected. But the backoff timer itself was also throttled while locked: an attempt scheduled 30s out took ~3 minutes of wall clock to fire, because a backgrounded tab's timers run only when the OS lets them. Recovery after unlocking was correspondingly delayed rather than prompt. Fix: an always-on visibilitychange listener (separate from the diagnostic one, and unlike it not gated on trace mode) resolves the current backoff wait immediately once the page is visible again, instead of waiting out whatever of it is left. The actual reconnect this enables is fast (~1.2s in the trace that showed the "Failed to fetch" run) — the wait was the throttled part. Also fixes a real bug the same trace exposed: connect() re-arms the diagnostic visibility listener and health-ping interval on every attempt without ever removing the previous instance's — 8 failed attempts during one lock left 8 duplicate `visibility` trace lines per real event, and (more than a cosmetic issue) 8 concurrent health-ping intervals once reconnected.
* fix(hub): no-store on the SPA HTML shell — pull-to-refresh wasn't enoughChristophe Besson2026-08-261-3/+15
| | | | | | | | | | | Likely root cause of a very confusing test result: the /app HTML response had no Cache-Control at all, so a browser that decided to cache it heuristically could keep re-serving the SAME old page (old ASSET_V, old JS) indefinitely — a normal reload, pull-to-refresh included, has no reason to override a cache entry it still considers fresh. Every static asset already gets a fresh URL from ASSET_V precisely so a change is visible, but that only matters if the HTML naming that URL is itself refetched. no-store forces every navigation here to hit the network.
* fix(music): don't throw "Transport not connected" while a reconnect is landingChristophe Besson2026-08-262-14/+38
| | | | | | | | | | | | | | Found live: >5min screen lock while music played, then "Transport not connected" at the 2nd track's end (~8min in) despite the auto-reconnect from the previous commit. Root cause: fetchTrackBlob's own `!transport.connected` pre-flight check ran and threw before the already-in-progress reconnect got the few seconds it needed — it never reached _sendAndWait, which is the only place the previous fix taught the transport to wait. Adds transport.waitForReconnect(), factored out of _sendAndWait's existing gate, and calls it from fetchTrackBlob before giving up. No-op when nothing is being reconnected, so the ordinary path is unchanged.
* feat(video): hold a Screen Wake Lock while a video is openChristophe Besson2026-08-261-0/+43
| | | | | | | | | | | | | Purely additive and isolated from the streaming/transport code: touches no DataChannel, SourceBuffer, or playback state, so it cannot itself cause a stall or regress existing playback (PC/Electron included) — feature-detected and try/caught, a no-op wherever unsupported or refused. Sidesteps the commonest real-world trigger for the WebRTC-drop recovery (the phone auto-locking on its own idle timer while someone just watches). Does nothing for a deliberate power-button lock or backgrounding the tab — released automatically in both cases per spec — so the reconnect path is still what handles those.
* fix(transport): auto-reconnect after WebRTC failure, without dropping ↵Christophe Besson2026-08-264-8/+224
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | in-flight streams/downloads Confirmed live (client trace + node logs, mobile screen-lock ~5min): ICE goes disconnected -> failed within ~10s on both ends, but the DataChannel's readyState stays "open" throughout, so nothing failed fast — every request just sat out its own 8s/30s timeout, matching the reported symptom (poster spinners, blocked chat, dead new streams). transport.js: on connectionState "failed", reject pending requests immediately (TransportLostError) and start a self-contained reconnect loop (capped exponential backoff, redoes the full signaling handshake — the node already discards the old session on its own "failed"/"closed", so there is nothing lower-level to resume). New hooks: onNeedToken (fetch a fresh JWT, since the captured one may have expired during the outage) and onReconnected (let a consumer resume something that was mid-flight). file-utils.js: pipelinedDownload retries a lost chunk instead of aborting the whole transfer — covers Files downloads, poster/thumbnail fetches, and music-player.js's blob-based track download, all of which go through it. video-player.js: onReconnected reissues the existing seek-to-current-time path, which already knows how to land a new stream_init on the live SourceBuffer without resetting playback. Playing audio is unaffected either way — musicbay.md's design downloads a track to a blob before playing it, so a dead transport was never a network dependency for what is already playing. Stays on this branch until confirmed by real-device testing.
* debug(transport): opt-in WebRTC health tracing for mobile-lock investigationChristophe Besson2026-08-262-0/+195
| | | | | | | | | | | | | | | Client (transport.js): a localStorage ring buffer of connection/ICE/ DataChannel state transitions, visibility changes, request timeouts, and periodic health pings — enabled once via ?trace=1 (persists), read back at any time via #mb-debug without devtools. Off by default, zero behavior change unless enabled. Node (webrtc_server.py): MESHBAY_WEBRTC_TRACE=1 gates ICE-state-change logging and a per-session heartbeat (message count, seconds since last message, ICE/connection state) every 30s. Debugging aid for the "stuck after several minutes of mobile screen lock" report — not a fix. Stays on this branch until confirmed useful/resolved.
* fix(hub): disable Android's default tap-highlight flashChristophe Besson2026-08-261-0/+10
| | | | | | | | | | | | | | | | | | | | | The :hover/:focus fix in 909505f didn't fully clear the "stuck pressed" look reported on mobile Chrome: screenshots showed the music player's "next" button (which carries no state class in the JS at all — not .active, not .music-player-play) filled with a solid accent-blue circle after a tap, and the same on "repeat". Neither :hover (already scoped to `hover: hover`) nor the default focus outline (already cleared for non-:focus-visible) produces a filled background, so neither explained it. Third, separate mechanism: -webkit-tap-highlight-color, Android's own touch-feedback flash, never disabled anywhere in this stylesheet. Left at its default, many Android builds tint it from the OS accent colour, which is why it read as "the app's own blue" rather than an obviously foreign highlight. Unrelated to real :hover/:focus, so disabling it carries no keyboard-accessibility tradeoff at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* fix(hub): stop hover/focus states from sticking on mobile touchChristophe Besson2026-08-261-2/+22
| | | | | | | | | | | | | | | | | | | | | | | | | | A tap on a touchscreen fires a synthetic hover with no "pointer left" to end it, and leaves the tapped element genuinely focused (unlike a desktop click) — so any :hover-styled button looked permanently "pressed" until something else was tapped, and any plain <button> with no custom hover at all (the nav hamburger) showed the browser's default focus ring for the same reason. Not reproducible on desktop, which actually has a mouse to move away with. Found live on mobile Chrome, 2026-08-26 — most visible on the music player's next/prev/shuffle buttons, but the same mechanism affects any button in the app. Two small, general fixes rather than a per-button patch: - `:focus:not(:focus-visible) { outline: none; }` in the global reset — clears a lingering focus ring from a pointer/touch interaction while leaving real keyboard-navigation focus untouched. - `.music-player-btn`/`.music-player-play`'s :hover rules scoped inside `@media (hover: hover)`, so a touch tap never triggers them at all. Only the two reported, confirmed cases are touched here — the same `@media (hover: hover)` wrap applies to any of the stylesheet's other :hover rules if the same stickiness shows up elsewhere. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* fix(node,hub): key music/media metadata lookups by file_id, not pathChristophe Besson2026-08-268-104/+413
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | IndexEntry.path is the *folder* a file is in (indexer.py's _virtual_dir docstring: "the directory a file appears in"), not the file itself. GroupIndex.get_entry_by_path() treated it as if it named one file, and every one of its four callers did too: _do_music_meta_request, _do_media_meta_request, _do_tmdb_override, and _admin_exec_tmdb_override. Any two files sharing a folder — an album is one folder with many tracks, a season is one folder with many episodes — collided: a lookup by path silently returned whichever entry the index happened to iterate to first, regardless of which file the client actually asked about. Found live (2026-08-25): three unrelated albums ("High Tone - Various", two "Le Peuple de l'Herbe" albums) all showed the same MusicBrainz cover, because all their representative tracks happened to sit in one "high_tone" folder alongside a track that legitimately matched that cover. A force-reload didn't help — the bug is server-side, not a stale client state. Fixed by keying these four request/response pairs by `file_id` (the entry's own content hash — already unique, already how every other lookup in the system identifies a file) instead of `path`, both in the wire messages (music_meta_req/resp, media_meta_req/resp, tmdb_override) and in music-app.js/video-app.js's own hooks. GroupIndex.get_entry_by_path is now unused and removed — GroupIndex.get_entry(file_id) already did the right thing. No test previously exercised either handler with two entries sharing a folder — the only existing coverage (test_tmdb_override_policy.py) gave each entry its own folder, so the bug never had a chance to show up. Added that scenario there and in two new test files, all confirmed failing against the pre-fix code before being confirmed green against the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* feat(node): share the (path,size,mtime)->hash index cache across every groupChristophe Besson2026-08-268-26/+475
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | An operator routinely shares the same physical folder into more than one group (a music library, a Séries drive) — IndexCache used to be opened once per group (data_dir/{group_id}/index_cache.db), so the second group to reference an already-fully-hashed multi-terabyte folder paid the same full content read the first one did. IndexCache itself carried no group_id in its schema; only daemon.py's wiring did. Now one instance, opened once at startup (data_dir/index_cache.db), shared by every group's DirectoryIndexer. Confirmed against a real deployment (2026-08-25/26): a group sharing an already-indexed folder with an existing group indexes it instantly, with zero rehashing. Also fixes a related cross-group correctness gap found during this work: media_cache.db (thumbnails, TMDB/MusicBrainz metadata — already node-wide, untouched by this change) was pruned for a file the moment it left *one* group's index, even if another group's index still held the same content hash — forcing a redundant re-fetch/re-probe/re-thumbnail for a group that never actually lost anything. Prune now runs only once no group's index references the file_id any more. Adds a node admin UI action ("Maintenance" card, prune-index-cache) to drop cache rows that no longer belong to any group's roots — skips anything under a root that is merely temporarily unavailable (indexer.py's "a root that goes away freezes, never empties" rule extends to this cache too, or a reconnected drive would pay a full rehash for no reason). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* fix(hub,node): Create Group wizard silently skipped apps, and lost track of ↵Christophe Besson2026-08-254-29/+198
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | scanning progress Two real-world bugs found together while testing multi-root group creation: - CreateGroupWizard only sent the enabled-apps PUT when the operator had *unchecked* something, assuming "every box left checked" already matched the node's own default (Roster.DEFAULT_APPS = chat, files). It doesn't — so leaving every app checked, the common case, silently left Videos/Music/Photos disabled on the node. Now sent unconditionally. - The wizard's "add extra roots" step never polled index-status, so once step 3 (which only watches the first/upload root) finished, the progress bar froze while the node kept scanning the remaining roots for minutes, unwatched. Added waitForRootsIndexed (platform.js), mirroring waitForGroupHosted's own race handling. That fix exposed a deeper one: indexer.py's _scan_root() only flipped `progress.scanning` on *after* walking the directory and stat()-ing every file — both off-loop, but slow enough on a large root that a poller's grace period (waitForRootsIndexed's 5s) could expire before ever observing `scanning: true` (confirmed against production logs: a GEK-init step fired 5.058s after a root started scanning, matching the grace period almost exactly). The stat() pass was also a synchronous loop directly on the asyncio event loop — blocking the whole daemon (WebRTC, chat, admin UI) for as long as it took on a root with many files. Both fixed: `scanning` now flips on before the walk starts, and stat()-ing is now off-loop too (_size_files). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
* fix(node): fall back to a loose MusicBrainz query when the strict one finds ↵Christophe Besson2026-08-252-13/+159
| | | | | | | | | | | | | | | | | | | | | | nothing search_release() only ever tried a field-scoped exact-phrase Lucene query (artist:"..." AND release:"..."). Verified live against musicbrainz.org: any deviation from MusicBrainz's own spelling (a year suffix, an edition tag, an artist credited under an older/aliased name) drops it to zero results outright rather than a low-scored one, so the confidence check never even ran — this is why well-recognized artists were still getting almost no cover art. Add an unscoped loose-query fallback, escape Lucene special characters in the interpolated tag text, and make the confidence score consider the artist match too (not just the album title) now that the fallback has no field scoping to rely on. Also document MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT in the systemd units and man page, mirroring MESHBAY_TMDB_DEFAULT_TOKEN's precedent — never a literal value in source, configured via node.env. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3