From 36fc2a77d9913200b4d605cffd49b5f7ceb57345 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 24 May 2026 02:37:09 +0000 Subject: [PATCH] feat: merge duplicate items across roots with combined releases - Add root_id to Torrent type for per-torrent root tracking - Merge movies: combine all torrents, keep best metadata, sort by quality - Merge series: combine seasons/episodes/torrents across roots - Torrents are quality-sorted (resolution, HDR, DV, Atmos) across all roots - App.vue play/openFolder use torrent-specific root_id when available --- frontend/src/App.vue | 8 +- frontend/src/composables/useMediaWebSocket.ts | 170 +++++++++++++----- frontend/src/types.ts | 1 + 3 files changed, 135 insertions(+), 44 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d93c64d..6860c88 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1478,14 +1478,18 @@ function findRootIdForPath(filePath: string): string | null { if (!mediaIndex.value) return null; for (const movie of mediaIndex.value.movies) { for (const torrent of Object.values(movie.torrents || {})) { - if (torrent.playable_file === filePath) return movie.root_id; + if (torrent.playable_file === filePath) { + return torrent.root_id || movie.root_id; + } } } for (const series of mediaIndex.value.series) { for (const season of series.seasons || []) { for (const episode of season.episodes || []) { for (const torrent of Object.values(episode.torrents || {})) { - if (torrent.playable_file === filePath) return series.root_id; + if (torrent.playable_file === filePath) { + return torrent.root_id || series.root_id; + } } } } diff --git a/frontend/src/composables/useMediaWebSocket.ts b/frontend/src/composables/useMediaWebSocket.ts index 4224f63..551fe65 100644 --- a/frontend/src/composables/useMediaWebSocket.ts +++ b/frontend/src/composables/useMediaWebSocket.ts @@ -1,5 +1,5 @@ import { ref, readonly, onUnmounted } from 'vue'; -import type { Movie, Series, MediaIndex, TaskInfo, WsMessage } from '../types'; +import type { Movie, Series, Episode, Season, Torrent, MediaIndex, TaskInfo, WsMessage } from '../types'; interface RootState { rootId: string; @@ -34,52 +34,138 @@ export function useMediaWebSocket() { return itemId.split(':').pop() || itemId; } - function movieQualityScore(m: Movie): number { + function torrentQualityScore(t: Torrent): number { let score = 0; - if (m.info) score += 10; - if (m.cover_path) score += 5; - if (m.backdrop_path) score += 3; - if (m.showreel_images?.length) score += 3; - score += Object.keys(m.torrents || {}).length; + const res = (t.resolution || '').toLowerCase(); + if (res.includes('2160') || res.includes('4k') || res.includes('uhd')) score += 100; + else if (res.includes('1080') || res.includes('fhd')) score += 80; + else if (res.includes('720') || res === 'hd') score += 60; + else if (res.includes('480') || res === 'sd') score += 40; + else if (res.includes('360')) score += 20; + if (t.has_dolby_vision) score += 15; + if (t.is_hdr) score += 10; + if (t.has_dolby_atmos) score += 5; return score; } - function seriesQualityScore(s: Series): number { - let score = 0; - if (s.info) score += 10; - if (s.cover_path) score += 5; - if (s.backdrop_path) score += 3; - const seasons = s.seasons || []; - for (const season of seasons) { - for (const ep of season.episodes || []) { - score += Object.keys(ep.torrents || {}).length; - } - } - return score; + function annotateTorrents(torrents: { [key: string]: Torrent }, rootId: string | null): { [key: string]: Torrent } { + return Object.fromEntries( + Object.entries(torrents || {}).map(([k, t]) => [k, { ...t, root_id: t.root_id || rootId }]) + ); } - function deduplicateMovies(items: Movie[]): Movie[] { - const map = new Map(); - for (const m of items) { - const hash = getContentHash(m.id); - const existing = map.get(hash); - if (!existing || movieQualityScore(m) > movieQualityScore(existing)) { - map.set(hash, m); - } + function mergeTorrentDicts(a: { [key: string]: Torrent }, b: { [key: string]: Torrent }): { [key: string]: Torrent } { + const merged: { [key: string]: Torrent } = { ...a }; + for (const [k, t] of Object.entries(b)) { + const uniqueKey = merged[k] ? `${t.root_id || 'unknown'}:${k}` : k; + merged[uniqueKey] = t; } - return Array.from(map.values()); + const sorted = Object.entries(merged).sort(([, t1], [, t2]) => { + const s1 = torrentQualityScore(t1); + const s2 = torrentQualityScore(t2); + if (s2 !== s1) return s2 - s1; + return (t2.size || 0) - (t1.size || 0); + }); + return Object.fromEntries(sorted); } - function deduplicateSeries(items: Series[]): Series[] { - const map = new Map(); - for (const s of items) { - const hash = getContentHash(s.id); - const existing = map.get(hash); - if (!existing || seriesQualityScore(s) > seriesQualityScore(existing)) { - map.set(hash, s); + function mergeMovies(a: Movie, b: Movie): Movie { + const torrentsA = annotateTorrents(a.torrents, a.root_id); + const torrentsB = annotateTorrents(b.torrents, b.root_id); + return { + ...a, + torrents: mergeTorrentDicts(torrentsA, torrentsB), + info: a.info || b.info, + cover_path: a.cover_path || b.cover_path, + backdrop_path: a.backdrop_path || b.backdrop_path, + showreel_images: a.showreel_images?.length ? a.showreel_images : b.showreel_images, + showreel_source_sets: a.showreel_source_sets?.length ? a.showreel_source_sets : b.showreel_source_sets, + }; + } + + function mergeEpisodes(a: Episode, b: Episode, rootIdA: string | null, rootIdB: string | null): Episode { + const torrentsA = annotateTorrents(a.torrents, rootIdA); + const torrentsB = annotateTorrents(b.torrents, rootIdB); + return { + ...a, + torrents: mergeTorrentDicts(torrentsA, torrentsB), + reel_image: a.reel_image || b.reel_image, + reel_sources: a.reel_sources?.length ? a.reel_sources : b.reel_sources, + }; + } + + function mergeSeasons(a: Season, b: Season, rootIdA: string | null, rootIdB: string | null): Season { + const episodeMap = new Map(); + for (const ep of a.episodes) { + episodeMap.set(ep.episode_number, ep); + } + for (const ep of b.episodes) { + const existing = episodeMap.get(ep.episode_number); + if (existing) { + episodeMap.set(ep.episode_number, mergeEpisodes(existing, ep, rootIdA, rootIdB)); + } else { + episodeMap.set(ep.episode_number, { + ...ep, + torrents: annotateTorrents(ep.torrents, rootIdB), + }); } } - return Array.from(map.values()); + return { + ...a, + episodes: Array.from(episodeMap.values()).sort((a, b) => a.episode_number - b.episode_number), + poster_path: a.poster_path || b.poster_path, + }; + } + + function mergeSeries(a: Series, b: Series): Series { + const seasonMap = new Map(); + for (const season of a.seasons || []) { + seasonMap.set(season.season_number, { ...season, episodes: season.episodes.map(ep => ({ ...ep, torrents: annotateTorrents(ep.torrents, a.root_id) })) }); + } + for (const season of b.seasons || []) { + const existing = seasonMap.get(season.season_number); + if (existing) { + seasonMap.set(season.season_number, mergeSeasons(existing, season, a.root_id, b.root_id)); + } else { + seasonMap.set(season.season_number, { + ...season, + episodes: season.episodes.map(ep => ({ ...ep, torrents: annotateTorrents(ep.torrents, b.root_id) })), + }); + } + } + return { + ...a, + seasons: Array.from(seasonMap.values()).sort((a, b) => a.season_number - b.season_number), + info: a.info || b.info, + cover_path: a.cover_path || b.cover_path, + backdrop_path: a.backdrop_path || b.backdrop_path, + }; + } + + function mergeItemsByHash( + items: T[], + mergeFn: (a: T, b: T) => T + ): T[] { + const map = new Map(); + for (const item of items) { + const hash = getContentHash(item.id); + const arr = map.get(hash) || []; + arr.push(item); + map.set(hash, arr); + } + const merged: T[] = []; + for (const [, group] of map) { + if (group.length === 1) { + merged.push(group[0]); + } else { + let result = group[0]; + for (let i = 1; i < group.length; i++) { + result = mergeFn(result, group[i]); + } + merged.push(result); + } + } + return merged; } function buildIndex(): MediaIndex { @@ -89,17 +175,17 @@ export function useMediaWebSocket() { movies.push(...state.movieMap.values()); series.push(...state.seriesMap.values()); } - const dedupedMovies = deduplicateMovies(movies); - const dedupedSeries = deduplicateSeries(series); + const mergedMovies = mergeItemsByHash(movies, mergeMovies); + const mergedSeries = mergeItemsByHash(series, mergeSeries); return { version: 0, generated_at: new Date().toISOString(), stats: { - total_movies: dedupedMovies.length, - total_series: dedupedSeries.length, + total_movies: mergedMovies.length, + total_series: mergedSeries.length, }, - movies: dedupedMovies, - series: dedupedSeries, + movies: mergedMovies, + series: mergedSeries, }; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ed41a29..ba02f72 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -56,6 +56,7 @@ export interface Torrent { encoder: string | null; size: number | null; added_at: number | null; + root_id?: string | null; } export interface Movie {