diff --git a/library.js b/library.js index c55f9b5..357aa40 100644 --- a/library.js +++ b/library.js @@ -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; }, }; } diff --git a/library.test.js b/library.test.js new file mode 100644 index 0000000..89cff3c --- /dev/null +++ b/library.test.js @@ -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'); +}); diff --git a/package.json b/package.json index 4a0f783..5784940 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/server.js b/server.js index 09e1529..8b45ef7 100644 --- a/server.js +++ b/server.js @@ -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}`));