diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-29 11:15:54 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-29 11:15:54 +0200 |
| commit | b7733e812fadd6007976d262bd1d793572a36ba7 (patch) | |
| tree | 52e2ac07c9ba4929fa50b6ba8d19f94f86e8b304 /packages | |
| parent | 09a007937f03fad04a390323f3817f1ae7d7c2a9 (diff) | |
| download | meshbay-b7733e812fadd6007976d262bd1d793572a36ba7.tar.gz | |
feat: node workflow redesign — wizard auto-config, reset, MusicBrainz contact
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>
Diffstat (limited to 'packages')
29 files changed, 474 insertions, 552 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 91fc4fa..3926c01 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -829,19 +829,28 @@ function registerBridge() { { signal: AbortSignal.timeout(3000) }); if (!r.ok) return null; const status = await r.json(); - if (status.status !== 'running') return null; + const READY = ['running', 'waiting_for_node_key', 'waiting_for_account', 'starting']; + if (!READY.includes(status.status)) return null; _nodeToken = token; _nodePort = port; - return { pk_node_ed25519: status.pk_node_ed25519 || '' }; + return { pk_node_ed25519: status.pk_node_ed25519 || '', status: status.status }; } catch { return null; } } function provisionNode(hubUrl, username) { const configDir = path.join(os.homedir(), '.config', 'meshbay'); + const dataDir = path.join(os.homedir(), '.local', 'share', 'meshbay'); fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); const configFile = nodeConfigPath(); - if (!fs.existsSync(configFile)) { + if (fs.existsSync(configFile)) { + const content = fs.readFileSync(configFile, 'utf8'); + const updated = content + .replace(/^url\s*=\s*"[^"]*"/m, `url = "${hubUrl}"`) + .replace(/^username\s*=\s*"[^"]*"/m, `username = "${username}"`); + fs.writeFileSync(configFile, updated, { mode: 0o600 }); + } else { const toml = [ '[hub]', `url = "${hubUrl}"`, @@ -867,7 +876,9 @@ function registerBridge() { ipcMain.handle('node:start', async (_e, opts) => { const already = await probeNode(); - if (already) return { started: true, ...already }; + if (already && already.status === 'running') { + return { started: true, ...already }; + } if (process.platform !== 'linux') { throw new Error('Automatic node start is only supported on Linux'); @@ -881,8 +892,28 @@ function registerBridge() { const configFile = nodeConfigPath(); let launched = false; + // Clear any failed state from a prior crash loop. + await new Promise((r) => { + execFile('systemctl', ['--user', 'reset-failed', 'meshbay-node'], + () => r()); + }); + + // If a daemon is running in a bad state, restart it with the fresh config. + if (already) { + try { + await new Promise((resolve, reject) => { + execFile('systemctl', ['--user', 'restart', 'meshbay-node'], + (err, _stdout, stderr) => { + if (err) return reject(new Error(stderr.trim() || err.message)); + resolve(); + }); + }); + launched = true; + } catch { /* not systemd-managed — fall through to start */ } + } + // Try systemctl first — the production path. - try { + if (!launched) try { await new Promise((resolve, reject) => { execFile('systemctl', ['--user', 'enable', '--now', 'meshbay-node'], (err, _stdout, stderr) => { @@ -890,27 +921,25 @@ function registerBridge() { resolve(); }); }); - // Give the service a moment, then check if it actually stayed up. + // Give the service a moment, then check if it stayed up. for (let i = 0; i < 6 && Date.now() < deadline; i++) { await new Promise((r) => setTimeout(r, 500)); const result = await probeNode(); - if (result) return { started: true, ...result }; + if (result) { launched = true; break; } } - // The unit may be stuck in auto-restart (e.g. ExecStart points to - // /usr/bin which doesn't exist in dev). is-active returns 0 only when - // the service is genuinely running. - const isActive = await new Promise((resolve) => { - execFile('systemctl', ['--user', 'is-active', 'meshbay-node'], - (err) => resolve(!err)); - }); - if (isActive) { - launched = true; - } else { - // Stop the broken unit so it doesn't compete with the direct start. - await new Promise((resolve) => { - execFile('systemctl', ['--user', 'stop', 'meshbay-node'], - () => resolve()); + if (!launched) { + const isActive = await new Promise((resolve) => { + execFile('systemctl', ['--user', 'is-active', 'meshbay-node'], + (err) => resolve(!err)); }); + if (isActive) { + launched = true; + } else { + await new Promise((resolve) => { + execFile('systemctl', ['--user', 'stop', 'meshbay-node'], + () => resolve()); + }); + } } } catch { // systemctl itself failed (e.g. no unit file). @@ -930,45 +959,36 @@ function registerBridge() { child.unref(); } + // Wait for the daemon to reach 'running'. Along the way, auto-link the + // node key on the hub so the daemon can authenticate. let keyLinked = false; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 500)); - // If the node's admin UI is up but hub auth is stuck, link the key now. - if (!keyLinked && opts && opts.token) { + const result = await probeNode(); + if (!result) continue; + + if (result.status === 'running') { + return { started: true, ...result }; + } + + // Daemon is up but stuck on hub auth — link the key so it can proceed. + if (!keyLinked && opts && opts.token && result.pk_node_ed25519 && + (result.status === 'waiting_for_node_key' || + result.status === 'waiting_for_account')) { try { - const nc = readNodeConfig(); - const dd = nc ? nc.dataDir - : path.join(os.homedir(), '.local', 'share', 'meshbay'); - const tk = readNodeToken(dd); - if (tk) { - const port = nc ? nc.uiPort : 18000; - const sr = await fetch( - `http://127.0.0.1:${port}/api/status?t=${tk}`, - { signal: AbortSignal.timeout(3000) }); - if (sr.ok) { - const st = await sr.json(); - if (st.pk_node_ed25519 && - (st.status === 'waiting_for_node_key' || - st.status === 'waiting_for_account')) { - const lr = await fetch( - `${opts.hubUrl}/v1/users/me/node_key`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${opts.token}` }, - body: JSON.stringify({ - pk_node_ed25519: st.pk_node_ed25519 }), - signal: AbortSignal.timeout(5000), - }); - if (lr.ok) keyLinked = true; - } - } - } + const lr = await fetch( + `${opts.hubUrl}/v1/users/me/node_key`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', + 'Authorization': `Bearer ${opts.token}` }, + body: JSON.stringify({ + pk_node_ed25519: result.pk_node_ed25519 }), + signal: AbortSignal.timeout(5000), + }); + if (lr.ok) keyLinked = true; } catch { /* best effort */ } } - - const result = await probeNode(); - if (result) return { started: true, ...result }; } throw new Error( 'meshbay-node was started but did not become ready within 60 seconds'); diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index 19dfe8e..f96dd57 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -83,10 +83,8 @@ OP_VIDEO_ROOT = "video_root" # (media_cache is shared, not per-viewer), so an unsigned override would let # any member vandalize another show's metadata. OP_TMDB_OVERRIDE = "tmdb_override" -# Music app (docs/musicbay.md §6) — same shape as OP_TMDB_CONFIG, minus a -# secret: MusicBrainz needs no API key, only a rate-limited, self-identifying -# client, so this only ever carries the User-Agent contact string, node-wide. -OP_MUSICBRAINZ_CONFIG = "musicbrainz_config" +# MusicBrainz contact is now the owner's hub email (musicbrainz.py) — no +# signed config op needed. Only the per-group toggle remains. # Whether the node calls MusicBrainz *at all* for this group — per-group from # the start (unlike TMDB, which started node-wide and was split later once # the lesson was already learned once). Signed for the same reason as diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index d4e3ccd..651fe2d 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -109,11 +109,9 @@ class MNP: TMDB_SEARCH_RESP = "tmdb_search_resp" # node → client: candidate list (id, title, year, poster) TMDB_OVERRIDE = "tmdb_override" # operator → node: replace a show/movie's TMDB match TMDB_OVERRIDE_ACK = "tmdb_override_ack" - # Music app (docs/musicbay.md) — same shape as the TMDB pair above, minus - # a credential: MusicBrainz read lookups need no API key, only a - # rate-limited, self-identifying client (§3 there). - MUSICBRAINZ_CONFIG = "musicbrainz_config" # operator → node: contact string - MUSICBRAINZ_CONFIG_ACK = "musicbrainz_config_ack" # node → everyone: new config (no secret) + # Music app (docs/musicbay.md). Contact is derived from the owner's hub + # email at login — no config/ack pair needed. Only the per-group toggle + # remains. MUSICBRAINZ_ENABLED = "musicbrainz_enabled" # operator → node: enable/disable MUSICBRAINZ_ENABLED_ACK = "musicbrainz_enabled_ack" # node → this group: new enabled state MUSIC_META_REQ = "music_meta_req" # client → node: metadata for a path diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index b1489ec..7db3fc9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -625,6 +625,17 @@ async def register_node_key( return {"status": "stored", "pk_node_ed25519": body.pk_node_ed25519} +@router.delete("/me/node_key") +async def unlink_node_key( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Remove the linked node key from the operator's account.""" + current_user.pk_node_ed25519 = None + await db.commit() + return {"status": "unlinked"} + + # Key rotation used to live here (`PUT /me/keys`). Identity keys are per node # now, so rotating means `meshbay-node member unpin <user>` and pairing again with # a fresh code — an operator decision on the machine that pinned it, not a hub diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 50a972b..4aab150 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1130,7 +1130,31 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru } } - // Step 1: Group details + directories + // Node detected but not fully ready — provision config and/or link key, + // then wait for 'running' before showing the group form. + const provisionAttempted = useRef(false); + useEffect(() => { + if (step === 1 && nodeStatus && !nodeStarting + && !provisionAttempted.current + && (nodeStatus.status === 'waiting_for_account' + || nodeStatus.status === 'waiting_for_node_key' + || nodeStatus.status === 'starting')) { + provisionAttempted.current = true; + startNode(); + } + }, [step, nodeStatus, nodeStarting, startNode]); + + if (step === 1 && nodeStatus + && nodeStatus.status !== 'running' && nodeStatus.status !== undefined) { + return html`<div class="page-content"> + <h2>${t('wizard.title')}</h2> + <p class="page-message">${error + ? t('wizard.wrong_account') + : t('wizard.detecting')}</p> + ${error && html`<button class="btn btn-secondary" onClick=${detectNode}> + ${t('wizard.retry')}</button>`} + </div>`; + } if (step === 1) { const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0; return html`<div class="page-content"> @@ -2352,6 +2376,10 @@ function NodePage({ token, username, userId, groups }) { const [attachGroup_, setAttachGroup_] = useState(''); const [attachDir, setAttachDir] = useState(''); const [attachUpload, setAttachUpload] = useState(''); + const [operatorPaired, setOperatorPaired] = useState(false); + const [pairCode, setPairCode] = useState(''); + const [pairStatus, setPairStatus] = useState(''); + const [pairing, setPairing] = useState(false); const transportRef = useRef(null); const connectAndFetch = useCallback(async () => { @@ -2405,6 +2433,7 @@ function NodePage({ token, username, userId, groups }) { console.log('[NodePage] got status:', result.groups?.length, 'groups'); transportRef.current = transport; setNodeGroups(result.groups || []); + setOperatorPaired(!!result.operator_paired); setStatus('connected'); return; } catch (err) { @@ -2437,6 +2466,7 @@ function NodePage({ token, username, userId, groups }) { try { const result = await transport.fetchNodeStatus(); setNodeGroups(result.groups || []); + setOperatorPaired(!!result.operator_paired); } catch {} }, []); @@ -2606,6 +2636,27 @@ function NodePage({ token, username, userId, groups }) { } }, [attachGroup_, attachDir, attachUpload, signFn]); + const doPairOperator = useCallback(async (e) => { + e.preventDefault(); + const code = pairCode.trim(); + if (!code) return; + setPairing(true); + setPairStatus(''); + try { + const transport = transportRef.current; + if (!transport || !transport.connected) throw new Error('Not connected'); + await transport.pairOperator(userId, code); + setPairCode(''); + setPairStatus('paired'); + setOperatorPaired(true); + await refresh(); + } catch (err) { + setPairStatus(err.message); + } finally { + setPairing(false); + } + }, [pairCode, userId, refresh]); + const reloadConfig = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; @@ -2656,6 +2707,22 @@ function NodePage({ token, username, userId, groups }) { </div> <${NodeServicePanel} onChanged=${connectAndFetch} /> ${actionMsg && html`<div class="node-message">${actionMsg}</div>`} + ${!operatorPaired && html` + <div class="node-pair-banner"> + <p>${t('node.pair_needed')}</p> + <form onSubmit=${doPairOperator} class="node-pair-form"> + <input type="text" placeholder=${t('node.pair_code_placeholder')} + value=${pairCode} onInput=${e => setPairCode(e.target.value)} + disabled=${pairing} /> + <button class="btn btn-primary btn-small" type="submit" + disabled=${pairing || !pairCode.trim()}> + ${t('node.pair_button')}</button> + </form> + ${pairStatus === 'paired' + ? html`<p class="success-msg">${t('node.pair_success')}</p>` + : pairStatus ? html`<p class="error-msg">${pairStatus}</p>` : null} + </div> + `} ${(() => { const hubIds = new Set((groups || []).map(g => g.id)); return nodeGroups.map(g => { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 5a2a79b..dac0f45 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -94,8 +94,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // above (docs/photos.md §2.1: a photo library is routinely scattered // across several folders). Empty means nothing configured yet. const [photoRoots, setPhotoRoots] = useState([]); - // MusicBrainz on/off (per-group) + whether a contact string is configured - // (node-wide) — docs/musicbay.md §3.2, same shape as tmdbConfig above. + // MusicBrainz on/off (per-group) — docs/musicbay.md §3.2. const [musicbrainzConfig, setMusicbrainzConfig] = useState(null); const onPlayQueue = useCallback((tracks, startIndex) => { setVideoEntry(null); @@ -245,7 +244,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setPhotoRoots(ack.photo_roots || []); setMusicbrainzConfig({ enabled: ack.musicbrainz_enabled !== false, - contactConfigured: !!ack.musicbrainz_contact_configured, }); // Changed while we are connected, by an operator who may be someone // else entirely. Without this the button stays until a reconnection, @@ -262,8 +260,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, transport.onVideoRoot = (path) => setVideoRoot(path); transport.onAudioRoot = (path) => setAudioRoot(path); transport.onPhotoRoots = (roots) => setPhotoRoots(roots); - transport.onMusicbrainzConfig = (cfg) => - setMusicbrainzConfig((prev) => ({ ...(prev || {}), ...cfg })); transport.onMusicbrainzEnabled = (enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled })); // The node's own scan (a root added while we were already connected, @@ -599,7 +595,6 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, onTmdbConfig=${(cfg) => setTmdbConfig((prev) => ({ ...(prev || {}), ...cfg }))} onTmdbEnabled=${(enabled) => setTmdbConfig((prev) => ({ ...(prev || {}), enabled }))} musicbrainzConfig=${musicbrainzConfig} - onMusicbrainzConfig=${(cfg) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), ...cfg }))} onMusicbrainzEnabled=${(enabled) => setMusicbrainzConfig((prev) => ({ ...(prev || {}), enabled }))} entries=${entries} nodeDirs=${nodeDirs} videoRoot=${videoRoot} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 59d1456..4f55dfb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -191,7 +191,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, enabledApps, onEnabledApps, scanSettings, onScanSettings, tmdbConfig, onTmdbConfig, onTmdbEnabled, - musicbrainzConfig, onMusicbrainzConfig, onMusicbrainzEnabled, + musicbrainzConfig, onMusicbrainzEnabled, entries, nodeDirs, videoRoot, onVideoRoot, audioRoot, onAudioRoot, photoRoots, onPhotoRoots, onRefreshIndex, @@ -548,9 +548,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, saveTmdbConfig(); }, [isNodeAdmin, connected, tmdbConfig, saveTmdbConfig]); - const [mbBusy, setMbBusy] = useState(false); const [mbMsg, setMbMsg] = useState(''); - const [mbContactDraft, setMbContactDraft] = useState(''); const [mbEnabledBusy, setMbEnabledBusy] = useState(false); const mbEnabled = musicbrainzConfig ? musicbrainzConfig.enabled : true; @@ -579,43 +577,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, } }, [transportRef, onMusicbrainzEnabled]); - /** - * The node-wide MusicBrainz contact string (docs/musicbay.md §3.2) — not - * a secret, unlike TMDB's token, but still cleared from the draft field - * after a save: the node never echoes it back - * (musicbrainz_config_ack carries only whether one is set), so there is - * nothing to keep showing. - */ - const saveMusicbrainzConfig = useCallback(async () => { - const transport = transportRef && transportRef.current; - setMbMsg(''); - setMbBusy(true); - try { - if (!transport || !transport.connected) { - throw new Error('Not connected to the node'); - } - const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; - const signFn = (sk && window.MeshBayKeys) - ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) - : null; - const contact = mbContactDraft.trim(); - await transport.setMusicbrainzConfig(contact || undefined, signFn); - setMbContactDraft(''); - if (onMusicbrainzConfig) { - onMusicbrainzConfig({ - contactConfigured: contact - ? true - : (musicbrainzConfig ? musicbrainzConfig.contactConfigured : false), - }); - } - setMbMsg(t('settings_node.scan_saved')); - } catch (err) { - setMbMsg(err.message); - } finally { - setMbBusy(false); - } - }, [transportRef, onMusicbrainzConfig, mbContactDraft, musicbrainzConfig]); - // Every folder anywhere in the group's shared index, deepest included — // `entries[].path` is each file's containing directory (files-app.js's own // convention), so every ancestor prefix of it is a real folder, and @@ -1033,23 +994,6 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, onChange=${(v) => saveMusicbrainzEnabled(v)} label=${mbEnabled ? t('settings_node.musicbrainz_enabled') : t('settings_node.musicbrainz_disabled')} /> </div> - <div class="settings-row"> - <label class="settings-label"> - ${t('settings_node.musicbrainz_contact_label')} - <input type="text" placeholder=${t('settings_node.musicbrainz_contact_placeholder')} - value=${mbContactDraft} disabled=${mbBusy} - onInput=${e => setMbContactDraft(e.target.value)} /> - </label> - <p class="settings-hint"> - ${musicbrainzConfig && musicbrainzConfig.contactConfigured - ? t('settings_node.musicbrainz_contact_set') - : t('settings_node.musicbrainz_contact_unset')} - </p> - </div> - <button class="btn btn-small btn-secondary" style="margin-top:8px" - disabled=${mbBusy} onClick=${() => saveMusicbrainzConfig()}> - ${mbBusy ? t('settings_node.scan_saving') : t('settings_node.musicbrainz_save')} - </button> ${mbMsg && html`<p class="settings-hint">${mbMsg}</p>`} </${CollapsibleSection}> `} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index e3d9ee2..525f70c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -594,6 +594,10 @@ export default { 'node.not_operator': 'Ihr Node konnte nicht erreicht werden. Stellen Sie sicher, dass er läuft.', 'node.offline': 'Node ist offline', 'node.retry': 'Erneut versuchen', + 'node.pair_needed': 'Kein Operator gekoppelt. Geben Sie einen Kopplungscode ein, um Administratoraktionen (Einladungen, Dateilöschung) zu aktivieren.', + 'node.pair_code_placeholder': 'Kopplungscode', + 'node.pair_button': 'Diesen Browser koppeln', + 'node.pair_success': 'Erfolgreich gekoppelt.', 'node.reload': 'Konfiguration neu laden', 'node.reloaded': 'Konfiguration neu geladen. Verzeichnisänderungen an bestehenden Gruppen sind jetzt aktiv.', 'node.restart_needed': 'Nach dem Hinzufügen: Gruppenschlüssel initialisieren (meshbay-node gek-init --group <name>).', @@ -673,11 +677,6 @@ export default { 'settings_node.musicbrainz_hint': 'Ermöglicht der Musik-App, Cover und kanonische Benennungen von MusicBrainz anzuzeigen, wenn ein Titel kein brauchbares eingebettetes Cover hat. Aus bedeutet nur Tag-/Dateiname-basiertes Durchsuchen, ohne Anfrage an Dritte.', 'settings_node.musicbrainz_enabled': 'Aktiviert', 'settings_node.musicbrainz_disabled': 'Deaktiviert', - 'settings_node.musicbrainz_contact_label': 'Kontakt (erforderlich, damit MusicBrainz antwortet)', - 'settings_node.musicbrainz_contact_placeholder': 'du@beispiel.de oder eine Projekt-URL', - 'settings_node.musicbrainz_contact_set': 'Ein Kontakt ist konfiguriert.', - 'settings_node.musicbrainz_contact_unset': 'Kein Kontakt konfiguriert — MusicBrainz-Abfragen bleiben deaktiviert, bis einer festgelegt ist.', - 'settings_node.musicbrainz_save': 'Speichern', 'settings_node.video_root_save': 'Speichern', 'settings_node.video_root_change_confirm': 'Das Ändern des Videos-Stammordners ersetzt, was jedes Mitglied im Videos-Tab sieht. Fortfahren?', @@ -702,6 +701,7 @@ export default { 'wizard.node_not_found': 'Kein lokaler Node erkannt. Stellen Sie sicher, dass meshbay-node läuft.', 'wizard.node_offline_warning': 'Node läuft nicht. Die Gruppe wird nur auf dem Hub erstellt. Sie können den Node später über die Node-Seite verbinden.', 'wizard.retry': 'Erneut versuchen', + 'wizard.wrong_account': 'Der Node läuft, aber der konfigurierte Benutzername stimmt nicht mit Ihrem Konto überein. Überprüfen Sie hub.username in node.toml.', 'wizard.skip_node': 'Ohne Node fortfahren', 'wizard.directories': 'Freigegebene Verzeichnisse', 'wizard.directories_hint': 'Wählen Sie die Verzeichnisse, die diese Gruppe teilen soll. Mindestens eines ist erforderlich.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index e8bfca3..a35eaf5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -428,6 +428,7 @@ export default { 'wizard.detecting': 'Detecting local node...', 'wizard.node_not_found': 'No local node detected. Make sure meshbay-node is running.', 'wizard.retry': 'Retry', + 'wizard.wrong_account': 'The node is running but its configured username does not match your account. Check hub.username in node.toml.', 'wizard.skip_node': 'Continue without node', 'wizard.node_offline_warning': 'Node is not running. The group will be created on the hub only. You can connect the node later from the Node page.', 'wizard.directories': 'Shared directories', @@ -495,11 +496,6 @@ export default { 'settings_node.musicbrainz_hint': 'Lets the Music app show cover art and canonical naming from MusicBrainz when a track has no usable embedded cover. Off means tag/filename-only browsing, with no request to a third party.', 'settings_node.musicbrainz_enabled': 'Enabled', 'settings_node.musicbrainz_disabled': 'Disabled', - 'settings_node.musicbrainz_contact_label': 'Contact (required for MusicBrainz to answer requests)', - 'settings_node.musicbrainz_contact_placeholder': 'you@example.com or a project URL', - 'settings_node.musicbrainz_contact_set': 'A contact is configured.', - 'settings_node.musicbrainz_contact_unset': 'No contact configured — MusicBrainz lookups stay off until one is set.', - 'settings_node.musicbrainz_save': 'Save', 'settings_node.video_root_save': 'Save', 'settings_node.video_root_change_confirm': 'Changing the Videos root replaces what every member sees in the Videos tab. Continue?', @@ -691,6 +687,10 @@ export default { 'node.denylist_clear_all': 'Clear all', 'node.denylist_clear_confirm': 'Remove "{subject}" from the denylist?', 'node.denylist_cleared': 'Denylist entry removed.', + 'node.pair_needed': 'No operator paired. Enter a pairing code to enable admin operations (invites, file deletion).', + 'node.pair_code_placeholder': 'Pairing code', + 'node.pair_button': 'Pair this browser', + 'node.pair_success': 'Paired successfully.', 'node.reload': 'Reload config', 'node.reloaded': 'Config reloaded. Root changes on existing groups are now active.', 'node.attach_group': 'Add group', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 0847d79..7400fc3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -590,6 +590,10 @@ export default { 'node.not_operator': 'No se pudo contactar con su node. Asegúrese de que esté en ejecución.', 'node.offline': 'Node sin conexión', 'node.retry': 'Reintentar', + 'node.pair_needed': 'Ningún operador emparejado. Introduzca un código de emparejamiento para habilitar las operaciones de administración (invitaciones, eliminación de archivos).', + 'node.pair_code_placeholder': 'Código de emparejamiento', + 'node.pair_button': 'Emparejar este navegador', + 'node.pair_success': 'Emparejamiento exitoso.', 'node.reload': 'Recargar configuración', 'node.reloaded': 'Configuración recargada. Los cambios de directorios en los grupos existentes ya están activos.', 'node.restart_needed': 'Después de añadir: inicialice la clave de grupo (meshbay-node gek-init --group <nombre>).', @@ -669,11 +673,6 @@ export default { 'settings_node.musicbrainz_hint': 'Permite que la app de Música muestre carátulas y nombres canónicos de MusicBrainz cuando una pista no tiene una carátula incrustada utilizable. Desactivado significa navegación solo por etiquetas/nombre de archivo, sin solicitudes a terceros.', 'settings_node.musicbrainz_enabled': 'Activado', 'settings_node.musicbrainz_disabled': 'Desactivado', - 'settings_node.musicbrainz_contact_label': 'Contacto (necesario para que MusicBrainz responda)', - 'settings_node.musicbrainz_contact_placeholder': 'tu@ejemplo.com o una URL de proyecto', - 'settings_node.musicbrainz_contact_set': 'Hay un contacto configurado.', - 'settings_node.musicbrainz_contact_unset': 'Sin contacto configurado — las búsquedas de MusicBrainz permanecen desactivadas hasta que se configure uno.', - 'settings_node.musicbrainz_save': 'Guardar', 'settings_node.video_root_save': 'Guardar', 'settings_node.video_root_change_confirm': 'Cambiar la raíz de Vídeos reemplaza lo que ve cada miembro en la pestaña Vídeos. ¿Continuar?', @@ -698,6 +697,7 @@ export default { 'wizard.node_not_found': 'No se detectó un node local. Asegúrese de que meshbay-node esté en ejecución.', 'wizard.node_offline_warning': 'El node no está en ejecución. El grupo se creará solo en el hub. Podrá conectar el node más tarde desde la página Node.', 'wizard.retry': 'Reintentar', + 'wizard.wrong_account': 'El node está en ejecución, pero el nombre de usuario configurado no coincide con su cuenta. Compruebe hub.username en node.toml.', 'wizard.skip_node': 'Continuar sin node', 'wizard.directories': 'Directorios compartidos', 'wizard.directories_hint': 'Elija los directorios que este grupo compartirá. Se requiere al menos uno.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index bcc82d1..98b155a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -594,6 +594,10 @@ export default { 'node.not_operator': 'Impossible de joindre votre node. Vérifiez qu\'il est en cours d\'exécution.', 'node.offline': 'Node hors ligne', 'node.retry': 'Réessayer', + 'node.pair_needed': "Aucun opérateur appairé. Entrez un code d'appairage pour activer les opérations d'administration (invitations, suppression de fichiers).", + 'node.pair_code_placeholder': "Code d'appairage", + 'node.pair_button': 'Appairer ce navigateur', + 'node.pair_success': 'Appairage réussi.', 'node.reload': 'Recharger la configuration', 'node.reloaded': 'Configuration rechargée. Les modifications de répertoires sur les groupes existants sont maintenant actives.', 'node.restart_needed': 'Après l\'ajout : initialisez la clé de groupe (meshbay-node gek-init --group <name>).', @@ -685,11 +689,6 @@ export default { 'settings_node.musicbrainz_hint': "Permet à l'app Musique d'afficher les pochettes et les noms canoniques depuis MusicBrainz quand un morceau n'a pas de pochette intégrée utilisable. Désactivé signifie une navigation par tags/nom de fichier uniquement, sans requête vers un tiers.", 'settings_node.musicbrainz_enabled': 'Activé', 'settings_node.musicbrainz_disabled': 'Désactivé', - 'settings_node.musicbrainz_contact_label': 'Contact (requis pour que MusicBrainz réponde)', - 'settings_node.musicbrainz_contact_placeholder': 'vous@exemple.com ou une URL de projet', - 'settings_node.musicbrainz_contact_set': 'Un contact est configuré.', - 'settings_node.musicbrainz_contact_unset': "Aucun contact configuré — les recherches MusicBrainz restent désactivées tant que rien n'est renseigné.", - 'settings_node.musicbrainz_save': 'Enregistrer', 'settings_node.video_root_save': 'Enregistrer', 'settings_node.video_root_change_confirm': "Changer la racine des Vidéos remplace ce que chaque membre voit dans l'onglet Vidéos. Continuer ?", @@ -714,6 +713,7 @@ export default { 'wizard.node_not_found': 'Aucun node local détecté. Vérifiez que meshbay-node est en cours d\'exécution.', 'wizard.node_offline_warning': 'Le node ne tourne pas. Le groupe sera créé sur le hub uniquement. Vous pourrez connecter le node plus tard depuis la page Node.', 'wizard.retry': 'Réessayer', + 'wizard.wrong_account': "Le node tourne mais le nom d'utilisateur configuré ne correspond pas à votre compte. Vérifiez hub.username dans node.toml.", 'wizard.skip_node': 'Continuer sans node', 'wizard.directories': 'Répertoires partagés', 'wizard.directories_hint': 'Choisissez les répertoires que ce groupe partagera. Au moins un est requis.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index bfdd964..9843e7c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -592,6 +592,10 @@ export default { 'node.not_operator': 'Impossibile raggiungere il suo node. Si assicuri che sia in esecuzione.', 'node.offline': 'Node non in linea', 'node.retry': 'Riprova', + 'node.pair_needed': "Nessun operatore associato. Inserisci un codice di associazione per abilitare le operazioni di amministrazione (inviti, eliminazione file).", + 'node.pair_code_placeholder': 'Codice di associazione', + 'node.pair_button': 'Associa questo browser', + 'node.pair_success': 'Associazione riuscita.', 'node.reload': 'Ricarica configurazione', 'node.reloaded': 'Configurazione ricaricata. Le modifiche alle directory dei gruppi esistenti sono ora attive.', 'node.restart_needed': "Dopo l'aggiunta: inizializzi la chiave di gruppo (meshbay-node gek-init --group <nome>).", @@ -683,11 +687,6 @@ export default { 'settings_node.musicbrainz_hint': "Permette all'app Musica di mostrare copertine e nomi canonici da MusicBrainz quando una traccia non ha una copertina incorporata utilizzabile. Disattivato significa navigazione basata solo su tag/nome file, senza richieste a terzi.", 'settings_node.musicbrainz_enabled': 'Attivato', 'settings_node.musicbrainz_disabled': 'Disattivato', - 'settings_node.musicbrainz_contact_label': 'Contatto (necessario perché MusicBrainz risponda)', - 'settings_node.musicbrainz_contact_placeholder': 'tu@esempio.com o un URL di progetto', - 'settings_node.musicbrainz_contact_set': 'È configurato un contatto.', - 'settings_node.musicbrainz_contact_unset': 'Nessun contatto configurato — le ricerche MusicBrainz restano disattivate finché non ne viene impostato uno.', - 'settings_node.musicbrainz_save': 'Salva', 'settings_node.video_root_save': 'Salva', 'settings_node.video_root_change_confirm': 'Cambiare la radice di Video sostituisce ciò che ogni membro vede nella scheda Video. Continuare?', @@ -712,6 +711,7 @@ export default { 'wizard.node_not_found': 'Nessun node locale rilevato. Si assicuri che meshbay-node sia in esecuzione.', 'wizard.node_offline_warning': 'Il node non è in esecuzione. Il gruppo sarà creato solo sul hub. Potrà collegare il node in seguito dalla pagina Node.', 'wizard.retry': 'Riprova', + 'wizard.wrong_account': 'Il node è in esecuzione, ma il nome utente configurato non corrisponde al suo account. Verifichi hub.username in node.toml.', 'wizard.skip_node': 'Continua senza node', 'wizard.directories': 'Directory condivise', 'wizard.directories_hint': 'Scelga le directory che questo gruppo condividerà. Ne serve almeno una.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 5d86e32..41635d4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -580,6 +580,10 @@ export default { 'node.not_operator': 'node に接続できませんでした。node が実行中であることをご確認ください。', 'node.offline': 'Node はオフラインです', 'node.retry': '再試行', + 'node.pair_needed': 'オペレーターがペアリングされていません。管理操作(招待、ファイル削除)を有効にするにはペアリングコードを入力してください。', + 'node.pair_code_placeholder': 'ペアリングコード', + 'node.pair_button': 'このブラウザをペアリング', + 'node.pair_success': 'ペアリングに成功しました。', 'node.reload': '設定を再読み込み', 'node.reloaded': '設定を再読み込みしました。既存グループのルート変更が反映されました。', 'node.restart_needed': '追加後、グループ鍵を初期化してください(meshbay-node gek-init --group <name>)。', @@ -667,11 +671,6 @@ export default { 'settings_node.musicbrainz_hint': 'トラックに使用可能な埋め込みカバーがない場合、MusicBrainzのカバーアートと正式名称をMusicアプリで表示できるようにします。オフにするとタグ・ファイル名のみでの閲覧になり、第三者へのリクエストは発生しません。', 'settings_node.musicbrainz_enabled': '有効', 'settings_node.musicbrainz_disabled': '無効', - 'settings_node.musicbrainz_contact_label': '連絡先(MusicBrainzが応答するために必要)', - 'settings_node.musicbrainz_contact_placeholder': 'you@example.com またはプロジェクトのURL', - 'settings_node.musicbrainz_contact_set': '連絡先が設定されています。', - 'settings_node.musicbrainz_contact_unset': '連絡先が設定されていません — 設定されるまでMusicBrainzの検索は無効のままです。', - 'settings_node.musicbrainz_save': '保存', 'settings_node.video_root_save': '保存', 'settings_node.video_root_change_confirm': '動画のルートフォルダを変更すると、全メンバーの動画タブの表示内容が変わります。続行しますか?', @@ -696,6 +695,7 @@ export default { 'wizard.node_not_found': 'ローカル node が検出できませんでした。meshbay-node が実行中であることをご確認ください。', 'wizard.node_offline_warning': 'Node が実行されていません。グループは hub 上のみに作成されます。後から Node ページで node を接続できます。', 'wizard.retry': '再試行', + 'wizard.wrong_account': 'node は実行中ですが、設定されたユーザー名がお使いのアカウントと一致しません。node.toml の hub.username をご確認ください。', 'wizard.skip_node': 'node なしで続行', 'wizard.directories': '共有ディレクトリ', 'wizard.directories_hint': 'このグループで共有するディレクトリを選択してください。少なくとも 1 つ必要です。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 77ba822..38f9845 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -594,6 +594,10 @@ export default { 'node.not_operator': 'Uw node is niet bereikbaar. Controleer of hij draait.', 'node.offline': 'Node is offline', 'node.retry': 'Opnieuw proberen', + 'node.pair_needed': 'Geen operator gekoppeld. Voer een koppelingscode in om beheeracties (uitnodigingen, bestandsverwijdering) in te schakelen.', + 'node.pair_code_placeholder': 'Koppelingscode', + 'node.pair_button': 'Deze browser koppelen', + 'node.pair_success': 'Succesvol gekoppeld.', 'node.reload': 'Configuratie herladen', 'node.reloaded': 'Configuratie herladen. Mapwijzigingen op bestaande groepen zijn nu actief.', 'node.restart_needed': 'Na het toevoegen: initialiseer de groepssleutel (meshbay-node gek-init --group <naam>).', @@ -685,11 +689,6 @@ export default { 'settings_node.musicbrainz_hint': "Laat de Muziek-app hoesfoto's en canonieke namen van MusicBrainz tonen wanneer een nummer geen bruikbare ingesloten hoes heeft. Uit betekent alleen bladeren op tag/bestandsnaam, zonder verzoek aan derden.", 'settings_node.musicbrainz_enabled': 'Ingeschakeld', 'settings_node.musicbrainz_disabled': 'Uitgeschakeld', - 'settings_node.musicbrainz_contact_label': 'Contact (vereist zodat MusicBrainz kan antwoorden)', - 'settings_node.musicbrainz_contact_placeholder': 'jij@voorbeeld.com of een project-URL', - 'settings_node.musicbrainz_contact_set': 'Er is een contact ingesteld.', - 'settings_node.musicbrainz_contact_unset': 'Geen contact ingesteld — MusicBrainz-opzoekingen blijven uit totdat er een is ingesteld.', - 'settings_node.musicbrainz_save': 'Opslaan', 'settings_node.video_root_save': 'Opslaan', 'settings_node.video_root_change_confirm': "Het wijzigen van de hoofdmap voor Video's vervangt wat elk lid ziet in het tabblad Video's. Doorgaan?", @@ -714,6 +713,7 @@ export default { 'wizard.node_not_found': 'Geen lokale node gedetecteerd. Controleer of meshbay-node draait.', 'wizard.node_offline_warning': 'Node draait niet. De groep wordt alleen op de hub aangemaakt. U kunt de node later verbinden via de Node-pagina.', 'wizard.retry': 'Opnieuw proberen', + 'wizard.wrong_account': 'De node draait, maar de geconfigureerde gebruikersnaam komt niet overeen met uw account. Controleer hub.username in node.toml.', 'wizard.skip_node': 'Doorgaan zonder node', 'wizard.directories': 'Gedeelde mappen', 'wizard.directories_hint': 'Kies de mappen die deze groep deelt. Minimaal één is vereist.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 23fc4d9..bfad68b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -613,6 +613,10 @@ export default { 'node.not_operator': 'Nie udało się połączyć z Pana/Pani node. Upewnij się, że działa.', 'node.offline': 'Node jest niedostępny', 'node.retry': 'Ponów', + 'node.pair_needed': 'Brak sparowanego operatora. Wprowadź kod parowania, aby włączyć operacje administracyjne (zaproszenia, usuwanie plików).', + 'node.pair_code_placeholder': 'Kod parowania', + 'node.pair_button': 'Sparuj tę przeglądarkę', + 'node.pair_success': 'Parowanie zakończone sukcesem.', 'node.reload': 'Przeładuj konfigurację', 'node.reloaded': 'Konfiguracja przeładowana. Zmiany katalogów w istniejących grupach są teraz aktywne.', 'node.restart_needed': 'Po dodaniu: zainicjalizuj klucz grupy (meshbay-node gek-init --group <nazwa>).', @@ -712,11 +716,6 @@ export default { 'settings_node.musicbrainz_hint': 'Pozwala aplikacji Muzyka pokazywać okładki i kanoniczne nazwy z MusicBrainz, gdy utwór nie ma użytecznej wbudowanej okładki. Wyłączone oznacza przeglądanie tylko na podstawie tagów/nazwy pliku, bez żądań do strony trzeciej.', 'settings_node.musicbrainz_enabled': 'Włączone', 'settings_node.musicbrainz_disabled': 'Wyłączone', - 'settings_node.musicbrainz_contact_label': 'Kontakt (wymagany, aby MusicBrainz odpowiadał)', - 'settings_node.musicbrainz_contact_placeholder': 'ty@przyklad.com lub URL projektu', - 'settings_node.musicbrainz_contact_set': 'Kontakt jest skonfigurowany.', - 'settings_node.musicbrainz_contact_unset': 'Brak skonfigurowanego kontaktu — wyszukiwania MusicBrainz pozostają wyłączone, dopóki nie zostanie ustawiony.', - 'settings_node.musicbrainz_save': 'Zapisz', 'settings_node.video_root_save': 'Zapisz', 'settings_node.video_root_change_confirm': 'Zmiana katalogu głównego Wideo zastępuje to, co widzi każdy członek w karcie Wideo. Kontynuować?', @@ -741,6 +740,7 @@ export default { 'wizard.node_not_found': 'Nie wykryto lokalnego node. Upewnij się, że meshbay-node działa.', 'wizard.node_offline_warning': 'Node nie działa. Grupa zostanie utworzona wyłącznie na hub. Node można podłączyć później na stronie Node.', 'wizard.retry': 'Ponów', + 'wizard.wrong_account': 'Node działa, ale skonfigurowana nazwa użytkownika nie odpowiada Pana/Pani kontu. Proszę sprawdzić hub.username w pliku node.toml.', 'wizard.skip_node': 'Kontynuuj bez node', 'wizard.directories': 'Katalogi współdzielone', 'wizard.directories_hint': 'Wybierz katalogi, które ta grupa będzie współdzielić. Wymagany jest co najmniej jeden.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 5c217f0..a6ddadd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -591,6 +591,10 @@ export default { 'node.not_operator': 'Não foi possível alcançar seu node. Verifique se ele está em execução.', 'node.offline': 'Node está off-line', 'node.retry': 'Tentar novamente', + 'node.pair_needed': 'Nenhum operador pareado. Insira um código de pareamento para habilitar operações administrativas (convites, exclusão de arquivos).', + 'node.pair_code_placeholder': 'Código de pareamento', + 'node.pair_button': 'Parear este navegador', + 'node.pair_success': 'Pareamento realizado com sucesso.', 'node.reload': 'Recarregar configuração', 'node.reloaded': 'Configuração recarregada. Alterações de diretórios em grupos existentes estão ativas.', 'node.restart_needed': 'Após adicionar: inicialize a chave do grupo (meshbay-node gek-init --group <nome>).', @@ -670,11 +674,6 @@ export default { 'settings_node.musicbrainz_hint': 'Permite que o app Música mostre capas e nomes canônicos do MusicBrainz quando uma faixa não tem uma capa incorporada utilizável. Desativado significa navegação apenas por tags/nome de arquivo, sem solicitação a terceiros.', 'settings_node.musicbrainz_enabled': 'Ativado', 'settings_node.musicbrainz_disabled': 'Desativado', - 'settings_node.musicbrainz_contact_label': 'Contato (necessário para o MusicBrainz responder)', - 'settings_node.musicbrainz_contact_placeholder': 'voce@exemplo.com ou uma URL de projeto', - 'settings_node.musicbrainz_contact_set': 'Um contato está configurado.', - 'settings_node.musicbrainz_contact_unset': 'Nenhum contato configurado — as buscas no MusicBrainz permanecem desativadas até que um seja definido.', - 'settings_node.musicbrainz_save': 'Salvar', 'settings_node.video_root_save': 'Salvar', 'settings_node.video_root_change_confirm': 'Alterar a raiz de Vídeos substitui o que cada membro vê na aba Vídeos. Continuar?', @@ -699,6 +698,7 @@ export default { 'wizard.node_not_found': 'Nenhum node local detectado. Verifique se o meshbay-node está em execução.', 'wizard.node_offline_warning': 'O node não está em execução. O grupo será criado apenas no hub. Você pode conectar o node posteriormente pela página Node.', 'wizard.retry': 'Tentar novamente', + 'wizard.wrong_account': 'O node está em execução, mas o nome de usuário configurado não corresponde à sua conta. Verifique hub.username em node.toml.', 'wizard.skip_node': 'Continuar sem node', 'wizard.directories': 'Diretórios compartilhados', 'wizard.directories_hint': 'Escolha os diretórios que este grupo compartilhará. Pelo menos um é obrigatório.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index b00779d..5fb2a0b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -566,6 +566,10 @@ export default { 'node.not_operator': '无法连接到您的 node。请确保它正在运行。', 'node.offline': 'Node 已离线', 'node.retry': '重试', + 'node.pair_needed': '尚未配对操作员。请输入配对码以启用管理操作(邀请、文件删除)。', + 'node.pair_code_placeholder': '配对码', + 'node.pair_button': '配对此浏览器', + 'node.pair_success': '配对成功。', 'node.reload': '重新加载配置', 'node.reloaded': '配置已重新加载。对现有群组的目录更改现已生效。', 'node.restart_needed': '添加后请初始化群组密钥(meshbay-node gek-init --group <名称>)。', @@ -653,11 +657,6 @@ export default { 'settings_node.musicbrainz_hint': '当曲目没有可用的内嵌封面时,允许音乐应用显示来自 MusicBrainz 的封面和规范名称。关闭表示仅按标签/文件名浏览,不向第三方发送请求。', 'settings_node.musicbrainz_enabled': '已启用', 'settings_node.musicbrainz_disabled': '已禁用', - 'settings_node.musicbrainz_contact_label': '联系方式(MusicBrainz 需要它才能响应请求)', - 'settings_node.musicbrainz_contact_placeholder': 'you@example.com 或项目 URL', - 'settings_node.musicbrainz_contact_set': '已配置联系方式。', - 'settings_node.musicbrainz_contact_unset': '未配置联系方式 — 在设置之前,MusicBrainz 查询将保持关闭。', - 'settings_node.musicbrainz_save': '保存', 'settings_node.video_root_save': '保存', 'settings_node.video_root_change_confirm': '更改视频根目录会替换每位成员在"视频"标签页中看到的内容。是否继续?', @@ -683,6 +682,7 @@ export default { 'wizard.node_offline_warning': 'Node 未在运行。群组将仅在 hub 上创建。' + '您可以之后在 Node 页面连接 node。', 'wizard.retry': '重试', + 'wizard.wrong_account': 'node 正在运行,但配置的用户名与您的账户不匹配。请检查 node.toml 中的 hub.username。', 'wizard.skip_node': '不使用 node 继续', 'wizard.directories': '共享目录', 'wizard.directories_hint': '选择此群组要共享的目录。至少需要一个。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 120c7d7..d6a28d9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -75,7 +75,7 @@ function _aborted() { const ADMIN_OP_TYPES = new Set([ 'tmdb_override', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', 'photo_roots', - 'musicbrainz_config', 'musicbrainz_enabled', 'file_delete', 'dir_delete', + 'musicbrainz_enabled', 'file_delete', 'dir_delete', 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke', 'root_add', 'root_remove', 'member_unpin', 'gek_rotate', 'group_attach', 'group_detach', 'invite_create', @@ -276,7 +276,6 @@ class MeshBayTransport { set onVideoRoot(fn) { this._onVideoRoot = fn; } set onAudioRoot(fn) { this._onAudioRoot = fn; } set onPhotoRoots(fn) { this._onPhotoRoots = fn; } - set onMusicbrainzConfig(fn) { this._onMusicbrainzConfig = fn; } set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; } set onIndexProgress(fn) { this._onIndexProgress = fn; } // Fired once an automatic reconnect (see _reconnectLoop) lands a fresh @@ -1055,31 +1054,6 @@ class MeshBayTransport { } /** - * Set/clear the node-wide MusicBrainz contact string — the User-Agent - * identity MusicBrainz's usage policy asks for, not a credential (there - * is none, docs/musicbay.md §3.1). Signed like setTmdbConfig: this turns - * on outbound third-party network traffic the operator has to agree to. - * `contact: ''` explicitly clears it (reverting to "no calls at all"); - * omit it (undefined/null) to leave whatever is stored unchanged. - */ - async setMusicbrainzConfig(contact, signFn) { - const msg = await this._sendAndWait({ - type: 'musicbrainz_config', v: '0.8', - contact: contact === undefined ? null : contact, - }); - if (msg.type === 'error') throw new Error(msg.detail); - if (msg.type === 'admin_challenge') { - // Must match the node's subject byte-for-byte (webrtc_server.py - // _do_musicbrainz_config) — not a secret like tmdb_config's token, - // but still kept out of the audit log as free text: only whether one - // was supplied travels in the subject. - const subject = `contact_configured=${contact ? 'yes' : 'no'}`; - return this._authorizeAdminOp(msg, 'musicbrainz_config', subject, signFn); - } - return msg; - } - - /** * Whether MusicBrainz lookups run for this group at all — per-group from * the start (docs/musicbay.md §3.2/§6). Signed like setTmdbEnabled. */ @@ -2077,12 +2051,6 @@ class MeshBayTransport { this._onPhotoRoots(msg.roots || []); } - // Node-wide, like tmdb_config_ack above — no token equivalent to hide, - // only whether a contact string is configured (docs/musicbay.md §3.2). - if (msg.type === 'musicbrainz_config_ack' && this._onMusicbrainzConfig) { - this._onMusicbrainzConfig({ contactConfigured: Boolean(msg.contact_configured) }); - } - // Per-group, like tmdb_enabled_ack above. if (msg.type === 'musicbrainz_enabled_ack' && this._onMusicbrainzEnabled) { this._onMusicbrainzEnabled(Boolean(msg.enabled)); diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 74ab7b1..779ad91 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -240,6 +240,11 @@ class NodeDaemon: session = await self._login_with_retry(hub) self._state["endpoint_hint"] = session.node_id + try: + session.email = await hub.fetch_owner_email() + except Exception as e: + log.warning("Could not fetch owner email: %s", e) + # 4. Bundle store (P2P GEK bundles) data_dir = self._config.data_dir data_dir.mkdir(parents=True, exist_ok=True) @@ -433,12 +438,11 @@ class NodeDaemon: # 6c. Music app (docs/musicbay.md) — same media_cache.db, its own # enricher (mutagen, not ffmpeg) and its own MusicBrainz client. - # No token to read here (§3.1): only a contact string, and unset - # simply means the client makes no calls (musicbrainz.py). + # The User-Agent contact is the owner's hub email, resolved at + # login — no roster setting or env var needed any more. self._audio_enricher = AudioEnricher(self._media_cache) - self._musicbrainz_client = MusicBrainzClient(roster=self._roster) - musicbrainz_contact = await self._roster.musicbrainz_contact() - self._state["musicbrainz_contact_configured"] = bool(musicbrainz_contact) + self._musicbrainz_client = MusicBrainzClient(owner_email=session.email) + self._state["musicbrainz_contact_configured"] = bool(session.email) # 6d. Photos app (docs/photos.md) — same media_cache.db, its own # enricher (Pillow, not ffmpeg/mutagen). No credential, no @@ -1570,11 +1574,12 @@ def main() -> None: parser = argparse.ArgumentParser(description="MeshBay Node daemon") parser.add_argument("command", nargs="?", - choices=["init", "status", "ui", "gek-init", "gek", - "operator", "member", "group", "file", + choices=["init", "reset", "status", "ui", "gek-init", + "gek", "operator", "member", "group", "file", "denylist", "reload", "restart-daemon", "calibrate-argon2"], - help="init: write example config | status: node state and keys " + help="init: provision config + keystore | reset: erase all " + "node state | status: node state and keys " "| ui: print the admin UI URL | operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " "| group list|add|remove | gek init|rotate | file list|rm " @@ -1590,6 +1595,10 @@ def main() -> None: help="username for member invite|revoke|unpin; group name " "for group add; file id for file rm; identifier for " "denylist clear") + parser.add_argument("--hub-url", default=None, + help="hub URL, for init (e.g. https://meshbay.org)") + parser.add_argument("--username", default=None, + help="hub username, for init") parser.add_argument("--dir", default=None, help="shared directory, for group add") parser.add_argument("--upload-dir", default=None, @@ -1607,7 +1616,7 @@ def main() -> None: # Query commands print a report; library logging would interleave with it. quiet = args.command in ("status", "ui", "gek-init", "gek", "operator", "member", "group", "file", "denylist", "reload", - "restart-daemon") + "restart-daemon", "reset") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -1615,19 +1624,145 @@ def main() -> None: if args.command == "init": cfg_path = args.config or DEFAULT_CONFIG_PATH - if not cfg_path.exists(): - write_example_config(cfg_path) - print(f"Config written to {cfg_path}") + config_dir = cfg_path.parent + config_dir.mkdir(parents=True, exist_ok=True) + + hub_url = args.hub_url + username = args.username + + if not hub_url: + hub_url = input("Hub URL [https://meshbay.org]: ").strip() or "https://meshbay.org" + if not username: + username = input("Hub username: ").strip() + if not username: + print("Username is required.") + sys.exit(1) + + if cfg_path.exists(): + existing = cfg_path.read_text() + import re as _re + m = _re.search(r'username\s*=\s*"([^"]*)"', existing) + existing_user = m.group(1) if m else "" + if existing_user and existing_user != "myusername" and existing_user != username: + print(f"Config already exists with username {existing_user!r}.") + print("This node belongs to another operator. Use 'meshbay-node reset' first.") + sys.exit(1) + if existing_user in ("", "myusername"): + updated = _re.sub( + r'(username\s*=\s*)"[^"]*"', rf'\1"{username}"', existing) + updated = _re.sub( + r'(url\s*=\s*)"[^"]*"', rf'\1"{hub_url}"', updated, count=1) + cfg_path.write_text(updated) + print(f"Config updated: username={username}, hub={hub_url}") + else: + print(f"Config already exists: {cfg_path}") else: - print(f"Config already exists: {cfg_path}") + unlock_file = config_dir / "unlock.key" + toml_lines = [ + "[hub]", + f'url = "{hub_url}"', + f'username = "{username}"', + "", + "[node]", + "quic_port = 19010", + "ui_port = 18000", + "", + "[keystore]", + f'unlock_file = "{unlock_file}"', + "", + ] + cfg_path.write_text("\n".join(toml_lines) + "\n") + os.chmod(cfg_path, 0o600) + print(f"Config written to {cfg_path}") + + unlock_file = config_dir / "unlock.key" + if not unlock_file.exists(): + import secrets + key = secrets.token_urlsafe(32) + unlock_file.write_text(key + "\n") + os.chmod(unlock_file, 0o600) + print(f"Unlock key created: {unlock_file}") + cfg = load_config(cfg_path) if cfg.keystore.path.exists(): print(f"Keystore already exists: {cfg.keystore.path}") + keys = load_keystore( + path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file) else: keys = create_keystore( path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file) print(f"Keystore created: {cfg.keystore.path}") - print(f"Node key: {keys.pk_ed25519_b64}") + + print(f"Node key: {keys.pk_ed25519_b64}") + print() + print("Next steps:") + print(f" 1. Link this node key on {hub_url} → Settings → Link Node") + print(" 2. systemctl --user enable --now meshbay-node") + print(" 3. meshbay-node group add <name> --dir /path/to/files") + print(" 4. meshbay-node gek init") + print(" 5. meshbay-node operator pair") + return + + if args.command == "reset": + import shutil + + config_dir = Path.home() / ".config" / "meshbay" + data_dir = Path.home() / ".local" / "share" / "meshbay" + state_dir = Path.home() / ".local" / "state" / "meshbay" + + items = [] + for d in (config_dir, data_dir): + if d.exists(): + for child in sorted(d.iterdir()): + items.append(child) + + if not items: + print("Nothing to reset — no node state found.") + return + + print("This will permanently erase all node state:") + for p in items: + print(f" {p}") + print() + print("WARNING: a new keystore means a new identity. All group") + print("memberships, operator pairings, and invitations are lost.") + + if not args.yes: + answer = input("\nProceed? [y/N] ").strip().lower() + if answer != "y": + print("Aborted.") + return + + import subprocess as _sp + import json as _json + import urllib.request + import urllib.error + + token_file = data_dir / "ui-token" + if token_file.exists(): + try: + cfg = Config(config_dir / "node.toml") + tok = token_file.read_text().strip() + url = (f"http://127.0.0.1:{cfg.node.ui_port}" + f"/api/unlink?t={tok}") + req = urllib.request.Request(url, method="DELETE") + with urllib.request.urlopen(req, timeout=5) as r: + _json.loads(r.read()) + print("Unlinked node key from hub.") + except Exception: + print("Could not unlink from hub (daemon not reachable).") + + _sp.run(["systemctl", "--user", "disable", "--now", "meshbay-node"], + capture_output=True) + + for d in (config_dir, data_dir): + if d.exists(): + shutil.rmtree(d) + print(f"Removed {d}") + if state_dir.is_dir(): + shutil.rmtree(state_dir) + print(f"Removed {state_dir}") + print("Node state erased. Run 'meshbay-node init' to start over.") return if args.command == "calibrate-argon2": @@ -1668,6 +1803,33 @@ def main() -> None: f" files {live.get('total_files', 0)}" f" peers {live.get('webrtc_peers', 0)}") print(f"admin UI meshbay-node ui") + + needs = live.get("needs", []) + if needs: + _GUIDANCE = { + "node_key_link": ( + "Link node key", + f"Copy the node key above and paste it in Settings → Link Node on {cfg.hub.url}"), + "group_add": ( + "Add a group", + "meshbay-node group add <name> --dir /path/to/files"), + "operator_pair": ( + "Pair as operator", + "meshbay-node operator pair"), + } + print() + print("action needed:") + for need in needs: + if need.startswith("gek_init:"): + name = need.split(":", 1)[1] + print(f" → Initialize group key for {name}") + print(f" meshbay-node gek init --group \"{name}\"") + elif need in _GUIDANCE: + label, hint = _GUIDANCE[need] + print(f" → {label}") + print(f" {hint}") + else: + print(f" → {need}") else: print("daemon not running") diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py index 92c39db..1a746bd 100644 --- a/packages/meshbay-node/src/meshbay_node/hub_client.py +++ b/packages/meshbay-node/src/meshbay_node/hub_client.py @@ -41,6 +41,7 @@ class HubSession: refresh_token: str hub_pk_pem: bytes # cached hub Ed25519 public key node_id: str = "" + email: str = "" _token_exp: int = 0 @property @@ -203,6 +204,27 @@ class HubClient: r.raise_for_status() return r.json().get("groups", []) + # ── Owner profile ──────────────────────────────────────────────────────── + + async def fetch_owner_email(self) -> str: + """Fetch the authenticated user's email from the hub.""" + if self._session is None: + raise RuntimeError("Not logged in") + await self.ensure_fresh_token() + r = await self._http.get("/v1/users/me", + headers=self._session.auth_headers) + r.raise_for_status() + return r.json().get("email", "") + + async def unlink_node_key(self) -> None: + """Clear pk_node_ed25519 on the hub (best-effort before reset).""" + if self._session is None: + return + await self.ensure_fresh_token() + r = await self._http.delete("/v1/users/me/node_key", + headers=self._session.auth_headers) + r.raise_for_status() + # ── User pubkey lookup ──────────────────────────────────────────────────── async def get_user_pubkeys(self, username: str) -> dict: diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py index 63cda41..707a046 100644 --- a/packages/meshbay-node/src/meshbay_node/media_cache.py +++ b/packages/meshbay-node/src/meshbay_node/media_cache.py @@ -3,10 +3,10 @@ MeshBay Node — TMDB/MusicBrainz metadata and thumbnail cache, shared by the Videos and Music group apps. Node-wide (not per-group, `data_dir/media_cache.db`), same rationale as -`tmdb_enabled`/`tmdb_api_token` (and `musicbrainz_enabled`/ -`musicbrainz_contact`, docs/musicbay.md §6) living in `group_settings` under -the `group_id=""` sentinel (docs/mediacenter.md §5.5): the credential/budget -is one operator's, and a thumbnail or cover image is the same bytes +`tmdb_enabled`/`tmdb_api_token` (and `musicbrainz_enabled`, +docs/musicbay.md §6) living in `group_settings` under the `group_id=""` +sentinel (docs/mediacenter.md §5.5): the credential/budget is one +operator's, and a thumbnail or cover image is the same bytes regardless of which group happens to share the file. The `file_mbid`/ `mbid_meta` tables below are the Music app's equivalent of `file_tmdb`/ `tmdb_meta`, sharing the same `thumbs` table for cover art (a MusicBrainz diff --git a/packages/meshbay-node/src/meshbay_node/musicbrainz.py b/packages/meshbay-node/src/meshbay_node/musicbrainz.py index 6ca1cf4..59f3778 100644 --- a/packages/meshbay-node/src/meshbay_node/musicbrainz.py +++ b/packages/meshbay-node/src/meshbay_node/musicbrainz.py @@ -17,40 +17,25 @@ account, only: enforced by convention (and by MusicBrainz throttling abusive clients), not a token bucket handed out by the server. -Contact resolution order (docs/musicbay.md §3.2), same shape as tmdb.py's -token resolution: - - 1. an operator-supplied contact string (roster.py group_settings, - group_id="") - 2. the MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT environment variable - 3. none — MusicBrainz lookups are inert (callers get an empty result, - never an exception). Deliberately **not** falling back to a generic - User-Agent: sending an unidentified client to a service that polices - its User-Agent policy risks the node's IP being blocked, which is a - worse failure than "no music metadata yet". - -No literal contact value lives in this file, for the same reason tmdb.py -carries no literal token — see docs/musicbay.md §3.2 on why a personal -address must never land in source control. +Contact resolution: the node owner's hub account email, fetched once at +login via ``GET /v1/users/me`` and passed to this client at construction. +If the owner has no email on file, lookups are inert (callers get an +empty result, never an exception). """ import asyncio import difflib import logging -import os import re import time import httpx -from meshbay_node.roster import Roster - log = logging.getLogger(__name__) _BASE_URL = "https://musicbrainz.org/ws/2/" _COVER_ART_BASE = "https://coverartarchive.org/release/" _TIMEOUT = 10.0 -_DEFAULT_CONTACT_ENV = "MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT" _APP_NAME = "MeshBay-Node" # MusicBrainz's own stated courtesy limit for unauthenticated use. Enforced @@ -112,9 +97,9 @@ def _best_match_release(artist: str, album: str, results: list[dict]) -> tuple[d class MusicBrainzClient: """One instance per node, holding the resolved contact and an httpx client.""" - def __init__(self, roster: Roster | None = None, + def __init__(self, owner_email: str = "", transport: httpx.AsyncBaseTransport | None = None): - self._roster = roster + self._owner_email = owner_email # `transport` is a test-only seam (httpx.MockTransport) — production # callers never pass it. self._client = httpx.AsyncClient(timeout=_TIMEOUT, transport=transport) @@ -125,11 +110,7 @@ class MusicBrainzClient: await self._client.aclose() async def _resolve_contact(self) -> str | None: - if self._roster is not None: - contact = await self._roster.musicbrainz_contact() - else: - contact = None - return contact or os.environ.get(_DEFAULT_CONTACT_ENV) or None + return self._owner_email or None async def _pace(self) -> None: """Serializes every call through this client to >= _MIN_INTERVAL_SECS apart.""" diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index c880c75..cb8e01e 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -326,7 +326,13 @@ async def list_groups(state: dict) -> dict: "roots": roots.describe() if roots else [], "peers": sum(1 for p in peers.values() if p.get("group_id") == gid), }) - return {"groups": out} + roster = state.get("roster") + has_operator = False + if roster: + members = await roster.list_members() + has_operator = any(m["role"] == "operator" and m["status"] == "active" + for m in members) + return {"groups": out, "operator_paired": has_operator} async def attach_group(state: dict, name: str, shared_dir: str, @@ -783,22 +789,8 @@ async def set_tmdb_enabled(state: dict, group_id: str, enabled: bool) -> dict: # ── MusicBrainz config (Music app) ─────────────────────────────────────────── -async def set_musicbrainz_config(state: dict, contact: str | None = None) -> dict: - """ - The node-wide User-Agent contact string MusicBrainz's usage policy asks - for (docs/musicbay.md §3.2). Unlike set_tmdb_config there is no token to - manage — MusicBrainz's read endpoints need no credential — so this is a - single field. `contact=""` explicitly clears a previously-set contact - (reverting to "no calls at all", never a generic/unidentified - User-Agent); `contact=None` leaves whatever was there unchanged. - """ - roster = _roster(state) - await roster.set_musicbrainz_contact(contact, set_by=state.get("node_user_id", "")) - if contact is not None: - state["musicbrainz_contact_configured"] = bool(contact) - log.info("MusicBrainz config: contact_configured=%s", bool(contact)) - return {"contact_configured": state.get("musicbrainz_contact_configured", False)} - +# set_musicbrainz_config removed — MusicBrainz contact is now the owner's +# hub email, resolved at login (daemon.py / musicbrainz.py). async def set_musicbrainz_enabled(state: dict, group_id: str, enabled: bool) -> dict: """ diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index d6dc769..ff5282d 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -705,25 +705,8 @@ class Roster: await self.set_setting(group_id, self.SETTING_TMDB_ENABLED, "1" if enabled else "0", set_by) - # The Music app's MusicBrainz contact string (docs/musicbay.md §3.2) — - # node-wide, under the same group_id="" sentinel as the TMDB token/ - # language above, for the identical reason: one operator's User-Agent - # identity, not a per-group concern. Unlike TMDB there is no secret to - # store — this is the contact MusicBrainz's usage policy asks a client - # to identify itself with, not a credential. Unset means "no contact - # configured", which musicbrainz.py treats as "make no calls at all" - # (docs/musicbay.md §3.1) rather than sending an unidentified client. - SETTING_MUSICBRAINZ_CONTACT = "musicbrainz_contact" - - async def musicbrainz_contact(self) -> str | None: - return await self.get_setting( - self.NODE_WIDE_GROUP_ID, self.SETTING_MUSICBRAINZ_CONTACT) or None - - async def set_musicbrainz_contact(self, contact: str | None = None, set_by: str = "") -> None: - """`contact=""` clears it; `contact=None` leaves it unchanged (tmdb_config's shape).""" - if contact is not None: - await self.set_setting(self.NODE_WIDE_GROUP_ID, self.SETTING_MUSICBRAINZ_CONTACT, - contact, set_by) + # MusicBrainz contact is now the node owner's hub email, resolved at + # login (musicbrainz.py) — no roster setting needed. # Whether MusicBrainz lookups run for this group at all — per-group from # the start (unlike tmdb_enabled, which started node-wide and moved diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index dfa775e..dd68e18 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -70,7 +70,6 @@ from meshbay_common.adminop import ( OP_TMDB_ENABLED, OP_VIDEO_ROOT, OP_TMDB_OVERRIDE, - OP_MUSICBRAINZ_CONFIG, OP_MUSICBRAINZ_ENABLED, OP_AUDIO_ROOT, OP_PHOTO_ROOTS, @@ -476,8 +475,6 @@ class WebRTCPeerSession: self._spawn(self._do_tmdb_search_request(msg)) elif mtype == MNP.TMDB_OVERRIDE: self._do_tmdb_override(msg) - elif mtype == MNP.MUSICBRAINZ_CONFIG: - self._do_musicbrainz_config(msg) elif mtype == MNP.MUSICBRAINZ_ENABLED: self._do_musicbrainz_enabled(msg) elif mtype == MNP.MUSIC_META_REQ: @@ -753,8 +750,6 @@ class WebRTCPeerSession: # fields above. No language field: MusicBrainz search doesn't # take one the way TMDB does. "musicbrainz_enabled": bool(self._group_ctx().get("musicbrainz_enabled", True)), - "musicbrainz_contact_configured": bool( - self._ctx.get("daemon_state", {}).get("musicbrainz_contact_configured", False)), # Which folder the Music app treats as its entry point for this # group — same shape as video_root above, "" means unset (the # Music tab shows nothing yet). @@ -2069,59 +2064,6 @@ class WebRTCPeerSession: except Exception: pass - def _do_musicbrainz_config(self, msg: dict) -> None: - """ - Set (or clear) the node-wide MusicBrainz User-Agent contact string - (docs/musicbay.md §3.2). Unlike tmdb_config there is no token field — - MusicBrainz's read endpoints need no credential, only a descriptive - client identity. Signed like tmdb_config: this changes outbound - third-party network traffic the node did not have before the Music - app (§8) — an unsigned change would let any member alter egress the - operator never agreed to. - """ - contact = msg.get("contact") - if contact is not None and not isinstance(contact, str): - self._send({"type": "error", "detail": "Invalid 'contact'"}) - return - if not self._has_admin_authority(): - self._send({"type": "error", "detail": "No authorized key for this"}) - return - # Not a secret (unlike tmdb_config's token) — a contact address is - # meant to be visible to whoever receives it (MusicBrainz), but it is - # still not committed to the audit log's subject line as free text: - # the same "yes/no configured" shape as tmdb_config keeps the audit - # log itself free of a personal address. - subject = f"contact_configured={'yes' if contact else 'no'}" - self._issue_admin_challenge( - OP_MUSICBRAINZ_CONFIG, subject, - payload={"contact": contact}, group_id="") - - async def _admin_exec_musicbrainz_config( - self, pending: dict, transcript: bytes, sig: bytes, - ) -> None: - if not await self._verify_admin_sig(transcript, sig): - self._send({"type": "error", "detail": "Signature verification failed"}) - self._audit("admin_auth_failed", f"musicbrainz_config:{pending['subject']}") - return - p = pending.get("payload") or {} - try: - result = await self._run_op(ops.set_musicbrainz_config, p.get("contact")) - except ops.OpError as e: - self._send({"type": "error", "detail": e.message}) - return - self._audit("musicbrainz_config", pending["subject"]) - - notice = { - "type": MNP.MUSICBRAINZ_CONFIG_ACK, "v": MNP_VERSION, - "contact_configured": result["contact_configured"], - } - for gctx in self._ctx.get("groups", {}).values(): - for session in list(gctx.get("_peers", {}).values()): - try: - session._send(notice) - except Exception: - pass - def _do_musicbrainz_enabled(self, msg: dict) -> None: """ Whether MusicBrainz lookups run for this group at all. Per-group @@ -3859,9 +3801,6 @@ class WebRTCPeerSession: elif pending["op"] == OP_TMDB_OVERRIDE: self._spawn( self._admin_exec_tmdb_override(pending, transcript, sig_bytes)) - elif pending["op"] == OP_MUSICBRAINZ_CONFIG: - self._spawn( - self._admin_exec_musicbrainz_config(pending, transcript, sig_bytes)) elif pending["op"] == OP_MUSICBRAINZ_ENABLED: self._spawn( self._admin_exec_musicbrainz_enabled(pending, transcript, sig_bytes)) diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 3dc320b..dfd9f68 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -116,9 +116,31 @@ def create_ui_app(state: dict) -> FastAPI: total_files = sum(idx.count for idx in indexes.values()) groups_ctx = state.get("groups_ctx", {}) webrtc = state.get("webrtc") + status = state.get("status", "starting") + + needs = [] + if status == "waiting_for_node_key": + needs.append("node_key_link") + if status == "running" and not groups_ctx: + needs.append("group_add") + if status == "running": + roster = state.get("roster") + if roster: + from meshbay_node.roster import Roster + members = await roster.list_members() + operators = [m for m in members + if m["role"] == "operator" and m["status"] == "active"] + if not operators: + needs.append("operator_pair") + for gid, gctx in groups_ctx.items(): + if not gctx.get("gek"): + name = gctx.get("name", gid[:8]) + needs.append(f"gek_init:{name}") + return { "version": __version__, - "status": state.get("status", "starting"), + "status": status, + "needs": needs, "hub_url": state.get("hub_url", ""), "username": state.get("username", ""), "quic_port": state.get("quic_port", 0), @@ -129,6 +151,14 @@ def create_ui_app(state: dict) -> FastAPI: "pk_node_ed25519": state.get("pk_node_ed25519", ""), } + @app.delete("/api/unlink") + async def api_unlink(): + hub = state.get("hub") + if not hub: + raise HTTPException(status_code=503, detail="Hub not connected") + await hub.unlink_node_key() + return {"status": "unlinked"} + @app.get("/api/groups") async def api_groups(): return await _op(lambda: ops.list_groups(state)) diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index d9edb17..aff7330 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -128,7 +128,7 @@ def test_the_verb_list_here_matches_the_parser(): exercised = {argv[0] for argv in VERBS} # `init` writes a config file and `calibrate-argon2` burns CPU for seconds; # both are excluded on purpose rather than by omission. - untested = declared - exercised - {"init", "calibrate-argon2"} + untested = declared - exercised - {"init", "reset", "calibrate-argon2"} assert not untested, ( f"CLI verbs with no dispatch test: {sorted(untested)} — add them to " f"VERBS above") diff --git a/packages/meshbay-node/tests/test_musicbrainz.py b/packages/meshbay-node/tests/test_musicbrainz.py index 4d075d3..843a691 100644 --- a/packages/meshbay-node/tests/test_musicbrainz.py +++ b/packages/meshbay-node/tests/test_musicbrainz.py @@ -7,14 +7,6 @@ import pytest from meshbay_node.musicbrainz import _MIN_INTERVAL_SECS, MusicBrainzClient, _escape_lucene -class FakeRoster: - def __init__(self, contact: str | None = "operator@example.invalid"): - self._contact = contact - - async def musicbrainz_contact(self): - return self._contact - - def _handler(response_map): def handle(request: httpx.Request) -> httpx.Response: path = request.url.path @@ -30,7 +22,7 @@ async def test_search_release_returns_top_result_and_confidence(): body = {"releases": [{"id": "abc-123", "title": "The Great Album", "artist-credit": [{"name": "Some Artist"}]}]} client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(_handler({"release": body})), ) result, ratio = await client.search_release("Some Artist", "The Great Album") @@ -44,7 +36,7 @@ async def test_search_release_returns_top_result_and_confidence(): @pytest.mark.asyncio async def test_no_results_returns_none_and_zero_confidence(): client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(_handler({"release": {"releases": []}})), ) result, ratio = await client.search_release("Nobody", "Nonexistent Obscure Album") @@ -80,7 +72,7 @@ async def test_strict_match_does_not_pay_for_a_second_request(): }) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) await client.search_release("Some Artist", "The Great Album") @@ -112,7 +104,7 @@ async def test_falls_back_to_a_loose_query_when_the_strict_one_finds_nothing(): }) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) result, ratio = await client.search_release("Groundation", "Hebron Gate (2003)") @@ -139,7 +131,7 @@ async def test_confidence_reflects_a_wrong_artist_on_a_same_titled_release(): }) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) result, ratio = await client.search_release("Groundation", "Live") @@ -150,8 +142,7 @@ async def test_confidence_reflects_a_wrong_artist_on_a_same_titled_release(): @pytest.mark.asyncio -async def test_no_contact_configured_makes_no_request(monkeypatch): - monkeypatch.delenv("MESHBAY_MUSICBRAINZ_CONTACT_DEFAULT", raising=False) +async def test_no_contact_configured_makes_no_request(): calls = [] def handle(request: httpx.Request) -> httpx.Response: @@ -159,7 +150,7 @@ async def test_no_contact_configured_makes_no_request(monkeypatch): return httpx.Response(200, json={"releases": []}) client = MusicBrainzClient( - roster=FakeRoster(contact=None), + owner_email="", transport=httpx.MockTransport(handle), ) result, ratio = await client.search_release("Anyone", "Anything") @@ -178,7 +169,7 @@ async def test_the_configured_contact_is_sent_as_user_agent(): return httpx.Response(200, json={"releases": []}) client = MusicBrainzClient( - roster=FakeRoster(contact="operator@example.invalid"), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) await client.search_release("Anyone", "Anything") @@ -193,7 +184,7 @@ async def test_http_error_returns_none_gracefully(): return httpx.Response(500, json={"error": "server error"}) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) result, ratio = await client.search_release("Anyone", "Anything") @@ -209,7 +200,7 @@ async def test_cover_art_missing_returns_none_not_an_error(): return httpx.Response(404) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) content = await client.fetch_cover_art("abc-123") @@ -224,7 +215,7 @@ async def test_cover_art_found_returns_bytes(): return httpx.Response(200, content=b"\xff\xd8fake-jpeg-bytes") client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) content = await client.fetch_cover_art("abc-123") @@ -245,7 +236,7 @@ async def test_calls_are_paced_at_least_min_interval_apart(): return httpx.Response(200, json={"releases": []}) client = MusicBrainzClient( - roster=FakeRoster(), + owner_email="operator@example.invalid", transport=httpx.MockTransport(handle), ) start = time.monotonic() diff --git a/packages/meshbay-node/tests/test_musicbrainz_config_policy.py b/packages/meshbay-node/tests/test_musicbrainz_config_policy.py deleted file mode 100644 index 3590f01..0000000 --- a/packages/meshbay-node/tests/test_musicbrainz_config_policy.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -The operator's MusicBrainz User-Agent contact string — docs/musicbay.md -§3.2/§6. Same shape as test_tmdb_config_policy.py: a signed operator -instruction, node-wide (group_id="") rather than per-group, stored via -roster.py's group_settings table. - -Unlike TMDB's token, a contact string is not a secret — MusicBrainz's usage -policy expects it to be visible to the service it's sent to — but the -subject signed/audited still only ever says whether one was configured -(never the address itself), the same "yes/no" shape as tmdb_config's -subject, to keep a personal contact out of the audit log as free text. -""" - -from pathlib import Path - -import pytest - -from meshbay_common.adminop import OP_MUSICBRAINZ_CONFIG -from meshbay_node.indexer.group_index import GroupIndex -from meshbay_node.roster import Roster -from meshbay_node.transport.webrtc_server import WebRTCPeerSession -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey - -from conftest import one_root - -pytestmark = pytest.mark.asyncio - - -def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession: - shared_root = tmp_path / "shared" - shared_root.mkdir(exist_ok=True) - index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate()) - ctx = { - "roots": one_root(shared_root), - "index": index, - "sk_node": index.sk_node, - "node_user_id": operator, - } - session = WebRTCPeerSession.__new__(WebRTCPeerSession) - session._ctx = ctx - session._group_id = None - session._user_id = user_id - session._pk_user = "" - session.sent = [] - session._send = session.sent.append - session._audit = lambda *a, **k: None - return session - - -def _fake_challenge(issued: list): - return lambda op, subject, payload=None, group_id=None: issued.append( - (op, subject, payload, group_id)) - - -# ── Refused before a challenge is even issued ─────────────────────────────── - -async def test_non_string_contact_is_refused(tmp_path): - session = _session(tmp_path, "op", operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = _fake_challenge(issued) - - session._do_musicbrainz_config({"contact": 12345}) - - assert not issued - assert [m for m in session.sent if m.get("type") == "error"] - - -async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path): - session = _session(tmp_path, "member-1", operator="the-operator") - session._has_admin_authority = lambda: False - - session._do_musicbrainz_config({"contact": "https://example.invalid/contact"}) - - assert [m for m in session.sent if m.get("type") == "error"] - - -# ── Who may change it, and what gets signed ───────────────────────────────── - -async def test_changing_it_needs_a_signature(tmp_path): - session = _session(tmp_path, "op", operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = _fake_challenge(issued) - - session._do_musicbrainz_config({}) - - assert len(issued) == 1 - op, subject, payload, group_id = issued[0] - assert op == OP_MUSICBRAINZ_CONFIG - assert group_id == "", "node-wide, like tmdb_config — not tied to self._group_id" - - -async def test_the_contact_itself_never_appears_in_the_signed_subject(tmp_path): - """ - Not a secret the way a TMDB token is, but still kept out of the audited - subject line as free text — same "yes/no configured" shape. - """ - session = _session(tmp_path, "op", operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = _fake_challenge(issued) - - contact = "operator@example.invalid" - session._do_musicbrainz_config({"contact": contact}) - - _, subject, payload, _ = issued[0] - assert contact not in subject - assert payload["contact"] == contact, "the real value still has to reach the exec step somehow" - - -async def test_subject_reflects_whether_a_contact_was_supplied(tmp_path): - session = _session(tmp_path, "op", operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = _fake_challenge(issued) - - session._do_musicbrainz_config({"contact": "x"}) - - _, subject, _, _ = issued[0] - assert subject == "contact_configured=yes" - - -async def test_subject_says_no_contact_when_none_given(tmp_path): - session = _session(tmp_path, "op", operator="op") - session._has_admin_authority = lambda: True - issued = [] - session._issue_admin_challenge = _fake_challenge(issued) - - session._do_musicbrainz_config({}) - - _, subject, _, _ = issued[0] - assert subject == "contact_configured=no" - - -# ── Where it is stored ────────────────────────────────────────────────────── - -async def test_the_setting_lives_on_the_node_and_survives_a_restart(tmp_path): - roster = Roster(db_path=tmp_path / "roster.db") - await roster.open() - try: - assert await roster.musicbrainz_contact() is None, \ - "absent must mean 'no contact configured' — no shipped default to fall back to" - await roster.set_musicbrainz_contact("operator@example.invalid", set_by="op") - assert await roster.musicbrainz_contact() == "operator@example.invalid" - finally: - await roster.close() - - reopened = Roster(db_path=tmp_path / "roster.db") - await reopened.open() - try: - assert await reopened.musicbrainz_contact() == "operator@example.invalid" - finally: - await reopened.close() - - -async def test_clearing_the_contact_reverts_to_unconfigured(tmp_path): - roster = Roster(db_path=tmp_path / "roster.db") - await roster.open() - try: - await roster.set_musicbrainz_contact("a-contact", set_by="op") - assert await roster.musicbrainz_contact() == "a-contact" - - await roster.set_musicbrainz_contact("", set_by="op") - assert await roster.musicbrainz_contact() is None, \ - "an explicit empty string clears the contact" - finally: - await roster.close() - - -async def test_omitting_the_contact_leaves_it_unchanged(tmp_path): - roster = Roster(db_path=tmp_path / "roster.db") - await roster.open() - try: - await roster.set_musicbrainz_contact("a-contact", set_by="op") - await roster.set_musicbrainz_contact(None, set_by="op") - assert await roster.musicbrainz_contact() == "a-contact" - finally: - await roster.close() |