summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py7
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py5
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py6
-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
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py39
-rw-r--r--packages/meshbay-node/src/meshbay_node/media_cache.py35
-rw-r--r--packages/meshbay-node/src/meshbay_node/tmdb.py22
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py163
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py16
-rw-r--r--packages/meshbay-node/tests/test_rename_reenrichment.py163
-rw-r--r--packages/meshbay-node/tests/test_season_and_search_requests.py187
-rw-r--r--packages/meshbay-node/tests/test_tmdb_override_policy.py172
-rw-r--r--packages/meshbay-node/tests/test_wizard_apps_endpoint.py76
27 files changed, 1411 insertions, 27 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index 4c3bea0..938f5d4 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -13,5 +13,10 @@ __version__ = "0.6.0"
# `thumb_hash`, and added `media_meta_req`/`media_meta_resp`, for the Videos
# group app. Additive — an older client simply doesn't render the new
# fields — so this is a MINOR bump too.
-MNP_VERSION = "0.5"
+# 0.6: added `season_meta_req`/`season_meta_resp` (per-season TMDB overview,
+# rather than one static show-level summary applied to every season alike)
+# and `tmdb_search_req`/`tmdb_search_resp` + `tmdb_override`/`tmdb_override_ack`
+# (an operator correcting a wrong automatic TMDB match, found live against a
+# real show fragmented across TMDB entries per season). All additive.
+MNP_VERSION = "0.6"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index e86f322..51396c7 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -71,6 +71,11 @@ OP_TMDB_CONFIG = "tmdb_config"
# entry point for this group — per-group, unlike tmdb_config. Signed like
# the rest: it decides what every member's Videos tab shows.
OP_VIDEO_ROOT = "video_root"
+# Correcting a wrong automatic TMDB match — signed for the same reason as
+# tmdb_config: it changes what every member sees for a show/movie, node-wide
+# (media_cache is shared, not per-viewer), so an unsigned override would let
+# any member vandalize another show's metadata.
+OP_TMDB_OVERRIDE = "tmdb_override"
OP_ROOT_ADD = "root_add"
OP_ROOT_REMOVE = "root_remove"
OP_GROUP_ATTACH = "group_attach"
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index 246bd00..900acc2 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -96,6 +96,12 @@ class MNP:
VIDEO_ROOT_ACK = "video_root_ack"
TMDB_CONFIG = "tmdb_config" # operator → node: enable/disable TMDB, set token
TMDB_CONFIG_ACK = "tmdb_config_ack" # node → everyone: new TMDB config (never the token)
+ SEASON_META_REQ = "season_meta_req" # client → node: TMDB overview/poster for one season
+ SEASON_META_RESP = "season_meta_resp" # node → client: season-level TMDB fields (or none)
+ TMDB_SEARCH_REQ = "tmdb_search_req" # client → node: candidate TMDB matches for a query
+ TMDB_SEARCH_RESP = "tmdb_search_resp" # node → client: candidate list (id, title, year, poster)
+ TMDB_OVERRIDE = "tmdb_override" # operator → node: replace a show/movie's TMDB match
+ TMDB_OVERRIDE_ACK = "tmdb_override_ack"
# Device linking. A new device files a request bound to a code it displays;
# an already-pinned device of the same account approves it. Neither the hub
# nor the node can produce the countersignature.
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} />`}
`}
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 65c7da8..bf6bd64 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -938,6 +938,7 @@ class NodeDaemon:
prev = self._last_broadcast_snapshot.get(group_id)
delta = None
+ previous = None
if prev is not None:
prev_version, prev_entries = prev
previous = GroupIndex._snapshot(
@@ -953,6 +954,18 @@ class NodeDaemon:
new_entries = delta.additions if delta is not None else list(idx.entries)
asyncio.ensure_future(self._enrich_new_video_entries(indexer, new_entries))
+ # A rename/move changes the very filename (or season folder) that
+ # §3.3/§3.4's title-parse read display_title/season/episode from,
+ # but leaves the file's content — and so its id and everything
+ # ffprobe/thumbnailing already found — untouched. Only entries
+ # whose name or path actually differ from the last broadcast get a
+ # fresh pass; an update that is enrichment's own field-fill
+ # (duration/thumb_hash/... landing via _on_enriched below) leaves
+ # name/path alone and must not re-trigger itself forever.
+ if delta is not None and delta.updates and previous is not None:
+ asyncio.ensure_future(
+ self._reenrich_renamed_video_entries(indexer, delta.updates, previous))
+
# Videos app: a file that leaves the index also loses its thumbnail
# and file->tmdb mapping — the "real deletion obligation" docs/
# mediacenter.md §2/§8 calls out explicitly rather than leaving
@@ -1060,6 +1073,32 @@ class NodeDaemon:
return
await self._enrich_new_video_entries(indexer, list(indexer.index.entries))
+ async def _reenrich_renamed_video_entries(
+ self, indexer: DirectoryIndexer, updates: list, previous: GroupIndex,
+ ) -> None:
+ """
+ Videos app: found live — a French-named episode file, renamed by
+ the operator to match its English-named siblings, kept showing as
+ its own separate poster-grid card (and its own row in Flat list)
+ indefinitely, because `_enriched_attempted` — there specifically to
+ stop enrichment's own field-fill from re-triggering itself forever
+ (see the caller) — also silently blocked the *new* filename from
+ ever being title-parsed at all. `entry.id in self._enriched_attempted`
+ is the same content, so simply discarding it here and re-running
+ the ordinary enrichment path is enough: a fresh ffprobe/thumbnail
+ for an unchanged file is redundant work, not a correctness issue,
+ and renames are rare enough that the redundancy is not worth a
+ separate "title-parse only" code path.
+ """
+ for entry in updates:
+ if entry.type != "video":
+ continue
+ old = previous.get_entry(entry.id)
+ if old is None or (old.name == entry.name and old.path == entry.path):
+ continue
+ self._enriched_attempted.discard(entry.id)
+ await self._enrich_new_video_entries(indexer, updates)
+
async def _on_enriched(self, indexer: DirectoryIndexer, file_id: str, fields: dict) -> None:
"""
Merge enrichment fields into the live index and re-trigger a
diff --git a/packages/meshbay-node/src/meshbay_node/media_cache.py b/packages/meshbay-node/src/meshbay_node/media_cache.py
index 129927d..6daae3f 100644
--- a/packages/meshbay-node/src/meshbay_node/media_cache.py
+++ b/packages/meshbay-node/src/meshbay_node/media_cache.py
@@ -41,6 +41,13 @@ CREATE TABLE IF NOT EXISTS thumbs (
jpeg BLOB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_thumbs_file ON thumbs(file_id);
+CREATE TABLE IF NOT EXISTS season_meta (
+ tmdb_id TEXT NOT NULL,
+ season INTEGER NOT NULL,
+ json TEXT NOT NULL,
+ fetched_at REAL NOT NULL,
+ PRIMARY KEY (tmdb_id, season)
+);
"""
# TMDB overviews/ratings do drift; a file's own resolved tmdb_id does not
@@ -109,6 +116,34 @@ class MediaCache:
)
await self._db.commit()
+ # ── tmdb id + season number -> season-level metadata json ────────────────
+ #
+ # A show's own overview (tmdb_meta above) is one static field an operator
+ # found does not necessarily describe every season alike (mediacenter.md
+ # §5.4) — this is TMDB's per-season `overview`/`air_date`/`poster_path`,
+ # fetched and cached independently, on the same staleness schedule.
+
+ async def get_season_meta(self, tmdb_id: str, season: int) -> dict | None:
+ async with self._db.execute(
+ "SELECT json, fetched_at FROM season_meta WHERE tmdb_id = ? AND season = ?",
+ (tmdb_id, season),
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ return None
+ raw_json, fetched_at = row
+ if time.time() - fetched_at > TMDB_META_TTL_SECS:
+ return None
+ return json.loads(raw_json)
+
+ async def set_season_meta(self, tmdb_id: str, season: int, meta: dict) -> None:
+ await self._db.execute(
+ "INSERT OR REPLACE INTO season_meta (tmdb_id, season, json, fetched_at) "
+ "VALUES (?, ?, ?, ?)",
+ (tmdb_id, season, json.dumps(meta), time.time()),
+ )
+ await self._db.commit()
+
# ── thumbnails ────────────────────────────────────────────────────────────
async def get_thumb(self, thumb_hash: str) -> bytes | None:
diff --git a/packages/meshbay-node/src/meshbay_node/tmdb.py b/packages/meshbay-node/src/meshbay_node/tmdb.py
index 5e5ed9f..448a2b9 100644
--- a/packages/meshbay-node/src/meshbay_node/tmdb.py
+++ b/packages/meshbay-node/src/meshbay_node/tmdb.py
@@ -132,8 +132,26 @@ class TmdbClient:
results = (data or {}).get("results", [])
return _best_match(title, results, ("name", "original_name"))
- async def tv_season(self, tmdb_id: str | int, season: int) -> dict | None:
- return await self._get(f"tv/{tmdb_id}/season/{season}", {})
+ async def search_movie_results(self, title: str) -> list[dict]:
+ """
+ The raw candidate list (capped), for an operator correcting a wrong
+ automatic match (§ webrtc_server.py's tmdb_search_req) — unlike
+ `search_movie`, this doesn't collapse to TMDB's own top result: a
+ human picks from several, so several is the point.
+ """
+ data = await self._get("search/movie", {"query": title, "include_adult": "false"})
+ return (data or {}).get("results", [])[:8]
+
+ async def search_tv_results(self, title: str) -> list[dict]:
+ data = await self._get("search/tv", {"query": title})
+ return (data or {}).get("results", [])[:8]
+
+ async def tv_season(self, tmdb_id: str | int, season: int,
+ language: str | None = None) -> dict | None:
+ """`language`, when given, overrides the configured one — same
+ English-fallback use as `movie_details`/`tv_details`."""
+ params = {"language": language} if language else {}
+ return await self._get(f"tv/{tmdb_id}/season/{season}", params)
async def movie_details(self, tmdb_id: str | int, language: str | None = None) -> dict | None:
"""
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 938ce3b..f4d1dee 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -67,6 +67,7 @@ from meshbay_common.adminop import (
OP_SET_SCAN_SETTINGS,
OP_TMDB_CONFIG,
OP_VIDEO_ROOT,
+ OP_TMDB_OVERRIDE,
OP_ROOT_ADD,
OP_ROOT_REMOVE,
OP_GROUP_ATTACH,
@@ -378,6 +379,12 @@ class WebRTCPeerSession:
self._do_video_root(msg)
elif mtype == MNP.MEDIA_META_REQ:
self._spawn(self._do_media_meta_request(msg))
+ elif mtype == MNP.SEASON_META_REQ:
+ self._spawn(self._do_season_meta_request(msg))
+ elif mtype == MNP.TMDB_SEARCH_REQ:
+ self._spawn(self._do_tmdb_search_request(msg))
+ elif mtype == MNP.TMDB_OVERRIDE:
+ self._do_tmdb_override(msg)
elif mtype == MNP.MEMBER_UNPIN:
self._do_member_unpin(msg)
elif mtype == MNP.GEK_ROTATE:
@@ -2452,6 +2459,159 @@ class WebRTCPeerSession:
resp["episode"] = entry.episode
self._send(resp)
+ async def _do_season_meta_request(self, msg: dict) -> None:
+ """
+ Per-season TMDB overview/poster/air_date for a multi-season show —
+ found live: `media_meta_resp`'s one static show-level overview does
+ not necessarily describe every season alike (a season-3-specific
+ promotional summary applied to all three seasons of a show).
+ `tmdb_id` is whatever the client's own prior `media_meta_resp`
+ already resolved — never re-derived from a path here, so this
+ never re-runs a TMDB search of its own.
+ """
+ tmdb_id = msg.get("tmdb_id")
+ season = msg.get("season")
+ if not isinstance(tmdb_id, str) or not tmdb_id or not isinstance(season, int):
+ self._send({"type": "error", "detail": "Missing tmdb_id or season"})
+ return
+ media_cache = self._ctx.get("media_cache")
+ tmdb_client = self._ctx.get("tmdb_client")
+ if media_cache is None or tmdb_client is None:
+ self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
+ "tmdb_id": tmdb_id, "season": season, "confidence": 0})
+ return
+
+ details = await media_cache.get_season_meta(tmdb_id, season)
+ if details is None:
+ fetched = await tmdb_client.tv_season(tmdb_id, season)
+ if fetched is None:
+ self._send({"type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
+ "tmdb_id": tmdb_id, "season": season, "confidence": 0})
+ return
+ # Same per-field English fallback as _tmdb_build_meta: TMDB
+ # returns "" for an untranslated field rather than falling back
+ # itself.
+ if not fetched.get("overview"):
+ fallback = await tmdb_client.tv_season(tmdb_id, season, language="en-US") or {}
+ fetched = {**fallback, **{k: v for k, v in fetched.items() if v not in (None, "", [])}}
+ await media_cache.set_season_meta(tmdb_id, season, fetched)
+ details = fetched
+
+ poster_thumb_hash = await self._fetch_and_cache_poster(
+ media_cache, tmdb_client, details.get("poster_path"))
+ self._send({
+ "type": MNP.SEASON_META_RESP, "v": MNP_VERSION,
+ "tmdb_id": tmdb_id, "season": season, "confidence": 1.0,
+ "name": details.get("name"),
+ "overview": details.get("overview"),
+ "air_date": details.get("air_date"),
+ "poster_thumb_hash": poster_thumb_hash,
+ })
+
+ async def _do_tmdb_search_request(self, msg: dict) -> None:
+ """
+ Candidate TMDB matches for an operator correcting a wrong automatic
+ match (docs/mediacenter.md, §V-whatever this becomes) — a plain
+ lookup, not a mutation, so unlike `tmdb_override` this needs no
+ admin authority: any member can see what TMDB itself would offer,
+ the same as the automatic search already silently does on their
+ behalf. Only `tmdb_override` actually changes what everyone sees.
+ """
+ query = msg.get("query")
+ media_type = msg.get("media_type")
+ if not isinstance(query, str) or not query.strip() or media_type not in ("movie", "tv"):
+ self._send({"type": "error", "detail": "Missing query or media_type"})
+ return
+ media_cache = self._ctx.get("media_cache")
+ tmdb_client = self._ctx.get("tmdb_client")
+ if media_cache is None or tmdb_client is None:
+ self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION,
+ "query": query, "media_type": media_type, "results": []})
+ return
+
+ raw = (await tmdb_client.search_movie_results(query) if media_type == "movie"
+ else await tmdb_client.search_tv_results(query))
+ results = []
+ for r in raw:
+ poster_thumb_hash = await self._fetch_and_cache_poster(
+ media_cache, tmdb_client, r.get("poster_path"))
+ results.append({
+ "tmdb_id": str(r.get("id")),
+ "title": r.get("title") or r.get("name"),
+ "year": (r.get("release_date") or r.get("first_air_date") or "")[:4],
+ "poster_thumb_hash": poster_thumb_hash,
+ })
+ # media_type echoed back, not just query: a client can fire a "war"
+ # tv search and a "war" movie search close together, and without it
+ # the two responses are indistinguishable for keyed matching
+ # (transport.js's tmdb_search_resp handler).
+ self._send({"type": MNP.TMDB_SEARCH_RESP, "v": MNP_VERSION,
+ "query": query, "media_type": media_type, "results": results})
+
+ def _do_tmdb_override(self, msg: dict) -> None:
+ """
+ An operator correcting a wrong automatic TMDB match. Signed like
+ video_root/tmdb_config: it replaces what every member sees for a
+ show/movie, node-wide (media_cache is shared, not per-viewer).
+
+ Applied to every entry sharing the representative file's
+ display_title — the same grouping the poster grid itself uses
+ (§3.4/§V6) — not just the one file the operator happened to be
+ looking at, so the correction actually sticks regardless of which
+ episode a future render picks as representative.
+ """
+ path = msg.get("path")
+ tmdb_id = msg.get("tmdb_id")
+ media_type = msg.get("media_type")
+ if not isinstance(path, str) or not path:
+ self._send({"type": "error", "detail": "Missing path"})
+ return
+ if not isinstance(tmdb_id, str) or not tmdb_id or media_type not in ("movie", "tv"):
+ self._send({"type": "error", "detail": "Missing tmdb_id or media_type"})
+ return
+ ctx = self._group_ctx()
+ entry = ctx["index"].get_entry_by_path(path)
+ if not entry:
+ self._send({"type": "error", "detail": "File not found"})
+ return
+ if not self._has_admin_authority():
+ self._send({"type": "error", "detail": "No authorized key for this"})
+ return
+ subject = f"path={path},tmdb_id={tmdb_id},media_type={media_type}"
+ self._issue_admin_challenge(OP_TMDB_OVERRIDE, subject)
+
+ async def _admin_exec_tmdb_override(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ subject = pending["subject"]
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"tmdb_override:{subject}")
+ return
+ fields = dict(part.split("=", 1) for part in subject.split(","))
+ path, tmdb_id, media_type = fields["path"], fields["tmdb_id"], fields["media_type"]
+
+ ctx = self._group_ctx()
+ entry = ctx["index"].get_entry_by_path(path)
+ media_cache = self._ctx.get("media_cache")
+ if entry is None or media_cache is None:
+ self._send({"type": "error", "detail": "File or media cache not available"})
+ return
+ target_title = entry.display_title or entry.name
+ matched = [e for e in ctx["index"].entries
+ if e.type == "video" and (e.display_title or e.name) == target_title]
+ for e in matched:
+ await media_cache.set_file_tmdb(e.id, tmdb_id, media_type)
+ self._audit("tmdb_override", subject)
+
+ notice = {"type": MNP.TMDB_OVERRIDE_ACK, "v": MNP_VERSION, "path": path,
+ "tmdb_id": tmdb_id, "media_type": media_type}
+ for uid, session in list(self._peer_registry().items()):
+ try:
+ session._send(notice)
+ except Exception:
+ pass
+
async def _tmdb_search(self, tmdb_client, entry, is_show: bool):
"""
§3.3's retry ladder: the parsed title first, then a couple of
@@ -3045,6 +3205,9 @@ class WebRTCPeerSession:
elif pending["op"] == OP_VIDEO_ROOT:
self._spawn(
self._admin_exec_video_root(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_TMDB_OVERRIDE:
+ self._spawn(
+ self._admin_exec_tmdb_override(pending, transcript, sig_bytes))
elif pending["op"] == OP_ROOT_ADD:
self._spawn(
self._admin_exec_root_add(pending, transcript, sig_bytes))
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index d5b3f94..18764e8 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -20,7 +20,7 @@ import time
from html import escape
from pathlib import Path
-from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
+from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, Query
from fastapi.responses import HTMLResponse, JSONResponse
from meshbay_node import __version__
@@ -361,6 +361,20 @@ def create_ui_app(state: dict) -> FastAPI:
state, group_id, bool(payload.get("allowed", False)),
))
+ # ── Enabled apps (operator only, localhost) ────────────────────────────
+ #
+ # Same loopback shape as member-upload: the Create Group wizard sets this
+ # once, right after creating the group and before the (potentially long)
+ # initial scan, so an operator narrowing this down to just Files+Videos
+ # never briefly has Chat live for other members to notice.
+
+ @app.put("/api/groups/{group_id}/apps")
+ async def set_enabled_apps(group_id: str, payload: dict):
+ apps = payload.get("apps")
+ if not isinstance(apps, list) or not apps:
+ raise HTTPException(400, "apps must be a non-empty list")
+ return await _op(lambda: ops.set_enabled_apps(state, group_id, apps))
+
# ── Scan settings (operator only, localhost) ──────────────────────────
@app.put("/api/groups/{group_id}/scan-settings")
diff --git a/packages/meshbay-node/tests/test_rename_reenrichment.py b/packages/meshbay-node/tests/test_rename_reenrichment.py
new file mode 100644
index 0000000..87e0d1a
--- /dev/null
+++ b/packages/meshbay-node/tests/test_rename_reenrichment.py
@@ -0,0 +1,163 @@
+"""
+Bug found live, 2026-08-24: an episode file first named in French (its
+release folder mixed languages across seasons) was renamed by the operator
+to match its English-named siblings — but kept showing as its own
+separate poster-grid card, and its own row in Flat list, indefinitely.
+
+`_enriched_attempted` (daemon.py) exists so that enrichment's own
+field-fill (duration/thumb_hash/display_title/... landing back via
+`_on_enriched`) does not re-trigger itself forever — but it also silently
+blocked the *new* filename from ever being title-parsed at all, since the
+file's content (and so its id) is unchanged by a rename. A rename/move is
+exactly the case `_reenrich_renamed_video_entries` exists to detect: same
+id, but `name` or `path` differs from the version last broadcast.
+"""
+
+import asyncio
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.crypto import generate_gek
+from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, KeystoreConfig
+from meshbay_node.daemon import NodeDaemon
+from meshbay_node.indexer import DirectoryIndexer
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _free_port() -> int:
+ import socket
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+class _StubRoster:
+ async def video_root(self, group_id):
+ return "shared"
+
+
+class _SpyEnricher:
+ """Records which file ids were actually (re-)scheduled, without
+ needing a real ffmpeg/ffprobe pipeline for this test."""
+
+ def __init__(self):
+ self.spawned = []
+
+ def spawn(self, entry, file_path, on_done):
+ self.spawned.append(entry.id)
+
+ async def _noop():
+ return None
+
+ return asyncio.ensure_future(_noop())
+
+
+async def test_a_renamed_file_gets_re_enriched(tmp_path):
+ group_id = "a" * 32
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ old_path = shared / "la-guerre-des-mondes-s03e02.mkv"
+ old_path.write_bytes(b"not a real video, just needs to be indexed as one")
+
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=29016,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01
+ daemon._enricher = _SpyEnricher()
+ daemon._roster = _StubRoster()
+
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(),
+ # reconcile() below calls this itself — the initial scan doesn't
+ # (see test_startup_scan_enrichment.py), so that first broadcast is
+ # still triggered manually, matching _bg_scan's real sequence.
+ on_change=daemon._on_index_change)
+ await indexer.initial_scan()
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+ entry = next(iter(indexer.index.entries))
+ file_id = entry.id
+ assert daemon._enricher.spawned == [file_id], (
+ "the file must be scheduled for enrichment once, under its original name")
+
+ new_path = shared / "war-of-the-worlds-s03e02.mkv"
+ old_path.rename(new_path)
+ changed = await indexer.reconcile()
+ assert changed, "the rename must actually be picked up by reconcile()"
+ # The re-broadcast is a fire-and-forget task chained behind the
+ # coalescing timer, itself scheduling another fire-and-forget task —
+ # poll rather than guess a single sleep long enough for both hops.
+ for _ in range(30):
+ if len(daemon._enricher.spawned) >= 2:
+ break
+ await asyncio.sleep(0.02)
+
+ renamed_entry = indexer.index.get_entry(file_id)
+ assert renamed_entry is not None
+ assert renamed_entry.name == "war-of-the-worlds-s03e02.mkv"
+ assert daemon._enricher.spawned == [file_id, file_id], (
+ "a rename must re-schedule enrichment for the same file id — "
+ "_enriched_attempted must not permanently block the new filename "
+ "from ever being title-parsed")
+
+
+async def test_an_unrelated_update_does_not_re_trigger_enrichment(tmp_path):
+ """
+ The other half of the same fix: an update whose name/path did *not*
+ change (the ordinary case — enrichment's own field-fill, or the
+ reconcile sweep confirming a file unmodified) must not re-schedule
+ enrichment. Without this, `_on_enriched` merging a file's own results
+ back into the index would count as its own trigger and loop forever.
+ """
+ group_id = "a" * 32
+ shared = tmp_path / "shared"
+ shared.mkdir()
+ path = shared / "movie.mkv"
+ path.write_bytes(b"not a real video, just needs to be indexed as one")
+
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id=group_id, name="test-group", shared_dir=str(shared),
+ visibility="private", quic_port=29017,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._broadcast_coalesce_secs = 0.01
+ daemon._enricher = _SpyEnricher()
+ daemon._roster = _StubRoster()
+
+ indexer = DirectoryIndexer(
+ roots=one_root(shared), group_id=group_id,
+ sk_node=Ed25519PrivateKey.generate(), gek=generate_gek(),
+ on_change=daemon._on_index_change)
+ await indexer.initial_scan()
+ await daemon._on_index_change(indexer)
+ await asyncio.sleep(0.05)
+
+ entry = next(iter(indexer.index.entries))
+ assert daemon._enricher.spawned == [entry.id]
+
+ # A reconcile pass that finds nothing changed at all — not even a
+ # rename — must not re-schedule anything.
+ changed = await indexer.reconcile()
+ await asyncio.sleep(0.05)
+ assert not changed
+ assert daemon._enricher.spawned == [entry.id]
diff --git a/packages/meshbay-node/tests/test_season_and_search_requests.py b/packages/meshbay-node/tests/test_season_and_search_requests.py
new file mode 100644
index 0000000..8ba55fb
--- /dev/null
+++ b/packages/meshbay-node/tests/test_season_and_search_requests.py
@@ -0,0 +1,187 @@
+"""
+`_do_season_meta_request` (per-season TMDB overview/poster/air_date, for the
+season-tab view — docs/mediacenter.md §5.4's fix for a 3-season show whose
+overview read as season-3-specific for every season) and
+`_do_tmdb_search_request` (raw TMDB candidates for an operator correcting a
+wrong automatic match). Neither is a signed admin op — see each handler's own
+docstring for why — so these tests only exercise the read path, unlike
+test_tmdb_override_policy.py.
+"""
+
+import pytest
+
+from meshbay_common.protocol import MNP
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(media_cache=None, tmdb_client=None) -> WebRTCPeerSession:
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {"media_cache": media_cache, "tmdb_client": tmdb_client}
+ session.sent = []
+ session._send = session.sent.append
+ return session
+
+
+@pytest.fixture
+async def media_cache(tmp_path):
+ c = MediaCache(db_path=tmp_path / "media_cache.db")
+ await c.open()
+ yield c
+ await c.close()
+
+
+class FakeTmdbClient:
+ def __init__(self, season_json=None):
+ self.season_json = season_json
+ self.tv_season_calls = []
+ self.movie_search_calls = []
+ self.tv_search_calls = []
+
+ @staticmethod
+ def poster_url(path):
+ return f"https://image.tmdb.org/t/p/w500{path}"
+
+ async def fetch_image(self, url):
+ return b"jpeg-bytes-for-" + url.encode()
+
+ async def tv_season(self, tmdb_id, season, language=None):
+ self.tv_season_calls.append((tmdb_id, season, language))
+ return self.season_json
+
+ async def search_movie_results(self, title):
+ self.movie_search_calls.append(title)
+ return [{"id": 111, "title": title, "release_date": "2019-05-01", "poster_path": "/m.jpg"}]
+
+ async def search_tv_results(self, title):
+ self.tv_search_calls.append(title)
+ return [{"id": 222, "name": title, "first_air_date": "2021-03-01", "poster_path": "/t.jpg"}]
+
+
+# ── season_meta_req ──────────────────────────────────────────────────────────
+
+async def test_season_meta_missing_tmdb_id_is_refused():
+ session = _session()
+ await session._do_season_meta_request({"season": 1})
+ assert session.sent == [{"type": "error", "detail": "Missing tmdb_id or season"}]
+
+
+async def test_season_meta_non_int_season_is_refused():
+ session = _session()
+ await session._do_season_meta_request({"tmdb_id": "42", "season": "1"})
+ assert session.sent == [{"type": "error", "detail": "Missing tmdb_id or season"}]
+
+
+async def test_season_meta_with_no_cache_or_client_reports_zero_confidence():
+ session = _session(media_cache=None, tmdb_client=None)
+ await session._do_season_meta_request({"tmdb_id": "42", "season": 1})
+ assert session.sent == [{
+ "type": MNP.SEASON_META_RESP, "v": session.sent[0]["v"],
+ "tmdb_id": "42", "season": 1, "confidence": 0,
+ }]
+
+
+async def test_season_meta_cache_hit_skips_the_tmdb_call(media_cache):
+ await media_cache.set_season_meta("42", 3, {
+ "name": "Season 3", "overview": "cached overview", "air_date": "2023-01-01",
+ "poster_path": "/cached.jpg",
+ })
+ client = FakeTmdbClient()
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_season_meta_request({"tmdb_id": "42", "season": 3})
+
+ assert client.tv_season_calls == [], "a cached season must not be re-fetched"
+ resp = session.sent[0]
+ assert resp["type"] == MNP.SEASON_META_RESP
+ assert resp["confidence"] == 1.0
+ assert resp["overview"] == "cached overview"
+
+
+async def test_season_meta_cache_miss_fetches_and_caches(media_cache):
+ client = FakeTmdbClient(season_json={
+ "name": "Season 1", "overview": "fresh overview", "air_date": "2020-01-01",
+ "poster_path": "/fresh.jpg",
+ })
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_season_meta_request({"tmdb_id": "7", "season": 1})
+
+ assert client.tv_season_calls == [("7", 1, None)]
+ resp = session.sent[0]
+ assert resp["overview"] == "fresh overview"
+ assert resp["poster_thumb_hash"] is not None
+ cached = await media_cache.get_season_meta("7", 1)
+ assert cached["overview"] == "fresh overview", "a fetched season must be cached for next time"
+
+
+async def test_season_meta_empty_overview_falls_back_to_english(media_cache):
+ async def tv_season(tmdb_id, season, language=None):
+ if language == "en-US":
+ return {"name": "S1", "overview": "English overview", "air_date": "2020-01-01",
+ "poster_path": "/p.jpg"}
+ return {"name": "S1", "overview": "", "air_date": "2020-01-01", "poster_path": "/p.jpg"}
+
+ client = FakeTmdbClient()
+ client.tv_season = tv_season
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_season_meta_request({"tmdb_id": "9", "season": 1})
+
+ assert session.sent[0]["overview"] == "English overview"
+
+
+# ── tmdb_search_req ──────────────────────────────────────────────────────────
+
+async def test_search_missing_query_is_refused():
+ session = _session()
+ await session._do_tmdb_search_request({"media_type": "movie"})
+ assert session.sent == [{"type": "error", "detail": "Missing query or media_type"}]
+
+
+async def test_search_bad_media_type_is_refused():
+ session = _session()
+ await session._do_tmdb_search_request({"query": "war", "media_type": "album"})
+ assert session.sent == [{"type": "error", "detail": "Missing query or media_type"}]
+
+
+async def test_search_with_no_cache_or_client_returns_empty_results():
+ session = _session(media_cache=None, tmdb_client=None)
+ await session._do_tmdb_search_request({"query": "war", "media_type": "tv"})
+ assert session.sent == [{
+ "type": MNP.TMDB_SEARCH_RESP, "v": session.sent[0]["v"],
+ "query": "war", "media_type": "tv", "results": [],
+ }]
+
+
+async def test_search_movie_calls_movie_search_and_echoes_media_type(media_cache):
+ client = FakeTmdbClient()
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "movie"})
+
+ assert client.movie_search_calls == ["War of the Worlds"]
+ assert client.tv_search_calls == []
+ resp = session.sent[0]
+ assert resp["type"] == MNP.TMDB_SEARCH_RESP
+ assert resp["media_type"] == "movie", (
+ "media_type must be echoed back — otherwise a movie search and a tv "
+ "search for the same query are indistinguishable to the client's "
+ "keyed response matching (transport.js tmdb_search_resp handler)")
+ assert resp["results"] == [{
+ "tmdb_id": "111", "title": "War of the Worlds", "year": "2019",
+ "poster_thumb_hash": resp["results"][0]["poster_thumb_hash"],
+ }]
+
+
+async def test_search_tv_calls_tv_search(media_cache):
+ client = FakeTmdbClient()
+ session = _session(media_cache=media_cache, tmdb_client=client)
+
+ await session._do_tmdb_search_request({"query": "War of the Worlds", "media_type": "tv"})
+
+ assert client.tv_search_calls == ["War of the Worlds"]
+ assert client.movie_search_calls == []
+ assert session.sent[0]["media_type"] == "tv"
diff --git a/packages/meshbay-node/tests/test_tmdb_override_policy.py b/packages/meshbay-node/tests/test_tmdb_override_policy.py
new file mode 100644
index 0000000..b8fa6f9
--- /dev/null
+++ b/packages/meshbay-node/tests/test_tmdb_override_policy.py
@@ -0,0 +1,172 @@
+"""
+An operator correcting a wrong automatic TMDB match (found live: a real
+show's search consistently matched a season-3-specific promotional TMDB
+entry instead of the show itself). Signed like video_root/tmdb_config —
+it changes what every member sees, node-wide (media_cache is shared, not
+per-viewer) — and, once authorized, applies to every index entry sharing
+the representative file's display_title, the same grouping the poster
+grid itself uses (§3.4/§V6), not just the one file the operator happened
+to be looking at.
+"""
+
+import hashlib
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.adminop import OP_TMDB_OVERRIDE
+from meshbay_common.protocol import IndexEntry, MNP
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.media_cache import MediaCache
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+from conftest import one_root
+
+pytestmark = pytest.mark.asyncio
+
+
+def _session(tmp_path: Path, user_id: str, *, operator: str | None = None) -> WebRTCPeerSession:
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ ctx = {
+ "roots": one_root(shared_root),
+ "index": index,
+ "sk_node": index.sk_node,
+ "node_user_id": operator,
+ }
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = ctx
+ session._group_id = None
+ session._user_id = user_id
+ session._pk_user = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def _entry(path: str, name: str, display_title: str) -> IndexEntry:
+ # A real id is a blake3 content hash; sha256 here is just a stand-in with
+ # the same property that matters for these tests — deterministic and
+ # effectively collision-free across the handful of entries a test builds.
+ # (`hash((path, name)) % 10` was tried here before and is NOT that: it's
+ # randomized per-process by PYTHONHASHSEED and collides constantly across
+ # only 10 possible values, silently dropping entries in GroupIndex's
+ # id-keyed dict.)
+ digest = hashlib.sha256(f"{path}/{name}".encode()).hexdigest()
+ return IndexEntry(
+ id=digest, name=name, path=path,
+ size=1, type="video", added_at=0, display_title=display_title,
+ season=1, episode=1,
+ )
+
+
+# ── Refused before a challenge is even issued ───────────────────────────────
+
+async def test_missing_path_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"tmdb_id": "123", "media_type": "tv"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_missing_tmdb_id_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "Show"))
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"path": "shared", "media_type": "tv"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_unknown_path_is_refused(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"path": "nope", "tmdb_id": "123", "media_type": "tv"})
+
+ assert not issued
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_a_request_with_nobody_to_authorize_it_is_refused(tmp_path):
+ session = _session(tmp_path, "member-1", operator="the-operator")
+ session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "Show"))
+ session._has_admin_authority = lambda: False
+
+ session._do_tmdb_override({"path": "shared", "tmdb_id": "123", "media_type": "tv"})
+
+ assert [m for m in session.sent if m.get("type") == "error"]
+
+
+async def test_a_valid_request_is_signed(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ session._ctx["index"].add_entry(_entry("shared", "ep.mkv", "War of the Worlds"))
+ session._has_admin_authority = lambda: True
+ issued = []
+ session._issue_admin_challenge = lambda op, subject: issued.append((op, subject))
+
+ session._do_tmdb_override({"path": "shared", "tmdb_id": "2255", "media_type": "tv"})
+
+ assert issued == [(OP_TMDB_OVERRIDE,
+ "path=shared,tmdb_id=2255,media_type=tv")]
+
+
+# ── Applying the override ───────────────────────────────────────────────────
+
+async def test_override_updates_every_entry_sharing_the_display_title(tmp_path):
+ session = _session(tmp_path, "op", operator="op")
+ index = session._ctx["index"]
+ s1 = _entry("shared/S1", "s01e01.mkv", "War of the Worlds")
+ s2 = _entry("shared/S2", "s02e01.mkv", "War of the Worlds")
+ s3 = _entry("shared/S3", "s03e02.mkv", "War of the Worlds")
+ other_show = _entry("shared/Other", "ep.mkv", "A Different Show")
+ for e in (s1, s2, s3, other_show):
+ index.add_entry(e)
+
+ media_cache = MediaCache(db_path=tmp_path / "media_cache.db")
+ await media_cache.open()
+ try:
+ session._ctx["media_cache"] = media_cache
+ # Signature verification itself is exercised generically elsewhere
+ # (test_roster_pairing.py) — this test is about the policy once a
+ # signature is known good: which entries actually get updated, and
+ # who is told about it.
+ session._verify_admin_sig = lambda transcript, sig: _true()
+ peer = type("Peer", (), {"sent": []})()
+ peer._send = peer.sent.append
+ session._peer_registry = lambda: {"peer-1": peer}
+
+ await session._admin_exec_tmdb_override(
+ {"subject": "path=shared/S1,tmdb_id=999,media_type=tv"},
+ b"transcript", b"sig")
+
+ for e in (s1, s2, s3):
+ assert await media_cache.get_file_tmdb(e.id) == ("999", "tv"), (
+ "every entry sharing the representative file's display_title "
+ "must be corrected, not just the one the operator clicked on")
+ assert await media_cache.get_file_tmdb(other_show.id) is None, (
+ "a different show's own match must be left alone")
+ # The ack is broadcast to other connected peers, never echoed onto
+ # the requester's own `sent` — see the loop in
+ # _admin_exec_tmdb_override, which sends via each peer's own _send.
+ assert [m for m in peer.sent if m.get("type") == MNP.TMDB_OVERRIDE_ACK]
+ finally:
+ await media_cache.close()
+
+
+async def _true():
+ return True
diff --git a/packages/meshbay-node/tests/test_wizard_apps_endpoint.py b/packages/meshbay-node/tests/test_wizard_apps_endpoint.py
new file mode 100644
index 0000000..10ab489
--- /dev/null
+++ b/packages/meshbay-node/tests/test_wizard_apps_endpoint.py
@@ -0,0 +1,76 @@
+"""
+Create Group wizard: choosing which apps a brand-new group offers, before
+the (potentially long) initial scan — see app.js's CreateGroupWizard. This
+is a loopback-only, operator-authenticated endpoint (11.5.3), same shape as
+the existing member-upload one: a thin adapter over `ops.set_enabled_apps`,
+with only the validation `_do_apps_enabled` (the signed MNP front door)
+already does client-side in the wizard, but worth enforcing at this front
+door too since nothing else would.
+"""
+
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from fastapi.testclient import TestClient
+
+from conftest import one_root
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import Roster
+from meshbay_node.ui.app import create_ui_app
+
+pytestmark = pytest.mark.asyncio
+
+
+async def _client(tmp_path: Path):
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
+ state = {
+ "status": "running",
+ "groups_ctx": {"g" * 32: {"index": index, "roots": one_root(shared)}},
+ "indexes": {"g" * 32: index},
+ "roster": roster,
+ }
+ app = create_ui_app(state)
+ return TestClient(app), roster
+
+
+async def test_narrowing_the_apps_persists_to_the_roster(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put(f"/api/groups/{'g' * 32}/apps", json={"apps": ["files", "video"]})
+ assert resp.status_code == 200, resp.text
+ assert sorted(resp.json()["apps"]) == ["files", "video"]
+ assert sorted(await roster.enabled_apps("g" * 32)) == ["files", "video"]
+ finally:
+ await roster.close()
+
+
+async def test_empty_apps_list_is_refused(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put(f"/api/groups/{'g' * 32}/apps", json={"apps": []})
+ assert resp.status_code == 400
+ finally:
+ await roster.close()
+
+
+async def test_missing_apps_field_is_refused(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put(f"/api/groups/{'g' * 32}/apps", json={})
+ assert resp.status_code == 400
+ finally:
+ await roster.close()
+
+
+async def test_unhosted_group_is_refused(tmp_path):
+ client, roster = await _client(tmp_path)
+ try:
+ resp = client.put("/api/groups/" + "z" * 32 + "/apps", json={"apps": ["files"]})
+ assert resp.status_code == 404
+ finally:
+ await roster.close()