| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`.node-page` carried `max-width: 700px` of its own while Settings, Profile
and the create-group wizard take `.main`'s. The audit tab is a six-column
table — timestamp, event, user, IP, group, detail — with every fixed-shape
column set `white-space: nowrap` so an IP is never clipped, so at 700px it
scrolled sideways inside `.node-table-scroll` with a couple of hundred pixels
of `.main` empty beside it. Measured at 1024: 596px of table box where the
page had 856px to give.
The rule goes; the class stays as the anchor for the assertions.
`.node-group` and `.settings-section` are already the same rule twice over
(same background, border, radius, padding), so the two pages now line up card
for card.
test_node_page_width_measured.py: the Node page and a Settings page are the
same width at every width from 320 up, their cards the same rectangle, the
audit table no longer wider than its scroller at 1024, and — the narrow case
being the design rather than a regression — the table still scrolling inside
its own box on a phone without pushing the document sideways.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Reported live: a show with a dozen seasons opened on season 6. Every season
was in the picker and none was missing — the default was wrong.
VideoDetailModal took it from `repEntry.season`. `repEntry` is the show's
"representative entry", which the poster grid picks as
`episodes.find((e) => e.thumb_hash) || episodes[0]`: the first episode that
has a thumbnail, so the card has a fallback frame when TMDB has no poster.
That is from the original Videos commit; the season tabs came later and read
the same entry as "the episode the reader is looking at", which it never was
on that path. Episodes are sorted by (season, episode), so a show whose first
five seasons had no thumbnail yet — a partial enrichment pass, or ffmpeg
failing on those files — hands back a season-6 episode.
`defaultSeason(show)` reads the season list and nothing else: the lowest
season present, specials only when there is nothing else, and the lowest
*number* rather than the first entry so it does not quietly depend on
buildSeasons keeping its sort. The effect's dependency on repEntry goes with
it — nothing in it reads that any more.
test_video_default_season.py runs the function in node. No input it takes can
carry a thumbnail, which is the point. docs/mediacenter.md §10.5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The previous pass fixed the synopsis and the cast, and the dialog still
jumped: the episode count moves things a fixed-height synopsis cannot reach.
- The body scrolled as a whole, so a thirteen-episode season pushed the modal
to its max-height where a six-episode one had not. `.video-overlay` centres
its child, so the taller modal also *started higher up the screen* — title
bar, close button and all. `.video-detail-steady` (a multi-season show only)
gives the modal a height rather than a max-height, makes the body a flex
column, and hands the leftover to the episode list as the one scrolling
part. A constant-height box is centred in the same place every time, so both
halves settle at once.
- A scrolling season draws a scrollbar where a non-scrolling one draws none,
which is a scrollbar's width of content and re-wrapped the file path above
it, shifting everything below by a line. `scrollbar-gutter: stable`.
- The season panel was clipped by the modal's own `overflow: hidden` whenever
the seasons outran the room under the picker — at a 740px viewport it wanted
320px and had 288, and the rest sat where no scroll could reach it. It is
`position: fixed` now, placed by `placeSeasonPanel()`, which takes the
trigger's rect and the window height, picks whichever side has more room,
and caps the panel to it.
Scoped to multi-season shows throughout: a movie has no season to switch to
and a fixed height would buy it nothing but empty space.
test_video_detail_measured.py now builds each block inside a real
`.video-overlay`, since the centring is half the defect, and asserts the modal
top and height as well as the picker's offset — for a long and a short
synopsis and for a six- and a twenty-four-episode season.
test_season_panel_placement.py runs placeSeasonPanel() in node over a rect and
a window height. Two guards are declarations rather than rectangles and say so
in their docstrings: headless Chrome gives the probe zero-width overlay
scrollbars, so the gutter cannot be measured there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Opening a different season of the same show moved everything under the
synopsis, which is where the season control and the episode list are, so the
thing just clicked was no longer under the pointer.
- The synopsis is exactly three lines for a multi-season show, with a "read
more" link floated into the third line box (-webkit-line-clamp only ever
puts its ellipsis at the end of the last line and leaves no room after it).
Clamped from above and pinned from below to the same number: a constant,
not a range — a season summary runs two lines and the next one twelve, and
a band still reads as a jump. Whether three lines is all of it depends on
the modal's width, so it is measured in the browser and re-measured on a
resize.
- The cast is clamped to two lines.
- SeasonMenu replaces SeasonTabs: the tab row scrolled sideways once a show
had more seasons than fit, which is close to unusable on a phone. One
trigger reading "Season 5 · 1997" and a menu of every season with its
episode count, one row high whatever the season count.
- media_meta_resp.director was filled from the credits crew's job ==
"Director", a movie shape. TMDB's aggregate tv_credits crew is routinely
empty and never carries that job, so every show answered null and the modal
dropped the line. It now comes from created_by on the show details. Cached
show metadata keeps its null until TMDB_META_TTL_SECS expires or an
operator re-matches.
The facts line is joined rather than concatenated (a title with no rating
used to open with " · ") and carries the show's own year next to the
director; the selected season's air year moved onto the picker.
test_video_detail_measured.py asserts rectangles through layout_probe.py, not
declarations: the picker's offset inside its own modal body is the same pixel
either way, the synopsis and cast heights, where the read-more link lands,
and the open menu at 320 px. Each measured block sits in a whole-pixel-height
container, or two identical layouts an eighth of a pixel apart round to tops
one pixel apart. test_tmdb_show_director.py covers the credit.
docs/mediacenter.md §10.4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A group could open on a tab that rendered nothing: no panel, no tab shown
active, and nothing on screen to explain it.
The landing tab is chosen at mount from a preference -- default_tab for the
group, else the account-wide one, else 'chat'. Which applications the group
runs comes from the node, in the handshake ack, several awaits later. A
preference is a preference, not a promise that the app exists here, so the
two disagree in two ordinary cases: the group has Chat disabled while
'chat' is everyone's default, or the reader prefers an app this group does
not run. `apps.map(a => tab === a.key && ...)` then matches nothing.
The first app the group does offer answers both. Two more cases come free:
a preference naming an app that no longer exists, and an operator disabling
the app someone is currently looking at -- enabledApps changes live over
apps_enabled, and being moved to a working tab beats staring at an empty
panel. Settings is exempt: it is not an application, and the create-group
wizard lands on it deliberately.
`const apps` moves above the effect that reads it; a const further down
would be in its temporal dead zone, which is the hook-ordering trap already
recorded in CLAUDE.md.
tests/harness/group_tab_probe.py renders the real GroupPage against a stub
node answering a chosen enabled_apps and reads the tab bar back, over five
cases. With the fix reverted the three fallback cases report no active tab
at all and four of the six tests fail; the two that pass either way are the
ones that must not change -- a group running everything, and a preference
the group does honour (Videos stays selected, so the fallback has not
become "always the first app").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The chat could not be read back: any wheel gesture was undone in the frame
it happened in, and the "jump to latest" button never appeared.
None of the pins in ChatPanel are at fault -- every one of them is guarded
by "only if the reader is at the bottom". The reader never got to stop
being at the bottom.
fit() set the panel's height, read documentElement.scrollHeight back and
subtracted the overflow, so the document alternately did and did not
overflow the window. The page scrollbar appeared and vanished with it and
visualViewport fired resize at every pass -- the event fit() is bound to.
It therefore re-entered itself for the life of the panel: measured at 240
firings in two seconds on a page nobody was touching, against 2 for a bare
document. Each pass ran fitAndPin, which re-pinned the list to the bottom
before the scroll event that would have recorded the gesture was delivered
a frame later, so atBottomRef never went false.
- fit() learns the space below the panel once and remembers it on the
element instead of re-deriving it by writing and measuring back. At the
steady state it writes nothing, so it produces no resize. A real window
resize or an orientation change forgets the learnt value and measures
again (the page under the panel may have reflowed); visualViewport
deliberately does not, since a phone fires it constantly.
- The scroll-to-bottom is now scoped to *arrival*, which is all it was ever
for: opening the group, or coming back to the Chat tab, including the
thumbnails and link-preview cards that keep growing the list for a second
afterwards. It ends when the reader takes hold of the scroll, and the
ResizeObserver disconnects there.
- That release is recorded from the gesture (wheel/touchmove/pointerdown/
keydown), not from the scroll event, which arrives too late to protect
anything.
Unchanged: landing on the newest message, following new messages while
already at the bottom, the "load older" anchor and the unread marker.
tests/harness/chat_scroll_probe.py mounts the real ChatPanel in a browser
and reads a conversation back; test_chat_scroll_up.py asserts against it.
With the fix reverted, five of its six tests fail and the sixth -- landing
on the newest message -- still passes, which is the property that must not
have been traded away. A structural test cannot see any of this, which is
why it is measured.
test_layout_responsive.py pinned the listener's name and follows the
rename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W8oRqEHhnKUr1NfmTVdcyL
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The server only checked the captcha when auth_key was absent — but
every real client (browser included, via the password split) sends
auth_key, so the check was off for everyone, and a bot skipped it by
including the field. The Register form still made humans solve a
widget whose token was never transmitted.
Gate is now unconditional on captcha.enabled. The web client
(registerUser in keyderive.js) forwards captcha.token; RegisterPage
resets the (single-use) token on a failed attempt. The desktop client
shares this UI source and is Chromium, so it renders the same widget
(see the paired meshbay-client commit for the CSP change that allows
it).
Tests: test_register_captcha.py.
Third security review, finding M1 (Option A).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
No-group home/explore message: when the hub offers no public groups,
drop the "browse public groups" invitation and just ask to be invited
by an admin. New home.invite_only key added to all ten catalogues.
Create-group wizard done step: reword the message to point at inviting
members, and send the button to the group's Settings tab (where the
invite form lives) instead of a stale /groups/<id> path that never
matched the router. The landing tab is a one-shot session hint, so the
usual per-user default-tab preference is left untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNPfgH6VWcRzJDZGuzy1jJ
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The cross-group "Search files" view caps its WebRTC connection pool at
MAX_POOL_SIZE but pre-connected every indexed group, so any account with
more groups than the cap thrashed the pool. An evicted transport was
never removed from SearchPage's own groupConns map, so every tile of that
group kept a `_tRef` pointing at a closed transport; MediaThumb and
useMediaMeta bail on a disconnected transport with no retry, so posters
rendered for a moment then fell back to a loading spinner for good. Every
connect also fired the module-wide bumpMediaMetaGeneration(), clearing
every mounted tile's metadata across all groups and flickering the whole
grid through the warm-up walk. A one-group account (grenet) never hit it;
a many-group account (cbesson) always did.
- ConnectionPool takes an onEvict callback; SearchPage prunes groupConns
and rebuilds the entry lists when a connection is evicted or closed.
- MAX_POOL_SIZE 3 -> 12; the pre-connect walk is capped to it. Groups
past the cap connect lazily when a tile scrolls into view (onNeedConn).
- connectGroup() no longer fires the module-wide meta/thumb generation
bumps on a normal connect (kept for operator TMDB override/rematch).
Per-group gen counter + concurrent-caller guard: a group's tiles
refetch once per (re)connect, not once per mounting tile.
- MediaThumb/useMediaMeta take an optional reloadKey; each group's
`_connGen` is threaded through so a tile refetches when its group
reconnects instead of staying stuck on a spinner.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4Yj8EuXkjjYxbeS5kPd3U
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Remove the node daemon's server-rendered admin UI (GET / and /audit, the
_render_* helpers and inline templates) and the `meshbay-node ui` CLI verb.
The loopback control API stays; it is now JSON only, ruff-clean, and 453
lines (was 1074). Also drop three never-wired endpoints (/api/config,
/api/chat/history, /ws/chat, plus broadcast_chat) and the pointless
18000/tcp firewall profiles.
The desktop client's Node page (static/node-page.js) takes over what the
dashboard showed, reorganised into six tabs (Overview, Groups, Roster,
Peers, Audit, Settings):
- Overview: version, node id, QUIC port, hub, index-cache maintenance
- Roster: node-wide view with unpin
- Peers and Audit: auto-load on open, no Load button
- Audit: real usernames and group names (resolved from the roster and
node.toml), Previous/Next pagination newest-first, Export CSV of every
matching row
- Settings: node settings, STUN, ICE, denylist, then Unlink from hub
Backend: audit.get_entries gains `offset`; /api/audit and /api/peers
resolve ids to names via a new _display_names helper; CSP tightened to
default-src 'none' now that no HTML is served. draft-v6 sections 2.11 and
2.12 corrected -- the Node page uses the loopback API, not MNP.
One capability is intentionally dropped: browser-based admin on a headless
server. The CLI covers every operation there.
See docs/refactor-node-ui.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQCaZnde4Bjjdu84dhSuF5
|
| |
|
|
|
|
|
| |
The clause was redundant with the surrounding UI context.
Removed from all 10 locale files.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
Server-side verification module, CaptchaConfig in hub.toml,
captcha_site_key exposed via /v1/hub/info, useCaptcha() hook
in the SPA with stable DOM rendering (strength bar always present
to avoid Preact re-ordering the captcha widget). Native clients
(auth_key path) skip captcha. All 10 locales updated.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
| |
Admin page is lazy-loaded so non-admin users never fetch it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
| |
.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
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |\ |
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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>
|
| |\ \
| |/
|/| |
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |\ |
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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>
|
| |/
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
| |
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
|
| |\ |
|
| | |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| | |
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
|
| |/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|