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-29 15:46:42 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-29 15:46:42 +0200
commitbc40ab2c4486cca4ae63e33976151b5d7f856a84 (patch)
treed824ad20b1c17c1879ec715df5d8c3fb01605dff /packages/meshbay-hub/src/meshbay_hub/static/video-app.js
parent5d28d0c96cc8489645178b483835489779cb3887 (diff)
downloadmeshbay-bc40ab2c4486cca4ae63e33976151b5d7f856a84.tar.gz
feat(hub): V12 — merge movies by TMDB id in the poster grid
Two movie files that TMDB resolves to the same id (the same film at two resolutions, or the same rip in two folders) now collapse to one poster card, mirroring the existing show merge — same keying discipline so an unmerged movie keeps its card and a merge updates props rather than remounting. The detail modal lists the versions (resolution · duration · size), each a Play button, when there is more than one; a single-file movie is unchanged. New `video.versions` key in all ten locales. Known edge, noted in §10.1: "Fix match" on a merged movie corrects only the representative file; the other version un-merges and can be corrected on its own. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BMLQjqFGCize2KtNBT79v
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.js83
1 files changed, 66 insertions, 17 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 f73def0..48709f9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js
@@ -478,7 +478,7 @@ function TmdbSearchOverlay({
}
function VideoDetailModal({
- title, meta, repEntry, show, transportRef, gekRef, onClose, onPlay, isNodeAdmin,
+ title, meta, repEntry, show, files, transportRef, gekRef, onClose, onPlay, isNodeAdmin,
}) {
const confident = Boolean(meta && meta.confidence && meta.tmdb_id);
const [searching, setSearching] = useState(false);
@@ -543,12 +543,27 @@ function VideoDetailModal({
<${SeasonTabs} seasons=${show.seasons} selected=${selectedSeason}
onSelect=${setSelectedSeason} />
`}
- ${!show && html`
+ ${!show && (!files || files.length <= 1) && html`
<button class="admin-btn" onClick=${() => onPlay(repEntry)}>
<${Icon} name="play" /> ${t('group.play')}
${repEntry.duration ? ` (${formatDuration(repEntry.duration)})` : ''}
</button>
`}
+ ${!show && files && files.length > 1 && html`
+ <div class="video-version-list">
+ ${[...files]
+ .sort((a, b) => (b.height || 0) - (a.height || 0) || (b.size || 0) - (a.size || 0))
+ .map((f) => html`
+ <button class="video-episode-row" key=${f.id} onClick=${() => onPlay(f)}>
+ <${Icon} name="play" />
+ <span class="video-episode-label">
+ ${[formatResolution(f.width, f.height), formatDuration(f.duration),
+ formatSize(f.size)].filter(Boolean).join(' · ')}
+ </span>
+ </button>
+ `)}
+ </div>
+ `}
${show && html`
<div class="video-season-list">
${(showMultiSeason ? show.seasons.filter((s) => s.season === selectedSeason) : show.seasons)
@@ -643,28 +658,62 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
});
}, [shows, metaByGroup]);
- const openDetail = (title, repEntry, show) => setDetail({ title, repEntry, show });
+ // The movie counterpart of mergedShows (§10.1/V12): two files that TMDB
+ // resolves to the same id — the usual case being one film present at two
+ // resolutions, or the same rip in two folders — collapse to one card
+ // whose detail modal lists the versions. Same keying discipline as
+ // mergedShows: the first constituent's id is stable and unique, so an
+ // unmerged movie keeps the exact key its card already had and a merge
+ // updates props instead of remounting (which would throw away an
+ // already-resolved poster).
+ const mergedMovies = useMemo(() => {
+ const byTmdbId = new Map();
+ const standalone = [];
+ for (const e of movies) {
+ const meta = metaByGroup[`movie:${e.id}`];
+ const tmdbId = meta && meta.confidence && meta.tmdb_id;
+ if (tmdbId) {
+ if (!byTmdbId.has(tmdbId)) byTmdbId.set(tmdbId, []);
+ byTmdbId.get(tmdbId).push(e);
+ } else {
+ standalone.push([e]);
+ }
+ }
+ return [...byTmdbId.values(), ...standalone].map((files) => ({
+ key: `movie:${files[0].id}`,
+ title: files[0].display_title || files[0].name,
+ files,
+ }));
+ }, [movies, metaByGroup]);
+
+ const openDetail = (title, repEntry, show, files) => setDetail({ title, repEntry, show, files });
const detailTRef = detail && detail.repEntry._tRef ? detail.repEntry._tRef : transportRef;
const detailGRef = detail && detail.repEntry._gRef ? detail.repEntry._gRef : gekRef;
const detailMeta = useMediaMeta(detailTRef, detail ? detail.repEntry.id : null, !!detail);
return html`
<div class="video-grid">
- ${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}`}
+ ${mergedMovies.map((m) => {
+ const repEntry = m.files.find((e) => e.thumb_hash) || m.files[0];
+ const subtitle = m.files.length > 1
+ ? t('video.versions', { n: m.files.length })
+ : formatDuration(repEntry.duration);
+ // With TMDB off and only one file there is nothing the detail modal
+ // would add for a movie (no overview, no season list) — straight to
+ // the player. More than one file always needs the version picker.
+ const straightToPlayer = !tmdbEnabled && m.files.length === 1;
+ return html`
+ <${LazyTile} key=${m.key}>
+ <${PosterCard} title=${m.title}
+ subtitle=${subtitle} repEntry=${repEntry}
+ groupKey=${`movie:${m.files[0].id}`}
+ onMetaResolved=${handleMetaResolved}
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))} />
+ onOpen=${() => (straightToPlayer
+ ? onPreview(repEntry)
+ : openDetail(m.title, repEntry, null, m.files))} />
</${LazyTile}>
- `)}
+ `; })}
${mergedShows.map((s) => {
// Prefer an episode that actually has a thumbnail over blindly
// episodes[0]: if that specific file's enrichment hasn't produced
@@ -696,7 +745,7 @@ function PosterGrid({ movies, shows, transportRef, gekRef, onPreview, tmdbEnable
</div>
${detail && html`
<${VideoDetailModal} title=${detail.title} meta=${detailMeta}
- repEntry=${detail.repEntry} show=${detail.show}
+ repEntry=${detail.repEntry} show=${detail.show} files=${detail.files}
transportRef=${detailTRef} gekRef=${detailGRef} isNodeAdmin=${isNodeAdmin}
onClose=${() => setDetail(null)}
onPlay=${(entry) => { setDetail(null); onPreview(entry); }} />