diff --git a/src/servers/api/file-browser/router.ts b/src/servers/api/file-browser/router.ts index f97af2a2..1044be49 100644 --- a/src/servers/api/file-browser/router.ts +++ b/src/servers/api/file-browser/router.ts @@ -454,6 +454,74 @@ router.get('/audio-tracks', async (ctx) => { return ctx.json(tracks); }); +// Read-only audio metadata for the Get Lyrics run-task panel: title/artist/duration + whether the file +// already has embedded lyrics. Tolerant of missing tags / probe failures (defaults to empty/0/false). +router.get('/audio-meta', 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); + + // Format tags + duration. + const fmtProc = Bun.spawn( + [ + 'ffprobe', + '-v', + 'error', + '-show_entries', + 'format=duration:format_tags=title,artist,album,TITLE,ARTIST', + '-of', + 'json', + absPath, + ], + { stdout: 'pipe', stderr: 'ignore' }, + ); + const fmtOut = await new Response(fmtProc.stdout).text(); + await fmtProc.exited; + + let format: { duration?: string; tags?: Record } = {}; + try { + format = (JSON.parse(fmtOut) as { format?: typeof format }).format ?? {}; + } catch { + format = {}; + } + const tags: Record = {}; + for (const [k, val] of Object.entries(format.tags ?? {})) tags[k.toLowerCase()] = val; // ID3 case varies + const durNum = format.duration ? parseFloat(format.duration) : 0; + + // ID3 lyrics frames (USLT/SYLT) don't reliably surface in format_tags — probe stream+format tags and + // flag lyrics if any key matches USLT/SYLT/lyrics (case-insensitive; some muxers emit `lyrics-XXX`). + const lyrProc = Bun.spawn( + ['ffprobe', '-v', 'error', '-show_entries', 'stream_tags:format_tags', '-of', 'json', absPath], + { stdout: 'pipe', stderr: 'ignore' }, + ); + const lyrOut = await new Response(lyrProc.stdout).text(); + await lyrProc.exited; + + let hasLyrics = false; + try { + const data = JSON.parse(lyrOut) as { + format?: { tags?: Record }; + streams?: Array<{ tags?: Record }>; + }; + const keys = [ + ...Object.keys(data.format?.tags ?? {}), + ...(data.streams ?? []).flatMap((s) => Object.keys(s.tags ?? {})), + ]; + hasLyrics = keys.some((k) => /uslt|sylt|lyrics/i.test(k)); + } catch { + hasLyrics = false; + } + + return ctx.json({ + title: tags.title ?? '', + artist: tags.artist ?? '', + duration: Number.isFinite(durNum) ? durNum : 0, + hasLyrics, + }); +}); + // 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 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 e1345d09..07c17cd1 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -15,6 +15,7 @@ import { usePipelineRunner } from './usePipelineRunner'; import { useFilesAPI, type AudioTrack, + type AudioMeta, type SubtitleTrack, type FolderProbe, type FolderTrackGroup, @@ -518,6 +519,39 @@ const TaskInputForm = ({ ); }; +// ── Get Lyrics info panel (read-only audio metadata) ── + +const fmtDuration = (d: number): string => { + if (!d || d <= 0) return ''; + const s = Math.round(d); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +}; + +const AudioMetaPanel = ({ meta, fileName }: { meta: AudioMeta; fileName: string }) => { + const title = meta.title || fileName; + const dur = fmtDuration(meta.duration); + return ( +
+
{title}
+ {meta.artist ? ( +
{meta.artist}
+ ) : null} +
+ {dur ? {dur} : null} + + Lyrics: {meta.hasLyrics ? 'Yes' : 'No'} + +
+
+ ); +}; + // ── Folder-mode summary (batch track selection) ── type FolderSummaryProps = { @@ -899,6 +933,8 @@ const ScriptRunner = ({ const [formValues, setFormValues] = useState>({}); const [audioTracks, setAudioTracks] = useState([]); const [subtitleTracks, setSubtitleTracks] = useState([]); + // Get Lyrics: read-only audio metadata for the info panel (single file only). + const [audioMeta, setAudioMeta] = useState(null); 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); @@ -943,6 +979,16 @@ const ScriptRunner = ({ // (which return early below) need this so the batch only touches the selection. if (selectedNames && selectedNames.length > 0) setIncludeFiles(selectedNames.join('\n')); + // Get Lyrics: prefetch read-only audio metadata for the info panel (single file only), regardless + // of track pickers (it has none, so it returns early below). Errors are tolerated — never blocks. + setAudioMeta(null); + if (taskDirName === 'get-lyrics' && filePath && entryType !== 'directory') { + files + .audioMeta(filePath) + .then(setAudioMeta) + .catch(() => setAudioMeta(null)); + } + // 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( @@ -1164,6 +1210,9 @@ const ScriptRunner = ({ } /> )} + {taskDirName === 'get-lyrics' && audioMeta && ( + + )} {inputDefs && ( { readFile: (path: string) => client.get<{ content: string; size: number }>(withRoot(`/file-browser/read?path=${encodeURIComponent(path)}`)), - writeFile: (path: string, content: string) => - client.post(withRoot('/file-browser/write'), { path, content }), + writeFile: (path: string, content: string) => client.post(withRoot('/file-browser/write'), { path, content }), search: (query: string, path?: string) => - client.get<{ results: DirEntry[] }>(withRoot(`/file-browser/search?q=${encodeURIComponent(query)}${path ? `&path=${encodeURIComponent(path)}` : ''}`)), + client.get<{ results: DirEntry[] }>( + withRoot( + `/file-browser/search?q=${encodeURIComponent(query)}${path ? `&path=${encodeURIComponent(path)}` : ''}`, + ), + ), copy: (sources: string[], destination: string) => client.post(withRoot('/file-browser/copy'), { @@ -61,20 +64,25 @@ export const useFilesAPI = (root: string = 'home') => { client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path, root, ...opts }), transcribe: (path: string, opts?: { saveNextTo?: boolean }) => - client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path, root, ...opts }), + client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { + path, + root, + ...opts, + }), extractAudio: (path: string) => client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path, root }), - extract: (path: string) => - client.post<{ extractedPath: string }>('/file-browser/extract', { path, root }), + extract: (path: string) => client.post<{ extractedPath: string }>('/file-browser/extract', { path, root }), - saveResult: (path: string) => - client.post<{ savedPath: string }>('/file-browser/save-result', { path }), + saveResult: (path: string) => client.post<{ savedPath: string }>('/file-browser/save-result', { path }), audioTracks: (path: string) => client.get(withRoot(`/file-browser/audio-tracks?path=${encodeURIComponent(path)}`)), + audioMeta: (path: string) => + client.get(withRoot(`/file-browser/audio-meta?path=${encodeURIComponent(path)}`)), + subtitles: (path: string) => client.get(withRoot(`/file-browser/subtitles?path=${encodeURIComponent(path)}`)), @@ -148,8 +156,16 @@ export type DownloadVideoStatus = { filename?: string; }; -export type AudioTrack = { id: number; codec: string; channels: number; bitrate: number | null; lang: string; title: string }; +export type AudioTrack = { + id: number; + codec: string; + channels: number; + bitrate: number | null; + lang: string; + title: string; +}; export type SubtitleTrack = { id: number; codec: string; lang: string; title: string }; +export type AudioMeta = { title: string; artist: string; duration: number; hasLyrics: boolean }; export type FolderTrackGroup = { signature: string;