Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b959f32e76 | ||
|
|
9d67f79c2f | ||
|
|
77343e8cfb | ||
|
|
fa75c37306 | ||
|
|
7af20febf2 | ||
|
|
ceba36fd00 | ||
|
|
d968fb7037 | ||
|
|
3a57674fee |
+16
-10
@@ -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}[^>]*>([^<]+)</${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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+164
-19
@@ -19,6 +19,108 @@ 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) {
|
||||
if (imdb) return imdb;
|
||||
const title = slug(item.title);
|
||||
return title || null;
|
||||
}
|
||||
if (imdb) return imdb;
|
||||
const title = slug(item.title);
|
||||
return title ? `${title}${item.year ? `-${item.year}` : ''}` : null;
|
||||
}
|
||||
|
||||
const uniqueFiles = files => {
|
||||
const seen = new Set();
|
||||
return files.filter(file => {
|
||||
if (seen.has(file.id)) return false;
|
||||
seen.add(file.id);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export function catalogTitleKey(item) {
|
||||
return catalogGroupKey({ ...item, imdbId: null, suggestedImdbId: null });
|
||||
}
|
||||
|
||||
export function catalogAliases(item) {
|
||||
return new Set([item?.id, item?.imdbId, item?.suggestedImdbId, catalogTitleKey(item)].filter(Boolean));
|
||||
}
|
||||
|
||||
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()].flatMap(([id, members]) => {
|
||||
const files = uniqueFiles(members
|
||||
.filter(member => (member.size || 0) > 0)
|
||||
.map(member => ({ id: member.id, file: member.file, size: member.size || 0 }))
|
||||
.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()
|
||||
.sort((a, b) => (b.size || 0) - (a.size || 0) || String(a.file).localeCompare(String(b.file)));
|
||||
if (requestedId && item && !catalogAliases(item).has(requestedId)) {
|
||||
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 = () => {
|
||||
@@ -33,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;
|
||||
@@ -87,16 +193,18 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
|
||||
metadataCache.set(key, pending);
|
||||
return pending;
|
||||
};
|
||||
let cachedScan = null;
|
||||
const scan = async () => {
|
||||
const index = load();
|
||||
const files = fs.existsSync(mediaRoot) ? fs.readdirSync(mediaRoot, { recursive: true }).filter(file => video.test(file)) : [];
|
||||
const present = new Set(files);
|
||||
const listed = fs.existsSync(mediaRoot) ? fs.readdirSync(mediaRoot, { recursive: true }).filter(file => video.test(file)) : [];
|
||||
const present = new Set();
|
||||
const items = [];
|
||||
let changed = false;
|
||||
for (const file of Object.keys(index.mappings)) {
|
||||
if (!present.has(file) && !index.mappings[file].missing) { index.mappings[file].missing = true; changed = true; }
|
||||
}
|
||||
const items = files.map(file => {
|
||||
const stat = fs.statSync(path.join(mediaRoot, file));
|
||||
for (const file of listed) {
|
||||
let stat;
|
||||
try { stat = fs.statSync(path.join(mediaRoot, file)); } catch { continue; }
|
||||
if (!stat.isFile()) continue;
|
||||
present.add(file);
|
||||
const fingerprint = `${stat.size}:${Math.round(stat.mtimeMs)}`;
|
||||
const parsed = guessit(path.basename(file));
|
||||
const previous = Object.entries(index.mappings).find(([oldPath, value]) => oldPath !== file && value.missing && value.fingerprint === fingerprint)?.[1];
|
||||
@@ -106,9 +214,10 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
|
||||
index.mappings[file] = { ...mapping, fingerprint, missing: false, updatedAt: mapping.updatedAt || new Date().toISOString() };
|
||||
changed = true;
|
||||
}
|
||||
return {
|
||||
items.push({
|
||||
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,
|
||||
@@ -116,8 +225,15 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
|
||||
season: mapping.season || parsed.season || null,
|
||||
episode: mapping.episode || parsed.episode || null,
|
||||
mapped: Boolean(mapping.imdbId),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
for (const file of Object.keys(index.mappings)) {
|
||||
if (present.has(file) || index.mappings[file].missing) continue;
|
||||
const size = Number(String(index.mappings[file].fingerprint || '').split(':')[0]);
|
||||
if (size > 0) continue;
|
||||
index.mappings[file].missing = true;
|
||||
changed = true;
|
||||
}
|
||||
const discovered = await Promise.all(items.map(discover));
|
||||
for (const item of discovered) {
|
||||
if (!item.autoMapped || index.mappings[item.file]?.imdbId) continue;
|
||||
@@ -134,8 +250,32 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
|
||||
changed = true;
|
||||
}
|
||||
if (changed) save(index);
|
||||
cachedScan = discovered;
|
||||
return discovered;
|
||||
};
|
||||
const itemsFromIndex = () => Object.entries(load().mappings).flatMap(([file, mapping]) => {
|
||||
const size = Number(String(mapping.fingerprint || '').split(':')[0]);
|
||||
if (!size || !video.test(file) || file.includes('..')) return [];
|
||||
const parsed = guessit(path.basename(file));
|
||||
return [{
|
||||
file,
|
||||
id: Buffer.from(file).toString('base64url'),
|
||||
size,
|
||||
title: mapping.title || parsed.title || path.basename(file, path.extname(file)),
|
||||
year: mapping.year || parsed.year || null,
|
||||
imdbId: mapping.imdbId || parsed.imdb_id || file.match(/\btt\d{7,10}\b/i)?.[0]?.toLowerCase() || null,
|
||||
type: parsed.type === 'episode' ? 'episode' : 'video',
|
||||
season: mapping.season || parsed.season || null,
|
||||
episode: mapping.episode || parsed.episode || null,
|
||||
mapped: Boolean(mapping.imdbId),
|
||||
}];
|
||||
});
|
||||
const findItem = (items, id) => items.find(item => catalogAliases(item).has(id) || item.files?.some(file => file.id === id)) || null;
|
||||
let indexGrouped = null;
|
||||
const groupedFromIndex = () => {
|
||||
if (!indexGrouped) indexGrouped = groupCatalog(itemsFromIndex());
|
||||
return indexGrouped;
|
||||
};
|
||||
return {
|
||||
scan,
|
||||
startReconciler(seconds = 900) {
|
||||
@@ -155,10 +295,15 @@ 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 findItem(await this.catalog(), id);
|
||||
},
|
||||
async members(id) {
|
||||
const grouped = cachedScan ? groupCatalog(cachedScan) : groupedFromIndex();
|
||||
const item = findItem(grouped, id);
|
||||
return item ? catalogMembers(item, id) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { catalogAliases, 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 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, 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', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
test('size-0 torrent folders are dropped when a real file exists', () => {
|
||||
const catalog = groupCatalog([
|
||||
{ id: 'dir', file: 'Rons.Gone.Wrong.2021.mkv', title: 'Rons Gone Wrong', year: 2021, type: 'video', size: 0 },
|
||||
{ id: 'real', file: 'Rons.Gone.Wrong.2021.mkv/Rons.Gone.Wrong.2021.mkv', title: 'Rons Gone Wrong', year: 2021, type: 'video', size: 1506040768 },
|
||||
{ id: 'yts', file: 'Rons Gone Wrong (2021)/Rons.Gone.Wrong.2021.mp4', title: 'Rons Gone Wrong', year: 2021, type: 'video', size: 2112849010 },
|
||||
]);
|
||||
assert.equal(catalog.length, 1);
|
||||
assert.equal(catalog[0].id, 'rons-gone-wrong-2021');
|
||||
assert.equal(catalog[0].size, 2112849010);
|
||||
assert.deepEqual(catalog[0].files.map(file => file.id), ['yts', 'real']);
|
||||
assert.equal(catalogMembers(catalog[0], catalog[0].id).length, 2);
|
||||
});
|
||||
|
||||
test('duplicate file ids collapse to one stream', () => {
|
||||
const catalog = groupCatalog([
|
||||
{ id: 'a', file: 'Movie.mp4', title: 'Movie', year: 2020, type: 'video', size: 10 },
|
||||
{ id: 'a', file: 'Movie.mp4', title: 'Movie', year: 2020, type: 'video', size: 10 },
|
||||
]);
|
||||
assert.equal(catalog[0].files.length, 1);
|
||||
});
|
||||
|
||||
test('a size-0-only group is omitted', () => {
|
||||
const catalog = groupCatalog([
|
||||
{ id: 'dir', file: 'Rons.Gone.Wrong.2021.mkv', title: 'Rons Gone Wrong', year: 2021, type: 'video', size: 0 },
|
||||
]);
|
||||
assert.equal(catalog.length, 0);
|
||||
});
|
||||
|
||||
test('title-year id still resolves after imdb grouping', () => {
|
||||
const catalog = groupCatalog([
|
||||
{ id: 'a', file: 'Rons.mkv', title: 'Rons Gone Wrong', year: 2021, imdbId: 'tt7504818', type: 'video', size: 10 },
|
||||
{ id: 'b', file: 'Rons.mp4', title: 'Rons Gone Wrong', year: 2021, imdbId: 'tt7504818', type: 'video', size: 20 },
|
||||
]);
|
||||
assert.equal(catalog[0].id, 'tt7504818');
|
||||
assert.equal(catalogMembers(catalog[0], 'rons-gone-wrong-2021').length, 2);
|
||||
assert.ok(catalogAliases(catalog[0]).has('rons-gone-wrong-2021'));
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -59,6 +59,20 @@ const admin = req => {
|
||||
const html = value => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
const libraryPage = items => `<!doctype html><title>CleanStream Library</title><style>body{font:16px system-ui;max-width:1100px;margin:2rem auto}form{display:grid;grid-template-columns:2fr 2fr 1fr auto;gap:.5rem;margin:.5rem 0}input{padding:.5rem}small{grid-column:1/-1;color:#666}</style><h1>Library review</h1><p>Confirm titles and IMDb IDs. Original files are never renamed or moved.</p>${items.map(item => `<form method="post" action="/library/mapping"><input type="hidden" name="file" value="${encodeURIComponent(item.file)}"><input name="title" value="${html(item.title)}"><input name="imdbId" placeholder="tt1234567" value="${html(item.imdbId || item.suggestedImdbId || '')}"><input name="year" value="${item.year || ''}" placeholder="year"><button>Save</button><small>${html(item.file)}${item.candidate ? ` · TMDB: ${html(item.candidate.title)} (${html(item.candidate.year)})` : ''}</small></form>`).join('')}`;
|
||||
|
||||
const resolveMediaTarget = file => {
|
||||
if (!file || file.includes('..')) return null;
|
||||
const target = path.resolve(mediaRoot, file);
|
||||
if (!target.startsWith(`${mediaRoot}${path.sep}`)) return null;
|
||||
try {
|
||||
const stat = fs.statSync(target);
|
||||
if (stat.isFile()) return target;
|
||||
if (stat.isDirectory()) {
|
||||
const nested = path.join(target, path.basename(target));
|
||||
if (nested.startsWith(`${mediaRoot}${path.sep}`) && fs.statSync(nested).isFile()) return nested;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
};
|
||||
http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, publicBaseUrl);
|
||||
console.log(`${req.method} ${url.pathname}`);
|
||||
@@ -89,7 +103,31 @@ 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 === '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 === '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' || req.method === 'HEAD') && url.pathname.startsWith('/v1/media/')) {
|
||||
const token = url.searchParams.get('token');
|
||||
const file = Buffer.from(url.pathname.split('/').pop(), 'base64url').toString();
|
||||
const target = resolveMediaTarget(file);
|
||||
if (!tokens.has(token) || !target) return json(res, 404, { error: 'Not found' });
|
||||
const size = fs.statSync(target).size;
|
||||
const match = req.headers.range?.match(/^bytes=(\d*)-(\d*)$/);
|
||||
const start = Number(match?.[1] || 0);
|
||||
const end = Math.min(Number(match?.[2] || size - 1), size - 1);
|
||||
if (match && (start > end || start >= size)) {
|
||||
res.writeHead(416, { 'content-range': `bytes */${size}` });
|
||||
return res.end();
|
||||
}
|
||||
const headers = { 'content-type': 'application/octet-stream', 'accept-ranges': 'bytes', 'content-length': end - start + 1 };
|
||||
if (match) headers['content-range'] = `bytes ${start}-${end}/${size}`;
|
||||
res.writeHead(match ? 206 : 200, headers);
|
||||
if (req.method === 'HEAD') return res.end();
|
||||
const stream = fs.createReadStream(target, { start, end });
|
||||
stream.on('error', error => {
|
||||
console.error(`Media read failed: ${error.message}`);
|
||||
if (!res.headersSent) json(res, 502, { error: 'Media read failed' });
|
||||
else res.destroy(error);
|
||||
});
|
||||
return stream.pipe(res);
|
||||
}
|
||||
json(res, 404, { error: 'Not found' });
|
||||
}).listen(port, () => console.log(`Connector listening on ${publicBaseUrl}`));
|
||||
|
||||
Reference in New Issue
Block a user