diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-24 14:33:20 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-24 14:33:20 +0200 |
| commit | 0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a (patch) | |
| tree | ba8daf5dcf050d44b7b0e766babbfda8fadac59f /packages/meshbay-hub/src/meshbay_hub | |
| parent | 6af05abf410bbd038ce7fa6915a659defc509071 (diff) | |
| download | meshbay-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/meshbay_hub')
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} />`} `} |