aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-client/src/main.js126
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py6
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js69
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js58
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js34
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py190
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py22
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py8
-rw-r--r--packages/meshbay-node/src/meshbay_node/musicbrainz.py33
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py26
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py21
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py61
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py32
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py2
-rw-r--r--packages/meshbay-node/tests/test_musicbrainz.py33
-rw-r--r--packages/meshbay-node/tests/test_musicbrainz_config_policy.py179
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()