From ae1ccdec33331ae9d64d1b4a66b72fc595cbbffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 21 Jul 2026 05:21:01 +0000 Subject: [PATCH] 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 --- src/servers/api/file-browser/router.ts | 91 +++++++++++++ .../components/TaskRunnerModal.tsx | 124 ++++++++++++++---- .../officerdev/src/hooks/useFilesAPI.ts | 12 ++ 3 files changed, 201 insertions(+), 26 deletions(-) diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index 15501c9a..591a7cd0 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -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 { + 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(); + 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/']; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index 4a8bd77e..084777e1 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -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 (
{def.description ?? key} - {entryType !== 'file' ? ( - All tracks kept (folder mode) - ) : probing ? ( + {probing ? ( - Probing… + {entryType === 'directory' ? 'Analyzing files…' : 'Probing…'} ) : tracks.length === 0 ? ( None @@ -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 ( +
+ No videos found in this folder. +
+ ); + } + + if (skipped.length === 0) { + return ( +
+ + All {fileCount} videos share the same track layout — settings apply to every file. +
+ ); + } + + return ( +
+ + + {majorityCount} of {fileCount} videos share this layout and will be converted. + +
+ Different layout — convert these separately: +
    + {skipped.map((f) => ( +
  • + • {f} +
  • + ))} +
+
+
+ ); +}; + // ── Script-mode runner ── type ScriptRunnerProps = { @@ -405,6 +449,10 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa const [audioTracks, setAudioTracks] = useState([]); const [subtitleTracks, setSubtitleTracks] = useState([]); 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 (
+ {entryType === 'directory' && folder && !probing && } {inputDefs && ( { subtitles: (path: string) => client.get(withRoot(`/file-browser/subtitles?path=${encodeURIComponent(path)}`)), + probeFolder: (path: string) => + client.get(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; 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;