From 6af05abf410bbd038ce7fa6915a659defc509071 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 24 Aug 2026 10:04:46 +0200 Subject: feat(node,hub): add Videos group app (poster grid, flat list, TMDB metadata) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements docs/mediacenter.md: a "Videos" group application built on the existing files index rather than a separate catalogue. On the node side, new indexer enrichment (technical probe, filename/season parsing, thumbnail generation) runs per-file once an operator has chosen a video_root for the group, plus a TMDB client for on-demand poster/metadata lookups (never client-side, thumbnails delivered over the existing chunk path). On the hub side, a new video-app.js renders a lazily-mounted poster grid or a thumbnail-only flat list, with TMDB entirely optional per group. Along the way: the global apps registry now drives Settings' default-tab picker instead of a hardcoded list, and the video_root is configured from group Settings (like uploads) rather than from Files, with the node refusing to run any TMDB/thumbnail work until one is set. Fixes several bugs found via live testing against a real library, notably a race between two effects writing the same "image ready" state that could leave a poster grid spinning forever on a same-tab revisit — see mediacenter.md §5.4 for the full account of each one. --- .../src/meshbay_hub/static/video-app.js | 599 +++++++++++++++++++++ 1 file changed, 599 insertions(+) create mode 100644 packages/meshbay-hub/src/meshbay_hub/static/video-app.js (limited to 'packages/meshbay-hub/src/meshbay_hub/static/video-app.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js new file mode 100644 index 0000000..250bf8b --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -0,0 +1,599 @@ +import { + html, useState, useEffect, useRef, useMemo, useCallback, +} from './vendor/htm-preact.js'; +import { t } from './i18n.js'; +import { Icon } from './icon.js'; +import { formatSize, pipelinedDownload } from './file-utils.js'; + +// ── Videos ─────────────────────────────────────────────────────────────────── +// +// A poster-grid (TMDB-enriched) or flat (thumbnail-only) browser for a +// group's video files, per docs/mediacenter.md. Grouping: one card per movie, +// one card per show — shows are grouped by `display_title` (already +// resolved/corroborated at index time, §3.4), not by folder path, since a +// client-side path convention would have to guess how many roots/subfolders +// deep a show folder sits, which display_title already settled once. +// +// TMDB metadata is fetched lazily, only for a tile once it is actually +// visible (LazyTile below) — apps.md §5's virtualization requirement for a +// grid of many tiles. Thumbnails go through the same `file_req`/chunk path +// as a real file (docs/mediacenter.md §5.3) via MediaThumb, reusing +// chat-app.js's ChatImage pattern. + +const VIEW_MODE_KEY = 'meshbay_video_view_mode'; + +function loadViewMode() { + try { return localStorage.getItem(VIEW_MODE_KEY) === 'flat' ? 'flat' : 'poster'; } + catch { return 'poster'; } +} +function saveViewMode(mode) { + try { localStorage.setItem(VIEW_MODE_KEY, mode); } catch { /* per-device convenience only */ } +} + +function formatDuration(seconds) { + if (!seconds) return ''; + const total = Math.round(seconds); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`; +} + +function formatResolution(width, height) { + if (!width || !height) return ''; + if (height >= 2100) return '4K'; + if (height >= 1000) return `${height}p`; + return `${width}x${height}`; +} + +function yearOf(dateStr) { + return dateStr ? String(dateStr).slice(0, 4) : ''; +} + +// ── grouping ───────────────────────────────────────────────────────────────── + +// Nothing shows until an operator has actually chosen a root in Settings +// (§5.6/V-whatever this is now): the node itself runs no TMDB/thumbnail +// work for this group before that either (daemon.py's +// _enrich_new_video_entries), so falling back to "the whole index" here +// would just show files nothing has enriched. +function underVideoRoot(entry, videoRoot) { + if (!videoRoot) return false; + const p = entry.path || ''; + return p === videoRoot || p.startsWith(videoRoot + '/'); +} + +function buildSeasons(episodes) { + const sorted = [...episodes].sort((a, b) => (a.season - b.season) || (a.episode - b.episode)); + const bySeason = new Map(); + for (const ep of sorted) { + if (!bySeason.has(ep.season)) bySeason.set(ep.season, []); + bySeason.get(ep.season).push(ep); + } + return [...bySeason.entries()].sort((a, b) => a[0] - b[0]) + .map(([season, seasonEpisodes]) => ({ season, episodes: seasonEpisodes })); +} + +function groupVideoEntries(entries, videoRoot) { + const movies = []; + const showsByTitle = new Map(); + for (const e of entries) { + if (e.type !== 'video') continue; + if (!underVideoRoot(e, videoRoot)) continue; + if (e.season != null && e.episode != null) { + const title = e.display_title || e.name; + if (!showsByTitle.has(title)) showsByTitle.set(title, { title, episodes: [] }); + showsByTitle.get(title).episodes.push(e); + } else { + movies.push(e); + } + } + movies.sort((a, b) => (a.display_title || a.name).localeCompare(b.display_title || b.name)); + const shows = [...showsByTitle.values()].sort((a, b) => a.title.localeCompare(b.title)); + for (const show of shows) { + show.episodes.sort((a, b) => (a.season - b.season) || (a.episode - b.episode)); + show.seasons = buildSeasons(show.episodes); + } + return { movies, shows }; +} + +// ── lazy-mount tile (apps.md §5 virtualization) ───────────────────────────── + +const LAZY_TILE_MARGIN = 300; + +function LazyTile({ cls = 'video-tile-slot', children }) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (visible || !ref.current) return; + // A tile that is already on screen (or within the margin) the moment + // it mounts — the overwhelmingly common case, since a merge (§V6) or a + // tab revisit mounts tiles into a grid that was already scrolled to + // wherever the operator was looking — doesn't need to wait for + // IntersectionObserver's own first callback at all: that first + // delivery is only a *microtask/next-paint* guarantee, not an + // immediate one, and was observed live taking upwards of 30 seconds + // (matching the browser's own periodic intersection-computation + // cadence exactly) — which read as "the poster never finishes + // loading" even though every fetch behind it had already completed. + // Checked synchronously so a genuinely below-the-fold tile still only + // mounts once actually scrolled near. + const rect = ref.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const alreadyNear = rect.bottom >= -LAZY_TILE_MARGIN && rect.top <= viewportHeight + LAZY_TILE_MARGIN; + if (alreadyNear) { setVisible(true); return; } + const obs = new IntersectionObserver((obsEntries) => { + if (obsEntries.some((oe) => oe.isIntersecting)) { setVisible(true); obs.disconnect(); } + }, { rootMargin: `${LAZY_TILE_MARGIN}px` }); + obs.observe(ref.current); + return () => obs.disconnect(); + }, [visible]); + + return html`
${visible ? children : null}
`; +} + +// ── thumbnail/poster image, decrypted via the chunk path ──────────────────── +// +// Cached per session by thumb_hash (a content hash, so it never goes stale): +// the same poster reused across a season's worth of episode tiles is +// decrypted once, not once per tile. Blob URLs are not revoked — the number +// of distinct thumbnails one session ever visits is bounded by the library +// size, and reference-counting revocation across many tile mounts/unmounts +// would cost real complexity for a benefit that only matters in a very long +// session. +const _thumbBlobCache = new Map(); + +function MediaThumb({ + thumbHash, transportRef, gekRef, alt, cls = 'video-thumb', onReady, +}) { + const [blobUrl, setBlobUrl] = useState(() => _thumbBlobCache.get(thumbHash) || null); + + // Re-checks the cache by the CURRENT thumbHash on every change rather than + // trusting the `blobUrl` state variable — a PosterCard swaps this same + // component instance's thumbHash prop from the raw fallback frame to the + // TMDB poster once metadata resolves, and gating on "is blobUrl already + // set" (from the *previous* hash) would leave the fallback frame on + // screen forever instead of ever fetching the poster. + // + // `onReady` fires exactly once per settled thumbHash — cache hit, fetch + // success, fetch failure, or no hash at all — so a caller that hides this + // component until its image has actually arrived (PosterCard) always + // gets unstuck, even when there's nothing to show. + useEffect(() => { + const cached = _thumbBlobCache.get(thumbHash); + if (cached) { setBlobUrl(cached); if (onReady) onReady(cached); return; } + setBlobUrl(null); + if (!thumbHash) { if (onReady) onReady(null); return; } + let cancelled = false; + (async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) { if (onReady) onReady(null); return; } + try { + const chunks = await pipelinedDownload(transport, gekRef.current, thumbHash, 1); + if (cancelled) return; + const url = URL.createObjectURL(new Blob(chunks, { type: 'image/jpeg' })); + _thumbBlobCache.set(thumbHash, url); + setBlobUrl(url); + if (onReady) onReady(url); + } catch { + /* leave the placeholder — a transient fetch failure isn't an error state */ + if (!cancelled && onReady) onReady(null); + } + })(); + return () => { cancelled = true; }; + }, [thumbHash]); + + if (!blobUrl) return html`
<${Icon} name="video" />
`; + return html`${alt`; +} + +// ── TMDB metadata, fetched once per visible tile ──────────────────────────── + +function useMediaMeta(transportRef, path, active) { + const [meta, setMeta] = useState(null); + useEffect(() => { + if (!active || !path) return; + let cancelled = false; + (async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const resp = await transport.fetchMediaMeta(path); + if (!cancelled) setMeta(resp); + } catch { if (!cancelled) setMeta({ confidence: 0 }); } + })(); + return () => { cancelled = true; }; + }, [path, active]); + return meta; +} + +// ── Mode A: poster grid ────────────────────────────────────────────────────── + +function PosterCard({ title, subtitle, repEntry, transportRef, gekRef, onOpen, groupKey, onMetaResolved }) { + const meta = useMediaMeta(transportRef, repEntry.path, true); + const confident = meta && meta.confidence && meta.tmdb_id; + const metaReady = meta !== null; + + // Reports this tile's own resolution upward so PosterGrid can notice two + // differently-parsed folders (a show split across release groups that + // named its seasons inconsistently, §3.4/V6) resolving to the same TMDB + // id, and merge them into one card — never required for the generic + // per-folder display to work, only an enhancement once it's known safe. + useEffect(() => { + if (meta && onMetaResolved) onMetaResolved(groupKey, meta); + }, [meta, groupKey]); + + const posterHash = confident && meta.poster_thumb_hash ? meta.poster_thumb_hash : repEntry.thumb_hash; + + // Which hash MediaThumb has actually confirmed ready — compared against + // the *current* posterHash below, rather than a separate boolean reset + // by its own effect on posterHash change. That second-writer shape had a + // real bug: the instant metaReady flips true, posterHash jumps from the + // raw fallback frame to the resolved poster in the very same commit that + // mounts MediaThumb for it — and when that poster's bytes are already in + // MediaThumb's session cache (a revisit within the same tab, no reload), + // its onReady fires synchronously from that same mount effect. Effects + // run children-first, so the "reset on posterHash change" effect fired + // *after* it, in the same commit, unconditionally setting the flag back + // to false — with no later event left to ever set it true again. The + // card stayed a spinner forever despite the image already being loaded. + // Deriving readiness from a direct comparison has no such ordering to + // get wrong: whichever of the two fires, in whichever order, the render + // that follows sees the same answer. + const [readyHash, setReadyHash] = useState(null); + const handleImageReady = useCallback(() => setReadyHash(posterHash), [posterHash]); + const imageReady = readyHash === posterHash; + + // Nothing is shown until BOTH the TMDB lookup and the final chosen image + // (the poster once matched, the file's own frame otherwise) have + // actually settled. Revealing the raw per-file frame first and swapping + // it for the poster a moment later — or showing a card that a moment + // later gets absorbed into a neighbour once §V6's merge kicks in — was + // exactly the flash an operator flagged as hard on the eyes. A slow + // lookup (a big, freshly-scanned library) just means the spinner stays a + // little longer, never a partially-drawn card. + const ready = metaReady && imageReady; + + return html` +
+ ${!ready && html` +
+ `} +
+ ${metaReady && html` + <${MediaThumb} thumbHash=${posterHash} alt=${title} + cls="video-poster" transportRef=${transportRef} gekRef=${gekRef} + onReady=${handleImageReady} /> + `} +
+ ${ready && html` +
+
${(confident && meta.title) || title}
+
+ ${confident && meta.release_date ? yearOf(meta.release_date) : ''} + ${confident && meta.first_air_date ? yearOf(meta.first_air_date) : ''} + ${subtitle ? ` · ${subtitle}` : ''} +
+
+ `} +
+ `; +} + +function VideoDetailModal({ title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay }) { + const confident = meta && meta.confidence && meta.tmdb_id; + return html` +
{ + if (e.target.classList.contains('video-overlay')) onClose(); + }}> +
+
+ ${(confident && meta.title) || title} + +
+
+ ${confident && html` +

${meta.overview}

+

+ ${meta.vote_average ? `★ ${meta.vote_average.toFixed(1)}` : ''} + ${meta.genres && meta.genres.length ? ` · ${meta.genres.join(', ')}` : ''} + ${meta.director ? ` · ${t('video.director')}: ${meta.director}` : ''} +

+ ${meta.cast && meta.cast.length > 0 && html` +

+ ${meta.cast.slice(0, 6).map((c) => c.name).join(', ')} +

+ `} + `} + ${!show && html` + + `} + ${show && html` +
+ ${show.seasons.map((s) => html` +
+
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })} +
+ ${s.episodes.map((ep) => html` + + `)} +
+ `)} +
+ `} +
+
+
+ `; +} + +function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnabled }) { + 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({}); + + const handleMetaResolved = useCallback((groupKey, meta) => { + setMetaByGroup((prev) => (prev[groupKey] === meta ? prev : { ...prev, [groupKey]: meta })); + }, []); + + // Two raw groups (grouped by parsed display_title, §4.1) resolving to the + // same confident TMDB id are almost certainly one show whose seasons + // were released under differently-named folders — confirmed live: one + // operator's show had its two seasons parsed as "Ovni" and "OVNIs" by + // two different release groups, showing as two identical-looking cards + // once both matched the same real show (§3.4/V6). Merged here once both + // are actually known — never required for the fallback to work: a group + // with no confident match yet, or ever, still shows on its own, exactly + // the generic per-folder display needs. + const mergedShows = useMemo(() => { + const byTmdbId = new Map(); + const standalone = []; + for (const s of shows) { + const meta = metaByGroup[s.title]; + const tmdbId = meta && meta.confidence && meta.tmdb_id; + if (tmdbId) { + if (!byTmdbId.has(tmdbId)) byTmdbId.set(tmdbId, []); + byTmdbId.get(tmdbId).push(s); + } else { + standalone.push([s]); + } + } + return [...byTmdbId.values(), ...standalone].map((groups) => { + const episodes = groups.flatMap((g) => g.episodes); + return { + // groups[0].title, not a joined string of every constituent's + // title: a fresh key here would make this a brand-new PosterCard + // (and LazyTile) the instant a second raw group merges into an + // already-visible one — throwing away its already-fired + // IntersectionObserver and already-resolved metadata/poster for no + // reason, and reintroducing exactly the flash the "ready" gating + // above exists to prevent. groups[0].title is already unique + // (raw titles are, via groupVideoEntries' showsByTitle) and, for + // the overwhelmingly common unmerged case, is the same key the + // card already had — so nothing about this changes when no merge + // ever happens. + key: groups[0].title, + title: groups[0].title, + episodes, + seasons: buildSeasons(episodes), + }; + }); + }, [shows, metaByGroup]); + + const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show }); + const detailMeta = useMediaMeta(transportRef, detail ? detail.repEntry.path : null, !!detail); + + return html` +
+ ${movies.map((e) => html` + <${LazyTile} key=${e.id}> + <${PosterCard} title=${e.display_title || e.name} + subtitle=${formatDuration(e.duration)} repEntry=${e} + groupKey=${`movie:${e.id}`} + transportRef=${transportRef} gekRef=${gekRef} + onOpen=${() => (tmdbEnabled + // With TMDB off there is nothing the detail modal would show + // for a movie (no overview, no season list to pick from, + // unlike a show) — so it would just be an extra click in + // front of a Play button. Straight to the player instead. + ? openDetail(e.display_title || e.name, e, null) + : onPreview(e))} /> + + `)} + ${mergedShows.map((s) => { + // Prefer an episode that actually has a thumbnail over blindly + // episodes[0]: if that specific file's enrichment hasn't produced + // one yet (or failed), the card showed an empty placeholder even + // though sibling episodes — visible right there in Flat list — + // have one. The TMDB path (or its absence) is the same regardless + // of which episode's own file supplies the fallback frame. + const repEntry = s.episodes.find((e) => e.thumb_hash) || s.episodes[0]; + // Known up front from the already-parsed index fields (§3.4), no + // TMDB needed: a card covering exactly one season says so before + // a click, rather than an anonymous episode count — the generic + // "N episodes" stays for a merged, multi-season, or special-only + // card, where a single number would misrepresent it. + const singleSeason = s.seasons.length === 1 ? s.seasons[0].season : null; + const subtitle = singleSeason != null + ? (singleSeason === 0 ? t('video.specials') : t('video.season_n', { n: singleSeason })) + : t('video.n_episodes', { n: s.episodes.length }); + return html` + <${LazyTile} key=${s.key}> + <${PosterCard} title=${s.title} + subtitle=${subtitle} + repEntry=${repEntry} + groupKey=${s.title} + onMetaResolved=${handleMetaResolved} + transportRef=${transportRef} gekRef=${gekRef} + onOpen=${() => openDetail(s.title, repEntry, s)} /> + + `; })} +
+ ${detail && html` + <${VideoDetailModal} title=${detail.title} meta=${detailMeta} + repEntry=${detail.repEntry} show=${detail.show} + transportRef=${transportRef} gekRef=${gekRef} + onClose=${() => setDetail(null)} + onPlay=${(entry) => { setDetail(null); onPreview(entry); }} /> + `} + `; +} + +// ── Mode B: flat, thumbnail-only, no TMDB ──────────────────────────────────── + +// Within a season group, every episode's own display_title is usually +// just the show name again (guessit rarely finds a per-episode subtitle +// for this kind of release) — repeating "OVNI" twelve times in a row said +// nothing an episode number wouldn't say better. Shown only when this row +// is actually inside a season group (`seasonContext` set); a real, +// distinct per-episode title (a show that *does* carry one) still wins +// over the generic "Episode N" label. +function FlatMovieRow({ entry, transportRef, gekRef, onPreview, seasonContext }) { + const isEpisode = seasonContext && entry.season != null && entry.episode != null; + const hasOwnTitle = entry.display_title && entry.display_title !== seasonContext; + const label = isEpisode + ? (hasOwnTitle ? `${t('video.episode_n', { n: entry.episode })} · ${entry.display_title}` + : t('video.episode_n', { n: entry.episode })) + : (entry.display_title || entry.name); + + return html` +
onPreview(entry)}> + <${LazyTile} cls="video-flat-thumb-slot"> + <${MediaThumb} thumbHash=${entry.thumb_hash} alt=${entry.display_title || entry.name} + cls="video-flat-thumb" transportRef=${transportRef} gekRef=${gekRef} /> + +
+
${label}
+
+ ${formatDuration(entry.duration)} ${formatResolution(entry.width, entry.height)} + ${' · '}${formatSize(entry.size)} +
+
+
+ `; +} + +function FlatShowFolder({ show, transportRef, gekRef, onPreview }) { + const [open, setOpen] = useState(false); + return html` +
+
setOpen((v) => !v)}> +
<${Icon} name="folder" />
+
+
${show.title}
+
${t('video.n_episodes', { n: show.episodes.length })}
+
+ <${Icon} name="chevron" cls=${open ? 'video-flat-chevron open' : 'video-flat-chevron'} /> +
+ ${open && show.seasons.map((s) => html` +
+
+ ${s.season === 0 ? t('video.specials') : t('video.season_n', { n: s.season })} +
+ ${s.episodes.map((ep) => html` + <${FlatMovieRow} key=${ep.id} entry=${ep} seasonContext=${show.title} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} /> + `)} +
+ `)} +
+ `; +} + +function FlatList({ movies, shows, transportRef, gekRef, onPreview }) { + const items = [ + ...movies.map((e) => ({ key: e.display_title || e.name, kind: 'movie', entry: e })), + ...shows.map((s) => ({ key: s.title, kind: 'show', show: s })), + ].sort((a, b) => a.key.localeCompare(b.key)); + + return html` +
+ ${items.map((it) => it.kind === 'movie' + ? html`<${FlatMovieRow} key=${it.entry.id} entry=${it.entry} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />` + : html`<${FlatShowFolder} key=${it.show.title} show=${it.show} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`)} +
+ `; +} + +// ── shell ──────────────────────────────────────────────────────────────────── + +function VideoApp({ + groupId, transportRef, gekRef, status, entries, onPreview, videoRoot, tmdbConfig, +}) { + const [mode, setMode] = useState(loadViewMode); + const [filter, setFilter] = useState(''); + const tmdbEnabled = tmdbConfig ? tmdbConfig.enabled : true; + + useEffect(() => { setMode(loadViewMode()); }, [groupId]); + useEffect(() => { setFilter(''); }, [groupId]); + + const setModeAndSave = (m) => { setMode(m); saveViewMode(m); }; + + const { movies, shows } = useMemo( + () => groupVideoEntries(entries, videoRoot), [entries, videoRoot]); + + const needle = filter.trim().toLowerCase(); + const filteredMovies = useMemo(() => (!needle ? movies : movies.filter( + (e) => (e.display_title || e.name).toLowerCase().includes(needle))), [movies, needle]); + const filteredShows = useMemo(() => (!needle ? shows : shows.filter( + (s) => s.title.toLowerCase().includes(needle))), [shows, needle]); + + return html` + ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html` +

${' '}${t('status.connecting_short')}

+ `} + ${status === 'offline' && html` +

${t('group.offline_title')} ${t('group.offline_hint')}

+ `} + ${status === 'connected' && !videoRoot && html` +

${t('video.no_root_configured')}

+ `} + ${status === 'connected' && videoRoot && html` +
+ + + +
+ ${filteredMovies.length === 0 && filteredShows.length === 0 && html` +

${needle ? t('group.empty_filter') : t('video.empty')}

+ `} + ${mode === 'poster' + ? html`<${PosterGrid} movies=${filteredMovies} shows=${filteredShows} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} + tmdbEnabled=${tmdbEnabled} />` + : html`<${FlatList} movies=${filteredMovies} shows=${filteredShows} + transportRef=${transportRef} gekRef=${gekRef} onPreview=${onPreview} />`} + `} + `; +} + +export { VideoApp }; -- cgit v1.2.3