aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 14:33:20 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 14:33:20 +0200
commit0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a (patch)
treeba8daf5dcf050d44b7b0e766babbfda8fadac59f /packages/meshbay-hub/src/meshbay_hub/static/video-app.js
parent6af05abf410bbd038ce7fa6915a659defc509071 (diff)
downloadmeshbay-0b0da86f1f9d6f0b1a27b5e1e1658c42de9f356a.tar.gz
feat(node,hub): season-specific overviews, manual TMDB match correction, and wizard polish
Two operator-facing fixes for a real 3-season show whose automatic TMDB match was wrong at the show level: per-season overview/air_date tabs in the detail modal (falling back to the show-level text when a season's own is empty), and a "Fix match…" search-and-correct affordance that re-resolves every file sharing the corrected show's display_title. New signed op OP_TMDB_OVERRIDE and two read-only pairs (season_meta_req/resp, tmdb_search_req/resp), MNP_VERSION 0.5 -> 0.6. Also: the create-group wizard gets a spinning indexing indicator and an app-selection step, group settings default the TMDB language to the operator's own locale (never as a global default), and a file renamed mid-session now re-triggers title parsing instead of being silently skipped by the enrichment dedup guard. Fixes two bugs found during this work: the search overlay's z-index lost to the base video-overlay class and rendered invisibly, and season_meta's own empty overview didn't fall back to the show-level one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LAmyXtc6dAADsH23ydXQpY
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app.js239
1 files changed, 228 insertions, 11 deletions
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} />`}
`}