fix: skip torrent folders named like videos so catalog sizes and streams resolve

NFS/WebDAV listings treat a TorBox folder named *.mkv as a 0-byte video. Scan now keeps real files only, grouping drops size-0 placeholders, and stream resolve uses the index instead of a full TMDB catalog walk.
This commit is contained in:
2026-08-14 17:03:25 +01:00
parent 3a57674fee
commit d968fb7037
3 changed files with 144 additions and 19 deletions
+66 -17
View File
@@ -33,6 +33,23 @@ export function catalogGroupKey(item) {
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) {
@@ -41,20 +58,23 @@ export function groupCatalog(items) {
if (members) members.push(item);
else groups.set(key, [item]);
}
return [...groups.entries()].map(([id, members]) => {
const files = members
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));
.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];
return { ...representative, id, files };
return [{ ...representative, id, file: files[0].file, files, size: files[0].size }];
});
}
export function catalogMembers(item, requestedId) {
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 && requestedId !== item.id) {
if (requestedId && item && !catalogAliases(item).has(requestedId)) {
const match = files.find(file => file.id === requestedId);
return match ? [match] : [];
}
@@ -129,16 +149,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];
@@ -148,7 +170,7 @@ 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,
@@ -159,8 +181,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;
@@ -177,8 +206,27 @@ 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;
return {
scan,
startReconciler(seconds = 900) {
@@ -201,10 +249,11 @@ export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
return groupCatalog(await Promise.all((await scan()).map(metadataFor)));
},
async metadata(id) {
return (await this.catalog()).find(item => item.id === id || item.files?.some(file => file.id === id)) || null;
return findItem(await this.catalog(), id);
},
async members(id) {
const item = await this.metadata(id);
const grouped = groupCatalog(cachedScan || itemsFromIndex());
const item = findItem(grouped, id);
return item ? catalogMembers(item, id) : null;
},
};