summaryrefslogtreecommitdiffstats
path: root/packages
Commit message (Collapse)AuthorAgeFilesLines
* 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
* 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