diff --git a/desktop/sidecar/library.js b/desktop/sidecar/library.js index a7cc4d8..324acf3 100644 --- a/desktop/sidecar/library.js +++ b/desktop/sidecar/library.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { guessit } from 'guessit-js'; +import { groupCatalog } from '../../library.js'; const video = /\.(mkv|mp4|webm|m4v)$/i; const text = (source, tag) => source.match(new RegExp(`<${tag}[^>]*>([^<]+)`, 'i'))?.[1]?.trim(); @@ -54,26 +55,30 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) { const type = item.type === 'episode' ? 'tv' : 'movie'; const query = new URLSearchParams({ api_key: tmdbApiKey, query: item.title }); if (item.year) query.set(type === 'movie' ? 'year' : 'first_air_date_year', item.year); - const results = await fetch(`https://api.themoviedb.org/3/search/${type}?${query}`).then(res => res.ok ? res.json() : { results: [] }); + const results = await fetch(`https://api.themoviedb.org/3/search/${type}?${query}`).then(res => res.ok ? res.json() : { results: [] }).catch(() => ({ results: [] })); const candidate = results.results?.[0]; if (!candidate) return item; const candidateTitle = candidate.title || candidate.name || ''; const candidateYear = (candidate.release_date || candidate.first_air_date || '').slice(0, 4); const normalize = value => value.toLowerCase().replace(/[^a-z0-9]/g, ''); - const confident = Boolean(item.year) && normalize(candidateTitle) === normalize(item.title) && candidateYear === String(item.year); + const titleMatch = normalize(candidateTitle) === normalize(item.title); + const yearMatch = !item.year || candidateYear === String(item.year); + const confident = titleMatch && yearMatch; if (!confident) return { ...item, candidate: { title: candidateTitle, year: candidateYear, tmdbId: candidate.id } }; - const external = await fetch(`https://api.themoviedb.org/3/${type}/${candidate.id}/external_ids?api_key=${tmdbApiKey}`).then(res => res.ok ? res.json() : {}); - return { ...item, suggestedImdbId: external.imdb_id || null, autoMapped: Boolean(external.imdb_id), candidate: { title: candidateTitle, year: candidateYear, tmdbId: candidate.id } }; + const external = await fetch(`https://api.themoviedb.org/3/${type}/${candidate.id}/external_ids?api_key=${tmdbApiKey}`).then(res => res.ok ? res.json() : {}).catch(() => ({})); + return { ...item, year: item.year || Number(candidateYear) || null, suggestedImdbId: external.imdb_id || null, autoMapped: Boolean(external.imdb_id), candidate: { title: candidateTitle, year: candidateYear, tmdbId: candidate.id } }; }; const tmdb = async (endpoint, query = {}) => { if (!tmdbApiKey) return null; const params = new URLSearchParams({ api_key: tmdbApiKey, ...query }); - const response = await fetch(`https://api.themoviedb.org/3${endpoint}?${params}`); - return response.ok ? response.json() : null; + try { + const response = await fetch(`https://api.themoviedb.org/3${endpoint}?${params}`); + return response.ok ? response.json() : null; + } catch { return null; } }; const imageUrl = (image, size) => image ? `https://image.tmdb.org/t/p/${size}${image}` : null; const metadataFor = async item => { - const key = `${item.imdbId || ''}:${item.title}:${item.year || ''}:${item.type}`; + const key = `${item.imdbId || ''}:${item.title}:${item.year || ''}:${item.type}:${item.file || ''}:${item.season || ''}:${item.episode || ''}`; if (metadataCache.has(key)) return metadataCache.get(key); const pending = (async () => { if (!tmdbApiKey) return item; @@ -132,6 +137,7 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) { return { file, id: Buffer.from(file).toString('base64url'), + size: stat.size, title: firstText(mapping.title) || firstText(nfo.title) || firstText(parsed.title) || path.basename(file, path.extname(file)), year: year(mapping.year) || year(nfo.year) || year(parsed.year), imdbId: mapping.imdbId || nfo.imdbId || parsed.imdb_id || file.match(/\btt\d{7,10}\b/i)?.[0]?.toLowerCase() || null, @@ -164,7 +170,7 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) { catalogRefresh = (async () => { const items = await Promise.all((await scan()).map(metadataFor)); catalogCache = items; - return items; + return groupCatalog(items); })().finally(() => { catalogRefresh = null; }); return catalogRefresh; }; @@ -188,10 +194,10 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) { save(index); }, async catalog() { - return catalogCache || refreshCatalog(); + return catalogCache ? groupCatalog(catalogCache) : refreshCatalog(); }, async metadata(id) { - return (await this.catalog()).find(item => item.id === id) || null; + return (await this.catalog()).find(item => item.id === id || item.imdbId === id || item.episodes?.some(episode => episode.id === id)) || null; }, }; } diff --git a/library.js b/library.js index ecb1b8f..cd0a466 100644 --- a/library.js +++ b/library.js @@ -26,7 +26,9 @@ export function catalogGroupKey(item) { const imdb = item.imdbId || item.suggestedImdbId || null; const episode = item.type === 'episode' || (item.season != null && item.episode != null); if (episode) { - return imdb && item.season != null && item.episode != null ? `${imdb}:${item.season}:${item.episode}` : null; + if (imdb) return imdb; + const title = slug(item.title); + return title || null; } if (imdb) return imdb; const title = slug(item.title); @@ -65,11 +67,49 @@ export function groupCatalog(items) { .sort((a, b) => b.size - a.size || a.file.localeCompare(b.file))); if (!files.length) return []; const representative = members.find(member => member.id === files[0].id) || members[0]; + const episodeMembers = members + .filter(member => (member.size || 0) > 0 && member.season != null && member.episode != null) + .sort((a, b) => (a.season - b.season) || (a.episode - b.episode) || String(a.file).localeCompare(String(b.file))); + const seenEp = new Set(); + const episodes = []; + for (const member of episodeMembers) { + const ek = `${member.season}:${member.episode}`; + if (seenEp.has(ek)) continue; + seenEp.add(ek); + episodes.push({ + id: member.id, + season: member.season, + episode: member.episode, + title: member.title, + file: member.file, + size: member.size, + imdbId: member.imdbId || member.suggestedImdbId || null, + }); + } + if (episodes.length) { + return [{ + ...representative, + id, + type: 'series', + season: null, + episode: null, + file: files[0].file, + files, + size: files[0].size, + episodes, + }]; + } return [{ ...representative, id, file: files[0].file, files, size: files[0].size }]; }); } export function catalogMembers(item, requestedId) { + if (requestedId && item?.episodes?.length) { + const episode = item.episodes.find(entry => entry.id === requestedId); + if (episode) { + return [{ id: episode.id, file: episode.file, size: episode.size || 0 }].filter(file => (file.size || 0) > 0); + } + } const files = (item?.files || (item ? [{ id: item.id, file: item.file, size: item.size || 0 }] : [])) .filter(file => (file.size || 0) > 0) .slice() @@ -95,26 +135,30 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) { const type = item.type === 'episode' ? 'tv' : 'movie'; const query = new URLSearchParams({ api_key: tmdbApiKey, query: item.title }); if (item.year) query.set(type === 'movie' ? 'year' : 'first_air_date_year', item.year); - const results = await fetch(`https://api.themoviedb.org/3/search/${type}?${query}`).then(res => res.ok ? res.json() : { results: [] }); + const results = await fetch(`https://api.themoviedb.org/3/search/${type}?${query}`).then(res => res.ok ? res.json() : { results: [] }).catch(() => ({ results: [] })); const candidate = results.results?.[0]; if (!candidate) return item; const candidateTitle = candidate.title || candidate.name || ''; const candidateYear = (candidate.release_date || candidate.first_air_date || '').slice(0, 4); const normalize = value => value.toLowerCase().replace(/[^a-z0-9]/g, ''); - const confident = Boolean(item.year) && normalize(candidateTitle) === normalize(item.title) && candidateYear === String(item.year); + const titleMatch = normalize(candidateTitle) === normalize(item.title); + const yearMatch = !item.year || candidateYear === String(item.year); + const confident = titleMatch && yearMatch; if (!confident) return { ...item, candidate: { title: candidateTitle, year: candidateYear, tmdbId: candidate.id } }; - const external = await fetch(`https://api.themoviedb.org/3/${type}/${candidate.id}/external_ids?api_key=${tmdbApiKey}`).then(res => res.ok ? res.json() : {}); - return { ...item, suggestedImdbId: external.imdb_id || null, autoMapped: Boolean(external.imdb_id), candidate: { title: candidateTitle, year: candidateYear, tmdbId: candidate.id } }; + const external = await fetch(`https://api.themoviedb.org/3/${type}/${candidate.id}/external_ids?api_key=${tmdbApiKey}`).then(res => res.ok ? res.json() : {}).catch(() => ({})); + return { ...item, year: item.year || Number(candidateYear) || null, suggestedImdbId: external.imdb_id || null, autoMapped: Boolean(external.imdb_id), candidate: { title: candidateTitle, year: candidateYear, tmdbId: candidate.id } }; }; const tmdb = async (endpoint, query = {}) => { if (!tmdbApiKey) return null; const params = new URLSearchParams({ api_key: tmdbApiKey, ...query }); - const response = await fetch(`https://api.themoviedb.org/3${endpoint}?${params}`); - return response.ok ? response.json() : null; + try { + const response = await fetch(`https://api.themoviedb.org/3${endpoint}?${params}`); + return response.ok ? response.json() : null; + } catch { return null; } }; const imageUrl = (image, size) => image ? `https://image.tmdb.org/t/p/${size}${image}` : null; const metadataFor = async item => { - const key = `${item.imdbId || ''}:${item.title}:${item.year || ''}:${item.type}`; + const key = `${item.imdbId || ''}:${item.title}:${item.year || ''}:${item.type}:${item.file || ''}:${item.season || ''}:${item.episode || ''}`; if (metadataCache.has(key)) return metadataCache.get(key); const pending = (async () => { if (!tmdbApiKey) return item; diff --git a/library.test.js b/library.test.js index abf2506..eec7e73 100644 --- a/library.test.js +++ b/library.test.js @@ -34,16 +34,42 @@ test('metadata keeps path-id as an alias of the group', () => { assert.equal(catalogMembers(item, path)[0].file, 'copy-a.mp4'); }); -test('episodes group by imdbId:season:episode and never collapse a series', () => { +test('episodes become one series with episodes[] and keep duplicate files on the same S/E', () => { const items = [ { id: 'e1', file: 'Show.S01E01.mkv', title: 'Show', year: 2020, imdbId: 'tt1111111', type: 'episode', season: 1, episode: 1, size: 1 }, { id: 'e2', file: 'Show.S01E02.mkv', title: 'Show', year: 2020, imdbId: 'tt1111111', type: 'episode', season: 1, episode: 2, size: 1 }, { id: 'e1b', file: 'Show.S01E01.alt.mkv', title: 'Show', year: 2020, imdbId: 'tt1111111', type: 'episode', season: 1, episode: 1, size: 2 }, ]; const catalog = groupCatalog(items); - assert.equal(catalog.length, 2); - assert.deepEqual(new Set(catalog.map(item => item.id)), new Set(['tt1111111:1:1', 'tt1111111:1:2'])); - assert.equal(catalog.find(item => item.id === 'tt1111111:1:1').files.length, 2); + assert.equal(catalog.length, 1); + assert.equal(catalog[0].id, 'tt1111111'); + assert.equal(catalog[0].type, 'series'); + assert.equal(catalog[0].imdbId, 'tt1111111'); + assert.deepEqual(catalog[0].episodes.map(episode => `${episode.season}:${episode.episode}`), ['1:1', '1:2']); + assert.equal(catalog[0].files.length, 3); + assert.equal(catalogMembers(catalog[0], 'e1b')[0].file, 'Show.S01E01.alt.mkv'); +}); + +test('a season of 52 episodes is one catalog row with episodes[] E01-E52', () => { + const items = Array.from({ length: 52 }, (_, index) => ({ + id: `e${index + 1}`, + file: `Bluey.S01E${String(index + 1).padStart(2, '0')}.mkv`, + title: 'Bluey', + imdbId: 'tt7678620', + type: 'episode', + season: 1, + episode: index + 1, + size: 10, + })); + const catalog = groupCatalog(items); + assert.equal(catalog.length, 1); + assert.notEqual(catalog.length, 52); + assert.equal(catalog[0].type, 'series'); + assert.equal(catalog[0].id, 'tt7678620'); + assert.equal(catalog[0].episodes.length, 52); + assert.notEqual(catalog[0].episodes.length, 1); + assert.equal(catalog[0].episodes[0].episode, 1); + assert.equal(catalog[0].episodes[51].episode, 52); }); test('unmapped titles group by lowercase title and year', () => {