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:
@@ -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/'];
|
||||
|
||||
|
||||
+98
-26
@@ -12,7 +12,7 @@ import { useClient } from 'hooks/useClient';
|
||||
import type { TaskSummary } from '../../useTasks';
|
||||
import { useTaskRunner } from './useTaskRunner';
|
||||
import { usePipelineRunner } from './usePipelineRunner';
|
||||
import { useFilesAPI, type AudioTrack, type SubtitleTrack } from '../../../../hooks/useFilesAPI';
|
||||
import { useFilesAPI, type AudioTrack, type SubtitleTrack, type FolderProbe } from '../../../../hooks/useFilesAPI';
|
||||
|
||||
const playDing = () => {
|
||||
const ctx = new AudioContext();
|
||||
@@ -297,11 +297,9 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium text-duck-dark dark:text-foreground">{def.description ?? key}</span>
|
||||
{entryType !== 'file' ? (
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">All tracks kept (folder mode)</span>
|
||||
) : probing ? (
|
||||
{probing ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> Probing…
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'}
|
||||
</span>
|
||||
) : tracks.length === 0 ? (
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">None</span>
|
||||
@@ -384,6 +382,52 @@ const TaskInputForm = ({ inputDefs, values, onChange, autoFilledKeys, audioTrack
|
||||
);
|
||||
};
|
||||
|
||||
// ── Folder-mode summary (batch track selection) ──
|
||||
|
||||
type FolderSummaryProps = {
|
||||
folder: { fileCount: number; majorityCount: number; skipped: string[] };
|
||||
};
|
||||
|
||||
const FolderSummary = ({ folder }: FolderSummaryProps) => {
|
||||
const { fileCount, majorityCount, skipped } = folder;
|
||||
|
||||
if (fileCount === 0) {
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" /> No videos found in this folder.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skipped.length === 0) {
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-duck-dark/10 flex items-center gap-2 text-sm text-duck-dark/70 dark:text-foreground/70">
|
||||
<CircleCheck className="h-4 w-4 shrink-0 text-duck-teal" />
|
||||
All {fileCount} videos share the same track layout — settings apply to every file.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-duck-dark/10 flex flex-col gap-1.5">
|
||||
<span className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-500">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{majorityCount} of {fileCount} videos share this layout and will be converted.
|
||||
</span>
|
||||
<div className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<span>Different layout — convert these separately:</span>
|
||||
<ul className="mt-1 max-h-24 overflow-y-auto flex flex-col gap-0.5 pl-1">
|
||||
{skipped.map((f) => (
|
||||
<li key={f} className="truncate">
|
||||
• {f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Script-mode runner ──
|
||||
|
||||
type ScriptRunnerProps = {
|
||||
@@ -405,6 +449,10 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
|
||||
const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
|
||||
const [probing, setProbing] = useState(false);
|
||||
// Folder mode: how many videos share the chosen layout, and which ones don't (converted separately).
|
||||
const [folder, setFolder] = useState<{ fileCount: number; majorityCount: number; skipped: string[] } | null>(null);
|
||||
// Newline-separated folder-relative paths to restrict a batch to the majority group (empty = all).
|
||||
const [includeFiles, setIncludeFiles] = useState('');
|
||||
|
||||
// Fetch task detail to get input definitions
|
||||
useEffect(() => {
|
||||
@@ -419,27 +467,50 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
}
|
||||
setFormValues(initial);
|
||||
|
||||
// If the task has track pickers and we're on a single file, probe it and seed keep-all.
|
||||
const hasAudio = Object.values(defs).some((d) => d.type === 'audio_tracks');
|
||||
const hasSubs = Object.values(defs).some((d) => d.type === 'subtitle_tracks');
|
||||
if ((hasAudio || hasSubs) && entryType === 'file' && filePath) {
|
||||
setProbing(true);
|
||||
Promise.all([
|
||||
hasAudio ? files.audioTracks(filePath).catch(() => [] as AudioTrack[]) : Promise.resolve([] as AudioTrack[]),
|
||||
hasSubs ? files.subtitles(filePath).catch(() => [] as SubtitleTrack[]) : Promise.resolve([] as SubtitleTrack[]),
|
||||
])
|
||||
.then(([aud, sub]: [AudioTrack[], SubtitleTrack[]]) => {
|
||||
setAudioTracks(aud);
|
||||
setSubtitleTracks(sub);
|
||||
setFormValues((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.type === 'audio_tracks') next[key] = aud.map((t) => t.id).join(',') || 'none';
|
||||
if (def.type === 'subtitle_tracks') next[key] = 'none';
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Track pickers: audio defaults to keep-all, subtitles to keep-none. A folder probes every
|
||||
// video and drives the pickers off the largest matching group (the rest convert separately).
|
||||
const hasTrackPickers = Object.values(defs).some(
|
||||
(d) => d.type === 'audio_tracks' || d.type === 'subtitle_tracks',
|
||||
);
|
||||
if (!hasTrackPickers || !filePath) return;
|
||||
|
||||
const seedTracks = (aud: AudioTrack[], sub: SubtitleTrack[]) => {
|
||||
setAudioTracks(aud);
|
||||
setSubtitleTracks(sub);
|
||||
setFormValues((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const [key, def] of Object.entries(defs)) {
|
||||
if (def.type === 'audio_tracks') next[key] = aud.map((t) => t.id).join(',') || 'none';
|
||||
if (def.type === 'subtitle_tracks') next[key] = 'none';
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
setProbing(true);
|
||||
if (entryType === 'directory') {
|
||||
files
|
||||
.probeFolder(filePath)
|
||||
.then((probe: FolderProbe) => {
|
||||
const majority = probe.groups[0];
|
||||
if (!majority) {
|
||||
setFolder({ fileCount: probe.fileCount, majorityCount: 0, skipped: [] });
|
||||
return;
|
||||
}
|
||||
seedTracks(majority.audioTracks, majority.subtitleTracks);
|
||||
const skipped = probe.groups.slice(1).flatMap((g) => g.files);
|
||||
setFolder({ fileCount: probe.fileCount, majorityCount: majority.count, skipped });
|
||||
// Only pin the include list when some files are being left out; a uniform folder converts all.
|
||||
setIncludeFiles(skipped.length > 0 ? majority.files.join('\n') : '');
|
||||
})
|
||||
.catch(() => setFolder({ fileCount: 0, majorityCount: 0, skipped: [] }))
|
||||
.finally(() => setProbing(false));
|
||||
} else {
|
||||
Promise.all([
|
||||
files.audioTracks(filePath).catch(() => [] as AudioTrack[]),
|
||||
files.subtitles(filePath).catch(() => [] as SubtitleTrack[]),
|
||||
])
|
||||
.then(([aud, sub]: [AudioTrack[], SubtitleTrack[]]) => seedTracks(aud, sub))
|
||||
.finally(() => setProbing(false));
|
||||
}
|
||||
});
|
||||
@@ -474,13 +545,14 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
};
|
||||
|
||||
const handleRun = () => {
|
||||
const allInputs = { ...formValues, ...autoInputs };
|
||||
const allInputs = { ...formValues, ...(includeFiles ? { include: includeFiles } : {}), ...autoInputs };
|
||||
runner.run(taskDirName, allInputs, cwd);
|
||||
};
|
||||
|
||||
if (runner.phase === 'ready') {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
{entryType === 'directory' && folder && !probing && <FolderSummary folder={folder} />}
|
||||
{inputDefs && (
|
||||
<TaskInputForm
|
||||
inputDefs={inputDefs}
|
||||
|
||||
@@ -75,6 +75,9 @@ export const useFilesAPI = (root: string = 'home') => {
|
||||
subtitles: (path: string) =>
|
||||
client.get<SubtitleTrack[]>(withRoot(`/file-browser/subtitles?path=${encodeURIComponent(path)}`)),
|
||||
|
||||
probeFolder: (path: string) =>
|
||||
client.get<FolderProbe>(withRoot(`/file-browser/probe-folder?path=${encodeURIComponent(path)}`)),
|
||||
|
||||
getRawUrl: (path: string) => {
|
||||
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
const rp = root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||
@@ -139,6 +142,15 @@ export type UseFilesAPIType = ReturnType<typeof useFilesAPI>;
|
||||
export type AudioTrack = { id: number; codec: string; channels: number; lang: string; title: string };
|
||||
export type SubtitleTrack = { id: number; codec: string; lang: string; title: string };
|
||||
|
||||
export type FolderTrackGroup = {
|
||||
signature: string;
|
||||
count: number;
|
||||
files: string[];
|
||||
audioTracks: AudioTrack[];
|
||||
subtitleTracks: SubtitleTrack[];
|
||||
};
|
||||
export type FolderProbe = { fileCount: number; groups: FolderTrackGroup[] };
|
||||
|
||||
export type DirEntry = {
|
||||
name: string;
|
||||
path?: string;
|
||||
|
||||
Reference in New Issue
Block a user