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
@@ -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;