diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 10:04:46 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 10:04:46 +0200 |
| commit | 6af05abf410bbd038ce7fa6915a659defc509071 (patch) | |
| tree | 09b1c941fa446b077ff51282fa18250998528263 /packages/meshbay-hub/src/meshbay_hub/static/group-settings.js | |
| parent | c4981454078a59f776d484f0f1828f2fc5eaad09 (diff) | |
| download | meshbay-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.js | 211 |
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"> |