task runner: batch track selection for folders

converting a folder now recursively probes every video and groups episodes by
track layout (audio language+channels, subtitle language). the pickers are
driven off the largest matching group; episodes with a different layout are
listed as skipped and converted separately. adds a /probe-folder endpoint and
passes the majority file list to run.sh as INPUT_INCLUDE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 05:21:01 +00:00
co-authored by Claude Opus 4.8
parent 0f2b71562a
commit ae1ccdec33
3 changed files with 201 additions and 26 deletions
+91
View File
@@ -415,6 +415,97 @@ router.get('/audio-tracks', async (ctx) => {
return ctx.json(tracks);
});
// Recursively probe a folder's videos and group them by track layout, so the task runner can offer
// one set of audio/subtitle pickers for a whole season when every episode matches — and flag the
// odd files out when they don't. Two files "match" when their audio (language + channel count) and
// subtitle (language) streams line up in order; per-episode titles are ignored (they always differ).
const VIDEO_EXTENSIONS = new Set([
'mp4', 'mkv', 'webm', 'mov', 'avi', 'wmv', 'flv', 'm4v', 'mpg', 'mpeg',
'ts', 'm2ts', 'mts', '3gp', 'ogv', 'vob', 'divx', 'asf', 'f4v', 'rm', 'rmvb',
]);
type FolderAudioTrack = { id: number; codec: string; channels: number; lang: string; title: string };
type FolderSubtitleTrack = { id: number; codec: string; lang: string; title: string };
type ProbedTracks = { audio: FolderAudioTrack[]; subtitle: FolderSubtitleTrack[] };
async function probeVideoTracks(absPath: string): Promise<ProbedTracks> {
const proc = Bun.spawn(
['ffprobe', '-v', 'error', '-show_entries', 'stream=codec_type,codec_name,channels:stream_tags=language,title,handler_name', '-of', 'json', absPath],
{ stdout: 'pipe', stderr: 'ignore' },
);
const out = await new Response(proc.stdout).text();
await proc.exited;
type ProbeStream = { codec_type?: string; codec_name?: string; channels?: number; tags?: { language?: string; title?: string; handler_name?: string } };
let streams: ProbeStream[] = [];
try {
streams = (JSON.parse(out).streams as ProbeStream[]) ?? [];
} catch {
streams = [];
}
const audio = streams
.filter((s) => s.codec_type === 'audio')
.map((s, id) => ({ id, codec: s.codec_name ?? '', channels: s.channels ?? 0, lang: s.tags?.language ?? '', title: trackName(s.tags) }));
// subtitle `id` is the index among ALL subtitle streams (what `-map 0:s:id` expects), assigned
// before filtering out image-based tracks that can't become soft subs.
const subtitle = streams
.filter((s) => s.codec_type === 'subtitle')
.map((s, id) => ({ id, codec: s.codec_name ?? '', lang: s.tags?.language ?? '', title: trackName(s.tags) }))
.filter((t) => TEXT_SUBTITLE_CODECS.has(t.codec.toLowerCase()));
return { audio, subtitle };
}
const layoutSignature = (t: ProbedTracks) =>
`A:${t.audio.map((a) => `${a.lang || 'und'}:${a.channels}`).join(',')}|S:${t.subtitle.map((s) => s.lang || 'und').join(',')}`;
router.get('/probe-folder', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
let entries: string[] = [];
try {
entries = await readdir(absPath, { recursive: true });
} catch {
entries = [];
}
const files = entries
.filter((p) => VIDEO_EXTENSIONS.has(p.split('.').pop()?.toLowerCase() ?? ''))
.sort((a, b) => a.localeCompare(b));
// Probe in small batches so a big season doesn't spawn dozens of ffprobes at once.
const CONCURRENCY = 8;
const probed: { file: string; tracks: ProbedTracks }[] = [];
for (let i = 0; i < files.length; i += CONCURRENCY) {
const batch = files.slice(i, i + CONCURRENCY);
const results = await Promise.all(
batch.map(async (file) => ({ file, tracks: await probeVideoTracks(resolveUserPath(rootDir, join(relPath, file))) })),
);
probed.push(...results);
}
type Group = { signature: string; files: string[]; audioTracks: FolderAudioTrack[]; subtitleTracks: FolderSubtitleTrack[] };
const groupsMap = new Map<string, Group>();
for (const { file, tracks } of probed) {
const sig = layoutSignature(tracks);
let group = groupsMap.get(sig);
if (!group) {
group = { signature: sig, files: [], audioTracks: tracks.audio, subtitleTracks: tracks.subtitle };
groupsMap.set(sig, group);
}
group.files.push(file);
}
const groups = [...groupsMap.values()]
.map((g) => ({ ...g, count: g.files.length }))
.sort((a, b) => b.count - a.count || (a.files[0] ?? '').localeCompare(b.files[0] ?? ''));
return ctx.json({ fileCount: files.length, groups });
});
// Save a cached result (ocr/tts/transcriptions/audio) next to the original file
const CACHE_PREFIXES = ['cache/ocr/', 'cache/tts/', 'cache/transcriptions/', 'cache/audio/'];