summaryrefslogtreecommitdiffstats
path: root/packages
Commit message (Collapse)AuthorAgeFilesLines
...
* 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
* feat(client): add app icon based on the MeshBay logoChristophe Besson2026-08-252-0/+11
| | | | | | | | | | | | | | Adds build/icon.png (1024x1024, cropped/upscaled from QE/meshbay-logo-v2.jpg) at electron-builder's default buildResources path, so packaged .deb/.rpm builds get a real icon instead of Electron's stock one. The generic `build/` gitignore rule (Python artifacts) was swallowing this legitimate source directory, so it gets an explicit exception. BrowserWindow now points at the same file for its own icon, and the process runs with --class=MeshBay so a dev run's WM_CLASS matches the StartupWMClass electron-builder derives from productName for the packaged app — kept consistent, even though the launcher/dash icon match itself only resolves once a .desktop file exists (i.e. after packaging, not in dev).
* fix(node): make Photos/Video/Music enrichment survive node restartsChristophe Besson2026-08-257-34/+377
| | | | | | | | | | | | | | | | | | | | | | | | | | Only thumbnail bytes were ever durable in media_cache.db — every other derived field (photo width/height/EXIF, video ffprobe duration/dims, audio cover art) lived solely on the in-memory GroupIndex entry, so a node restart re-decoded every photo through Pillow, re-ran ffprobe on every video, and re-scanned for every album cover from scratch, even though the answers already sat in the cache. Adds photo_meta and video_meta tables (content-only fields, keyed by file_id) and checks them before doing the expensive work. Audio gets no new table: mutagen reads tags and duration in one inseparable call, so caching duration alone buys nothing — instead cover-art extraction alone is skipped via a new skip_cover flag when a cached cover already exists. Deliberately excluded from all three caches: anything derived from the filename or folder path (video display_title/season/episode via guessit, audio artist/album folder-fallback) — those must keep being recomputed fresh so a rename/move is still correctly re-derived by the existing _reenrich_renamed_*_entries mechanisms, instead of silently handing back a stale parse under the new name/location. Regression tests prove cache reuse by deleting the source file (or cover) between two enrichment runs, and prove rename/move correctness survives the new cache by renaming/moving to a path that never exists on disk.
* feat: add Photos group appChristophe Besson2026-08-2534-105/+1812
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A new group application (docs/apps.md's plug-in mechanism), following the plan in docs/photos.md. Unlike Videos/Music: several photo roots per group instead of one (photo_roots is a set, one signed op replaces it whole), a single album-grid view with no third-party matching step, and per-photo info read from the file's own EXIF at index time — no metadata service, no credential, no outbound network call at all. Protocol (meshbay-common, MNP 0.10 -> 0.11, additive): `taken_at`/`camera` on IndexEntry; `photo_roots`/`photo_roots_ack`; `OP_PHOTO_ROOTS`. Node: roster.py stores photo_roots as a group_settings entry (JSON list, same shape as enabled_apps); ops.py/webrtc_server.py validate and sign the whole set in one op, same pattern as apps_enabled; a new PhotoEnricher (indexer/enrich_photo.py) runs Pillow in its own small bounded pool, separate from the video/audio pools, producing a resized thumbnail plus the two EXIF fields — never GPS, checked by a grep-based regression test. Client: photos-app.js — one album card per directory containing images, a per-album photo grid, and a lightbox with next/previous (keyboard and buttons), zoom in/out/fit/100% starting from the actual on-screen fit percentage, and a "zip this album" button reusing files-app.js's own zip mechanism (lifted into file-utils.js's downloadDirectory so both call the same implementation). group-settings.js gets an add/remove multi-root picker, distinct from Videos/Music's single-value one. Bugs found and fixed before this ever shipped, worth keeping the story of: - enrich_photo.py read width/height from the raw image *before* applying EXIF orientation correction, and read DateTimeOriginal off the plain 0th-IFD Exif object — a real camera stores it in the Exif sub-IFD, which Pillow only exposes via get_ifd(Exif). A flat, hand-built EXIF dict round-trips through Pillow either way, which is exactly what would have hidden both bugs; the regression test builds EXIF with piexif instead, matching what real hardware produces. - photos-app.js's album grouping stripped a trailing path segment from entry.path under the assumption it still carried a filename — it doesn't (files-app.js's own convention: e.path is already the containing directory), so every album collapsed one level into its parent. Found live against a real multi-folder library. - transport.js's ADMIN_OP_TYPES allowlist (already the fix for an identical bug on video_root/apps_enabled, see 4783d81) was missing photo_roots: its admin_challenge matched no pending request and was silently dropped, so saving a photo root just timed out after 30s with no error. - daemon.py pruned a thumbnail when its file left the index (root removed or reconfigured) but never forgot the content hash was "already attempted" — the same bytes reappearing under a renamed/relocated root (an operator's real workflow) were then permanently skipped, forever, with nothing to indicate why. Discarding the attempt alongside the cache entry on prune is what makes pruning actually reversible. - packages/meshbay-client's app:// protocol handler served every file with no Cache-Control header, so Chromium was free to serve a stale cached copy indefinitely — none of several `npm run sync-ui` + reload cycles during development actually picked up the new code until the renderer's disk cache was cleared by hand. Now sends Cache-Control: no-store. - the lightbox's zoomed image used flex centering (align-items/ justify-content: center) combined with overflow: auto — a well-known trap where the browser centers overflowing content by shifting it, and the leading half of that overflow (here, the top of a zoomed photo) sits outside what the scrollport can actually reach. Reported live as "unusable". Fixed by switching to top/left alignment once zoomed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiZG4AuSnxHohQMpwTHTyL
* fix(node): scope _enriched_attempted by group, not just content hash0.7Christophe Besson2026-08-254-15/+79
| | | | | | | | | | | | | | | | | | | | | | Major finding: entry.id is a content hash, so the exact same physical file — the same MP3, byte-for-byte — indexed into two different groups (a shared library reused across several demo/test groups, or genuinely the same folder shared into two groups) produces the *same id* in both. _enriched_attempted was a single flat set of bare ids shared across every group this node hosts. The moment one group's copy got enriched, every other group's otherwise-identical copy read as "already attempted" and was skipped forever — nothing else ever revisits an id once it's in this set. That group's Music tab (or Videos tab, same bug, same set) showed every affected file at duration 0 with no artist/album/thumbnail, permanently, no matter how long you waited or how many times you reloaded — group A having been enriched first was enough to silently starve every later group of the same content. Now keyed by (group_id, entry.id) throughout — the enrichment gate, the sweep, and the rename re-enrichment path, for both video and audio (they already shared the one set, and the collision risk is identical for both). New regression test constructs two groups with byte-identical audio content and confirms both enrich independently.
* fix(hub): poll for a root count change instead of trusting one fetchChristophe Besson2026-08-251-2/+34
| | | | | | | | | | | | | | | | | Point 1 (directory list not updating without a full page reload) turned out to still reproduce after the earlier onRefreshIndex fix — that one addressed the Videos/Music root pickers (nodeDirs), but this section's own list reads a different field entirely (ops.list_groups returns the *runtime* root set, groups_ctx[gid]["roots"]) that only gets replaced once _reload_config_inner's retarget actually finishes. /api/reload itself is fire-and-forget on the node by design (ops.start_reload's own docstring — a brand-new group's initial scan can take minutes, the caller must not block on it), so a single loadNodeInfo() call right after can land in the gap before that replacement happens and show the pre-change count. Both the add and remove handlers now poll /api/groups (up to ~4s, every 400ms) until the root count actually matches what the action should have produced, rather than fetching once and hoping the timing worked out.
* fix(hub): keep an unmatched admin_challenge visible, add trace loggingChristophe Besson2026-08-242-1/+19
| | | | | | | | | | | | | | | | | | A real report showed audio_root timing out with *nothing* logged in between the connection handshake and the timeout — no admin_challenge, no error, nothing. The previous fix made an unmatched admin_challenge return silently (correctly, to stop it stealing an unrelated pending request — see the earlier commit), but that silence is indistinguishable from "the request never reached the node at all", which is exactly the ambiguity blocking this investigation. An unmatched admin_challenge is now logged (op, op_id, and the full set of currently-pending keys) instead of dropped quietly, and setAudioRoot/_authorizeAdminOp trace both hops of the round trip explicitly. Node-side, _do_audio_root gets a debug log at entry — cheap, and the only way to know from server logs alone whether the request was ever received if the client-side trail comes up empty. Diagnostic only: no routing behavior changed from the previous fix, verified against the same reproduction script.
* fix(hub): make root-folder save success/failure actually visibleChristophe Besson2026-08-241-9/+10
| | | | | | | | | | Flagged directly: "Saving" ran for a few seconds then just stopped, with nothing telling the operator whether it had worked. Both outcomes used the same dim .settings-hint styling, so a real failure and a real success looked identical at a glance. Success and failure are now tracked separately (previously one plain string held either) and rendered with the same success-msg/error-msg styling already used elsewhere on this page, so which one happened is unambiguous.
* test(node): a real signed audio_root save through the full challenge pathChristophe Besson2026-08-241-3/+123
| | | | | | | | | | | | | | | | | | | | Every existing audio_root test either called ops.set_audio_root directly or mocked out _issue_admin_challenge — none of them exercised real signature verification, _do_admin_response, or the shared groups_ctx/ roster wiring _run_op depends on. Worth ruling out a break somewhere in that real path specifically: a report described a save that looked like it worked (the Music tab showed content right after) not surviving a reload. Drives the real _do_audio_root -> admin_challenge -> sign -> _do_admin_response -> _admin_exec_audio_root path with a genuine Ed25519 operator key, then opens a *separate* Roster instance against the same db file — the direct question a "worked, then reverted" report raises: does the value actually land durably, in a form any later connection reads back correctly. It does; this passes. The one thing missing from the session fixture to get this far was peer-registry self-registration (a real session adds itself on handshake completion — without it, the final ack has nowhere to go, including back to the requester).
* fix(hub): key admin_challenge/admin_response by op, not arrival orderChristophe Besson2026-08-241-0/+69
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Reproduced from a real report: enabling the Music app and saving its root folder in the same Settings visit (the new merged Directories section makes this a fast, natural back-to-back sequence) fired two signed admin ops within milliseconds. Neither the admin_challenge reply nor the admin_response ack two steps later was keyed by anything — both were matched purely by "whichever request happens to be oldest pending" (transport.js's own documented last-resort guess). apps_enabled's challenge stole audio_root's pending slot; audio_root's own request never received a challenge at all and just sat there until its 30s timeout. Both hops are now keyed by op name: admin_challenge already carries `op` from the node, and admin_response is given one client-side purely for this (the node's _do_admin_response never reads it — only op_id and signature). A stray admin_challenge with no matching request is dropped outright rather than guessed at — it is never a broadcast (one `self._send`, no peer loop, docs/webrtc_server.py), so a session with no matching key genuinely has nothing to do with it. A domain ack (an actual broadcast — every connected client gets audio_root_ack, not just the requester) still falls through to the existing per-type handling when nobody here is waiting on it, unchanged. Verified against a standalone reproduction of the exact race (two admin ops racing, reordered replies) — this codebase has no browser-JS test runner to add as a real regression test, so the repro lived in a scratch script rather than the suite.
* fix(hub): refresh directory list after adding/removing a shared rootChristophe Besson2026-08-242-1/+13
| | | | | | | | | | | | Folders (unlike files) only ever arrive over MNP as part of a full index_sync — the ongoing index_delta push has no `dirs` field at all (daemon.py never puts one there for incremental updates) — so a directory added or removed via the Electron-local add/remove flow never showed up in the Videos/Music root pickers until the whole page was reloaded. The merged Directories section made this easy to hit: add a shared folder, then immediately try to pick it as a root, in the same visit. Both actions now call the same onRefreshIndex a chat upload already uses to pick up its own effect on the index.
* feat(hub): audio_root wiring, mutually-exclusive players, Settings reworkChristophe Besson2026-08-2415-92/+441
| | | | | | | | | | | | | | | | | | | | | | | | Five related pieces of polish against the Music app and Settings, all from the same conversation: - Music app now requires audio_root, same as Videos requires video_root: an empty-state message until one is set, and grouping filtered to only what's under it (underAudioRoot, mirroring video-app.js's underVideoRoot). Wires the new audio_root/audio_root_ack pair through transport.js and group-page.js state the same way video_root already flows. - Starting one player now stops the other — opening a film closes the music queue, starting a track closes the video modal. Both used to run at once, found live. - Group Settings reworked: every section but a bare form (invite, pair-operator, approve-device) is now collapsible (CollapsibleSection); the uploads on/off button is a real toggle switch (ToggleSwitch, reused for TMDB/MusicBrainz's enabled switches too, each now with an icon + status badge in its header instead of a plain checkbox row); and shared directories, the Videos root picker, and the new Music root picker are merged into one "Directories" section (RootFolderRow) instead of three separate ones scattered down the page — the root pickers only show once their app is actually enabled.
* feat(node): add audio_root, gate Music enrichment on it like video_rootChristophe Besson2026-08-249-41/+436
| | | | | | | | | | | | | | | | | | | | | | | musicbay.md's original call — Music needs no root, tag reads are cheap so just cover the whole shared tree — didn't hold up against a real messy library: everything under every shared folder got mixed together with no way to scope Music down to an actual music collection. This adds an audio_root setting, symmetric to video_root in every respect: signed operator op (audio_root/audio_root_ack, MNP bumped to 0.10), validated against a real directory in the group's own roots before a signature is even asked for, gates tag/cover enrichment exactly like video_root gates ffprobe/TMDB (nothing runs until it's set, only files under it once it is), and a set/change fires a one-off sweep of whatever the folder already contains. The old trigger — sweep everything the instant "music" joins enabled_apps — is gone along with the root-less design it belonged to; setting audio_root is now the trigger, mirroring set_video_root's enrich_video_root_fn exactly. Test coverage mirrors the video_root suite: policy (refuse before a signature round trip, accept/store correctly) and the enrichment gate itself (nothing without a root, only files under it, sweep on set).
* fix(hub): give the Music flat list its own look instead of Videos' reskinChristophe Besson2026-08-242-33/+71
| | | | | | | | | | | | | | | | | | Two complaints against real use: the artist -> album -> track hierarchy was invisible (every depth sat flush left, distinguishable only by which chevron happened to be open — Videos' own flat list never needed more than one level, so there was nothing to reuse for this), and a filled-in album unfolded into a wall of identical little icon-box squares, one per track, carrying no information a track row can actually use (unlike Videos' per-episode thumbnail). Track rows now reuse Mode A's own numbered tracklist style (.music-track-row: number, title, duration, no icon box) instead of Videos' boxy thumb-slot row. A folder's expanded contents get wrapped in a new .music-flat-children indent + rule line, so nesting reads as visible steps into the tree rather than same-level siblings. Folder rows (artist/album headers) still reuse Videos' flat-row style, which fits them fine — this is not a wholesale rewrite, only what didn't actually work.
* fix(hub): starting one player stops the otherChristophe Besson2026-08-241-1/+8
| | | | | | | Opening a film while a track was playing left both audio tracks running together — nothing closed the music queue when a video opened, and nothing closed the video modal when a track started. Both directions now stop whichever player wasn't just asked for.
* fix(hub): show what's actually playing in the queue panelChristophe Besson2026-08-242-0/+35
| | | | | | | | The panel's header said "Playing now" but only the panel itself was named that — the current track was just a highlighted row you had to spot in the list, easy to miss on a long queue and often scrolled out of view entirely on open. Now shows the track's own title/artist right under the header and scrolls the highlighted row into view when the panel opens.
* feat(music): transcode WMA/Musepack to AAC so they actually playChristophe Besson2026-08-2416-16/+413
| | | | | | | | | | | | | | | | | | | | | Tagging and covers for these two formats landed already, but neither one decodes in any mainstream browser's <audio> element at all — a real library scan turned up 273 such files that would show up correctly in the Music app and then simply fail on click. This closes that gap: the node transcodes to AAC/M4A on request (a one-shot whole-file conversion, not live-piped like video's fMP4 segments — an audio file is small enough that streaming it buys nothing), caches the result under its own content hash the same way a TMDB poster or a MusicBrainz cover is cached, and serves it back through the ordinary file_req/chunk path. That path used to assume anything in the media cache was thumbnail-sized (single chunk, always); generalized it to slice a cached blob the same way a real file on disk gets sliced, since a transcoded track can be several MB. New MNP pair (`audio_transcode_req`/`_resp`, version bump to 0.9), shares its concurrency cap with video's transcode pool rather than getting its own — both are real ffmpeg processes on the same node. Every other audio format is untouched: this only fires for .wma/.mpc, the two extensions that need it.
* fix(node): skip indexing audio files under 50KB, likely-corrupt sourceChristophe Besson2026-08-243-2/+58
| | | | | | | | | | The "P.H. Theme" failure investigated earlier turned out to be a genuinely corrupt 1256-byte source file with no audio stream at all, just an ID3 tag — a real, if rare, corruption pattern worth guarding against directly rather than only handling gracefully at playback time. Scoped to audio only, applied wherever a file actually gets hashed/typed (fresh scan and the cache-miss rehash path alike) — a tiny file of any other type is still indexed normally.
* feat(node): recognize WMA and Musepack as audio, read their real tag keysChristophe Besson2026-08-244-15/+261
| | | | | | | | | | | | | | | | | | | | | | A real-library scan turned up 250 .wma and 23 .mpc files that the indexer was silently classifying as "other" — genuinely lost from the Music app, not a consolidation-rule artifact (checked separately: the grouping logic itself drops nothing). Both are now indexed as audio and tagged properly: - WMA has no mutagen "easy" wrapper, so the generic tag reader was reading nothing from it at all. Reads the real ASF keys directly instead (Title/Author/WM-AlbumTitle/WM-TrackNumber), confirmed against a real sample file before writing the mapping. - Musepack's format auto-detection is unreliable enough (misidentified a real .mpc as MP3 in spot checks) that it now always opens by its own class instead of guessing from content. - Filters out another placeholder value found along the way: a French ripping tool's auto-generated "Album inconnu (<timestamp>)". Neither format decodes natively in a browser's <audio> element, so this gets them correctly visible, tagged, and covered — not yet playable in-browser. That would need server-side transcoding, deliberately left out of this change.
* fix(hub): auto-skip a track that fails to load instead of stalling the queueChristophe Besson2026-08-2411-1/+45
| | | | | | | | | | A genuinely corrupt source file (found via a real "1 track, fails to play" report) would stop a "play all" queue dead with no way forward except manually picking the next track. The player now counts consecutive load failures and advances past them on its own, capped so a pathological queue (everything broken) doesn't spin forever; the existing Promise-rejection catch and a new <audio> onerror handler both feed the same counter, since a decode failure can surface either way depending on the browser.
* feat(hub): play audio files from Explorer, scoped to the current folderChristophe Besson2026-08-243-6/+23
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Audio files were invisible to file-utils.js's canPreview, so their name never rendered as a clickable link and the toolbar's Play/View buttons never enabled for them - the only way to do anything with an audio file in Files was to download it. - file-utils.js: canPreview now includes 'audio' alongside image/ video/document. - files-app.js: the toolbar's canPlay/canView split treats audio like video (Play, not View) rather than falling into the generic preview path, which was never built for it anyway. - group-page.js: onPreview routes an audio entry to onPlayQueue - the same persistent player Music uses - instead of the preview modal. Deliberately not Music's artist/album grouping: the queue is every audio entry sharing the clicked file's literal containing directory (entry.path), sorted by filename, so previous/next in Explorer stays scoped to what's actually in that folder, tags or no tags. onPlayQueue already replaces whatever queue is playing unconditionally, so starting a track from Files while Music (or another Files folder) is already playing needs no special handling - it's the same "just a new queue" path either way. Client-side only, no protocol/index change. npm run sync-ui re-run. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(hub): consolidate loose tracks, "&"/"and" fold, player close/queueChristophe Besson2026-08-2416-45/+188
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Album-grid readability, part two: - groupMusicEntries (music-app.js): an album bucket left with exactly one track - a real album tag, but only one song from it, not the whole release - clutters the grid the same way an untagged loose track does. Both kinds now fold into one "<artist> - Various" tile per artist, unless there is only one leftover track overall, where relabeling buys nothing and the track keeps its own name (or the generic placeholder, if it never had one). - foldKey also normalizes "&" vs "and" ("Artist & The Band" / "Artist and The Band" is one act, tagged both ways across different rips of the same catalogue) alongside the existing case/whitespace fold. - music-player.js: a close button pauses and tears the player down; an unmount cleanup effect (pause, revoke every cached blob URL) fires either way, whether that's the close button or the shell tearing the bar down on its own. A "current queue" button opens an overlay listing the whole playing queue with the current track highlighted, click any to jump to it - works identically regardless of how the queue was built (an album, the consolidated misc bucket, a single standalone track), since it only ever reads the player's own live tracks/order/pos. - group-page.js: this component is not remounted when switching to a *different* group on the same /group/:id route (only the groupId prop changes) - so without an explicit reset, music from one group would carry into the next one opened. Resets musicQueue to null on groupId change; a tab switch inside one group still leaves it alone. - Scrubbed real artist/band names that had leaked into code comments and test fixtures (enrich_audio.py's docstrings, several test_enrich_audio.py assertions, a music-app.js comment) - replaced with generic placeholders, no behavioural change. - i18n: music.various, music.player_close, music.player_queue, music.queue_title added across all ten locales. Client-side only except none of this touches the node at all. npm run sync-ui re-run. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(hub): draw an actual CD for covers with no art, not a flat iconChristophe Besson2026-08-243-22/+68
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The monochrome disc icon from the previous commit read as placeholder UI chrome, not as artwork - too plain for something that is, for most tiles in a real library, the default look of the grid. - DiscPlaceholder (music-app.js): a small inline SVG illustration - dark disc base, an iridescent radial-gradient sheen mimicking the rainbow reflection a real CD's data side has, two faint groove rings, a light label ring, a dark spindle hole. Each instance gets its own gradient id (a module-level counter) rather than one literal id repeated - a grid renders many of these at once, and a shared id would leave every disc after the first pointing at whichever <radialGradient> the browser happened to resolve. - AlbumCard and MusicDetailModal now branch on coverHash directly: MediaThumb (the real chunk-path image) when there is one, DiscPlaceholder when there isn't - rather than routing "no cover" through MediaThumb's own generic small-icon fallback, which is still right for its other callers (list rows, Videos). - icon.js: the flat monochrome "disc" icon this replaces is removed - nothing else used it. - style.css: .music-disc-empty/.music-disc-svg replace the old .video-thumb-empty overrides; still sized relative to the tile (82%) so it scales with .music-grid's auto-fill columns. Client-side only. npm run sync-ui re-run. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(hub): big monochrome disc placeholder for covers with no artChristophe Besson2026-08-243-2/+19
| | | | | | | | | | | | | | | | | | | | | | | Album tiles/detail covers with no embedded art, no sibling image file, and no MusicBrainz match (or MusicBrainz off) used the same small music-note icon list rows use elsewhere - fine as a rare fallback, but most tiles in a real library land here, so it read as broken rather than as the default look of the grid. - icon.js: a "disc" icon (two concentric circles, same stroked style as the rest of the set) - a plain CD/vinyl glyph. - music-app.js: AlbumCard and MusicDetailModal's cover now pass emptyIcon="disc" instead of "music". - style.css: sized at 55% of the tile via CSS rather than a fixed px value, so it scales with .music-grid's auto-fill columns; color stays var(--text-dim), the same neutral tone every other empty state already uses - monochrome, not a new accent. Client-side only. npm run sync-ui re-run. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* fix(hub): repair NUL-byte corruption in music-app.js, simplify groupingChristophe Besson2026-08-241-0/+0
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two spots in music-app.js held a literal NUL byte where a plain space was intended - present since the file's very first commit (the LazyTile key's template literal joining artist and album) and reintroduced by the previous commit's sentinel key for the untagged bucket. Root cause looks like a tool/transport artifact rather than anything in the logic itself: git rendering the file as binary ("0 insertions/0 deletions" on a real content change) was the tell, caught before it reached anyone actually running this code. Fixed by rewriting the file clean and, while at it, removing the sentinel-key approach that produced the second NUL entirely: the untagged-tracks bucket is now a plain array on each artist entry, appended as a synthetic album only at the end, rather than a Map key that had to be guaranteed to never collide with a real folded album name. No behavioural change from the previous commit's intent - same grouping, same flat-mode rendering - just without the fragile mechanism that broke. Verified: zero NUL bytes anywhere in the file (checked the whole repo for the same class of corruption - only genuine binaries, PNG/WASM, have any), valid UTF-8, node --check passes, git diff renders as text again. npm run sync-ui re-run. test_hook_ordering.py, test_transport_contracts.py, test_locales.py: 31 passed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* fix(hub): fold case/whitespace in album grouping, flatten loose tracksChristophe Besson2026-08-241-0/+0
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - groupMusicEntries: artist/album grouping keys are now case- and whitespace-folded ("Racing With The Sun" vs "Racing with the sun" — a single mistagged track split a real album into two cards, reported live). The first spelling seen stays the display string; nothing is rewritten. Validated against a real ~5700-file library: 22 distinct (artist, album) pairs had more than one raw spelling before this, including the reported Chinese Man case. - The untagged-tracks bucket per artist gets a dedicated, non-foldable key rather than folding the translated placeholder text, so it stays one bucket regardless of UI language, and sorts after every real album rather than wherever the placeholder's spelling lands alphabetically. - Flat mode: an artist folder whose only "album" is that untagged bucket — a real, common shape here, a pile of loose singles with no album layer at all — no longer nests them behind an always-empty "Unknown album" row to expand first. They render directly under the artist. onPlayQueue already builds its queue from the whole bucket regardless of nesting, so previous/next already spanned the full pile; this only removes the pointless extra click to reach it. Client-side only, no protocol/index change — a page reload picks it up, no node restart needed. `npm run sync-ui` re-run. Full suite: 1129 passed, no regressions (JS structural checks — test_hook_ordering.py, test_transport_contracts.py — included). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* fix(node): stop inventing a fake artist from the shared root's nameChristophe Besson2026-08-244-29/+345
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Music grouping was measured against a real ~5700-file library and came back worse than a plain file listing. Root cause: the artist/album ancestor walk always climbed exactly two levels (parent = album, grandparent = artist) with no idea where the group's own shared root was. Any file in a flat top-level folder — common here: bare `Artist/track.mp3`, no album subfolder at all — had its "grandparent" resolve to the root directory's own name, so the artist got replaced by the share's name. Measured: 289 of 5664 tracks across 41 real, unrelated artists (Ben Harper, Dire Straits, Jimi Hendrix, Janis Joplin, ...) collapsed into one fake artist this way — the single biggest bucket in the whole library, ahead of every real one. - `_artist_album_from_ancestors` now takes the entry's own root boundary (daemon.py resolves it via `RootSet.split`) and refuses to read it as a name. A file sitting in a top-level folder — genuinely ambiguous, artist or a standalone album/compilation — is handled by `_split_top_level_folder`: split on "Artist - Album" when the (cleaned) folder name has that shape, otherwise the whole name becomes the artist alone, the more common real case here. - `_clean_tag` treats known tagger placeholders ("No Artist", a French tool's "Nouvel artiste (334)") as absent rather than a real value — they were just as truthy as a real name and were locking out the fallback that would have done better. "Various Artists" is kept, a real compilation credit rather than a placeholder. - A `title` tag that's the bare filename copied verbatim (track number included — found live on a whole CD-single) is stripped through the same prefix rule the filename parser already used (`title_parse.strip_track_prefix`), since a tag normally wins over the parsed title. - Cover art: only 11% of a 400-file sample had embedded art (expected for this era of rip), but 267 loose cover images sit beside the tracks across the library (Windows Media Player's `Folder.jpg`/ `AlbumArt_{guid}_*.jpg`, manual `cover.jpg`) and were never looked at. `_find_sibling_cover` checks the track's own folder before giving up — measured coverage 11% -> 26% on the same library, zero network calls. `_enriched_attempted` is in-memory and resets on restart, so a node restart is enough to re-run enrichment over an already-scanned library with the fixed logic — no rescan flag, no cache to clear by hand. 22 tests in test_enrich_audio.py (11 new), including the exact regression case end to end through the real pool. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* fix(hub): Create Group wizard steps app selection before the scan waitChristophe Besson2026-08-241-19/+27
| | | | | | | | | | | | | | | | | | | | | | | | | | | "Choosing applications" 404ed with "Group not hosted on this node" on any real (multi-thousand-file) library — found live creating a group against a 5787-file MP3 collection while testing the Music app. daemon.py registers a brand-new group in groups_ctx only once its initial scan finishes (ui/app.py's index-status docstring already says so); nothing group-scoped can succeed before that, however many times it's retried. The wizard's apps step ran *before* platform.waitForGroupHosted() (which correctly waits up to 30 minutes for exactly this), protected only by a 5×400ms withRetry meant for a sub-second race — nowhere close to covering a real scan. The stated reason for running it early (so a mid-scan joiner never sees a not-yet-disabled app) doesn't hold either: nobody can join before the group is hosted, same gate. Fix: move the apps step after waitForGroupHosted, matching where the roots/GEK steps already correctly run. No node-side change — the scan-then-register ordering in daemon.py is intentional and untouched. npm run sync-ui re-run to propagate to the Electron client. Hub suite: 429 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(hub): Music app client — album grid, flat list, persistent playerChristophe Besson2026-08-2422-4/+1129
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Implements the client half of docs/musicbay.md against MNP 0.8: - music-app.js: album grid (grouped by artist -> album, from index-time artist/album fields) or flat folder view, per-group localStorage toggle like Videos. MusicBrainz (music_meta_req) is only looked up when a track has no embedded cover at all — well-tagged files never trigger a network call, unlike Videos where TMDB is unconditional. Reuses video-app.js's MediaThumb/LazyTile (now exported) rather than duplicating the chunk-path thumbnail decode + virtualization. - music-player.js: the persistent player bar — queue, shuffle (Fisher- Yates, keeps the current track in place when toggled), repeat (off/ all/one), volume (localStorage), prev/next, a one-track prefetch cache. No MSE, no node-side streaming: a track is downloaded and decrypted once via file-utils.js's pipelinedDownload, same chunk pipeline Files already uses, then played from a blob URL. - group-page.js: owns musicQueue/musicbrainzConfig state and renders MusicPlayerBar outside the tab-switched area — deliberately, so playback survives navigating to Chat/Files, the same reasoning the video/preview modals are shell-owned rather than app-owned. - apps.js: registers "music". transport.js: fetchMusicMeta (keyed by path, same reordering-hazard fix as fetchMediaMeta), setMusicbrainzConfig/setMusicbrainzEnabled (signed ops, mirroring TMDB's), and the three new ack handlers. group-settings.js: a MusicBrainz settings section (contact string, per-group toggle) — the existing Applications checklist already picks up "music" for free, per apps.md's own claim. - icon.js: music/pause/skip-next/skip-prev/shuffle/repeat/volume, drawn in the same stroked style as the existing set. - i18n: group.tab_music, the music.* and settings_node.musicbrainz_* keys, translated (not just copied) across all ten locales, Polish carrying full one/few/many/other plural forms for music.n_tracks. - webapp.py's _ASSETS, test_hook_ordering.py's STATIC_FILES and test_transport_contracts.py's SPLIT_FILES gain the two new files. Full suite (common + hub + node): 1116 passed, no regressions. `npm run sync-ui` in meshbay-client confirmed both files copied. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* feat(node): Music app node-side — indexing, MusicBrainz enrichment, protocolChristophe Besson2026-08-2417-22/+1567
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Implements the node half of docs/musicbay.md against MNP 0.8: - IndexEntry gains artist/album/track_no (reuses duration/thumb_hash/ display_title, already generic). New musicbrainz_config/_enabled and music_meta_req/_resp message pairs, mirroring the TMDB shape. - title_parse.parse_track_filename: track-number-prefix + title parsing, fallback-only (embedded tags are the primary source, unlike Videos). - indexer.enrich_audio.AudioEnricher: mutagen-based tag/embedded-cover extraction through its own bounded pool (asyncio.to_thread, no subprocess — no ffmpeg-shaped deadlock risk). Gated on "music" in a group's enabled_apps rather than a video_root-style scoped folder. - musicbrainz.py: MusicBrainzClient — no API key (unlike TMDB), just a self-imposed ~1 req/s pace and a configurable, non-default User-Agent contact string; inert (no calls at all) when no contact is configured, never sends an unidentified client. - media_cache.py: file_mbid/mbid_meta tables alongside the existing TMDB ones, cover art reusing the thumbs table via a synthetic musicbrainz:{mbid} id, pruned on file deletion. - roster.py/ops.py/webrtc_server.py: musicbrainz_contact (node-wide) and musicbrainz_enabled (per-group, from the start) as signed operator settings, ALLOWED_APPS gains "music", _do_music_meta_request resolves and caches a release-level MusicBrainz match per (artist, album). - daemon.py: AudioEnricher/MusicBrainzClient wired alongside the video ones; a group's existing library is swept when "music" is newly enabled (no video_root equivalent — see musicbay.md §2.1). 41 new tests (musicbrainz.py against a mocked transport, admin-op policy for both new settings, media_cache round-trip/pruning, enrich_audio end-to-end against real ffmpeg-generated MP3s). Full suite (common + node + hub): 1116 passed, no regressions. Client-side (music-app.js, persistent player bar) not started yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
* fix(node,hub): HEVC transcode fallback, live-add progress, per-group TMDB toggleChristophe Besson2026-08-2423-254/+883
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Three bugs found live testing the Videos app against a real HEVC/EAC3 show, plus a design change requested afterward: - Streaming always did "-c:v copy", which faithfully reports a source's real hev1 codec string but is unplayable in a browser with no HEVC decoder (most Chrome/Linux builds). The node now transcodes to H264 whenever the probed codec is browser-incompatible (media_probe.py's new BROWSER_INCOMPATIBLE_VIDEO_CODECS), with a `transcode_incompatible_video` node.toml opt-out for operators who know their viewers already decode it. - Dropping a whole season into an already-watched folder gave no scanning indicator and no progress bar: IndexProgress was only ever updated by the two bulk scan paths, never by the real-time per-file watchdog path (_schedule_update/_debounce/_update_entry). That path now accounts a "burst" the same way, without double-counting a file rewritten mid-debounce. - A stray literal "0" rendered in the video detail modal when there was no TMDB match (`meta.confidence` is 0, and `0 && x` renders "0" in JSX/htm, not nothing) — `confident` is now a real boolean. - Whether TMDB is used at all moves from a node-wide setting to per-group (OP_TMDB_ENABLED/tmdb_enabled/tmdb_enabled_ack, scoped like OP_VIDEO_ROOT): an operator running a real media-library group alongside test/demo groups on one node wants outbound TMDB traffic for the one that needs it, not all of them. The custom API token and query language stay node-wide, one shared credential/cache (tmdb_config/OP_TMDB_CONFIG, unchanged reasoning). MNP_VERSION 0.6 -> 0.7, additive. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
* feat(node,hub): season-specific overviews, manual TMDB match correction, and ↵Christophe Besson2026-08-2427-27/+1411
| | | | | | | | | | | | | | | | | | | | | | | | | wizard polish Two operator-facing fixes for a real 3-season show whose automatic TMDB match was wrong at the show level: per-season overview/air_date tabs in the detail modal (falling back to the show-level text when a season's own is empty), and a "Fix match…" search-and-correct affordance that re-resolves every file sharing the corrected show's display_title. New signed op OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp, tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6. Also: the create-group wizard gets a spinning indexing indicator and an app-selection step, group settings default the TMDB language to the operator's own locale (never as a global default), and a file renamed mid-session now re-triggers title parsing instead of being silently skipped by the enrichment dedup guard. Fixes two bugs found during this work: the search overlay's z-index lost to the base video-overlay class and rendered invisibly, and season_meta's own empty overview didn't fall back to the show-level one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY