| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Found live: a real show organized its first season as "House.of.the.Dragon.
S01E01...mkv" inside a folder named "S1" (the show name embedded in every
filename, so grouping worked by guessit's own title alone), but its later
seasons as "S02E01. Episode's Own Title.mkv" inside "S2"/"S3" — no show
name in any filename at all, relying entirely on the folder. Those seasons
showed up as loose individual entries instead of grouped under the show:
season_from_folder_name only recognized "season"/"saison"/"livre" as full
words, so "S2" didn't register as a season folder at all, and the
ancestor-based show-grouping fix (previous commits) never triggered for
those seasons.
A bare "S" + 1-2 digits as the *whole* folder name is now recognized too —
anchored to the entire name so it can't match some unrelated folder that
merely starts with "s" followed by digits.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
episodes
1. A season spanning more than one folder (a per-book Bonus folder nested
inside every numbered season) got the same synthetic episode numbers
handed out again in each folder independently — three unrelated
Specials all showing up as "S0E01". _synthetic_episode_number now ranks
across the whole show for the target season, not just one file's own
folder.
2. guessit reads a bare 3-digit leading episode number as a concatenated
season+episode guess rather than a plain episode number — "100" parses
as season=1, episode=0, not episode=100, with nothing in its output
distinguishing that from a real 2-digit episode. A season-like ancestor
already overrides guessit's season (previous commit); this applies the
same fix to episode via a direct regex on the leading number, capped at
3 digits so a leading year (4 digits) is never misread the same way.
Both confirmed against the real library this whole fix was found on:
per-book Bonus features across two books no longer collide, and a real
100th-episode file now resolves to episode 100 instead of 0.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
guessit title
The mechanism itself was wrong, not just the TMDB matching: a bare episode
numbering convention with no show name in the filename at all
(`001 Episode's Own Title.ext`, no SxxExx, no show prefix — entirely
ordinary on its own) makes guessit invent a "title" from whatever text
follows the number. That text is the individual episode's own name, and
differs for every episode in the folder — trusting it, as the code did,
groups nothing together at all: every episode became its own single-
episode "show", searched against TMDB by that one-off title alone.
Whenever a season-like ancestor folder exists (a numbered season, or
Specials/Bonus/Extras -> season 0), its own root folder now names the show
unconditionally — never a per-file guessit title, which cannot tell a
show's real name from an individual episode's one-off name when the
filename carries no reliable ShowName/SxxExx structure. The walk continues
past *every* consecutive season-like ancestor, not just the first: a
per-season Bonus folder (Show/Season N/Bonus/file.ext) is nested two
levels inside the show, both "Bonus" and "Season N" season-like on their
own, and stopping at the first would hand back "Season N" as the show's
name instead of "Show".
Also adds "livre" ("book") to the season-word vocabulary (§3.4) — some
shows number their seasons that way (Roman numerals) rather than
"season"/"saison".
Supersedes _title_from_show_siblings from the previous commit (removed):
that fallback assumed a per-file title could still be trusted often
enough to be worth borrowing from a sibling season folder — this fix
means it never needs trusting in the first place once a season-like
ancestor exists.
Verified directly against the real library this was found on, not just
synthetic tests: every sample file (a numbered episode, a Book/Bonus
feature, a Book-root "making of") now resolves to the show's real name.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The real reason a node restart alone didn't fix already-indexed entries
after the previous commit's enrichment change: _do_media_meta_request
checked media_cache's file->tmdb mapping (keyed by content hash) and
trusted it unconditionally, before ever comparing it against the file's
*current* movie/show classification. A file whose season/episode changed
on a later scan — exactly what the Specials-folder fix does, for every
file it reclassifies from "movie" to "tv" — kept answering with its
stale, wrong-kind-of-match forever, since nothing about a reclassification
touches this cache or its key.
Now falls through to a fresh search whenever the cached media_type
disagrees with what the entry resolves to right now, rather than trusting
a mapping that predates the file's current classification.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
unrelated standalone movies
Found live: a "Specials" folder full of one-off-named bonus episodes had
every file appear as its own poster, matched against TMDB by its own
title, because guessit finds no season/episode grammar at all in a
filename with no SxxExx of its own — so the classification (episode vs
standalone movie), based solely on that, fell to the movie branch. Real,
unrelated films happened to share several of those one-off titles and
matched confidently, one per Special, cluttering the Videos view with
dozens of wrong posters instead of grouping under the show.
An ancestor folder saying this is part of a show — a numbered season, or
Specials/Bonus/Extras -> season 0 — is now trusted over the filename
having no SxxExx of its own. The show's name can't come from this file's
own guessit title (that's the bug) or from siblings in the same Specials
folder (every one of them has the same gap) — it's borrowed from the
show's ordinary season folders next door, which do carry it in the usual
ShowName.SxxExx shape (_title_from_show_siblings). A synthetic, stable
episode number (alphabetical rank among the folder's video files) stands
in for the real one nothing in a Specials folder provides.
Generic, not specific to a folder literally named "Specials": the same
fallback fires for any season-like ancestor folder whose files lack
per-file episode grammar, numbered seasons included (covered by a
dedicated test).
Also hardens _title_from_siblings to require the sibling's own episode
number too, not just a title — otherwise it would borrow one Special's
one-off title as if it were representative, on a folder like this one.
Existing already-indexed entries will need a rescan (restart the node) to
be re-enriched under this logic — nothing re-derives them on its own.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
IndexEntry.path is the *folder* a file is in (indexer.py's
_virtual_dir docstring: "the directory a file appears in"), not the
file itself. GroupIndex.get_entry_by_path() treated it as if it named
one file, and every one of its four callers did too:
_do_music_meta_request, _do_media_meta_request, _do_tmdb_override, and
_admin_exec_tmdb_override. Any two files sharing a folder — an album is
one folder with many tracks, a season is one folder with many episodes
— collided: a lookup by path silently returned whichever entry the
index happened to iterate to first, regardless of which file the
client actually asked about.
Found live (2026-08-25): three unrelated albums ("High Tone - Various",
two "Le Peuple de l'Herbe" albums) all showed the same MusicBrainz
cover, because all their representative tracks happened to sit in one
"high_tone" folder alongside a track that legitimately matched that
cover. A force-reload didn't help — the bug is server-side, not a
stale client state.
Fixed by keying these four request/response pairs by `file_id` (the
entry's own content hash — already unique, already how every other
lookup in the system identifies a file) instead of `path`, both in the
wire messages (music_meta_req/resp, media_meta_req/resp, tmdb_override)
and in music-app.js/video-app.js's own hooks. GroupIndex.get_entry_by_path
is now unused and removed — GroupIndex.get_entry(file_id) already did
the right thing.
No test previously exercised either handler with two entries sharing a
folder — the only existing coverage (test_tmdb_override_policy.py) gave
each entry its own folder, so the bug never had a chance to show up.
Added that scenario there and in two new test files, all confirmed
failing against the pre-fix code before being confirmed green against
the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
An operator routinely shares the same physical folder into more than one
group (a music library, a Séries drive) — IndexCache used to be opened
once per group (data_dir/{group_id}/index_cache.db), so the second group
to reference an already-fully-hashed multi-terabyte folder paid the same
full content read the first one did. IndexCache itself carried no
group_id in its schema; only daemon.py's wiring did. Now one instance,
opened once at startup (data_dir/index_cache.db), shared by every group's
DirectoryIndexer.
Confirmed against a real deployment (2026-08-25/26): a group sharing an
already-indexed folder with an existing group indexes it instantly, with
zero rehashing.
Also fixes a related cross-group correctness gap found during this work:
media_cache.db (thumbnails, TMDB/MusicBrainz metadata — already node-wide,
untouched by this change) was pruned for a file the moment it left *one*
group's index, even if another group's index still held the same content
hash — forcing a redundant re-fetch/re-probe/re-thumbnail for a group that
never actually lost anything. Prune now runs only once no group's index
references the file_id any more.
Adds a node admin UI action ("Maintenance" card, prune-index-cache) to
drop cache rows that no longer belong to any group's roots — skips
anything under a root that is merely temporarily unavailable (indexer.py's
"a root that goes away freezes, never empties" rule extends to this
cache too, or a reconnected drive would pay a full rehash for no reason).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
scanning progress
Two real-world bugs found together while testing multi-root group
creation:
- CreateGroupWizard only sent the enabled-apps PUT when the operator had
*unchecked* something, assuming "every box left checked" already matched
the node's own default (Roster.DEFAULT_APPS = chat, files). It doesn't —
so leaving every app checked, the common case, silently left
Videos/Music/Photos disabled on the node. Now sent unconditionally.
- The wizard's "add extra roots" step never polled index-status, so once
step 3 (which only watches the first/upload root) finished, the
progress bar froze while the node kept scanning the remaining roots for
minutes, unwatched. Added waitForRootsIndexed (platform.js), mirroring
waitForGroupHosted's own race handling.
That fix exposed a deeper one: indexer.py's _scan_root() only flipped
`progress.scanning` on *after* walking the directory and stat()-ing every
file — both off-loop, but slow enough on a large root that a poller's
grace period (waitForRootsIndexed's 5s) could expire before ever
observing `scanning: true` (confirmed against production logs: a GEK-init
step fired 5.058s after a root started scanning, matching the grace
period almost exactly). The stat() pass was also a synchronous loop
directly on the asyncio event loop — blocking the whole daemon (WebRTC,
chat, admin UI) for as long as it took on a root with many files. Both
fixed: `scanning` now flips on before the walk starts, and stat()-ing is
now off-loop too (_size_files).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
nothing
search_release() only ever tried a field-scoped exact-phrase Lucene query
(artist:"..." AND release:"..."). Verified live against musicbrainz.org:
any deviation from MusicBrainz's own spelling (a year suffix, an edition
tag, an artist credited under an older/aliased name) drops it to zero
results outright rather than a low-scored one, so the confidence check
never even ran — this is why well-recognized artists were still getting
almost no cover art. Add an unscoped loose-query fallback, escape Lucene
special characters in the interpolated tag text, and make the confidence
score consider the artist match too (not just the album title) now that
the fallback has no field scoping to rely on.
Also document MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT in the systemd units
and man page, mirroring MESHBAY_TMDB_DEFAULT_TOKEN's precedent — never a
literal value in source, configured via node.env.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013XSohfUQQiaE77qyFLgSv3
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Only thumbnail bytes were ever durable in media_cache.db — every other
derived field (photo width/height/EXIF, video ffprobe duration/dims,
audio cover art) lived solely on the in-memory GroupIndex entry, so a
node restart re-decoded every photo through Pillow, re-ran ffprobe on
every video, and re-scanned for every album cover from scratch, even
though the answers already sat in the cache.
Adds photo_meta and video_meta tables (content-only fields, keyed by
file_id) and checks them before doing the expensive work. Audio gets no
new table: mutagen reads tags and duration in one inseparable call, so
caching duration alone buys nothing — instead cover-art extraction alone
is skipped via a new skip_cover flag when a cached cover already exists.
Deliberately excluded from all three caches: anything derived from the
filename or folder path (video display_title/season/episode via guessit,
audio artist/album folder-fallback) — those must keep being recomputed
fresh so a rename/move is still correctly re-derived by the existing
_reenrich_renamed_*_entries mechanisms, instead of silently handing back
a stale parse under the new name/location.
Regression tests prove cache reuse by deleting the source file (or cover)
between two enrichment runs, and prove rename/move correctness survives
the new cache by renaming/moving to a path that never exists on disk.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A new group application (docs/apps.md's plug-in mechanism), following the
plan in docs/photos.md. Unlike Videos/Music: several photo roots per group
instead of one (photo_roots is a set, one signed op replaces it whole),
a single album-grid view with no third-party matching step, and per-photo
info read from the file's own EXIF at index time — no metadata service,
no credential, no outbound network call at all.
Protocol (meshbay-common, MNP 0.10 -> 0.11, additive): `taken_at`/`camera`
on IndexEntry; `photo_roots`/`photo_roots_ack`; `OP_PHOTO_ROOTS`.
Node: roster.py stores photo_roots as a group_settings entry (JSON list,
same shape as enabled_apps); ops.py/webrtc_server.py validate and sign the
whole set in one op, same pattern as apps_enabled; a new PhotoEnricher
(indexer/enrich_photo.py) runs Pillow in its own small bounded pool,
separate from the video/audio pools, producing a resized thumbnail plus
the two EXIF fields — never GPS, checked by a grep-based regression test.
Client: photos-app.js — one album card per directory containing images,
a per-album photo grid, and a lightbox with next/previous (keyboard and
buttons), zoom in/out/fit/100% starting from the actual on-screen fit
percentage, and a "zip this album" button reusing files-app.js's own zip
mechanism (lifted into file-utils.js's downloadDirectory so both call the
same implementation). group-settings.js gets an add/remove multi-root
picker, distinct from Videos/Music's single-value one.
Bugs found and fixed before this ever shipped, worth keeping the story of:
- enrich_photo.py read width/height from the raw image *before* applying
EXIF orientation correction, and read DateTimeOriginal off the plain
0th-IFD Exif object — a real camera stores it in the Exif sub-IFD, which
Pillow only exposes via get_ifd(Exif). A flat, hand-built EXIF dict
round-trips through Pillow either way, which is exactly what would have
hidden both bugs; the regression test builds EXIF with piexif instead,
matching what real hardware produces.
- photos-app.js's album grouping stripped a trailing path segment from
entry.path under the assumption it still carried a filename — it
doesn't (files-app.js's own convention: e.path is already the
containing directory), so every album collapsed one level into its
parent. Found live against a real multi-folder library.
- transport.js's ADMIN_OP_TYPES allowlist (already the fix for an
identical bug on video_root/apps_enabled, see 4783d81) was missing
photo_roots: its admin_challenge matched no pending request and was
silently dropped, so saving a photo root just timed out after 30s with
no error.
- daemon.py pruned a thumbnail when its file left the index (root removed
or reconfigured) but never forgot the content hash was "already
attempted" — the same bytes reappearing under a renamed/relocated root
(an operator's real workflow) were then permanently skipped, forever,
with nothing to indicate why. Discarding the attempt alongside the
cache entry on prune is what makes pruning actually reversible.
- packages/meshbay-client's app:// protocol handler served every file
with no Cache-Control header, so Chromium was free to serve a stale
cached copy indefinitely — none of several `npm run sync-ui` + reload
cycles during development actually picked up the new code until the
renderer's disk cache was cleared by hand. Now sends Cache-Control:
no-store.
- the lightbox's zoomed image used flex centering (align-items/
justify-content: center) combined with overflow: auto — a well-known
trap where the browser centers overflowing content by shifting it, and
the leading half of that overflow (here, the top of a zoomed photo)
sits outside what the scrollport can actually reach. Reported live as
"unusable". Fixed by switching to top/left alignment once zoomed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiZG4AuSnxHohQMpwTHTyL
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Major finding: entry.id is a content hash, so the exact same physical
file — the same MP3, byte-for-byte — indexed into two different groups
(a shared library reused across several demo/test groups, or genuinely
the same folder shared into two groups) produces the *same id* in both.
_enriched_attempted was a single flat set of bare ids shared across every
group this node hosts. The moment one group's copy got enriched, every
other group's otherwise-identical copy read as "already attempted" and
was skipped forever — nothing else ever revisits an id once it's in this
set. That group's Music tab (or Videos tab, same bug, same set) showed
every affected file at duration 0 with no artist/album/thumbnail,
permanently, no matter how long you waited or how many times you
reloaded — group A having been enriched first was enough to silently
starve every later group of the same content.
Now keyed by (group_id, entry.id) throughout — the enrichment gate, the
sweep, and the rename re-enrichment path, for both video and audio (they
already shared the one set, and the collision risk is identical for
both). New regression test constructs two groups with byte-identical
audio content and confirms both enrich independently.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Every existing audio_root test either called ops.set_audio_root directly
or mocked out _issue_admin_challenge — none of them exercised real
signature verification, _do_admin_response, or the shared groups_ctx/
roster wiring _run_op depends on. Worth ruling out a break somewhere in
that real path specifically: a report described a save that looked like
it worked (the Music tab showed content right after) not surviving a
reload.
Drives the real _do_audio_root -> admin_challenge -> sign -> _do_admin_response
-> _admin_exec_audio_root path with a genuine Ed25519 operator key, then
opens a *separate* Roster instance against the same db file — the direct
question a "worked, then reverted" report raises: does the value actually
land durably, in a form any later connection reads back correctly. It
does; this passes. The one thing missing from the session fixture to get
this far was peer-registry self-registration (a real session adds itself
on handshake completion — without it, the final ack has nowhere to go,
including back to the requester).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
musicbay.md's original call — Music needs no root, tag reads are cheap so
just cover the whole shared tree — didn't hold up against a real messy
library: everything under every shared folder got mixed together with no
way to scope Music down to an actual music collection. This adds an
audio_root setting, symmetric to video_root in every respect: signed
operator op (audio_root/audio_root_ack, MNP bumped to 0.10), validated
against a real directory in the group's own roots before a signature is
even asked for, gates tag/cover enrichment exactly like video_root gates
ffprobe/TMDB (nothing runs until it's set, only files under it once it
is), and a set/change fires a one-off sweep of whatever the folder already
contains.
The old trigger — sweep everything the instant "music" joins enabled_apps
— is gone along with the root-less design it belonged to; setting
audio_root is now the trigger, mirroring set_video_root's
enrich_video_root_fn exactly.
Test coverage mirrors the video_root suite: policy (refuse before a
signature round trip, accept/store correctly) and the enrichment gate
itself (nothing without a root, only files under it, sweep on set).
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Tagging and covers for these two formats landed already, but neither one
decodes in any mainstream browser's <audio> element at all — a real
library scan turned up 273 such files that would show up correctly in the
Music app and then simply fail on click. This closes that gap: the node
transcodes to AAC/M4A on request (a one-shot whole-file conversion, not
live-piped like video's fMP4 segments — an audio file is small enough that
streaming it buys nothing), caches the result under its own content hash
the same way a TMDB poster or a MusicBrainz cover is cached, and serves it
back through the ordinary file_req/chunk path. That path used to assume
anything in the media cache was thumbnail-sized (single chunk, always);
generalized it to slice a cached blob the same way a real file on disk
gets sliced, since a transcoded track can be several MB.
New MNP pair (`audio_transcode_req`/`_resp`, version bump to 0.9), shares
its concurrency cap with video's transcode pool rather than getting its
own — both are real ffmpeg processes on the same node. Every other audio
format is untouched: this only fires for .wma/.mpc, the two extensions
that need it.
|
| |
|
|
|
|
|
|
|
|
| |
The "P.H. Theme" failure investigated earlier turned out to be a genuinely
corrupt 1256-byte source file with no audio stream at all, just an ID3
tag — a real, if rare, corruption pattern worth guarding against directly
rather than only handling gracefully at playback time. Scoped to audio
only, applied wherever a file actually gets hashed/typed (fresh scan and
the cache-miss rehash path alike) — a tiny file of any other type is still
indexed normally.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A real-library scan turned up 250 .wma and 23 .mpc files that the indexer
was silently classifying as "other" — genuinely lost from the Music app,
not a consolidation-rule artifact (checked separately: the grouping logic
itself drops nothing). Both are now indexed as audio and tagged properly:
- WMA has no mutagen "easy" wrapper, so the generic tag reader was reading
nothing from it at all. Reads the real ASF keys directly instead
(Title/Author/WM-AlbumTitle/WM-TrackNumber), confirmed against a real
sample file before writing the mapping.
- Musepack's format auto-detection is unreliable enough (misidentified a
real .mpc as MP3 in spot checks) that it now always opens by its own
class instead of guessing from content.
- Filters out another placeholder value found along the way: a French
ripping tool's auto-generated "Album inconnu (<timestamp>)".
Neither format decodes natively in a browser's <audio> element, so this
gets them correctly visible, tagged, and covered — not yet playable
in-browser. That would need server-side transcoding, deliberately left
out of this change.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Album-grid readability, part two:
- groupMusicEntries (music-app.js): an album bucket left with exactly
one track - a real album tag, but only one song from it, not the
whole release - clutters the grid the same way an untagged loose
track does. Both kinds now fold into one "<artist> - Various" tile
per artist, unless there is only one leftover track overall, where
relabeling buys nothing and the track keeps its own name (or the
generic placeholder, if it never had one).
- foldKey also normalizes "&" vs "and" ("Artist & The Band" / "Artist
and The Band" is one act, tagged both ways across different rips of
the same catalogue) alongside the existing case/whitespace fold.
- music-player.js: a close button pauses and tears the player down;
an unmount cleanup effect (pause, revoke every cached blob URL)
fires either way, whether that's the close button or the shell
tearing the bar down on its own. A "current queue" button opens an
overlay listing the whole playing queue with the current track
highlighted, click any to jump to it - works identically regardless
of how the queue was built (an album, the consolidated misc bucket,
a single standalone track), since it only ever reads the player's
own live tracks/order/pos.
- group-page.js: this component is not remounted when switching to a
*different* group on the same /group/:id route (only the groupId
prop changes) - so without an explicit reset, music from one group
would carry into the next one opened. Resets musicQueue to null on
groupId change; a tab switch inside one group still leaves it alone.
- Scrubbed real artist/band names that had leaked into code comments
and test fixtures (enrich_audio.py's docstrings, several
test_enrich_audio.py assertions, a music-app.js comment) - replaced
with generic placeholders, no behavioural change.
- i18n: music.various, music.player_close, music.player_queue,
music.queue_title added across all ten locales.
Client-side only except none of this touches the node at all. npm run
sync-ui re-run. Full suite: 1129 passed, no regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Music grouping was measured against a real ~5700-file library and came
back worse than a plain file listing. Root cause: the artist/album
ancestor walk always climbed exactly two levels (parent = album,
grandparent = artist) with no idea where the group's own shared root
was. Any file in a flat top-level folder — common here: bare
`Artist/track.mp3`, no album subfolder at all — had its "grandparent"
resolve to the root directory's own name, so the artist got replaced
by the share's name. Measured: 289 of 5664 tracks across 41 real,
unrelated artists (Ben Harper, Dire Straits, Jimi Hendrix, Janis
Joplin, ...) collapsed into one fake artist this way — the single
biggest bucket in the whole library, ahead of every real one.
- `_artist_album_from_ancestors` now takes the entry's own root
boundary (daemon.py resolves it via `RootSet.split`) and refuses to
read it as a name. A file sitting in a top-level folder — genuinely
ambiguous, artist or a standalone album/compilation — is handled by
`_split_top_level_folder`: split on "Artist - Album" when the
(cleaned) folder name has that shape, otherwise the whole name
becomes the artist alone, the more common real case here.
- `_clean_tag` treats known tagger placeholders ("No Artist", a French
tool's "Nouvel artiste (334)") as absent rather than a real value —
they were just as truthy as a real name and were locking out the
fallback that would have done better. "Various Artists" is kept, a
real compilation credit rather than a placeholder.
- A `title` tag that's the bare filename copied verbatim (track number
included — found live on a whole CD-single) is stripped through the
same prefix rule the filename parser already used
(`title_parse.strip_track_prefix`), since a tag normally wins over
the parsed title.
- Cover art: only 11% of a 400-file sample had embedded art (expected
for this era of rip), but 267 loose cover images sit beside the
tracks across the library (Windows Media Player's `Folder.jpg`/
`AlbumArt_{guid}_*.jpg`, manual `cover.jpg`) and were never looked
at. `_find_sibling_cover` checks the track's own folder before
giving up — measured coverage 11% -> 26% on the same library, zero
network calls.
`_enriched_attempted` is in-memory and resets on restart, so a node
restart is enough to re-run enrichment over an already-scanned library
with the fixed logic — no rescan flag, no cache to clear by hand.
22 tests in test_enrich_audio.py (11 new), including the exact
regression case end to end through the real pool. Full suite: 1129
passed, no regressions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Implements the node half of docs/musicbay.md against MNP 0.8:
- IndexEntry gains artist/album/track_no (reuses duration/thumb_hash/
display_title, already generic). New musicbrainz_config/_enabled and
music_meta_req/_resp message pairs, mirroring the TMDB shape.
- title_parse.parse_track_filename: track-number-prefix + title parsing,
fallback-only (embedded tags are the primary source, unlike Videos).
- indexer.enrich_audio.AudioEnricher: mutagen-based tag/embedded-cover
extraction through its own bounded pool (asyncio.to_thread, no
subprocess — no ffmpeg-shaped deadlock risk). Gated on "music" in a
group's enabled_apps rather than a video_root-style scoped folder.
- musicbrainz.py: MusicBrainzClient — no API key (unlike TMDB), just a
self-imposed ~1 req/s pace and a configurable, non-default User-Agent
contact string; inert (no calls at all) when no contact is configured,
never sends an unidentified client.
- media_cache.py: file_mbid/mbid_meta tables alongside the existing TMDB
ones, cover art reusing the thumbs table via a synthetic
musicbrainz:{mbid} id, pruned on file deletion.
- roster.py/ops.py/webrtc_server.py: musicbrainz_contact (node-wide) and
musicbrainz_enabled (per-group, from the start) as signed operator
settings, ALLOWED_APPS gains "music", _do_music_meta_request resolves
and caches a release-level MusicBrainz match per (artist, album).
- daemon.py: AudioEnricher/MusicBrainzClient wired alongside the video
ones; a group's existing library is swept when "music" is newly
enabled (no video_root equivalent — see musicbay.md §2.1).
41 new tests (musicbrainz.py against a mocked transport, admin-op policy
for both new settings, media_cache round-trip/pruning, enrich_audio
end-to-end against real ffmpeg-generated MP3s). Full suite (common +
node + hub): 1116 passed, no regressions.
Client-side (music-app.js, persistent player bar) not started yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Three bugs found live testing the Videos app against a real HEVC/EAC3 show,
plus a design change requested afterward:
- Streaming always did "-c:v copy", which faithfully reports a source's real
hev1 codec string but is unplayable in a browser with no HEVC decoder
(most Chrome/Linux builds). The node now transcodes to H264 whenever the
probed codec is browser-incompatible (media_probe.py's new
BROWSER_INCOMPATIBLE_VIDEO_CODECS), with a `transcode_incompatible_video`
node.toml opt-out for operators who know their viewers already decode it.
- Dropping a whole season into an already-watched folder gave no scanning
indicator and no progress bar: IndexProgress was only ever updated by the
two bulk scan paths, never by the real-time per-file watchdog path
(_schedule_update/_debounce/_update_entry). That path now accounts a
"burst" the same way, without double-counting a file rewritten mid-debounce.
- A stray literal "0" rendered in the video detail modal when there was no
TMDB match (`meta.confidence` is 0, and `0 && x` renders "0" in JSX/htm,
not nothing) — `confident` is now a real boolean.
- Whether TMDB is used at all moves from a node-wide setting to per-group
(OP_TMDB_ENABLED/tmdb_enabled/tmdb_enabled_ack, scoped like OP_VIDEO_ROOT):
an operator running a real media-library group alongside test/demo groups
on one node wants outbound TMDB traffic for the one that needs it, not all
of them. The custom API token and query language stay node-wide, one
shared credential/cache (tmdb_config/OP_TMDB_CONFIG, unchanged reasoning).
MNP_VERSION 0.6 -> 0.7, additive.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
wizard polish
Two operator-facing fixes for a real 3-season show whose automatic TMDB
match was wrong at the show level: per-season overview/air_date tabs in the
detail modal (falling back to the show-level text when a season's own is
empty), and a "Fix match…" search-and-correct affordance that re-resolves
every file sharing the corrected show's display_title. New signed op
OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp,
tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6.
Also: the create-group wizard gets a spinning indexing indicator and an
app-selection step, group settings default the TMDB language to the
operator's own locale (never as a global default), and a file renamed
mid-session now re-triggers title parsing instead of being silently
skipped by the enrichment dedup guard.
Fixes two bugs found during this work: the search overlay's z-index lost
to the base video-overlay class and rendered invisibly, and season_meta's
own empty overview didn't fall back to the show-level one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Implements docs/mediacenter.md: a "Videos" group application built on the
existing files index rather than a separate catalogue. On the node side,
new indexer enrichment (technical probe, filename/season parsing, thumbnail
generation) runs per-file once an operator has chosen a video_root for the
group, plus a TMDB client for on-demand poster/metadata lookups (never
client-side, thumbnails delivered over the existing chunk path). On the hub
side, a new video-app.js renders a lazily-mounted poster grid or a
thumbnail-only flat list, with TMDB entirely optional per group.
Along the way: the global apps registry now drives Settings' default-tab
picker instead of a hardcoded list, and the video_root is configured from
group Settings (like uploads) rather than from Files, with the node
refusing to run any TMDB/thumbnail work until one is set.
Fixes several bugs found via live testing against a real library, notably
a race between two effects writing the same "image ready" state that could
leave a poster grid spinning forever on a same-tab revisit — see
mediacenter.md §5.4 for the full account of each one.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
MSE only decodes AAC/Opus, so copying a source's real audio codec left
non-AAC files silently unplayable in-browser (E-AC-3 additionally made
ffmpeg itself refuse to write the fragmented MP4 header). Audio is now
always transcoded to AAC and downmixed to stereo — multichannel AAC is
accepted by ffprobe/VLC but silently rejected by some browsers' MSE
decoder once real fragments are appended, which forces the SourceBuffer
out of its MediaSource with no explicit error. Video stays copy-only.
Also: report a clear client-side error instead of a bare STREAM_END when
ffmpeg exits nonzero before producing any output, add video-element/
MediaSource error logging on the client for the next time this class of
bug needs diagnosing, and fix a hub test that had grown too broad a scan
window after an earlier, unrelated transport.js change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
reconcile, and delta sync
Indexer performance work, in four parts:
- Persistent (path, size, mtime) -> hash cache (indexer/cache.py) so a node
restart no longer re-hashes every file — measured at 23 minutes for a
114 GB library on a slow disk before this, near-instant after. Hashing
is deliberately kept sequential (max_workers=1): it was never actually
concurrent despite the pool size, and two interleaved reads seek-thrash
a spinning disk instead of going faster.
- Byte-based scan progress (IndexProgress), surfaced via the loopback
index-status route, the handshake ack, and a periodic INDEX_PROGRESS
push to connected peers — drives a progress bar in the Create Group
wizard and "add a directory" in Settings, and an animated presence dot.
Guaranteed to settle back to idle via try/finally and a final push on
the scanning->false transition.
- The reconcile backstop's directory walks now run in the executor
instead of blocking the daemon's event loop; its interval defaults to
10 min (was 60s) with adaptive backoff to 2h when nothing changes,
reset on a real change or a peer connecting, and is now a per-group
operator setting (signed op + group Settings UI).
- INDEX_DELTA wired up (protocol support existed, nothing called it):
_on_index_change now sends additions/deletions instead of rebuilding
the full entries list, coalesced over a short window so a burst of
file events produces one push, and the hub swarm registration for
public groups only (re-)registers newly added hashes.
Also fixes several bugs found while testing the above against real
libraries (a 114 GB and a 100+ GB group on a USB HDD):
- /api/reload blocked until the reload — including a brand-new group's
full initial scan — finished, which the Electron bridge's fixed 30s
call timeout turned into a hard failure on any real library. The route
now fires the reload without waiting (ops.start_reload), matching
add_root/remove_root's existing pattern; the wizard's own step order
was fixed to wait for the group to actually appear hosted before the
steps that need it (extra roots, GEK), with retries for the residual
race between that and the daemon's own bookkeeping.
- transport.js's hand-rolled msgpack codec had no case for uint64/int64
(0xcf/0xd3) and crashed decoding any message containing one — hit by
IndexProgress.scanned_bytes/total_bytes for any group over ~4.3 GB.
Verified against real msgpack-encoded bytes from the Python side.
- chat_hist_resp, and this change's own index_progress and
set_scan_settings_ack pushes, were not routed by message type and
could be handed to an unrelated pending request by the transport's
"oldest pending" fallback, stalling it until its own 30s timeout and
corrupting whatever received the wrong reply in its place.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
GroupPage's 6620-line app.js carried Chat and Files wedged in directly, with
no way to add another group-level app without touching the shell itself. It
is now app.js (routing, non-group pages) plus nine focused files — apps.js
(the registry), chat-app.js, files-app.js, video-player.js, group-page.js
(the shell), group-settings.js, hub-client.js, icon.js and file-utils.js —
with docs/apps.md as the checklist for adding one (Videos/Music/Photos are
sketched there, not built).
Node side gained the matching enablement mechanism, mirroring
member_upload exactly: a roster setting, a signed apps_enabled op enforced
by _has_admin_authority, exposed in the handshake ack. Operators toggle
applications per group from Settings, which also gained a small reorder:
Invite, Pairing, Applications, Shared directories, Uploads, danger zone,
Your devices, Members.
Two bugs surfaced during the split, both missing an import across the new
file boundary and invisible to node --check or a module-load probe since
they only throw when the code path actually runs:
- group-page.js called onRefreshAuth on a stale-token handshake rejection,
but app.js never imported refreshAccessToken from hub-client.js — so a
brand new member (including a group's own creator) hit "Not a member of
this group" and the retry silently failed, throwing before it could
refresh the token.
- chat-app.js called getLocale() for message timestamps without importing
it from i18n.js. Opening Chat on a group with real messages threw mid-
render; uncaught, that appears to wedge Preact's render scheduler, so
every button on the page stopped responding until reload.
Caught the second class of bug with a proper no-undef audit across all
split files (a temporarily installed ESLint 9, since the system one is too
old to parse this codebase's syntax) rather than trusting grep. 827 tests
pass; 6 new ones cover the apps_enabled policy.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Add a status panel at the top of the Node page — always visible, even
before an MNP connection exists — showing the meshbay-node systemd
unit's own state (via `systemctl --user show`, main process only) with
Start/Stop/Restart controls. This is the piece the rest of the page
cannot provide: it has to work while the daemon is stopped or crash-
looping, which the MNP-based sections require the daemon to already
answer.
While touching node lifecycle: `reload` and `restart-daemon` in the
CLI shelled out to pgrep + SIGTERM/SIGHUP and respawned the process by
hand, logging to a hardcoded /tmp path. That pattern already SIGHUPed
a developer's own running node by accident once (see the old
test_cli_dispatch.py comment). Both now delegate to
`systemctl --user reload|restart meshbay-node`, which the unit already
supports correctly (ExecReload=, Restart=on-failure).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SF6RKNBKg9qejmoMJ9ybA
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
Node daemon no longer blocks startup on slow directory scans — initial
indexing runs in the background so the node reaches "running" immediately
after transports are up. Fixes the wizard failing to detect the node when
large USB/NAS roots take minutes to scan.
Also: wizard key-linking deadlock resolved (main.js links during poll),
invite form stays in DOM during reconnects (disabled instead of destroyed),
pairing code bridges to renderer, and firewall docs for LAN casting added.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
- test_transport_contracts: CreateGroupPage was refactored into a
routing wrapper; assertions now read CreateGroupFormSimple
- test_task_lifetime: _spawn now uses an _on_done wrapper instead of
a bare self._tasks.discard callback; assertion checks both parts
- test_video_buffer_ceiling: target the real updateend handler, not
the settled() utility; add awaitingInitRef to the MSE harness scope
- test_video_seek: silence debug console.log in window_leak harness
so it does not pollute the JSON output
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
| |
- `init` now writes config AND creates the keystore (interactive password
or unlock.key/env var). Idempotent: skips either step if already done.
- `status` uses load_keystore instead of load_or_create_keystore — a
read-only command should never silently create identity keys.
- Stub getpass in test_cli_dispatch to prevent test hangs when no
keystore exists.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Create Group wizard (Electron-only) consolidates 6 steps across 4 interfaces
into a single multi-step page: group creation on hub, node attachment, root
selection via folder picker, GEK initialization, and auto-pairing — all in one
flow. Browser SPA keeps its current behavior unchanged.
Public group support (Option A — GEK for all groups):
- All groups have GEK regardless of visibility; open-join groups auto-admit
via TOFU when join_policy is "open"
- Key rotation blocked for public groups (API guard + UI hidden)
- Hub signaling allows WebRTC offers for nodes hosting open-join groups even
when the caller isn't a member yet
- attach_group writes join_policy to node.toml
- Daemon loads GEK for all groups, not just private ones
- Known-device path in join_request now auto-admits to open-join groups
Node loopback API bridge (Electron IPC):
- node:detect, node:call, node:pairing-code IPC handlers in main process
- Renderer never sees tokens, paths, or keys (session token = physical access)
- platform.js node namespace for UI consumption
- Loopback endpoints: roots CRUD, member-upload toggle, reload
Bug fixes:
- Root change detection: removed premature ctx["roots"] updates from add_root
and remove_root that prevented indexer retarget on reload
- Duplicate offline message: global fallback now gated on !group
- Signaling membership check: fallback to open-join groups for non-members
Sidebar groups sorted by last_activity_at (most recent first):
- New Group.last_activity_at column with Alembic migration
- POST /v1/groups/{id}/activity endpoint, called on connect and chat send
- Client-side sort + throttled hub updates (1/min)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Node admin panel (NodePage) now covers every CLI operation over MNP:
group attach/detach, roster, member unpin, GEK rotate, denylist, reload.
Daemon hot-loads new groups and tears down removed ones on config reload
instead of requiring a full restart. Group attach/detach via MNP or
local API triggers an automatic reload so the group is live immediately.
Fixed GroupPage hang on first visit to a newly created group: the JWT
issued at login didn't include the new group, the node rejected with
not_a_member, and the token-refresh path returned without re-triggering
the connect effect (Boolean(token) didn't change). Now bumps retryKey
after a successful refresh so the effect re-runs with the fresh token.
NodePage marks groups hosted by the node but absent from the hub with a
"not on hub" badge so stale groups are visible and easy to remove.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A group where every member may add files stays the default. Some groups want a
library the operator curates, and until now the only way to get one was to
designate no upload root at all — which refuses the operator too.
**The node enforces it; the interface merely stops offering it.** The Upload
button in the Files toolbar and the paperclip in the chat composer both
disappear, which is a courtesy to the people who are not trying. The control is
`_do_file_upload` refusing with `member_upload_off`, so a member on an old tab,
or one speaking MNP directly, gets the same answer. There is a test for each,
and the enforcement test is in the node package rather than beside the UI one so
nobody reads the hidden button as the mechanism.
**Changing it is a signed operator instruction** — `OP_MEMBER_UPLOAD`, on the
same path as removing a member. An unsigned one would let any member turn it
back on and make the setting a suggestion. The transcript's subject is `on` or
`off`: what the operator is shown before signing has to name the outcome, not
the operation.
**It lives on the node**, in a new `group_settings` table in `roster.db`. Not
the hub, which has no business deciding who may write to someone else's disk.
Not `node.toml` either: that file is hand-written and full of comments recording
decisions, `ops.py` appends to it rather than round-tripping it through a
writer, and a setting toggled from a panel must not rewrite the operator's file
or need a restart. The value is cached in the group context because the upload
path is synchronous, and the signed operation updates both — storing it without
applying it would make the panel say one thing while the node did another.
**Absent means allowed**, at every layer: no row in the table, no key in the
context, no field in `handshake_ack`. An older node and an older client both
behave exactly as before, and upgrading never silently closes a group. Each of
those three has its own test, because they fail independently.
The operator is always exempt — otherwise turning it off locks them out of their
own node with a config file and a restart as the only way back. `is_node_admin`
was being computed in two places by then and is now one function, since two
copies of "is this the operator" is how the ack and the gate come to disagree.
A change reaches everyone already connected via `member_upload_ack`, so the
button goes without a reconnection. That message is both a broadcast and the
reply to the request that caused it, which is why the client does not return
early on it.
Docs updated for a cold start: draft-v6 §2.1b and change 9, a new "Where Phase
13 stands" section in CLAUDE.md recording what is built, deployed and still
missing, the module map row, and desktop-client-v1 §10b on the Settings tab and
where group settings live.
883 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Stage D, and the honest half of it.
D1 — the seam (done, and verified)
----------------------------------
`static/platform.js`. `HUB` becomes `platform.hubBase()` and the transport is
built with the same base, so one address has one source. In a browser it returns
'' and every path stays relative to the origin that served the page — the
acceptance criterion for this split was "the browser SPA behaves identically",
and it does. `platform.js` joins `_ASSETS`, or a change to it would not move the
content hash and a cached browser would never ask for it.
D2 — the shell (written, never launched)
-----------------------------------------
**There is no npm on this machine. Electron was never installed and
`packages/meshbay-client/` has not been run once.** That is stated here rather
than discovered later.
What is there: a main process serving the packaged interface over a privileged
`app://` scheme (`secure` and `standard` are not cosmetic — without them the
service worker refuses to register and streamed downloads break silently), a
preload exposing an enumerated bridge that never passes a filesystem path, a
window with `sandbox`, `contextIsolation` and no node integration, navigation
away from the package refused, and a CSP where the hub is reachable over
connect-src and is not a script source. The hub address arrives as a process
argument because `platform.hubBase()` runs before anything can await.
`test_desktop_shell.py` pins each of those by reading the source — the treatment
`test_downloads.py` already gives the three browser save paths. It catches a
property being removed and proves nothing about the application running. Two
were checked by breaking them.
The interface is *copied* into the package by `build/sync-ui.js` from the hub's
static directory, and `ui/` is gitignored: a silent fork is the only real way to
end up maintaining the interface twice.
D3 — partial
------------
The bridge, and the part worth having now: safeStorage's backend is reported
rather than assumed. On Linux it falls back to a fixed key when no keyring is
running, silently — someone who believes the OS is holding their keys is told
when it is not. The native key lifecycle belongs with D4 and needs a running
application to mean anything.
D8 — partial, and a real defect found
--------------------------------------
`meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` —
into `%{_userunitdir}`. A user unit already runs as its owner and cannot carry
`User=`; systemd refuses the file, so the packaged unit could never have
started. Nothing noticed because nobody had built and installed the RPM.
Two units now: the template to `%{_unitdir}`, and a new `meshbay-node-user.service`
that a person enables themselves without a password — which is what lets the
desktop client install a node without asking for one. It carries ExecReload, so
`meshbay-node reload` does not have to stop a service somebody is streaming from,
and documents the drop-in for a drive outside the home, RequiresMountsFor
included.
798 tests pass; e2e.py still passes end to end. Nothing here was built or
launched: no npm, no rpmbuild.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|