feat: group catalog items by imdbId

Duplicate files of the same title become one catalog row, with one stream per member file.
This commit is contained in:
2026-08-14 10:59:05 +01:00
parent 74deabd4b1
commit 3a57674fee
4 changed files with 108 additions and 3 deletions
+49 -2
View File
@@ -19,6 +19,48 @@ const nfoFor = (mediaRoot, file) => {
} catch { return {}; }
};
const slug = value => String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
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 ? `${title}${item.year ? `-${item.year}` : ''}` : null;
}
export function groupCatalog(items) {
const groups = new Map();
for (const item of items) {
const key = catalogGroupKey(item) || item.id;
const members = groups.get(key);
if (members) members.push(item);
else groups.set(key, [item]);
}
return [...groups.entries()].map(([id, members]) => {
const files = members
.map(member => ({ id: member.id, file: member.file, size: member.size || 0 }))
.sort((a, b) => b.size - a.size || a.file.localeCompare(b.file));
const representative = members.find(member => member.id === files[0].id) || members[0];
return { ...representative, id, files };
});
}
export function catalogMembers(item, requestedId) {
const files = (item?.files || (item ? [{ id: item.id, file: item.file, size: item.size || 0 }] : []))
.slice()
.sort((a, b) => (b.size || 0) - (a.size || 0) || String(a.file).localeCompare(String(b.file)));
if (requestedId && item && requestedId !== item.id) {
const match = files.find(file => file.id === requestedId);
return match ? [match] : [];
}
return files;
}
export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
const metadataCache = new Map();
const load = () => {
@@ -109,6 +151,7 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
return {
file,
id: Buffer.from(file).toString('base64url'),
size: stat.size,
title: mapping.title || nfo.title || parsed.title || path.basename(file, path.extname(file)),
year: mapping.year || nfo.year || parsed.year || null,
imdbId: mapping.imdbId || nfo.imdbId || parsed.imdb_id || file.match(/\btt\d{7,10}\b/i)?.[0]?.toLowerCase() || null,
@@ -155,10 +198,14 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
save(index);
},
async catalog() {
return Promise.all((await scan()).map(metadataFor));
return groupCatalog(await Promise.all((await scan()).map(metadataFor)));
},
async metadata(id) {
return (await this.catalog()).find(item => item.id === id) || null;
return (await this.catalog()).find(item => item.id === id || item.files?.some(file => file.id === id)) || null;
},
async members(id) {
const item = await this.metadata(id);
return item ? catalogMembers(item, id) : null;
},
};
}
+57
View File
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { catalogGroupKey, catalogMembers, groupCatalog } from './library.js';
const pathId = file => Buffer.from(file).toString('base64url');
test('two files with the same imdbId become one catalog item with two streams', () => {
const items = [
{ id: pathId('Evolution.mp4'), file: 'Evolution.mp4', title: 'Evolution', year: 2026, imdbId: 'tt6150344', type: 'video', size: 100 },
{ id: pathId('Evolution (2026)/Evolution.mp4'), file: 'Evolution (2026)/Evolution.mp4', title: 'Evolution', year: 2026, imdbId: 'tt6150344', type: 'video', size: 250 },
];
const catalog = groupCatalog(items);
assert.equal(catalog.length, 1);
assert.equal(catalog[0].id, 'tt6150344');
assert.equal(catalog[0].title, 'Evolution');
assert.equal(catalog[0].year, 2026);
assert.equal(catalog[0].imdbId, 'tt6150344');
const streams = catalogMembers(catalog[0], catalog[0].id);
assert.equal(streams.length, 2);
assert.equal(streams[0].file, 'Evolution (2026)/Evolution.mp4');
assert.equal(streams[1].file, 'Evolution.mp4');
assert.equal(streams[0].id, pathId('Evolution (2026)/Evolution.mp4'));
});
test('metadata keeps path-id as an alias of the group', () => {
const path = pathId('copy-a.mp4');
const catalog = groupCatalog([
{ id: path, file: 'copy-a.mp4', title: 'Evolution', year: 2026, imdbId: 'tt6150344', type: 'video', size: 10 },
{ id: pathId('copy-b.mp4'), file: 'copy-b.mp4', title: 'Evolution', year: 2026, suggestedImdbId: 'tt6150344', type: 'video', size: 20 },
]);
const item = catalog.find(entry => entry.id === 'tt6150344' || entry.files.some(file => file.id === path));
assert.equal(item.id, 'tt6150344');
assert.equal(catalogMembers(item, path).length, 1);
assert.equal(catalogMembers(item, path)[0].file, 'copy-a.mp4');
});
test('episodes group by imdbId:season:episode and never collapse a series', () => {
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);
});
test('unmapped titles group by lowercase title and year', () => {
const catalog = groupCatalog([
{ id: 'a', file: 'a.mp4', title: 'Evolution', year: 2026, type: 'video', size: 1 },
{ id: 'b', file: 'b.mp4', title: 'evolution', year: 2026, type: 'video', size: 2 },
]);
assert.equal(catalog.length, 1);
assert.equal(catalog[0].id, 'evolution-2026');
assert.equal(catalogGroupKey({ title: 'Evolution', year: 2026, type: 'video' }), 'evolution-2026');
});
+1
View File
@@ -4,6 +4,7 @@
"type": "module",
"scripts": {
"start": "node server.js",
"test": "node --test library.test.js",
"package:macos": "sh scripts/build-desktop-package.sh macos",
"package:linux": "sh scripts/build-desktop-package.sh linux",
"package:windows": "sh scripts/build-desktop-package.sh windows"
+1 -1
View File
@@ -89,7 +89,7 @@ http.createServer(async (req, res) => {
const item = await library.metadata(url.searchParams.get('id') || '');
return item ? json(res, 200, { item: { ...item, imdbId: item.imdbId || item.suggestedImdbId, description: item.description || item.file } }) : json(res, 404, { error: 'Not found' });
}
if (req.method === 'POST' && url.pathname === '/v1/stream') { if (!authenticated(req)) return json(res,401,{error:'Unauthorized'}); const body=await readJson(req); if (body.id === 'demo-stream' && demoStreamUrl) return json(res,200,{streams:[{id:'demo-stream',url:demoStreamUrl,filename:'demo.m3u8',headers:{}}]}); const file=Buffer.from(body.id || '', 'base64url').toString(); if (!(await videos()).some(v => v.id === body.id) || file.includes('..')) return json(res,404,{error:'Not found'}); return json(res,200,{streams:[{id:body.id,url:`${publicBaseUrl}/v1/media/${body.id}?token=${bearer(req)}`,filename:path.basename(file),headers:{}}]}); }
if (req.method === 'POST' && url.pathname === '/v1/stream') { if (!authenticated(req)) return json(res,401,{error:'Unauthorized'}); const body=await readJson(req); if (body.id === 'demo-stream' && demoStreamUrl) return json(res,200,{streams:[{id:'demo-stream',url:demoStreamUrl,filename:'demo.m3u8',headers:{}}]}); const members=await library.members(body.id || ''); if (!members?.length || members.some(member => member.file.includes('..'))) return json(res,404,{error:'Not found'}); return json(res,200,{streams:members.map(member => ({id:member.id,url:`${publicBaseUrl}/v1/media/${member.id}?token=${bearer(req)}`,filename:path.basename(member.file),headers:{}}))}); }
if (req.method === 'GET' && url.pathname.startsWith('/v1/media/')) { const token=url.searchParams.get('token'); const file=Buffer.from(url.pathname.split('/').pop(), 'base64url').toString(); const target=path.resolve(mediaRoot,file); if (!tokens.has(token) || !target.startsWith(`${mediaRoot}${path.sep}`) || !fs.existsSync(target)) return json(res,404,{error:'Not found'}); res.writeHead(200,{'content-type':'application/octet-stream','content-length':fs.statSync(target).size}); return fs.createReadStream(target).pipe(res); }
json(res, 404, { error: 'Not found' });
}).listen(port, () => console.log(`Connector listening on ${publicBaseUrl}`));