Author SHA1 Message Date
tony b959f32e76 refactor: sidecar uses root groupCatalog 2026-08-16 13:25:18 +00:00
tony 9d67f79c2f test: lock one series with episodes[] E01-E52 2026-08-16 13:25:16 +00:00
tony 77343e8cfb feat: same series emit and cache key in desktop sidecar 2026-08-16 13:18:40 +00:00
tony fa75c37306 feat: emit one series with episodes[] and per-file metadata cache 2026-08-16 13:18:39 +00:00
tony 7af20febf2 Merge pull request 'feat: group catalog duplicates' (#1) from feat/catalog-group-duplicates into main
Grouping + skip torrent folders named like videos + index-backed streams.
2026-08-14 16:37:51 +00:00
3 changed files with 98 additions and 22 deletions
+16 -10
View File
@@ -1,6 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { guessit } from 'guessit-js'; import { guessit } from 'guessit-js';
import { groupCatalog } from '../../library.js';
const video = /\.(mkv|mp4|webm|m4v)$/i; const video = /\.(mkv|mp4|webm|m4v)$/i;
const text = (source, tag) => source.match(new RegExp(`<${tag}[^>]*>([^<]+)</${tag}>`, 'i'))?.[1]?.trim(); const text = (source, tag) => source.match(new RegExp(`<${tag}[^>]*>([^<]+)</${tag}>`, 'i'))?.[1]?.trim();
@@ -54,26 +55,30 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
const type = item.type === 'episode' ? 'tv' : 'movie'; const type = item.type === 'episode' ? 'tv' : 'movie';
const query = new URLSearchParams({ api_key: tmdbApiKey, query: item.title }); const query = new URLSearchParams({ api_key: tmdbApiKey, query: item.title });
if (item.year) query.set(type === 'movie' ? 'year' : 'first_air_date_year', item.year); 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]; const candidate = results.results?.[0];
if (!candidate) return item; if (!candidate) return item;
const candidateTitle = candidate.title || candidate.name || ''; const candidateTitle = candidate.title || candidate.name || '';
const candidateYear = (candidate.release_date || candidate.first_air_date || '').slice(0, 4); const candidateYear = (candidate.release_date || candidate.first_air_date || '').slice(0, 4);
const normalize = value => value.toLowerCase().replace(/[^a-z0-9]/g, ''); 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 } }; 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() : {}); 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, suggestedImdbId: external.imdb_id || null, autoMapped: Boolean(external.imdb_id), candidate: { title: candidateTitle, year: candidateYear, tmdbId: candidate.id } }; 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 = {}) => { const tmdb = async (endpoint, query = {}) => {
if (!tmdbApiKey) return null; if (!tmdbApiKey) return null;
const params = new URLSearchParams({ api_key: tmdbApiKey, ...query }); const params = new URLSearchParams({ api_key: tmdbApiKey, ...query });
const response = await fetch(`https://api.themoviedb.org/3${endpoint}?${params}`); try {
return response.ok ? response.json() : null; 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 imageUrl = (image, size) => image ? `https://image.tmdb.org/t/p/${size}${image}` : null;
const metadataFor = async item => { 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); if (metadataCache.has(key)) return metadataCache.get(key);
const pending = (async () => { const pending = (async () => {
if (!tmdbApiKey) return item; if (!tmdbApiKey) return item;
@@ -132,6 +137,7 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
return { return {
file, file,
id: Buffer.from(file).toString('base64url'), 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)), 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), 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, 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 () => { catalogRefresh = (async () => {
const items = await Promise.all((await scan()).map(metadataFor)); const items = await Promise.all((await scan()).map(metadataFor));
catalogCache = items; catalogCache = items;
return items; return groupCatalog(items);
})().finally(() => { catalogRefresh = null; }); })().finally(() => { catalogRefresh = null; });
return catalogRefresh; return catalogRefresh;
}; };
@@ -188,10 +194,10 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
save(index); save(index);
}, },
async catalog() { async catalog() {
return catalogCache || refreshCatalog(); return catalogCache ? groupCatalog(catalogCache) : refreshCatalog();
}, },
async metadata(id) { 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;
}, },
}; };
} }
+52 -8
View File
@@ -26,7 +26,9 @@ export function catalogGroupKey(item) {
const imdb = item.imdbId || item.suggestedImdbId || null; const imdb = item.imdbId || item.suggestedImdbId || null;
const episode = item.type === 'episode' || (item.season != null && item.episode != null); const episode = item.type === 'episode' || (item.season != null && item.episode != null);
if (episode) { 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; if (imdb) return imdb;
const title = slug(item.title); 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))); .sort((a, b) => b.size - a.size || a.file.localeCompare(b.file)));
if (!files.length) return []; if (!files.length) return [];
const representative = members.find(member => member.id === files[0].id) || members[0]; 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 }]; return [{ ...representative, id, file: files[0].file, files, size: files[0].size }];
}); });
} }
export function catalogMembers(item, requestedId) { 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 }] : [])) const files = (item?.files || (item ? [{ id: item.id, file: item.file, size: item.size || 0 }] : []))
.filter(file => (file.size || 0) > 0) .filter(file => (file.size || 0) > 0)
.slice() .slice()
@@ -95,26 +135,30 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
const type = item.type === 'episode' ? 'tv' : 'movie'; const type = item.type === 'episode' ? 'tv' : 'movie';
const query = new URLSearchParams({ api_key: tmdbApiKey, query: item.title }); const query = new URLSearchParams({ api_key: tmdbApiKey, query: item.title });
if (item.year) query.set(type === 'movie' ? 'year' : 'first_air_date_year', item.year); 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]; const candidate = results.results?.[0];
if (!candidate) return item; if (!candidate) return item;
const candidateTitle = candidate.title || candidate.name || ''; const candidateTitle = candidate.title || candidate.name || '';
const candidateYear = (candidate.release_date || candidate.first_air_date || '').slice(0, 4); const candidateYear = (candidate.release_date || candidate.first_air_date || '').slice(0, 4);
const normalize = value => value.toLowerCase().replace(/[^a-z0-9]/g, ''); 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 } }; 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() : {}); 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, suggestedImdbId: external.imdb_id || null, autoMapped: Boolean(external.imdb_id), candidate: { title: candidateTitle, year: candidateYear, tmdbId: candidate.id } }; 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 = {}) => { const tmdb = async (endpoint, query = {}) => {
if (!tmdbApiKey) return null; if (!tmdbApiKey) return null;
const params = new URLSearchParams({ api_key: tmdbApiKey, ...query }); const params = new URLSearchParams({ api_key: tmdbApiKey, ...query });
const response = await fetch(`https://api.themoviedb.org/3${endpoint}?${params}`); try {
return response.ok ? response.json() : null; 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 imageUrl = (image, size) => image ? `https://image.tmdb.org/t/p/${size}${image}` : null;
const metadataFor = async item => { 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); if (metadataCache.has(key)) return metadataCache.get(key);
const pending = (async () => { const pending = (async () => {
if (!tmdbApiKey) return item; if (!tmdbApiKey) return item;
+30 -4
View File
@@ -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'); 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 = [ const items = [
{ id: 'e1', file: 'Show.S01E01.mkv', title: 'Show', year: 2020, imdbId: 'tt1111111', type: 'episode', season: 1, episode: 1, size: 1 }, { 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: '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 }, { 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); const catalog = groupCatalog(items);
assert.equal(catalog.length, 2); assert.equal(catalog.length, 1);
assert.deepEqual(new Set(catalog.map(item => item.id)), new Set(['tt1111111:1:1', 'tt1111111:1:2'])); assert.equal(catalog[0].id, 'tt1111111');
assert.equal(catalog.find(item => item.id === 'tt1111111:1:1').files.length, 2); 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', () => { test('unmapped titles group by lowercase title and year', () => {