diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
13 files changed, 132 insertions, 21 deletions
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 d3bf11a..c4ee388 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -185,6 +185,8 @@ export default { 'video.search_apply_hint_movie': 'Gilt nur für diese Datei.', 'video.source_file': 'Datei: {name}', 'video.no_match': 'Keine sichere TMDB-Übereinstimmung — Dateiname wird angezeigt.', + 'video.versions': '{n} Versionen', + 'video.rematch_one': 'Neu zuordnen', // Musik 'music.mode_grid': 'Alben', 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 5c60f9e..16ae3ab 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -183,6 +183,8 @@ export default { 'video.search_apply_hint_movie': 'Applies to this file only.', 'video.source_file': 'File: {name}', 'video.no_match': 'No confident TMDB match — showing the filename.', + 'video.versions': '{n} versions', + 'video.rematch_one': 'Re-match', // Music 'music.mode_grid': 'Albums', 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 1e3aba2..88b222d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -183,6 +183,8 @@ export default { 'video.search_apply_hint_movie': 'Se aplica solo a este archivo.', 'video.source_file': 'Archivo: {name}', 'video.no_match': 'Sin coincidencia fiable en TMDB — se muestra el nombre del archivo.', + 'video.versions': '{n} versiones', + 'video.rematch_one': 'Volver a asociar', // Música 'music.mode_grid': 'Álbumes', 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 c07b57c..b08cf28 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -184,6 +184,8 @@ export default { 'video.search_apply_hint_movie': 'Ne s’applique qu’à ce fichier.', 'video.source_file': 'Fichier : {name}', 'video.no_match': 'Aucune correspondance TMDB fiable — nom de fichier affiché.', + 'video.versions': '{n} versions', + 'video.rematch_one': 'Relancer la recherche', // Musique 'music.mode_grid': 'Albums', 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 4b68951..f1e8467 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -184,6 +184,8 @@ export default { 'video.search_apply_hint_movie': 'Si applica solo a questo file.', 'video.source_file': 'File: {name}', 'video.no_match': 'Nessuna corrispondenza TMDB affidabile — mostrato il nome del file.', + 'video.versions': '{n} versioni', + 'video.rematch_one': 'Riassocia', // Musica 'music.mode_grid': 'Album', 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 471933d..95cc040 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -181,6 +181,8 @@ export default { 'video.search_apply_hint_movie': 'このファイルにのみ適用されます。', 'video.source_file': 'ファイル: {name}', 'video.no_match': '確実なTMDB一致なし — ファイル名を表示しています。', + 'video.versions': '{n} 個のバージョン', + 'video.rematch_one': '再マッチ', // 音楽 'music.mode_grid': 'アルバム', 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 a24cb42..076aa15 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -185,6 +185,8 @@ export default { 'video.search_apply_hint_movie': 'Alleen van toepassing op dit bestand.', 'video.source_file': 'Bestand: {name}', 'video.no_match': 'Geen betrouwbare TMDB-match — bestandsnaam wordt getoond.', + 'video.versions': '{n} versies', + 'video.rematch_one': 'Opnieuw koppelen', // Muziek 'music.mode_grid': 'Albums', 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 58dc7b5..00ef59e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -192,6 +192,8 @@ export default { 'video.search_apply_hint_movie': 'Dotyczy tylko tego pliku.', 'video.source_file': 'Plik: {name}', 'video.no_match': 'Brak pewnego dopasowania TMDB — pokazano nazwę pliku.', + 'video.versions': 'Wersje: {n}', + 'video.rematch_one': 'Dopasuj ponownie', // Muzyka 'music.mode_grid': 'Albumy', 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 00d9051..7d9173e 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 @@ -185,6 +185,8 @@ export default { 'video.search_apply_hint_movie': 'Aplica-se somente a este arquivo.', 'video.source_file': 'Arquivo: {name}', 'video.no_match': 'Sem correspondência confiável no TMDB — exibindo o nome do arquivo.', + 'video.versions': '{n} versões', + 'video.rematch_one': 'Combinar de novo', // Música 'music.mode_grid': 'Álbuns', 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 e5590d8..74d729e 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 @@ -178,6 +178,8 @@ export default { 'video.search_apply_hint_movie': '仅适用于此文件。', 'video.source_file': '文件:{name}', 'video.no_match': '没有可靠的 TMDB 匹配 — 显示文件名。', + 'video.versions': '{n} 个版本', + 'video.rematch_one': '重新匹配', // 音乐 'music.mode_grid': '专辑', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index a7e8e3d..8e714f3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2929,6 +2929,8 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } margin-bottom: 4px; } .video-card-unmatched { outline: 1px dashed var(--warn, #d98324); outline-offset: -1px; } +.video-version-list { margin-top: 12px; display: flex; flex-direction: column; gap: 4px; } +.video-admin-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; } .video-card-flag { display: inline-block; margin-left: 4px; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index d6a28d9..5a6e36b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -73,7 +73,7 @@ function _aborted() { // list is what lets a response two steps later be tied back to the right // one. const ADMIN_OP_TYPES = new Set([ - 'tmdb_override', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', + 'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root', 'photo_roots', 'musicbrainz_enabled', 'file_delete', 'dir_delete', 'member_upload', 'apps_enabled', 'set_scan_settings', 'member_revoke', @@ -916,6 +916,22 @@ class MeshBayTransport { } /** + * Drop one file's cached TMDB match so it re-resolves with the node's + * current matcher (§10.1/V13) — the one-click alternative to the full + * search-and-pick flow. Signed for the same reason as overrideTmdbMatch. + */ + async rematchTmdbMatch(fileId, signFn) { + const msg = await this._sendAndWait({ + type: 'tmdb_rematch', v: '0.7', file_id: fileId, + }); + if (msg.type === 'error') throw new Error(msg.detail); + if (msg.type === 'admin_challenge') { + return this._authorizeAdminOp(msg, 'tmdb_rematch', `file_id=${fileId}`, signFn); + } + return msg; + } + + /** * Set/clear a custom TMDB API token, and/or set the language TMDB is * queried in (e.g. "fr-FR") — one for the whole node, since both are one * operator's shared credential/cache, not a per-group concern (see @@ -2032,6 +2048,11 @@ class MeshBayTransport { fileId: msg.file_id || '', tmdbId: msg.tmdb_id || '', mediaType: msg.media_type || '', }); } + // Same shape: the operator dropped one file's match to have it + // re-resolved (§10.1/V13). No tmdbId — the node re-derives it. + if (msg.type === 'tmdb_rematch_ack' && this._onTmdbOverride) { + this._onTmdbOverride({ fileId: msg.file_id || '', tmdbId: '', mediaType: '' }); + } // Same shape: the operator changed which folder is the Videos app's // entry point for this group. 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..6d13e27 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -478,12 +478,26 @@ 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); + const [rematching, setRematching] = useState(false); const mediaType = show ? 'tv' : 'movie'; + // §10.1/V13: drop this file's cached match on the node and let it + // re-resolve with the current matcher — the one-click alternative to the + // full search-and-pick flow above. + const doRematch = useCallback(async () => { + if (rematching) return; + setRematching(true); + try { + await transportRef.current.rematchTmdbMatch(repEntry.id, buildSignFn(transportRef)); + bumpMediaMetaGeneration(); + } catch { /* leave the current match in place */ } + setRematching(false); + }, [rematching, repEntry, transportRef]); + // 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. @@ -535,20 +549,40 @@ function VideoDetailModal({ `} `} ${isNodeAdmin && html` - <button class="admin-btn video-fix-match" onClick=${() => setSearching(true)}> - ${t('video.fix_match')} - </button> + <div class="video-admin-actions"> + <button class="admin-btn video-fix-match" onClick=${() => setSearching(true)}> + ${t('video.fix_match')} + </button> + <button class="admin-btn" onClick=${doRematch} disabled=${rematching}> + ${rematching ? html`<span class="spinner"></span>` : t('video.rematch_one')} + </button> + </div> `} ${showMultiSeason && html` <${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 +677,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 +764,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); }} /> |