aboutsummaryrefslogtreecommitdiffstats
path: root/packages
Commit message (Collapse)AuthorAgeFilesLines
* fix(node): make WebRTC STUN fallback actually use every configured serverChristophe Besson2026-09-016-3/+309
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | aiortc's connection_kwargs() keeps only the first STUN URI from RTCConfiguration.iceServers ("only a single STUN server is supported"), and aioice.ice.Connection has a single stun_server field. So the node's four default STUN servers -- and anything added on the Node page or with `meshbay-node stun add` -- collapsed to stun:stun.l.google.com:19302. When that one server was slow or unreachable from the node, ICE gathering (get_component_candidates, timeout=5) burned its full 5 s with no server-reflexive candidate, adding seconds to every browser connection. The multi-server fallback of draft-v6 s2.12 was configuration only. transport/stun_multi patches aioice.ice.server_reflexive_candidate (same monkey-patch technique ice_filter.py uses on get_host_addresses) so a single ICE gather races the STUN binding request against every configured server on the one bound socket and takes the first answer. One reachable server anywhere in the list now yields a reflexive candidate in one RTT. - daemon: install_stun_multi() alongside install_ice_filter() - ops.set_node_settings: push the list to stun_multi.set_servers() so the CLI / Node-page hot-swap takes effect without a restart - webrtc_server.handle_offer: log ICE gather time and srflx count - test_stun_multi.py: fan-out, first-answer-wins, all-fail, empty-list fallback, DNS failure Verified end to end with a real RTCPeerConnection: with a black-hole STUN server first in the list, gathering still completes in ~0.07 s with full srflx candidates (previously a 5 s stall). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BSsQhfxEAhwi4nqc4hASmq
* feat: passphrase change and account recovery (auth-confirm)Christophe Besson2026-09-0133-78/+2759
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The passphrase derives two independent client-side values: auth_key (the hub verifier) and bundle_key (AES-GCM key for the per-node identity bundles, which live on nodes and never on the hub). Changing or recovering a passphrase is therefore two operations — swap the hub verifier, and re-wrap every reachable node's identity bundle. Flow A — change a known passphrase (Profile page) - POST /v1/users/password re-proves the current passphrase, swaps pw_hash/salt/version, revokes every refresh token and returns a fresh pair so the tab that made the change stays signed in. - MeshBayTransport.rewrapAllNodes: for every group's online node, connect with the old key, read the identity off the handshake, store it back under the new key. Returns updated / unreachable / failed so the UI can point at the operator-unpin fallback for the gaps. Always-shown confirmation dialog listing reachable and unreachable groups. Recovery key - keyderive.js generateRecoveryKey (32 random bytes, grouped Base32) and deriveRecoveryKey (HKDF-SHA256, domain meshbay:recovery:v1:<username>). - Every per-node identity gets a second copy wrapped under the recovery key: keypair_bundles.bundle_enc_recovery (node-only column, added in _SCHEMA_KEYPAIR and via a PRAGMA-guarded ALTER for existing DBs), carried on keypair_bundle_store / _resp. MNP 0.13 -> 0.14, additive. - session.recoveryKey is persisted in IndexedDB (slot rk) and lazy-loaded on connect, so a group joined in any later session still leaves a recovery copy. - Shown once at registration; optionally folded into the verification e-mail as a pass-through the hub never stores or logs, with an opt-out. - Profile -> Recovery key re-loads R and backfills every reachable node via rewrapAllNodes in bundleKey mode (no passphrase re-entry). Flow B — recover a lost passphrase (#/reset, linked from sign-in) - POST /v1/users/password/reset-request {username, email}: both must be the pair on file, checked against the blind email_hash (never decrypted). A mismatch — wrong e-mail, unknown username, non-active account — takes the identical no-op path (no code, no mail, same 200), so it reveals nothing and cannot be used to spray reset mail from a username alone. 5/min, 1-hour single-use code. - POST /v1/users/password/reset {username, code, new_auth_key}: same expiry / attempts / single-use checks as e-mail verification; revokes every session and deletes every registered device key so a stored one cannot sign back in past the reset. - ResetPasswordPage: request code -> code + optional recovery key + new passphrase -> reset + sign-in -> fan-out. connect() falls back to the recovery-wrapped copy when the passphrase key cannot open bundle_enc. Without a recovery key: sign-in is restored and each group needs the operator-unpin fallback. Supporting fixes (found in live testing) - member unpin now also deletes the keypair bundle; connect() mints a fresh identity when handed a bundle it cannot open (unless _rewrapOnly, set by rewrapAllNodes), so a rejoin completes instead of dead-ending before the invite-code prompt. - A browser with no bundle key gets a passphrase prompt on the group page instead of a "go back to the browser you registered on" message. - RegisterPage / LoginPage / ResetPasswordPage trim the username so every key derivation matches the hub's stored form. Docs: docs/auth-confirm.md. Locale keys across all ten catalogues. Tests: test_password_change, test_password_reset, test_recovery_email, test_recovery_key, test_rewrap_fanout, test_bundle_store_recovery, plus additions to test_admin_ops_mnp and test_webrtc_transport. Hub suite 492 passed; node suite 741 passed (the lone test_packaging_units failure is a pre-existing RPM-spec flake, reproducible on main). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGkxJW9br8Y9bhT8ywJ3oc
* feat: email verification for registration, email change, and invitationsChristophe Besson2026-08-3122-123/+923
| | | | | | | | | | | | | | | | | | Registration now creates a pending account and sends a 6-digit code via email; the account activates only after verification. Email changes on the profile page follow the same flow. Group invitations send a notification email to the invitee (without revealing their address to the inviter) containing the invite code and hub link. Backend: blind HMAC-SHA256 email index for uniqueness without decryption, mail.py for localhost Postfix delivery, verification endpoints, cleanup of expired codes and stale pending accounts, startup backfill of email_hash for existing users. Frontend: 3-phase register page, inline email change verification on profile, invite-notify call with status display. All 10 locales updated. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: bump version to 0.9.0v0.9.0Christophe Besson2026-08-316-8/+8
| | | | | | Packaging system complete and verified on Ubuntu 26.04 and Fedora 44. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(packaging): 4-package .deb/.rpm build system under /optChristophe Besson2026-08-312-28/+16
| | | | | | | | | | | | | | | | | | Shared venv architecture: meshbay-common owns the Python venv with all pip deps pre-installed; hub and node add only their code into it. Client is a standalone Electron app. No pip runs at install time. - Add build scripts (packaging/build/) for common, hub, node, client - Add orchestrator build-packages.sh with deb/rpm auto-detection - Add .deb control/postinst for all 4 packages - Add .rpm specs for all 4 packages (replaces python3-meshbay-common) - Add Gnome .desktop launcher and icon resizing - Add firewalld services (meshbay-cast, meshbay-node) and UFW profiles - Update systemd units to use /opt/meshbay-common/venv/bin/ paths - TMDB token baked into node package at build time via QE/node.env - Fix package-lock.json sync for protobufjs override Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(ui): extract Explore, Login, Register and CreateGroup from app.jsChristophe Besson2026-08-318-911/+851
| | | | | | | | | | | | | ExplorePage → explore-page.js (static import), LoginPage/RegisterPage/ FirstRunPage → auth-page.js (static import, LoginPage receives onLogin as a prop), CreateGroupPage/wizard → create-group-page.js (lazy-loaded via dynamic import(), same pattern as AdminPage/NodePage). app.js goes from 1803 to 914 lines. webapp.py _ASSETS extended with the three new files and the previously missing extracted pages. Test fixtures updated to follow the moved components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(ui): extract Settings, Profile and Admin pages from app.jsChristophe Besson2026-08-305-954/+990
| | | | | | Admin page is lazy-loaded so non-admin users never fetch it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(ui): use toggle switches in Settings page, trim verbose hintsChristophe Besson2026-08-3011-25/+28
| | | | | | | | | Replace checkboxes with the existing toggle-switch component for notifications, keep-screen-on, and allow-public-groups. Remove the "Notifications" label from per-group lines, and shorten the music-keep-screen-on and hub-URL hints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(ui): configurable ICE interfaces in the Node pageChristophe Besson2026-08-3014-14/+217
| | | | | | | | The ice_interfaces setting (auto-exclude vs explicit whitelist) is now editable from the Node page, persisted via the settings API and roster, and hot-swapped at runtime by re-installing the aioice filter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: configurable STUN server fallbacks for WebRTC ICEChristophe Besson2026-08-3020-16/+454
| | | | | | | | | | The WebRTC transport relied on a single Google STUN server — if it was unreachable, ICE gathering waited the full 4s timeout. Now four public servers are used by default (Google ×2, Cloudflare, Mozilla), configurable via node.toml, the Node page UI, and the CLI (meshbay-node stun list|add| remove|reset). Changes are hot-swapped on the live transport. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor(ui): extract Node page to dedicated module, use local APIChristophe Besson2026-08-302-737/+574
| | | | | | | | | | | | Move NodePage and NodeServicePanel from app.js into node-page.js, following the existing pattern (group-page.js, search-page.js). Lazy-loaded via dynamic import so browser users never fetch it. Replace all MNP/WebRTC calls with platform.node.call() (loopback HTTP API), eliminating the ~6s ICE gathering delay. The MNP protocol types and server-side handlers are kept for potential future browser use. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: scrub copyrighted names from tests, comments and docsChristophe Besson2026-08-307-31/+32
| | | | | | | | | | | | | | Real franchise / show / release-group names had crept back into test fixtures, code comments, a docstring and docs/mediacenter.md while fixing the saga-match and misclassification bugs. Replace them all with invented placeholders ("Some Saga", "A Different Show") and shape descriptions ("a franchise-origin film", "a 3-season show"). Behaviour and assertions unchanged; 738 node tests still pass. Record the rule in CLAUDE.md so it stops recurring. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(node): stop a numbered saga all matching its first filmChristophe Besson2026-08-302-11/+36
| | | | | | | | | | | | | | | | | | | | | Every "<Saga> Episode <N> - <subtitle>" file in a numbered franchise resolved to the series' first entry (a real, older film). `sequel_variants` stripped "Episode <N>" and offered the bare "<Saga>" as a candidate query; that matches the first film's original_title at ratio 1.0 and beat PASS 1's correct-but-lower hit. A franchise's bare name is very often a real, different film. When a Part/Episode/Chapitre/… keyword carried the index, sequel_variants no longer emits the bare base — only "<base> <digit>" and "<base> <roman>". Without a keyword ("<Franchise> 3") the bare base is still offered, so that fix is untouched. Verified live against TMDB: the franchise's episodes each resolve to their own entry; the earlier numbered-sequel, two-part-film and franchise-subtitle regressions all hold. docs/mediacenter.md §10.3. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* revert(hub): drop the poster-grid movie merge (V12)Christophe Besson2026-08-3012-77/+17
| | | | | | | | | | | | | | | | | | | | V12 collapsed movies that TMDB resolved to the same tmdb_id into one card. With the matcher still imperfect that fuses *different films*: every numbered entry of a saga whose bare title resolves to the same base id becomes one card, and two unrelated movies sharing a title do too (seen live on a 9-film saga and a 2-film pair). A tmdb_id-keyed merge only works once matching is reliable, which it is not yet. Movies render one card per file again; VideoDetailModal loses the `files` prop and the versions list, back to a single Play button; `.video-version-list` and the `video.versions` key (×10 locales) are removed. mergedShows (V6) is untouched — the operator's report was about movies. docs/mediacenter.md §10.1: V12 un-struck, marked reverted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(hub): make "Re-match" and "Fix match" the same size in the detail modalChristophe Besson2026-08-301-2/+4
| | | | | | | | | | | | .video-fix-match carried `margin: 8px 0` from when it was a standalone button; now that it sits in the .video-admin-actions flex row next to the plain .admin-btn "Re-match", `align-items: stretch` (the flex default) made the margined button 16px shorter than its neighbour — "Re-match" looked twice as big. Drop the margin (the row already has its own), pin the font-size to .admin-btn's, and center the row. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(hub): show the Files refresh button only on the Search pageChristophe Besson2026-08-302-5/+7
| | | | | | | | | | | | | In a real group the node streams every index change to connected clients as it happens (onIndexSync / onIndexDelta), so a manual re-fetch button there just duplicates the live push. The cross-group Search page has no such connection — its file list is a one-shot cached fetch — so the button belongs there and only there. Gated on a new explicit `showRefresh` prop that only search-page.js passes; `onRefreshIndex` stays in commonProps for ChatPanel's own use after an attachment upload. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(hub): wire the Files refresh button on the Search pageChristophe Besson2026-08-301-2/+6
| | | | | | | | | | | | The refresh button (added in 1cb9054, left of the breadcrumb home icon) was gated on `onRefreshIndex`, which the Search page's embedded FilesPanel did not pass — so it never showed on the one page it was asked for. The Search page now passes an `onRefreshIndex` that re-runs its all-groups index fetch (the only cache it has); the existing progress bar is the feedback. Unchanged in the normal group Files view. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* Merge remote-tracking branch 'origin/main'Christophe Besson2026-08-2919-1/+492
|\
| * feat(node): editable node settings in the Node page (D5)Christophe Besson2026-08-2919-1/+492
| | | | | | | | | | | | | | | | | | | | | | | | | | Expose invite_ttl_hours, pair_ttl_hours, device_request_ttl_minutes, max_concurrent_streams and transcode_incompatible_video in the Node management panel. Changes are applied immediately via roster.db and written back to node.toml so they survive a DB wipe. On startup, roster overrides take precedence over node.toml defaults. Draft v6 §2.11 documents the design; MNP gains node_settings_set / node_settings_set_ack for the browser path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* | Merge branch 'ui/search-title-and-refresh-button'Christophe Besson2026-08-2914-13/+39
|\ \
| * | feat(hub): drop the Search page title, add a Files refresh buttonChristophe Besson2026-08-2914-13/+39
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 1. The "Search Files" <h2> is removed from the search page — it duplicated the nav and cost a line of vertical space on mobile. `search.title` is dropped from all ten locales (its only use). 2. A monochrome refresh button sits left of the Files breadcrumb home icon. It calls `onRefreshIndex` (re-fetch the group index from the node — the file list is a view over a cached copy with no other way to pull a fresh one), spins while in flight, and only renders where an `onRefreshIndex` is wired (the real group view, not the cross-group search results). New `refresh` icon; `group.refresh_index` in all ten locales. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* | | fix(node): stop a movie with a mangled quality tag being shelved as a seriesChristophe Besson2026-08-294-2/+94
| |/ |/| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Found on demo35: "Some.Film.2017.MULTI.108.grp.mkv" — the release name's "1080p" truncated to "108" — makes guessit read S01E08, so enrich.py's flat-library branch (elif ep.episode is not None) filed a standalone film as a series. "Fix match" then only offered TV results for the phantom show, so there was no way out from the UI. - title_parse.has_episode_marker(): true only for an explicit SxxExx / 1x08 / "Episode N" / "Season N" token, not a bare 3-4 digit run. - enrich.py: the flat-library branch now needs ep.season AND ep.episode, plus either a real marker or the absence of a "(2019)"-style year. Every genuine flat-dumped episode in the corpus carries a marker, so real shows are untouched; the same misparse on "1280" ("...2013.1280...") is covered too. docs/mediacenter.md §10.2. Known gap left open: no operator control over the movie/show kind itself — a "this is a movie / a show" toggle would. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* | fix: on restart, serve a video's cached TMDB match instead of re-searchingChristophe Besson2026-08-293-16/+105
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Root of the 2026-08-29 demo35 storms. `_do_media_meta_request` could not tell "this is a movie" from "this video isn't enriched yet" — both have season/episode None — so during a slow initial scan with a browser on the Videos tab, every show episode requested was run through the *movie* search path with its raw filename as the query (`search/movie?query=Show S01E01 1080p WEB DL ...`), hundreds per second, until TMDB rate-limited and posters stopped loading. Worse, an un-enriched show episode's own valid cached "tv" match was treated as stale (its provisional kind was "movie"), so a file already resolved got re-queried anyway. - While a video is un-enriched (no display_title — enrich.py always sets one), never run a TMDB *search*. Serve the cached match if the content hash has one, honouring the cached kind ("tv"/"movie") rather than the provisional split; otherwise answer confidence 0. - Once enriched, the strict `cached_media_type == media_type` check is unchanged: an enrichment fix that reclassifies a folder movie->tv still drops the stale match and re-resolves. - video-app.js: `useMediaMeta` gains an `enrichSig` dependency (`entry.display_title`) so the client refetches once the enriched fields arrive on an index delta — the fileId is a content hash and never changes, so nothing else would retrigger it. Not caused by the V8-V13 work; it raised the per-file call count so the pre-existing race became a visible storm. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat: V13 — per-card "re-match this one file" (MNP 0.13)Christophe Besson2026-08-2920-5/+288
| | | | | | | | | | | | | | | | | | | | | | | | A one-click alternative to the full "Fix match" search-and-pick flow, and reachable without SSH (`meshbay-node video rematch` clears a whole group). - MNP 0.13: tmdb_rematch / tmdb_rematch_ack (additive — an older node logs "unknown type", the button just does nothing). OP_TMDB_REMATCH, signed like tmdb_override (media_cache is shared node-wide). - media_cache.drop_tmdb_match(file_id): forgets the match AND the override marker — deliberately stronger than clear_file_tmdb, since the operator is explicitly asking for a fresh resolution. - webrtc_server: _do_tmdb_rematch / _admin_exec_tmdb_rematch, dispatch + admin-response routing, broadcasts tmdb_rematch_ack. - transport.js: rematchTmdbMatch(fileId, signFn); 'tmdb_rematch' in the admin-op allowlist; tmdb_rematch_ack handled like tmdb_override_ack. - video-app.js: a "Re-match" button beside "Fix match" in the detail modal (isNodeAdmin), then bumpMediaMetaGeneration(). video.rematch_one key in all ten locales. docs/mediacenter.md §10.1: V8–V13 marked done. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat(hub): V12 — merge movies by TMDB id in the poster gridChristophe Besson2026-08-2912-17/+77
| | | | | | | | | | | | | | | | | Two movie files that TMDB resolves to the same id (the same film at two resolutions, or the same rip in two folders) now collapse to one poster card, mirroring the existing show merge — same keying discipline so an unmerged movie keeps its card and a merge updates props rather than remounting. The detail modal lists the versions (resolution · duration · size), each a Play button, when there is more than one; a single-file movie is unchanged. New `video.versions` key in all ten locales. Known edge, noted in §10.1: "Fix match" on a merged movie corrects only the representative file; the other version un-merges and can be corrected on its own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* feat(node): V8–V11 — show-branch ladder, year-aware _best_match, wider ↵Christophe Besson2026-08-296-89/+340
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | sequel_variants V8: the TV/show branch of _tmdb_search used the old "first candidate over 0.6 wins" shape. It now shares one _tmdb_ladder helper with the movie branch — score every candidate query, keep the best, fast-path a confident primary hit. A year lifted off the show's folder name (title_parse.year_in, e.g. "Some.Show.2022.S01") rescues a sub-0.6 hit that lands on the exact year. title_parse.clean_query de-dots a folder-derived title without naive_title's extension-stripping trap. V9: _best_match gains an optional `year`. When the top result is not a confident textual hit (ratio < 0.6) and a year was requested, a different result of that exact release year is preferred — TMDB already year-filtered the search, so this is a hard corroboration, not the fuzzy re-rank §3.3 warns against. A confident top hit is never overridden. search_movie/search_tv forward the year. V10: sequel_variants widened — trailing Roman→digit as well as digit→Roman, spelled-out indices (one..twelve / un..douze / ordinals), and a "Part N" / "Chapitre N" wrapper. Still empty for a trailing word that is not an index or a 4-digit year. V11: when the primary hit is already decent (>= 0.6) and there is nothing more specific to try (no alternative_title, no sequel variant — only a punctuation restatement left), the ladder returns without the extra requests. The clean-title common case is back to one call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* fix(hub): update layout test for the fit → fitAndPin renameChristophe Besson2026-08-291-3/+5
| | | | | | | | | | | b6f3139 renamed `fit` to `fitAndPin` in chat-app.js and rebound the resize / orientationchange / visualViewport listeners to it, but test_the_panel_refits_when_the_viewport_changes still grepped for the old name and failed. `fit` itself still exists (fitAndPin wraps it), so test_the_measurement_survives_a_scrolled_page was unaffected. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* Merge branch 'fix/videos-tmdb-matching'Christophe Besson2026-08-2925-27/+732
|\
| * fix(node): correct TMDB movie matching, per-file overrides, rematchChristophe Besson2026-08-2925-27/+732
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A batch of wrong poster-grid matches found live on a real library (2026-08-29): a two-volume film's second part matched the first; a numbered sequel matched a same-year making-of documentary; several entries of one franchise matched a single early entry whose localized TMDB title is the franchise name; one matched nothing. One mechanism: _tmdb_search returned the first candidate query whose title-similarity ratio merely cleared 0.6, before alternative_title / the Roman-numeral variant was ever tried. Matching: - title_parse: fold guessit's volume/part number back into display_title so the parts of a multi-part film stay distinct in the query, the card and the override. - _tmdb_search: keep a strong PASS 1 fast path (ratio >= 0.85, one request), otherwise score every candidate query and pick the best. A year-exact rescue lifts a sub-0.6 top hit to the confidence floor only when TMDB's own year-filtered result lands exactly on the filename's year. No local re-ranking of any single result list; no tmdb.py change. Fix match / rematch: - _admin_exec_tmdb_override: a movie override touches its own file only (guessit gives a whole franchise one display_title); a show override still fans out. Corrected files are marked in media_cache.tmdb_override. - media_cache: tmdb_override table; clear_file_tmdb / clear_tmdb_matches drop auto-resolved matches while sparing manual corrections. - ops.rematch_video + `meshbay-node video rematch` (loopback endpoint + CLI verb): re-resolve a group's video matches after a matcher fix. file_tmdb is keyed by content hash and otherwise only pruned on deletion, so nothing dislodged a cached match before. - a rename now drops the stale auto match too (daemon _reenrich_renamed_video_entries). UI: - VideoDetailModal shows the source filename and resolved TMDB id; an unmatched poster gets a badge (3 new video.* i18n keys x 10 locales). So a wrong match can actually be identified before hitting Fix match. docs/mediacenter.md 10.1 records this and the V8-V13 follow-up backlog (show-branch ladder, year-aware _best_match, wider sequel_variants, the 0.6-0.85 extra calls, movie grid merge, per-card rematch). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
* | perf(node): filter ICE interfaces to eliminate 5s STUN timeout on ↵Christophe Besson2026-08-293-0/+102
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | VPN/virtual adapters aioice sends STUN binding requests from every IPv4 interface and waits up to 5 seconds for all to complete. On a machine with Tailscale (wt0), the STUN request never gets a response, adding a fixed 5-second penalty to every WebRTC connection — measured at 6 s total (vs 1-2 s without it). Auto-exclude virtual/VPN adapters (tailscale, virbr, docker, veth, podman, cni) and CGNAT-range IPs (100.64.0.0/10). Operator can override with ice_interfaces in node.toml [node] section for explicit control. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* | fix(hub): chat scroll-to-bottom, message loading on group switch, connecting ↵Christophe Besson2026-08-292-29/+119
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | spinner Three regressions fixed in ChatPanel: - Scroll pinning: layout effect now depends on [messages, hasMore] so the "load older" button appearance triggers a re-pin; setHasMore is called before setMessages to avoid an intermediate render without the button; fit() is wrapped in fitAndPin() so panel resizing re-pins the scroll. - Message loading on group switch: fetch effect depends on the status prop instead of transportRef.current?.connected to avoid racing with GroupPage's cleanup; loadedRef resets on fetch failure so a retry works. - Connecting spinner restored in the Chat tab empty state. Adds structural regression tests (test_chat_scroll_bottom.py) that lock the dependency arrays and setState ordering so these invariants break loudly in CI. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* | fix(hub): chat kept stale messages when switching groups on the Chat tabChristophe Besson2026-08-291-1/+1
|/ | | | | | | | | App components were keyed by app name only (e.g. 'chat'), so switching groups while staying on the same tab reused the component instance — loadedRef stayed true and messages state was never cleared. Now keyed by app name + groupId, forcing a clean remount on group change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: node workflow redesign — wizard auto-config, reset, MusicBrainz contactChristophe Besson2026-08-2929-552/+474
| | | | | | | | | | | | | | | | | | | | | | | | Wizard (Electron): - Auto-provisions node config (hub URL + username) from logged-in user - node:start handles both cold start and restart of misconfigured daemon - Waits for daemon to reach 'running', auto-links node key on hub - probeNode accepts intermediate states for wizard progress feedback Reset (meshbay-node reset): - Unlinks node key from hub (DELETE /me/node_key, best-effort) - Stops and disables daemon (systemctl --user disable --now) - Erases ~/.config/meshbay, ~/.local/share/meshbay, ~/.local/state/meshbay MusicBrainz contact: - Resolved from owner's hub email instead of per-node roster config - Removed musicbrainz_contact UI and WebRTC handshake field - Removed set_musicbrainz_contact/musicbrainz_contact from roster Node pairing: - Added operator pairing banner on NodePage - Added operator_paired flag to list_groups Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(transport): chat_hist_resp dispatch skipped when older pending entries existChristophe Besson2026-08-281-8/+11
| | | | | | | | | | | chat_hist_resp checked only the single oldest pending entry across all types. When switching tabs, keyed requests (media_meta_req, file_req) from Videos/Music/Photos stayed in _pending and were older than the chat_hist entry, so the response was dropped and the chat appeared empty on return. Now scans for the first chat_hist entry instead. Same fix applied to index_sync dispatch for consistency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(hub): 10s connect timeout in search page, error on unreachable downloadChristophe Besson2026-08-282-9/+35
| | | | | | | | Search-page connections (indexing and pool) now time out after 10s instead of the transport's default 30s, so down nodes are skipped faster. Downloads show a user-facing error when a group is unreachable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): search downloads, photos view, pre-connect optimizationChristophe Besson2026-08-2814-37/+107
| | | | | | | | | | | | | | | | | | Downloads from search: FilesPanel accepts a getTransport callback for cross-group file downloads. Select/Download are available in search; delete and zip are hidden (readOnly). Each download connects to the entry's group via the pool on demand. Photos view: PhotosApp reused in search with the same pattern as Videos/Music — synthetic root, per-entry transport refs, group badges on album cards. Lightbox uses per-entry transport for full-resolution image fetches. Zip download hidden in search context. Pre-connecting: connections to all groups start as soon as indexing finishes, regardless of the active view. Thumbnails cache across pool evictions, so they're ready when the user switches views. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat(hub): cross-group search with reuse of existing viewsChristophe Besson2026-08-2819-250/+916
| | | | | | | | | | | | | Search page fetches indexes from all groups, then renders consolidated entries through the existing FilesPanel, VideoApp, and MusicApp components — no reimplemented views. Groups appear as top-level directories in the file browser; video/music entries use a synthetic root with per-entry transport refs for thumbnails and metadata across groups. Music player lifted to app.js with getConnection(groupId) for cross-group playback. Connection pool (max 3, LRU eviction) manages lazy WebRTC connections. All 10 locales updated with search keys. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(node): a pinned device can be invited to a further invite-only groupChristophe Besson2026-08-282-6/+123
| | | | | | | | | | | | | | | | | | | | | | | | | | | Regression from device linking (Stage C, 2026-08-18). Once a device is pinned on a node — as a member of one group, or an operator pairing — the `known` fast-path in `_do_join_request` dropped straight into `_join_ok`. For any *other* invite-only group it had no roster row for, that answered `not_authorized_for_group` and stopped there: the client never got `code_required`, so the pairing-code form never appeared and a legitimately invited member could not join. The `known` branch now, when there is no membership for the group being opened: - with a valid code → consumes the invite and admits (as the unknown- device path already does); - with no code but an invite waiting for this user here → `code_required`, so the client prompts; - with no code and nothing inviting them → `not_authorized_for_group`, unchanged, so the H3 guarantee (a hub-invented pin gets no key) holds. Also fixed: the group's own roster row is now consulted first, so an existing member opening their group is never mistaken for a stranger. Tests in test_roster_pairing.py cover all three branches plus the H3 guard. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(hub): Node page — "Remove group" only for a group the hub droppedChristophe Besson2026-08-281-5/+12
| | | | | | | | | | | The button detaches a group from this node's config. For a group that still exists on the hub that strands it with no host; deleting such a group is the group Settings tab's job (detach + hub delete together). It now shows only when the group is `stale` — present on the node, gone from the hub — which the page already computes for the "not on hub" badge. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(hub): sidebar — status dot on the name line, not between name and @ownerChristophe Besson2026-08-282-7/+26
| | | | | | | | | | | The @owner line turned the sidebar name into a two-line block, and the status dot (a flex sibling, align-items:center) floated to its vertical middle. A group entry is now a flex column: dot+name on one row (.si-head), @owner on a subordinate line indented past the dot. Non-group sidebar links are untouched (.sidebar-item stays a row; only .sidebar-group flips). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(hub): group names unique per owner, shown as name@ownerChristophe Besson2026-08-2812-30/+320
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A group's identity stays its UUID. What changes is that "the name is unique" — until now an unenforced expectation — becomes real, scoped to the owner account, and the owner's username is surfaced so two groups called "photos" on different nodes can be told apart. Hub: - `groups` gains a functional unique index `uq_groups_owner_name` on `(admin_id, lower(name))` (model + migration c3d4e5f6a7b8). The migration pre-flights: if the data already clashes it aborts and lists the offending (admin_id, name) pairs rather than renaming anyone's group. meshbay.org checked clean. - `create_group` trims the name, rejects blank (422) and an owner-scoped case-insensitive clash (409), with an IntegrityError backstop for the race, and returns `owner_username`. - `owner_username` added to `/v1/groups/mine`, `GET /v1/groups` (local rows), `POST /v1/groups/{id}/join`, `GET /v1/admin/groups`. SPA: - new `static/group-name.js` — `<GroupName name owner [inline]>` renders the name with the `@owner` handle on a smaller grey line under it. - used in the sidebar, the group-page header, Explore cards, the Admin groups table, and cross-group Search (via a widened `cacheGroupIndex` carrying the owner). Delete/leave confirmations show `name@owner` inline. - federated Explore rows show `@<source_hub>` instead of an account. Design record and the locked decisions: ~/next/groupnames.md (out of repo). MNP unchanged. Tests: test_group_name_unique.py, test_group_name_migration.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(hub): Explore page says nothing about public groups when they're offChristophe Besson2026-08-2811-2/+20
| | | | | | | | | | When the hub has public groups disabled, /explore showed an empty "Public Groups" screen with a search box and a Create button that do nothing. It now renders a single line — "This hub does not have public groups." — and the server already returns an empty directory regardless. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* chore: release 0.8.00.8Christophe Besson2026-08-286-8/+8
| | | | | | | | | | | | | | | | | Bump the three packages to 0.8.0 (released together) and realign meshbay-common's __version__, which had drifted to 0.7.0 while the pyproject stayed at 0.6.0. Dependency pins updated to meshbay-common>=0.8.0. Protocol versions are independent and unchanged: MNP 0.12, MHP 0.1. The Electron client stays on its own 0.1.0 track (hub MIN_CLIENT_VERSION). Since 0.6/0.7: public-groups admin switch with full server-side enforcement and a group Revoke action; chat link previews (node-side URL unfurl, SSRF- gated); chat composer focus + scroll-to-bottom on tab entry; whole-group "Filter files" search. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(files): make "Filter files" search the whole groupChristophe Besson2026-08-282-6/+29
| | | | | | | | | | | | | | | | | The filter only ever matched files whose folder was exactly the current path, so at the top of a group — where every row is a root, never a loose file — typing in the field did nothing at all. With text in the field the panel now searches every file in the group by name or containing folder, wherever it lives, and hides the folder rows (you are searching, not browsing). Each result shows its folder as a sub-line; clicking it clears the filter and opens that folder. Empty field restores the normal folder-by-folder view unchanged. Also hardened the sort comparators against an entry missing `name`/`type`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* feat(chat): focus the composer when the Chat tab opensChristophe Besson2026-08-281-0/+10
| | | | | | | | | | ChatPanel remounts on every tab switch, so a mount effect is the tab-entry hook. `focus({ preventScroll: true })` so it does not fight the panel-sizing and scroll-to-bottom effects still settling, and so a phone opens the keyboard without the view jumping. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* Merge branch 'feat/chat-link-previews'Christophe Besson2026-08-289-9/+777
|\
| * feat(chat): link previews for pasted URLsChristophe Besson2026-08-289-9/+777
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Paste an http(s) link in a group's chat and it unfurls into an OpenGraph card — title, description, site name, and image — the way WhatsApp/Signal/ Slack do it. The fetch is the node's, never the browser's or the hub's. The browser cannot: a strict img-src/connect-src and CORS block it, and a direct fetch would leak every reader's IP to the linked host on each render. The hub must not touch group content (draft-v6 §2.5). The node already fetches third-party metadata for the Videos and Music apps, over the same authorised path. Flow mirrors media_meta_req: the client sends `link_preview_req {url}`, the node replies `link_preview_resp` with the card fields (or `ok: false`), and any OG image is stored under its blake3 in the existing media_cache thumb store — the client then fetches it via the normal file_req path, exactly like a poster. Nothing durable is added: the card text lives in a bounded in-memory TTL cache on the node (draft-v6 §2.7 — enrichment on demand, the asking device caches), and MNP goes 0.11 → 0.12 (additive: an older node logs "unknown type" and the client shows the bare link). Because the URL is chosen by a *member* and triggers an outbound request from the operator's machine, `linkpreview.safe_url` is an SSRF gate: http(s) only, no credentials, and every resolved address must be globally routable — no loopback, private, link-local, multicast or reserved range, cloud-metadata included. Redirects are followed by hand so each hop is re-checked. Residual, documented in the module: DNS rebinding between the check and connect, closed properly by pinning the checked IP — a follow-up. Also fixes a long-standing chat annoyance the preview cards made worse: opening the Chat tab landed a screen or two above the newest message because the scroll-to-bottom ran before attachment thumbnails and (now) preview cards had loaded and grown the content. A ResizeObserver keeps the view pinned to the bottom through late content growth, and does nothing once the reader scrolls up. Tests: test_linkpreview.py (the SSRF gate and the OpenGraph parse, incl. redirect re-validation and image downscaling) and test_link_preview_request.py (reply shape, the media_cache image round-trip, the result cache). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* | feat(hub): let a hub admin disable public groups instance-wideChristophe Besson2026-08-2821-75/+729
|/ | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A new General tab in Administration carries one switch, allow_public_groups, stored in a hub_settings key/value table (runtime-editable, unlike hub.toml). Default is on; an absent row means on, so an upgrade changes nothing. Enforcement is server-side on every hub-mediated path, not just the SPA: - create_group refuses visibility=public (403), staff included - list_public_groups the directory returns nothing (local + federated) - join_group open-joining a public group is refused - group_online_nodes a non-member of a public group is handed no node - signaling.webrtc_offer drops the "node hosts an open group" fallback - federation.export_directory advertises nothing to peer hubs The switch is read live, so flipping it back restores every path. Existing members of a group that predates the switch keep their membership row and their access — this is plan A, not a purge. GET /v1/hub/info exposes the flag (unauthenticated) so the create-group form and the sidebar's "Public groups" link render correctly. Also in the admin Groups tab: a Revoke action beside Suspend. Suspend is the reversible hub flag; Revoke calls POST /v1/admin/revoke, which sets status=revoked and broadcasts a signed revocation every node enforces (denylist + dropped live sessions). It is confirm-guarded and names the group. And a message fix the revoke work surfaced: group_online_nodes, join_group and webrtc_offer answered "Group is suspended" for any non-active status. They now report the real state, so a member of a revoked group is told "Group is revoked" rather than something reversible-sounding. Tests: test_public_groups_toggle.py (10) covers the switch end to end and the five enforcement paths; test_revocation.py gains the status-message assertion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018gKJ85aZyvEwarXMFzFEwi
* fix(music): stop sharing one folder's cover across unrelated tracks, and ↵Christophe Besson2026-08-263-1/+99
| | | | | | | | | | | | | | | | | | | | | | | | | | | merge various-artists compilations into one album Two real, confirmed bugs in a large flat music library: - enrich_audio.py's sibling-cover fallback assumed one folder is one release. A large flat "chart ranking" folder mixing dozens of unrelated artists carried several distinct WMP AlbumArt-cache guids (one per original album a track was ripped from), and the fallback picked whichever one WMP had copied to Folder.jpg — attaching one unrelated release's cover to every other track in the folder. Now refuses to pick a cover at all once 2+ distinct guids show up, rather than guess. - music-app.js's groupMusicEntries grouped by artist first, album second, so a various-artists compilation (many genuinely different per-track artists, one shared album tag, no album-artist tag at all — a real ~20-track soundtrack rip has exactly this shape) could never be recognized as one release: every track landed alone in its own artist's bucket and got folded into a singleton pile. Now detects an album key shared across 2+ distinct artist keys and merges those tracks into one compilation card under a "Various" heading instead. Both verified against real, previously-affected files and live in the browser: the shared wrong cover is gone, and the compilation renders as one card with all its tracks in order.
* fix(video): a TMDB override never stored the metadata its chosen id namesChristophe Besson2026-08-262-0/+76
| | | | | | | | | | | | | | | | | | | | | | | Found live: "fix match" appeared to work for two shows but not for a movie whose own automatic search kept landing on the same wrong result. The override only ever recorded the file->tmdb_id mapping — never the metadata that id actually names. _do_media_meta_request's cache check agrees the mapping is fresh (same media_type) but finds nothing under that *new* id in tmdb_meta, since nothing had ever fetched it, and falls through to a brand-new search using the file's own title — reproducing the exact match the override was meant to replace. This stayed invisible for the two shows only because their own title happened to be enough for that fallback search to land on the right answer anyway, entirely independent of whatever the override recorded — never because the override was actually being honored. It surfaced on a movie whose own title search kept landing on the same wrong match regardless. Fetches and stores the real metadata for the chosen tmdb_id up front (via the existing _tmdb_build_meta, which needs only the id — no search result object required), so a later lookup finds the override itself instead of falling through to a search blind to it.