summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 10:04:46 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 10:04:46 +0200
commit6af05abf410bbd038ce7fa6915a659defc509071 (patch)
tree09b1c941fa446b077ff51282fa18250998528263 /packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
parentc4981454078a59f776d484f0f1828f2fc5eaad09 (diff)
downloadmeshbay-6af05abf410bbd038ce7fa6915a659defc509071.tar.gz
feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata)
Implements docs/mediacenter.md: a "Videos" group application built on the existing files index rather than a separate catalogue. On the node side, new indexer enrichment (technical probe, filename/season parsing, thumbnail generation) runs per-file once an operator has chosen a video_root for the group, plus a TMDB client for on-demand poster/metadata lookups (never client-side, thumbnails delivered over the existing chunk path). On the hub side, a new video-app.js renders a lazily-mounted poster grid or a thumbnail-only flat list, with TMDB entirely optional per group. Along the way: the global apps registry now drives Settings' default-tab picker instead of a hardcoded list, and the video_root is configured from group Settings (like uploads) rather than from Files, with the node refusing to run any TMDB/thumbnail work until one is set. Fixes several bugs found via live testing against a real library, notably a race between two effects writing the same "image ready" state that could leave a poster grid spinning forever on a same-tab revisit — see mediacenter.md §5.4 for the full account of each one.
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/group-settings.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js211
1 files changed, 209 insertions, 2 deletions
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 0b64a03..4ae832f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -1,12 +1,22 @@
import {
- html, useState, useEffect, useCallback,
+ html, useState, useEffect, useCallback, useMemo,
} from './vendor/htm-preact.js';
-import { t } from './i18n.js';
+import { t, getLocale, LOCALES } from './i18n.js';
import { Icon } from './icon.js';
import { hubFetch, navigate } from './hub-client.js';
import { APPS } from './apps.js';
import * as platform from './platform.js';
+// MeshBay's own locale codes (i18n.js LOCALES) to the language tag TMDB
+// expects — the two don't share a format (MeshBay's "en" vs TMDB's
+// required region, "en-US"). Used only to pre-fill the TMDB language field
+// with the operator's own current UI language, a reasonable default they
+// can still change; the node never guesses this on its own.
+const TMDB_LANGUAGE_BY_LOCALE = {
+ en: 'en-US', fr: 'fr-FR', es: 'es-ES', 'pt-BR': 'pt-BR', 'zh-CN': 'zh-CN',
+ ja: 'ja-JP', de: 'de-DE', it: 'it-IT', nl: 'nl-NL', pl: 'pl-PL',
+};
+
// ── Members Panel ────────────────────────────────────────────────────────
/**
@@ -23,6 +33,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
memberUpload, onMemberUpload,
enabledApps, onEnabledApps,
scanSettings, onScanSettings,
+ tmdbConfig, onTmdbConfig,
+ entries, nodeDirs, videoRoot, onVideoRoot,
onPaired, onLeft }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
@@ -244,6 +256,120 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [transportRef, onScanSettings, reconcileMinutes, debounceSeconds]);
+ const [tmdbBusy, setTmdbBusy] = useState(false);
+ const [tmdbMsg, setTmdbMsg] = useState('');
+ const [tmdbTokenDraft, setTmdbTokenDraft] = useState('');
+ const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true;
+ // Pre-filled from the operator's own current UI language the first time
+ // this renders with nothing configured yet — a sensible default, not a
+ // claim about what the node is actually using until they hit Save.
+ const [tmdbLanguage, setTmdbLanguage] = useState(
+ () => (tmdbConfig && tmdbConfig.language)
+ || TMDB_LANGUAGE_BY_LOCALE[getLocale()] || 'en-US');
+ useEffect(() => {
+ if (tmdbConfig && tmdbConfig.language) setTmdbLanguage(tmdbConfig.language);
+ }, [tmdbConfig && tmdbConfig.language]);
+
+ /**
+ * TMDB on/off, an optional custom API token, and the language TMDB is
+ * queried in — node-wide, not per-group (docs/mediacenter.md §5.5). Same
+ * shape as saveScanSettings: signed, and the toggle does not claim
+ * success until the node confirms it. The token field is cleared after a
+ * save either way: it is never echoed back by the node (tmdb_config_ack
+ * carries only whether one is set, never the value), so there is
+ * nothing to keep showing.
+ */
+ const saveTmdbConfig = useCallback(async (nextEnabled) => {
+ const transport = transportRef && transportRef.current;
+ setTmdbMsg('');
+ setTmdbBusy(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 token = tmdbTokenDraft.trim();
+ await transport.setTmdbConfig(nextEnabled, token || undefined, tmdbLanguage, signFn);
+ setTmdbTokenDraft('');
+ if (onTmdbConfig) {
+ onTmdbConfig({
+ enabled: nextEnabled,
+ tokenCustomized: token
+ ? true
+ : (tmdbConfig ? tmdbConfig.tokenCustomized : false),
+ language: tmdbLanguage,
+ });
+ }
+ setTmdbMsg(t('settings_node.scan_saved'));
+ } catch (err) {
+ setTmdbMsg(err.message);
+ } finally {
+ setTmdbBusy(false);
+ }
+ }, [transportRef, onTmdbConfig, tmdbTokenDraft, tmdbConfig, tmdbLanguage]);
+
+ // 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
+ // `nodeDirs` covers ones with nothing in them yet. A flat, depth-indented
+ // <select> rather than a live folder browser: choosing the Videos root is
+ // a rare, one-off decision, not something worth a whole navigable tree for.
+ const videoRootFolders = useMemo(() => {
+ const set = new Set();
+ const addAncestors = (path) => {
+ if (!path) return;
+ const parts = path.split('/');
+ for (let i = 1; i <= parts.length; i++) set.add(parts.slice(0, i).join('/'));
+ };
+ for (const e of (entries || [])) addAncestors(e.path);
+ for (const d of (nodeDirs || [])) addAncestors(d);
+ return [...set].sort();
+ }, [entries, nodeDirs]);
+
+ const [videoRootDraft, setVideoRootDraft] = useState(videoRoot || '');
+ useEffect(() => { setVideoRootDraft(videoRoot || ''); }, [videoRoot]);
+ const [videoRootBusy, setVideoRootBusy] = useState(false);
+ const [videoRootMsg, setVideoRootMsg] = useState('');
+
+ /**
+ * Which folder is the Videos app's entry point for this group — same
+ * shape as toggleApp/saveScanSettings: signed, and the picker does not
+ * claim success until the node confirms it.
+ *
+ * Changing an *already-set* root is destructive to every member's Videos
+ * tab (a different set of files, possibly none in common) — the operator
+ * confirms that explicitly. Setting it for the first time is not: there is
+ * nothing yet to lose.
+ */
+ const saveVideoRoot = useCallback(async () => {
+ const next = videoRootDraft;
+ const current = videoRoot || '';
+ if (next === current) return;
+ if (current && !confirm(t('settings_node.video_root_change_confirm'))) return;
+ const transport = transportRef && transportRef.current;
+ setVideoRootMsg('');
+ setVideoRootBusy(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;
+ await transport.setVideoRoot(next, signFn);
+ if (onVideoRoot) onVideoRoot(next);
+ setVideoRootMsg(t('settings_node.scan_saved'));
+ } catch (err) {
+ setVideoRootMsg(err.message);
+ } finally {
+ setVideoRootBusy(false);
+ }
+ }, [transportRef, onVideoRoot, videoRootDraft, videoRoot]);
+
const [removing, setRemoving] = useState('');
/**
@@ -463,6 +589,87 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
+ ${/* TMDB on/off + custom token, node-wide (docs/mediacenter.md §5.5) —
+ new outbound third-party traffic the node did not have before
+ the Videos app, so it is a signed operator setting like the
+ rest, not a display preference. */
+ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.tmdb_title')}</h3>
+ <p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
+ <div class="settings-row">
+ <label class="settings-label">
+ <input type="checkbox" checked=${tmdbEnabled} disabled=${tmdbBusy}
+ onChange=${(e) => saveTmdbConfig(e.target.checked)} />
+ ${' '}${tmdbEnabled ? t('settings_node.tmdb_enabled') : t('settings_node.tmdb_disabled')}
+ </label>
+ </div>
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.tmdb_token_label')}
+ <input type="password" placeholder=${t('settings_node.tmdb_token_placeholder')}
+ value=${tmdbTokenDraft} disabled=${tmdbBusy}
+ onInput=${e => setTmdbTokenDraft(e.target.value)} />
+ </label>
+ <p class="settings-hint">
+ ${tmdbConfig && tmdbConfig.tokenCustomized
+ ? t('settings_node.tmdb_token_customized')
+ : t('settings_node.tmdb_token_default')}
+ </p>
+ </div>
+ <div class="settings-row">
+ <label class="settings-label">
+ ${t('settings_node.tmdb_language_label')}
+ <select value=${tmdbLanguage} disabled=${tmdbBusy}
+ onChange=${e => setTmdbLanguage(e.target.value)}>
+ ${LOCALES.map(l => html`
+ <option key=${l.code} value=${TMDB_LANGUAGE_BY_LOCALE[l.code]}>
+ ${l.name}
+ </option>
+ `)}
+ </select>
+ </label>
+ <p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p>
+ </div>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${tmdbBusy} onClick=${() => saveTmdbConfig(tmdbEnabled)}>
+ ${tmdbBusy ? t('settings_node.scan_saving') : t('settings_node.tmdb_save')}
+ </button>
+ ${tmdbMsg && html`<p class="settings-hint">${tmdbMsg}</p>`}
+ </div>
+ `}
+
+ ${/* Which folder is the Videos app's entry point for this group —
+ per-group like uploads, not node-wide like TMDB (mediacenter.md
+ §5.6). Until one is chosen, the Videos tab says so instead of
+ listing anything, and the node runs no TMDB/thumbnail work for
+ this group at all (daemon.py's _enrich_new_video_entries). */
+ isNodeAdmin && connected && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('settings_node.video_root_title')}</h3>
+ <p class="settings-hint">${t('settings_node.video_root_hint')}</p>
+ <div class="settings-row">
+ <label class="settings-label">
+ <select value=${videoRootDraft} disabled=${videoRootBusy}
+ onChange=${e => setVideoRootDraft(e.target.value)}>
+ <option value="">${t('settings_node.video_root_none')}</option>
+ ${videoRootFolders.map(p => html`
+ <option key=${p} value=${p}>
+ ${'  '.repeat(p.split('/').length - 1)}${p.split('/').pop()}
+ </option>
+ `)}
+ </select>
+ </label>
+ </div>
+ <button class="btn btn-small btn-secondary" style="margin-top:8px"
+ disabled=${videoRootBusy || videoRootDraft === (videoRoot || '')}
+ onClick=${saveVideoRoot}>
+ ${videoRootBusy ? t('settings_node.scan_saving') : t('settings_node.video_root_save')}
+ </button>
+ ${videoRootMsg && html`<p class="settings-hint">${videoRootMsg}</p>`}
+ </div>
+ `}
+
${/* Roots management (Electron-only, when node is local) */
nodeDetected && nodeRoots.length > 0 && html`
<div class="settings-section">