feat: one series catalog item with episodes[] E01–E52 #2
+90
-10
@@ -38,6 +38,81 @@ 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) {
|
||||||
|
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 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 createLibrary(mediaRoot, indexPath, tmdbApiKey) {
|
export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
|
||||||
const metadataCache = new Map();
|
const metadataCache = new Map();
|
||||||
let catalogCache = null;
|
let catalogCache = null;
|
||||||
@@ -54,26 +129,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 +211,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 +244,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 +268,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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user