summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 14:33:20 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 14:33:20 +0200
commit0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a (patch)
treeba8daf5dcf050d44b7b0e766babbfda8fadac59f /packages/meshbay-hub/src
parent6af05abf410bbd038ce7fa6915a659defc509071 (diff)
downloadmeshbay-0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a.tar.gz
feat(node,hub): season-specific overviews, manual TMDB match correction, and wizard polish
Two operator-facing fixes for a real 3-season show whose automatic TMDB match was wrong at the show level: per-season overview/air_date tabs in the detail modal (falling back to the show-level text when a season's own is empty), and a "Fix match…" search-and-correct affordance that re-resolves every file sharing the corrected show's display_title. New signed op OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp, tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6. Also: the create-group wizard gets a spinning indexing indicator and an app-selection step, group settings default the TMDB language to the operator's own locale (never as a global default), and a file renamed mid-session now re-triggers title parsing instead of being silently skipped by the enrichment dedup guard. Fixes two bugs found during this work: the search overlay's z-index lost to the base video-overlay class and rendered invisibly, and season_meta's own empty overview didn't fall back to the show-level one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js66
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js91
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js239
15 files changed, 524 insertions, 23 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 58ffe8e..f9bcd43 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -839,6 +839,15 @@ function CreateGroupWizard({ token, username, onCreated }) {
const [joinPolicy, setJoinPolicy] = useState('invite');
const [roots, setRoots] = useState([]);
const [uploadIdx, setUploadIdx] = useState(0);
+ // Every registered app, on by default — narrowing this down here means
+ // members never briefly see one the operator meant to leave off, the way
+ // toggling it afterward from Settings would.
+ const [enabledApps, setEnabledApps] = useState(() => APPS.map(a => a.key));
+ const toggleWizardApp = useCallback((key) => {
+ setEnabledApps(prev => prev.includes(key)
+ ? prev.filter(k => k !== key)
+ : [...prev, key]);
+ }, []);
// Step 2 progress
const [setupSteps, setSetupSteps] = useState([]);
@@ -915,8 +924,13 @@ function CreateGroupWizard({ token, username, onCreated }) {
const steps = [
{ label: t('wizard.step_create_hub'), status: 'pending' },
{ label: t('wizard.step_attach'), status: 'pending' },
- { label: t('wizard.step_index'), status: 'pending' },
];
+ // Only a step at all when it does something — the common case (every
+ // app left on, the default) has nothing to set and no reason to show a
+ // step for it.
+ if (enabledApps.length < APPS.length)
+ steps.push({ label: t('wizard.step_apps'), status: 'pending' });
+ steps.push({ label: t('wizard.step_index'), status: 'pending' });
if (roots.length > 1)
steps.push({ label: t('wizard.step_add_roots'), status: 'pending' });
steps.push({ label: t('wizard.step_gek'), status: 'pending' });
@@ -980,11 +994,24 @@ function CreateGroupWizard({ token, username, onCreated }) {
update('done');
advance();
- // 3. Wait for the node's own initial scan of this group to finish —
+ // 3. Narrow the enabled apps down, if the operator unchecked any —
+ // before the scan below, so a member who joins while it is still
+ // running never briefly sees an app meant to be off. Same
+ // not-hosted-yet race as steps 4+ below: the group is attached, but
+ // may not have reached groups_ctx yet.
+ if (enabledApps.length < APPS.length) {
+ update('running');
+ await withRetry(() => platform.node.call(
+ 'PUT', `/api/groups/${gid}/apps`, { apps: enabledApps }));
+ update('done');
+ advance();
+ }
+
+ // 4. Wait for the node's own initial scan of this group to finish —
// the group is not usable for anything below (extra roots, GEK) until
// this finishes, so nobody lands on a page that looks broken, or hits
// a "not configured" error from racing ahead of it. Can take tens of
- // minutes on a slow disk (see the StarWars benchmark) — the node
+ // minutes on a slow disk with a large library — the node
// keeps scanning on its own either way (test_hot_reload_survives_
// client_close.py); this step is only about not lying about it.
update('running');
@@ -992,7 +1019,7 @@ function CreateGroupWizard({ token, username, onCreated }) {
update('done');
advance();
- // 4. Add extra roots (if >1)
+ // 5. Add extra roots (if >1)
if (roots.length > 1) {
update('running');
for (let i = 0; i < roots.length; i++) {
@@ -1007,13 +1034,13 @@ function CreateGroupWizard({ token, username, onCreated }) {
advance();
}
- // 5. GEK init
+ // 6. GEK init
update('running');
await withRetry(() => platform.node.call('POST', `/api/groups/${gid}/gek`));
update('done');
advance();
- // 6. Generate pairing code
+ // 7. Generate pairing code
update('running');
const pairResult = await platform.node.call('POST', '/api/operator/pair');
if (pairResult && pairResult.code) {
@@ -1031,7 +1058,7 @@ function CreateGroupWizard({ token, username, onCreated }) {
update('error');
setSetupError(platform.bridgeMessage(err));
}
- }, [name, description, joinPolicy, roots, uploadIdx, token, onCreated]);
+ }, [name, description, joinPolicy, roots, uploadIdx, enabledApps, token, onCreated]);
// Step 0: Node detection
if (step === 0) {
@@ -1065,7 +1092,7 @@ function CreateGroupWizard({ token, username, onCreated }) {
// Step 1: Group details + directories
if (step === 1) {
- const canProceed = name.trim() && roots.length > 0;
+ const canProceed = name.trim() && roots.length > 0 && enabledApps.length > 0;
return html`<div class="page-content">
<h2>${t('wizard.title')}</h2>
${error && html`<div class="error-msg" style="margin-bottom:16px">${error}</div>`}
@@ -1112,6 +1139,25 @@ function CreateGroupWizard({ token, username, onCreated }) {
</div>
<div class="settings-section">
+ <h3 class="settings-heading">${t('members.apps_title')}</h3>
+ <p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
+ ${t('members.apps_hint')}</p>
+ <ul class="apps-toggle-list">
+ ${APPS.map(a => html`
+ <li key=${a.key} class="settings-row">
+ <label class="settings-label">
+ <input type="checkbox" checked=${enabledApps.includes(a.key)}
+ onChange=${() => toggleWizardApp(a.key)} />
+ ${' '}${t(a.labelKey)}
+ </label>
+ </li>
+ `)}
+ </ul>
+ ${enabledApps.length === 0 && html`
+ <p class="error-msg">${t('members.apps_need_one')}</p>`}
+ </div>
+
+ <div class="settings-section">
<h3 class="settings-heading">${t('wizard.directories')}</h3>
<p style="font-size:0.85em;color:var(--text-dim);margin-bottom:8px">
${t('wizard.directories_hint')}</p>
@@ -1161,8 +1207,8 @@ function CreateGroupWizard({ token, username, onCreated }) {
${setupSteps.map((s, i) => html`
<div class="wizard-step wizard-step-${s.status}" key=${i}>
<span class="wizard-step-icon">
- ${s.status === 'done' ? '✓' :
- s.status === 'running' ? '●' :
+ ${s.status === 'running' ? html`<span class="spinner"></span>` :
+ s.status === 'done' ? '✓' :
s.status === 'error' ? '✗' : '○'}
</span>
<span>${s.label}</span>
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 4ae832f..22532fa 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -1,5 +1,5 @@
import {
- html, useState, useEffect, useCallback, useMemo,
+ html, useState, useEffect, useCallback, useMemo, useRef,
} from './vendor/htm-preact.js';
import { t, getLocale, LOCALES } from './i18n.js';
import { Icon } from './icon.js';
@@ -311,6 +311,24 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [transportRef, onTmdbConfig, tmdbTokenDraft, tmdbConfig, tmdbLanguage]);
+ // A node that has never had a language explicitly set would otherwise
+ // query TMDB with none at all — which TMDB itself resolves to English,
+ // regardless of who the operator is — even though this form already
+ // *suggests* their own UI language as the value. Applied once,
+ // automatically, the first time the operator (the only one who can sign
+ // this) is actually connected to see it: a real default tied to whoever
+ // runs this particular node, never a single hardcoded language for every
+ // node. `tmdbConfig.language` being set at all — from this or from an
+ // explicit save — is what stops it from ever firing again, so "unless
+ // manually changed" holds regardless of which of the two set it first.
+ const autoLanguageSetRef = useRef(false);
+ useEffect(() => {
+ if (!isNodeAdmin || !connected || !tmdbConfig || tmdbConfig.language) return;
+ if (autoLanguageSetRef.current) return;
+ autoLanguageSetRef.current = true;
+ saveTmdbConfig(tmdbEnabled);
+ }, [isNodeAdmin, connected, tmdbConfig, tmdbEnabled, saveTmdbConfig]);
+
// 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
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 6634cfb..7f05441 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -169,6 +169,12 @@ export default {
one: '{n} Episode',
other: '{n} Episoden',
},
+ 'video.fix_match': 'Übereinstimmung korrigieren…',
+ 'video.search_title': 'TMDB-Übereinstimmung korrigieren',
+ 'video.search_placeholder': 'TMDB durchsuchen…',
+ 'video.search_button': 'Suchen',
+ 'video.search_no_results': 'Keine Treffer gefunden.',
+ 'video.search_apply_hint': 'Gilt für alle Dateien, die derzeit unter diesem Titel gruppiert sind.',
// LAN-Cast
'cast.start': 'Auf Gerät übertragen',
@@ -620,6 +626,7 @@ export default {
'wizard.setting_up': 'Ihre Gruppe wird eingerichtet…',
'wizard.step_create_hub': 'Gruppe auf dem Hub erstellen',
'wizard.step_attach': 'An Node anbinden',
+ 'wizard.step_apps': 'Anwendungen auswählen',
'wizard.step_add_roots': 'Verzeichnisse hinzufügen',
'wizard.step_gek': 'Verschlüsselungsschlüssel initialisieren',
'wizard.step_pair': 'Kopplung einrichten',
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 ebdb3e2..7f31bb5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -167,6 +167,12 @@ export default {
one: '{n} episode',
other: '{n} episodes',
},
+ 'video.fix_match': 'Fix match…',
+ 'video.search_title': 'Correct the TMDB match',
+ 'video.search_placeholder': 'Search TMDB…',
+ 'video.search_button': 'Search',
+ 'video.search_no_results': 'No matches found.',
+ 'video.search_apply_hint': 'Applies to every file currently grouped under this title.',
// LAN cast
'cast.start': 'Cast to device',
@@ -374,6 +380,7 @@ export default {
'wizard.setting_up': 'Setting up your group...',
'wizard.step_create_hub': 'Creating group on hub',
'wizard.step_attach': 'Attaching to node',
+ 'wizard.step_apps': 'Choosing applications',
'wizard.step_add_roots': 'Adding directories',
'wizard.step_gek': 'Initializing encryption key',
'wizard.step_pair': 'Setting up pairing',
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 d028279..d7f0f1e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -167,6 +167,12 @@ export default {
one: '{n} episodio',
other: '{n} episodios',
},
+ 'video.fix_match': 'Corregir coincidencia…',
+ 'video.search_title': 'Corregir la coincidencia de TMDB',
+ 'video.search_placeholder': 'Buscar en TMDB…',
+ 'video.search_button': 'Buscar',
+ 'video.search_no_results': 'No se encontraron coincidencias.',
+ 'video.search_apply_hint': 'Se aplica a todos los archivos agrupados actualmente bajo este título.',
// LAN cast
'cast.start': 'Enviar a dispositivo',
@@ -615,6 +621,7 @@ export default {
'wizard.setting_up': 'Configurando su grupo…',
'wizard.step_create_hub': 'Creando grupo en el hub',
'wizard.step_attach': 'Conectando al node',
+ 'wizard.step_apps': 'Eligiendo aplicaciones',
'wizard.step_add_roots': 'Añadiendo directorios',
'wizard.step_gek': 'Inicializando clave de cifrado',
'wizard.step_pair': 'Configurando emparejamiento',
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 621650d..2befdb9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -168,6 +168,12 @@ export default {
one: '{n} épisode',
other: '{n} épisodes',
},
+ 'video.fix_match': 'Corriger la correspondance…',
+ 'video.search_title': 'Corriger la correspondance TMDB',
+ 'video.search_placeholder': 'Rechercher sur TMDB…',
+ 'video.search_button': 'Rechercher',
+ 'video.search_no_results': 'Aucune correspondance trouvée.',
+ 'video.search_apply_hint': 'S’applique à tous les fichiers actuellement regroupés sous ce titre.',
// LAN cast
'cast.start': 'Diffuser sur un appareil',
@@ -631,6 +637,7 @@ export default {
'wizard.setting_up': 'Configuration de votre groupe…',
'wizard.step_create_hub': 'Création du groupe sur le hub',
'wizard.step_attach': 'Rattachement au node',
+ 'wizard.step_apps': 'Choix des applications',
'wizard.step_add_roots': 'Ajout des répertoires',
'wizard.step_gek': 'Initialisation de la clé de chiffrement',
'wizard.step_pair': 'Mise en place de l\'appariement',
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 71fb9ba..bdfda75 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -168,6 +168,12 @@ export default {
one: '{n} episodio',
other: '{n} episodi',
},
+ 'video.fix_match': 'Correggi corrispondenza…',
+ 'video.search_title': 'Correggi la corrispondenza TMDB',
+ 'video.search_placeholder': 'Cerca su TMDB…',
+ 'video.search_button': 'Cerca',
+ 'video.search_no_results': 'Nessuna corrispondenza trovata.',
+ 'video.search_apply_hint': 'Si applica a tutti i file attualmente raggruppati sotto questo titolo.',
// LAN cast
'cast.start': 'Trasmetti al dispositivo',
@@ -629,6 +635,7 @@ export default {
'wizard.setting_up': 'Configurazione del gruppo…',
'wizard.step_create_hub': 'Creazione del gruppo sul hub',
'wizard.step_attach': 'Collegamento al node',
+ 'wizard.step_apps': 'Scelta delle applicazioni',
'wizard.step_add_roots': 'Aggiunta delle directory',
'wizard.step_gek': 'Inizializzazione della chiave di cifratura',
'wizard.step_pair': 'Configurazione del pairing',
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 3336705..f00a0ec 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -165,6 +165,12 @@ export default {
one: '{n}話',
other: '{n}話',
},
+ 'video.fix_match': '一致を修正…',
+ 'video.search_title': 'TMDBの一致を修正',
+ 'video.search_placeholder': 'TMDBを検索…',
+ 'video.search_button': '検索',
+ 'video.search_no_results': '一致する結果が見つかりません。',
+ 'video.search_apply_hint': '現在このタイトルでグループ化されているすべてのファイルに適用されます。',
// LAN cast
'cast.start': 'デバイスにキャスト',
@@ -613,6 +619,7 @@ export default {
'wizard.setting_up': 'グループをセットアップ中…',
'wizard.step_create_hub': 'hub 上にグループを作成中',
'wizard.step_attach': 'node に接続中',
+ 'wizard.step_apps': 'アプリを選択中',
'wizard.step_add_roots': 'ディレクトリを追加中',
'wizard.step_gek': '暗号化鍵を初期化中',
'wizard.step_pair': 'ペアリングをセットアップ中',
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 28be024..d9ed12e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -169,6 +169,12 @@ export default {
one: '{n} aflevering',
other: '{n} afleveringen',
},
+ 'video.fix_match': 'Overeenkomst corrigeren…',
+ 'video.search_title': 'TMDB-overeenkomst corrigeren',
+ 'video.search_placeholder': 'Zoeken in TMDB…',
+ 'video.search_button': 'Zoeken',
+ 'video.search_no_results': 'Geen overeenkomsten gevonden.',
+ 'video.search_apply_hint': 'Geldt voor alle bestanden die momenteel onder deze titel zijn gegroepeerd.',
// LAN cast
'cast.start': 'Naar apparaat casten',
@@ -631,6 +637,7 @@ export default {
'wizard.setting_up': 'Uw groep wordt ingesteld…',
'wizard.step_create_hub': 'Groep aanmaken op hub',
'wizard.step_attach': 'Koppelen aan node',
+ 'wizard.step_apps': 'Apps kiezen',
'wizard.step_add_roots': 'Mappen toevoegen',
'wizard.step_gek': 'Versleutelingssleutel initialiseren',
'wizard.step_pair': 'Koppeling instellen',
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 d4eb325..cbd27b1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -176,6 +176,12 @@ export default {
many: '{n} odcinków',
other: '{n} odcinka',
},
+ 'video.fix_match': 'Popraw dopasowanie…',
+ 'video.search_title': 'Popraw dopasowanie TMDB',
+ 'video.search_placeholder': 'Szukaj w TMDB…',
+ 'video.search_button': 'Szukaj',
+ 'video.search_no_results': 'Nie znaleziono dopasowań.',
+ 'video.search_apply_hint': 'Dotyczy wszystkich plików obecnie zgrupowanych pod tym tytułem.',
// LAN cast
'cast.start': 'Przesyłaj na urządzenie',
@@ -654,6 +660,7 @@ export default {
'wizard.setting_up': 'Konfigurowanie grupy…',
'wizard.step_create_hub': 'Tworzenie grupy na hub',
'wizard.step_attach': 'Podłączanie do node',
+ 'wizard.step_apps': 'Wybieranie aplikacji',
'wizard.step_add_roots': 'Dodawanie katalogów',
'wizard.step_gek': 'Inicjalizacja klucza szyfrowania',
'wizard.step_pair': 'Konfigurowanie parowania',
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 bba07a2..e7bf45c 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
@@ -169,6 +169,12 @@ export default {
one: '{n} episódio',
other: '{n} episódios',
},
+ 'video.fix_match': 'Corrigir correspondência…',
+ 'video.search_title': 'Corrigir a correspondência do TMDB',
+ 'video.search_placeholder': 'Pesquisar no TMDB…',
+ 'video.search_button': 'Pesquisar',
+ 'video.search_no_results': 'Nenhuma correspondência encontrada.',
+ 'video.search_apply_hint': 'Aplica-se a todos os arquivos atualmente agrupados sob este título.',
// LAN cast
'cast.start': 'Transmitir para dispositivo',
@@ -616,6 +622,7 @@ export default {
'wizard.setting_up': 'Configurando seu grupo…',
'wizard.step_create_hub': 'Criando grupo no hub',
'wizard.step_attach': 'Anexando ao node',
+ 'wizard.step_apps': 'Escolhendo aplicativos',
'wizard.step_add_roots': 'Adicionando diretórios',
'wizard.step_gek': 'Inicializando chave de criptografia',
'wizard.step_pair': 'Configurando pareamento',
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 c9199c4..33a0e25 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
@@ -162,6 +162,12 @@ export default {
one: '{n} 集',
other: '{n} 集',
},
+ 'video.fix_match': '修正匹配…',
+ 'video.search_title': '更正 TMDB 匹配',
+ 'video.search_placeholder': '搜索 TMDB…',
+ 'video.search_button': '搜索',
+ 'video.search_no_results': '未找到匹配项。',
+ 'video.search_apply_hint': '将应用于当前归类在此标题下的所有文件。',
// LAN cast
'cast.start': '投射到设备',
@@ -600,6 +606,7 @@ export default {
'wizard.setting_up': '正在设置您的群组…',
'wizard.step_create_hub': '在 hub 上创建群组',
'wizard.step_attach': '关联到 node',
+ 'wizard.step_apps': '选择应用',
'wizard.step_add_roots': '添加目录',
'wizard.step_gek': '初始化加密密钥',
'wizard.step_pair': '设置配对',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index e1900d9..446a751 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2624,3 +2624,64 @@ a.transfer-name {
.video-flat-chevron.open { transform: rotate(180deg); }
.video-flat-season { padding-left: 24px; margin-bottom: 8px; }
.video-flat-season .video-season-header { margin: 6px 0 4px; }
+
+/* Season tab bar — docs/mediacenter.md §5.4's per-season overview view */
+.video-season-tabs {
+ display: flex;
+ gap: 4px;
+ margin: 10px 0;
+ overflow-x: auto;
+}
+.video-season-tab {
+ padding: 5px 12px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--bg-surface);
+ color: var(--text-dim);
+ font-size: 0.85em;
+ white-space: nowrap;
+ cursor: pointer;
+}
+.video-season-tab:hover { border-color: var(--accent); color: var(--text); }
+.video-season-tab.active { border-color: var(--accent); color: var(--accent); background: var(--bg-raised); }
+
+.video-fix-match { margin: 8px 0; font-size: 0.85em; }
+
+/* Operator search-and-correct overlay, layered on top of the detail modal.
+ Must beat .video-overlay's own z-index: 200 — both are position: fixed,
+ so without an explicit higher value here this one loses the stacking
+ order despite being the later sibling in the DOM. */
+.video-search-overlay { z-index: 210; }
+.video-search-panel { max-width: 480px; }
+.video-search-form { display: flex; gap: 8px; }
+.video-search-form input {
+ flex: 1;
+ padding: 7px 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-surface);
+ color: var(--text);
+}
+.video-search-hint { font-size: 0.78em; color: var(--text-dim); margin: 6px 0 2px; }
+.video-search-error { font-size: 0.85em; color: var(--error); }
+.video-search-results { display: flex; flex-direction: column; gap: 4px; margin-top: 10px; }
+.video-search-result {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 6px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ background: none;
+ text-align: left;
+ cursor: pointer;
+}
+.video-search-result:hover:not(:disabled) { border-color: var(--accent); background: var(--bg-raised); }
+.video-search-result:disabled { opacity: 0.5; cursor: not-allowed; }
+.video-search-result-thumb { width: 46px; height: 68px; flex-shrink: 0; }
+.video-search-result-poster {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 4px;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index dd39df8..b4bfe87 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -481,6 +481,61 @@ class MeshBayTransport {
}
/**
+ * One season's own overview/air_date/poster (docs/mediacenter.md §5.4's
+ * per-season view) — a show's own tmdb_meta is one static field that does
+ * not necessarily describe every season alike, found live: a 3-season
+ * show whose overview read as season-3-specific for every season.
+ * Keyed like media_meta_req: a season-tab bar can fire a request per tab
+ * before the previous one lands, and matching by arrival order would hand
+ * one season's data to a different season's tab whenever two responses
+ * reordered.
+ */
+ async fetchSeasonMeta(tmdbId, season) {
+ const msg = await this._sendAndWait({
+ type: 'season_meta_req', v: '0.6', tmdb_id: tmdbId, season,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Raw TMDB search candidates for an operator correcting a wrong automatic
+ * match — unlike fetchMediaMeta, this never collapses to one best guess:
+ * a human picks from several, so several is the point. Read-only, not an
+ * admin op: it looks nothing up in this node's own state and changes
+ * nothing, so it needs no signature (mirrors why media_meta_req isn't
+ * signed either).
+ */
+ async searchTmdb(mediaType, query) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_search_req', v: '0.6', media_type: mediaType, query,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ return msg;
+ }
+
+ /**
+ * Correct a wrong automatic TMDB match. Signed like setVideoRoot/
+ * setTmdbConfig: it replaces what every member sees for a show/movie,
+ * node-wide (media_cache is shared, not per-viewer) — an unsigned
+ * override would let any member vandalize another show's metadata.
+ * Applies to every file sharing the representative one's display_title,
+ * not just the file the operator happened to be looking at (webrtc_
+ * server.py's _admin_exec_tmdb_override).
+ */
+ async overrideTmdbMatch(path, tmdbId, mediaType, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'tmdb_override', v: '0.6', path, tmdb_id: tmdbId, media_type: mediaType,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ const subject = `path=${path},tmdb_id=${tmdbId},media_type=${mediaType}`;
+ return this._authorizeAdminOp(msg, 'tmdb_override', subject, signFn);
+ }
+ return msg;
+ }
+
+ /**
* Turn TMDB lookups on/off node-wide, optionally set/clear a custom API
* token, and optionally set the language TMDB is queried in (e.g.
* "fr-FR") — one for the whole node, same reasoning as the token: one
@@ -1243,7 +1298,12 @@ class MeshBayTransport {
_key: obj.type === 'file_req'
? `chunk:${obj.file_id}:${obj.chunk_index}`
: obj.type === 'ping' ? `ping:${obj.token}`
- : obj.type === 'media_meta_req' ? `media_meta:${obj.path}` : null,
+ : obj.type === 'media_meta_req' ? `media_meta:${obj.path}`
+ // Same reordering hazard as media_meta_req: a season-tab bar or a
+ // search box can have more than one of these in flight at once.
+ : obj.type === 'season_meta_req' ? `season_meta:${obj.tmdb_id}:${obj.season}`
+ : obj.type === 'tmdb_search_req' ? `tmdb_search:${obj.media_type}:${obj.query}`
+ : null,
resolve: (msg) => { clearTimeout(timeout); this._pending.delete(id); resolve(msg); },
reject: (err) => { clearTimeout(timeout); this._pending.delete(id); reject(err); },
});
@@ -1355,6 +1415,17 @@ class MeshBayTransport {
});
}
+ // Same shape: an operator corrected a wrong automatic TMDB match, and
+ // everyone connected needs to know their poster grid/detail modal for
+ // this show is now stale — falls through so the operator's own
+ // admin_response promise resolves on this same message, exactly like
+ // member_upload_ack/apps_enabled_ack above.
+ if (msg.type === 'tmdb_override_ack' && this._onTmdbOverride) {
+ this._onTmdbOverride({
+ path: msg.path || '', tmdbId: msg.tmdb_id || '', mediaType: msg.media_type || '',
+ });
+ }
+
// Same shape: the operator changed which folder is the Videos app's
// entry point for this group.
if (msg.type === 'video_root_ack' && this._onVideoRoot) {
@@ -1461,6 +1532,24 @@ class MeshBayTransport {
return;
}
+ // Same reasoning as media_meta_resp: keyed, not arrival-order, and
+ // "nobody's waiting any more" must not fall through either.
+ if (msg.type === 'season_meta_resp') {
+ const key = `season_meta:${msg.tmdb_id}:${msg.season}`;
+ for (const [, handler] of this._pending) {
+ if (handler._key === key) { handler.resolve(msg); return; }
+ }
+ return;
+ }
+
+ if (msg.type === 'tmdb_search_resp') {
+ const key = `tmdb_search:${msg.media_type}:${msg.query}`;
+ for (const [, handler] of this._pending) {
+ if (handler._key === key) { handler.resolve(msg); return; }
+ }
+ return;
+ }
+
// chat_hist_resp answers a `chat_hist` request, but under a different
// type string — unlike index_sync, which is asked for and answered under
// the same name, so the generic fallback below happens to work for it by
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
index 250bf8b..00643f8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -189,8 +189,31 @@ function MediaThumb({
// ── TMDB metadata, fetched once per visible tile ────────────────────────────
+// An operator correcting a wrong match (TmdbSearchOverlay below) changes
+// what `media_meta_req` returns for a path every already-mounted tile/modal
+// already has cached in its own useMediaMeta state — nothing would ever
+// refetch otherwise, since path/active don't change. Bumping this and
+// telling every subscribed hook to redo its fetch is simpler than trying to
+// know which paths a given override actually affects (that's server-side
+// knowledge — display_title grouping — this module doesn't have).
+const _mediaMetaListeners = new Set();
+function bumpMediaMetaGeneration() {
+ for (const fn of _mediaMetaListeners) fn();
+}
+
function useMediaMeta(transportRef, path, active) {
const [meta, setMeta] = useState(null);
+ const [refetchToken, setRefetchToken] = useState(0);
+
+ useEffect(() => {
+ // Clears immediately (so the spinner shows right away, not only once
+ // the new fetch resolves) and bumps the token, which re-runs the fetch
+ // effect below regardless of whether path/active changed at all.
+ const listener = () => { setMeta(null); setRefetchToken((n) => n + 1); };
+ _mediaMetaListeners.add(listener);
+ return () => _mediaMetaListeners.delete(listener);
+ }, []);
+
useEffect(() => {
if (!active || !path) return;
let cancelled = false;
@@ -203,7 +226,40 @@ function useMediaMeta(transportRef, path, active) {
} catch { if (!cancelled) setMeta({ confidence: 0 }); }
})();
return () => { cancelled = true; };
- }, [path, active]);
+ }, [path, active, refetchToken]);
+ return meta;
+}
+
+// ── per-season TMDB metadata (overview/air_date/poster), season-tab view ───
+//
+// Found live: a show's own tmdb_meta.overview is one static field that does
+// not necessarily describe every season alike (a season-3-specific
+// promotional summary read as the synopsis for all three seasons). Cached
+// for the session by tmdb_id+season, mirroring _thumbBlobCache — the same
+// season is revisited every time its tab is reselected.
+const _seasonMetaCache = new Map();
+
+function useSeasonMeta(transportRef, tmdbId, season, active) {
+ const cacheKey = active && tmdbId != null && season != null ? `${tmdbId}:${season}` : null;
+ const [meta, setMeta] = useState(() => (cacheKey ? _seasonMetaCache.get(cacheKey) || null : null));
+ useEffect(() => {
+ if (!cacheKey) return;
+ const cached = _seasonMetaCache.get(cacheKey);
+ if (cached) { setMeta(cached); return; }
+ setMeta(null);
+ let cancelled = false;
+ (async () => {
+ const transport = transportRef.current;
+ if (!transport || !transport.connected) return;
+ try {
+ const resp = await transport.fetchSeasonMeta(tmdbId, season);
+ if (cancelled) return;
+ _seasonMetaCache.set(cacheKey, resp);
+ setMeta(resp);
+ } catch { if (!cancelled) setMeta({ confidence: 0 }); }
+ })();
+ return () => { cancelled = true; };
+ }, [cacheKey]);
return meta;
}
@@ -280,8 +336,150 @@ function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, g
`;
}
-function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay }) {
+// ── season tab bar (docs/mediacenter.md §5.4's fix for a mis-scoped overview) ─
+
+function SeasonTabs({ seasons, selected, onSelect }) {
+ return html`
+ <div class="video-season-tabs">
+ ${seasons.map((s) => html`
+ <button key=${s.season}
+ class="video-season-tab ${selected === s.season ? 'active' : ''}"
+ onClick=${() => onSelect(s.season)}>
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
+ </button>
+ `)}
+ </div>
+ `;
+}
+
+// ── operator: correct a wrong automatic TMDB match ──────────────────────────
+
+// Same shape as group-settings.js's own signFn construction (setVideoRoot,
+// setTmdbConfig, ...) — there is no group-wide "sign this" helper to share,
+// each caller builds one from the connection it already has.
+function buildSignFn(transportRef) {
+ const transport = transportRef.current;
+ const sk = transport && transport.sessionKeys && transport.sessionKeys.skEdB64;
+ return (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+}
+
+function TmdbSearchOverlay({
+ initialQuery, mediaType, path, transportRef, gekRef, onClose, onApplied,
+}) {
+ const [query, setQuery] = useState(initialQuery || '');
+ const [results, setResults] = useState(null); // null = not searched yet
+ const [searching, setSearching] = useState(false);
+ const [applying, setApplying] = useState(false);
+ const [error, setError] = useState('');
+
+ const runSearch = useCallback(async (e) => {
+ if (e) e.preventDefault();
+ const q = query.trim();
+ if (!q || searching) return;
+ setSearching(true);
+ setError('');
+ try {
+ const transport = transportRef.current;
+ const resp = await transport.searchTmdb(mediaType, q);
+ setResults(resp.results || []);
+ } catch (err) {
+ setError(err.message);
+ setResults([]);
+ } finally {
+ setSearching(false);
+ }
+ }, [query, mediaType, transportRef, searching]);
+
+ const apply = useCallback(async (tmdbId) => {
+ if (applying) return;
+ setApplying(true);
+ setError('');
+ try {
+ const signFn = buildSignFn(transportRef);
+ await transportRef.current.overrideTmdbMatch(path, tmdbId, mediaType, signFn);
+ bumpMediaMetaGeneration();
+ onApplied();
+ } catch (err) {
+ setError(err.message);
+ setApplying(false);
+ }
+ }, [applying, path, mediaType, transportRef, onApplied]);
+
+ return html`
+ <div class="video-overlay video-search-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-search-overlay')) onClose();
+ }}>
+ <div class="video-detail video-search-panel">
+ <div class="video-top-bar">
+ <span class="video-title">${t('video.search_title')}</span>
+ <button class="video-close" onClick=${onClose} title=${t('video.close')}>
+ <${Icon} name="close" /></button>
+ </div>
+ <div class="video-detail-body">
+ <form class="video-search-form" onSubmit=${runSearch}>
+ <input type="text" value=${query} autofocus
+ placeholder=${t('video.search_placeholder')}
+ onInput=${(e) => setQuery(e.target.value)} />
+ <button class="admin-btn" type="submit" disabled=${searching || !query.trim()}>
+ ${searching ? html`<span class="spinner"></span>` : t('video.search_button')}
+ </button>
+ </form>
+ <p class="video-search-hint">${t('video.search_apply_hint')}</p>
+ ${error && html`<p class="video-search-error">${error}</p>`}
+ ${results && results.length === 0 && !searching && html`
+ <p class="page-message">${t('video.search_no_results')}</p>
+ `}
+ ${results && results.length > 0 && html`
+ <div class="video-search-results">
+ ${results.map((r) => html`
+ <button class="video-search-result" key=${r.tmdb_id}
+ disabled=${applying}
+ onClick=${() => apply(r.tmdb_id)}>
+ <div class="video-search-result-thumb">
+ <${MediaThumb} thumbHash=${r.poster_thumb_hash} alt=${r.title}
+ cls="video-search-result-poster" transportRef=${transportRef} gekRef=${gekRef} />
+ </div>
+ <div class="video-flat-info">
+ <div class="video-flat-title">${r.title}</div>
+ <div class="video-flat-sub">${r.year}</div>
+ </div>
+ </button>
+ `)}
+ </div>
+ `}
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+function VideoDetailModal({
+ title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay, isNodeAdmin,
+}) {
const confident = meta && meta.confidence && meta.tmdb_id;
+ const [searching, setSearching] = useState(false);
+ const mediaType = show ? 'tv' : 'movie';
+
+ // Reset whenever a different file/show is opened in this same modal
+ // instance — repEntry/show change identity, selectedSeason must not
+ // silently keep pointing at whatever the previous show's season 4 was.
+ const [selectedSeason, setSelectedSeason] = useState(null);
+ useEffect(() => {
+ if (!show) { setSelectedSeason(null); return; }
+ const preferred = repEntry.season != null && show.seasons.some((s) => s.season === repEntry.season)
+ ? repEntry.season
+ : (show.seasons.find((s) => s.season !== 0) || show.seasons[0]).season;
+ setSelectedSeason(preferred);
+ }, [show, repEntry]);
+
+ const showMultiSeason = Boolean(show && show.seasons.length > 1);
+ const seasonMeta = useSeasonMeta(
+ transportRef, confident ? meta.tmdb_id : null, selectedSeason,
+ showMultiSeason && Boolean(confident) && selectedSeason != null);
+ const seasonConfident = showMultiSeason && seasonMeta && seasonMeta.confidence;
+
return html`
<div class="video-overlay" onClick=${(e) => {
if (e.target.classList.contains('video-overlay')) onClose();
@@ -294,11 +492,12 @@ function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, o
</div>
<div class="video-detail-body">
${confident && html`
- <p class="video-detail-overview">${meta.overview}</p>
+ <p class="video-detail-overview">${(seasonConfident && seasonMeta.overview) || meta.overview}</p>
<p class="video-detail-facts">
${meta.vote_average ? `★ ${meta.vote_average.toFixed(1)}` : ''}
${meta.genres && meta.genres.length ? ` · ${meta.genres.join(', ')}` : ''}
${meta.director ? ` · ${t('video.director')}: ${meta.director}` : ''}
+ ${seasonConfident && seasonMeta.air_date ? ` · ${yearOf(seasonMeta.air_date)}` : ''}
</p>
${meta.cast && meta.cast.length > 0 && html`
<p class="video-detail-cast">
@@ -306,6 +505,15 @@ function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, o
</p>
`}
`}
+ ${isNodeAdmin && html`
+ <button class="admin-btn video-fix-match" onClick=${() => setSearching(true)}>
+ ${t('video.fix_match')}
+ </button>
+ `}
+ ${showMultiSeason && html`
+ <${SeasonTabs} seasons=${show.seasons} selected=${selectedSeason}
+ onSelect=${setSelectedSeason} />
+ `}
${!show && html`
<button class="admin-btn" onClick=${() => onPlay(repEntry)}>
<${Icon} name="play" /> ${t('group.play')}
@@ -314,11 +522,14 @@ function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, o
`}
${show && html`
<div class="video-season-list">
- ${show.seasons.map((s) => html`
+ ${(showMultiSeason ? show.seasons.filter((s) => s.season === selectedSeason) : show.seasons)
+ .map((s) => html`
<div class="video-season" key=${s.season}>
- <div class="video-season-header">
- ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
- </div>
+ ${!showMultiSeason && html`
+ <div class="video-season-header">
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })}
+ </div>
+ `}
${s.episodes.map((ep) => html`
<button class="video-episode-row" key=${ep.id} onClick=${() => onPlay(ep)}>
<${LazyTile} cls="video-episode-thumb-slot">
@@ -341,10 +552,16 @@ function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, o
</div>
</div>
</div>
+ ${searching && html`
+ <${TmdbSearchOverlay} initialQuery=${(confident && meta.title) || title} mediaType=${mediaType}
+ path=${repEntry.path} transportRef=${transportRef} gekRef=${gekRef}
+ onClose=${() => setSearching(false)}
+ onApplied=${() => setSearching(false)} />
+ `}
`;
}
-function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled }) {
+function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled, isNodeAdmin }) {
const [detail, setDetail] = useState(null); // { title, repEntry, show? }
// raw (per-folder-parsed-title) show title -> its own resolved media_meta_resp.
const [metaByGroup, setMetaByGroup] = useState({});
@@ -449,7 +666,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
${detail && html`
<${VideoDetailModal} title=${detail.title} meta=${detailMeta}
repEntry=${detail.repEntry} show=${detail.show}
- transportRef=${transportRef} gekRef=${gekRef}
+ transportRef=${transportRef} gekRef=${gekRef} isNodeAdmin=${isNodeAdmin}
onClose=${() => setDetail(null)}
onPlay=${(entry) => { setDetail(null); onPreview(entry); }} />
`}
@@ -537,7 +754,7 @@ function FlatList({ movies, shows, transportRef, gekRef, onPreview }) {
// ── shell ────────────────────────────────────────────────────────────────────
function VideoApp({
- groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig,
+ groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, isNodeAdmin,
}) {
const [mode, setMode] = useState(loadViewMode);
const [filter, setFilter] = useState('');
@@ -589,7 +806,7 @@ function VideoApp({
${mode === 'poster'
? html`<${PosterGrid} movies=${filteredMovies} shows=${filteredShows}
transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview}
- tmdbEnabled=${tmdbEnabled} />`
+ tmdbEnabled=${tmdbEnabled} isNodeAdmin=${isNodeAdmin} />`
: html`<${FlatList} movies=${filteredMovies} shows=${filteredShows}
transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`}
`}