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:
+49
-2
@@ -19,6 +19,48 @@ const nfoFor = (mediaRoot, file) => {
|
|||||||
} catch { return {}; }
|
} 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) {
|
export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
|
||||||
const metadataCache = new Map();
|
const metadataCache = new Map();
|
||||||
const load = () => {
|
const load = () => {
|
||||||
@@ -109,6 +151,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: mapping.title || nfo.title || parsed.title || path.basename(file, path.extname(file)),
|
title: mapping.title || nfo.title || parsed.title || path.basename(file, path.extname(file)),
|
||||||
year: mapping.year || nfo.year || parsed.year || null,
|
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,
|
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);
|
save(index);
|
||||||
},
|
},
|
||||||
async catalog() {
|
async catalog() {
|
||||||
return Promise.all((await scan()).map(metadataFor));
|
return groupCatalog(await Promise.all((await scan()).map(metadataFor)));
|
||||||
},
|
},
|
||||||
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.files?.some(file => file.id === id)) || null;
|
||||||
|
},
|
||||||
|
async members(id) {
|
||||||
|
const item = await this.metadata(id);
|
||||||
|
return item ? catalogMembers(item, id) : null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
|
"test": "node --test library.test.js",
|
||||||
"package:macos": "sh scripts/build-desktop-package.sh macos",
|
"package:macos": "sh scripts/build-desktop-package.sh macos",
|
||||||
"package:linux": "sh scripts/build-desktop-package.sh linux",
|
"package:linux": "sh scripts/build-desktop-package.sh linux",
|
||||||
"package:windows": "sh scripts/build-desktop-package.sh windows"
|
"package:windows": "sh scripts/build-desktop-package.sh windows"
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ http.createServer(async (req, res) => {
|
|||||||
const item = await library.metadata(url.searchParams.get('id') || '');
|
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' });
|
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); }
|
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' });
|
json(res, 404, { error: 'Not found' });
|
||||||
}).listen(port, () => console.log(`Connector listening on ${publicBaseUrl}`));
|
}).listen(port, () => console.log(`Connector listening on ${publicBaseUrl}`));
|
||||||
|
|||||||
Reference in New Issue
Block a user