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