task modal: audio info panel for Get Lyrics (title/artist/length/lyrics)

Display-only panel at the top of the Get Lyrics run-task form (single file only):

- Backend: GET /file-browser/audio-meta?path= — ffprobe format tags + duration,
  plus a second probe for embedded lyrics (USLT/SYLT/lyrics* keys, case-insensitive).
  Returns { title, artist, duration, hasLyrics }; tolerant of missing tags/probe
  failures.
- Client: files.audioMeta(path) + AudioMeta type in useFilesAPI.
- TaskRunnerModal: prefetch audioMeta for get-lyrics single-file runs (bypasses
  the hasTrackPickers early-return, error-tolerant), and render AudioMetaPanel
  above TaskInputForm — title/artist + a muted length + Lyrics: Yes/No chip,
  filename fallback. Directories + other tasks unaffected (no panel, no probing).

Verified ffprobe logic on a real embedded-lyrics file. tsgo clean; formatted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 17:07:18 +00:00
co-authored by Claude Opus 4.8
parent 55bf3aecca
commit 11263ce120
3 changed files with 142 additions and 9 deletions
@@ -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 (
<div className="px-5 py-3 border-b border-duck-dark/10">
<div className="truncate text-sm font-medium text-duck-dark dark:text-foreground">{title}</div>
{meta.artist ? (
<div className="truncate text-xs text-duck-dark/60 dark:text-foreground/60">{meta.artist}</div>
) : null}
<div className="mt-1.5 flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
{dur ? <span className="tabular-nums">{dur}</span> : null}
<span
className={`rounded-md px-1.5 py-0.5 font-medium ${
meta.hasLyrics
? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
: 'bg-duck-dark/10 dark:bg-foreground/10'
}`}
>
Lyrics: {meta.hasLyrics ? 'Yes' : 'No'}
</span>
</div>
</div>
);
};
// ── Folder-mode summary (batch track selection) ──
type FolderSummaryProps = {
@@ -899,6 +933,8 @@ const ScriptRunner = ({
const [formValues, setFormValues] = useState<Record<string, string>>({});
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
// Get Lyrics: read-only audio metadata for the info panel (single file only).
const [audioMeta, setAudioMeta] = useState<AudioMeta | null>(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 && (
<AudioMetaPanel meta={audioMeta} fileName={filePath?.split('/').pop() ?? ''} />
)}
{inputDefs && (
<TaskInputForm
inputDefs={inputDefs}
@@ -21,11 +21,14 @@ export const useFilesAPI = (root: string = 'home') => {
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<AudioTrack[]>(withRoot(`/file-browser/audio-tracks?path=${encodeURIComponent(path)}`)),
audioMeta: (path: string) =>
client.get<AudioMeta>(withRoot(`/file-browser/audio-meta?path=${encodeURIComponent(path)}`)),
subtitles: (path: string) =>
client.get<SubtitleTrack[]>(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;