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:
@@ -454,6 +454,74 @@ router.get('/audio-tracks', async (ctx) => {
|
|||||||
return ctx.json(tracks);
|
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<string, string> } = {};
|
||||||
|
try {
|
||||||
|
format = (JSON.parse(fmtOut) as { format?: typeof format }).format ?? {};
|
||||||
|
} catch {
|
||||||
|
format = {};
|
||||||
|
}
|
||||||
|
const tags: Record<string, string> = {};
|
||||||
|
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<string, string> };
|
||||||
|
streams?: Array<{ tags?: Record<string, string> }>;
|
||||||
|
};
|
||||||
|
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
|
// 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
|
// 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
|
// odd files out when they don't. Two files "match" when their audio (language + channel count) and
|
||||||
|
|||||||
+49
@@ -15,6 +15,7 @@ import { usePipelineRunner } from './usePipelineRunner';
|
|||||||
import {
|
import {
|
||||||
useFilesAPI,
|
useFilesAPI,
|
||||||
type AudioTrack,
|
type AudioTrack,
|
||||||
|
type AudioMeta,
|
||||||
type SubtitleTrack,
|
type SubtitleTrack,
|
||||||
type FolderProbe,
|
type FolderProbe,
|
||||||
type FolderTrackGroup,
|
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) ──
|
// ── Folder-mode summary (batch track selection) ──
|
||||||
|
|
||||||
type FolderSummaryProps = {
|
type FolderSummaryProps = {
|
||||||
@@ -899,6 +933,8 @@ const ScriptRunner = ({
|
|||||||
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
const [formValues, setFormValues] = useState<Record<string, string>>({});
|
||||||
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
|
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([]);
|
||||||
const [subtitleTracks, setSubtitleTracks] = useState<SubtitleTrack[]>([]);
|
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);
|
const [probing, setProbing] = useState(false);
|
||||||
// Folder mode: how many videos share the chosen layout, and which ones don't (converted separately).
|
// 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);
|
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.
|
// (which return early below) need this so the batch only touches the selection.
|
||||||
if (selectedNames && selectedNames.length > 0) setIncludeFiles(selectedNames.join('\n'));
|
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
|
// 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).
|
// video and drives the pickers off the largest matching group (the rest convert separately).
|
||||||
const hasTrackPickers = Object.values(defs).some(
|
const hasTrackPickers = Object.values(defs).some(
|
||||||
@@ -1164,6 +1210,9 @@ const ScriptRunner = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{taskDirName === 'get-lyrics' && audioMeta && (
|
||||||
|
<AudioMetaPanel meta={audioMeta} fileName={filePath?.split('/').pop() ?? ''} />
|
||||||
|
)}
|
||||||
{inputDefs && (
|
{inputDefs && (
|
||||||
<TaskInputForm
|
<TaskInputForm
|
||||||
inputDefs={inputDefs}
|
inputDefs={inputDefs}
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ export const useFilesAPI = (root: string = 'home') => {
|
|||||||
readFile: (path: string) =>
|
readFile: (path: string) =>
|
||||||
client.get<{ content: string; size: number }>(withRoot(`/file-browser/read?path=${encodeURIComponent(path)}`)),
|
client.get<{ content: string; size: number }>(withRoot(`/file-browser/read?path=${encodeURIComponent(path)}`)),
|
||||||
|
|
||||||
writeFile: (path: string, content: string) =>
|
writeFile: (path: string, content: string) => client.post(withRoot('/file-browser/write'), { path, content }),
|
||||||
client.post(withRoot('/file-browser/write'), { path, content }),
|
|
||||||
|
|
||||||
search: (query: string, path?: string) =>
|
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) =>
|
copy: (sources: string[], destination: string) =>
|
||||||
client.post(withRoot('/file-browser/copy'), {
|
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 }),
|
client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path, root, ...opts }),
|
||||||
|
|
||||||
transcribe: (path: string, opts?: { saveNextTo?: boolean }) =>
|
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) =>
|
extractAudio: (path: string) =>
|
||||||
client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path, root }),
|
client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path, root }),
|
||||||
|
|
||||||
extract: (path: string) =>
|
extract: (path: string) => client.post<{ extractedPath: string }>('/file-browser/extract', { path, root }),
|
||||||
client.post<{ extractedPath: string }>('/file-browser/extract', { path, root }),
|
|
||||||
|
|
||||||
saveResult: (path: string) =>
|
saveResult: (path: string) => client.post<{ savedPath: string }>('/file-browser/save-result', { path }),
|
||||||
client.post<{ savedPath: string }>('/file-browser/save-result', { path }),
|
|
||||||
|
|
||||||
audioTracks: (path: string) =>
|
audioTracks: (path: string) =>
|
||||||
client.get<AudioTrack[]>(withRoot(`/file-browser/audio-tracks?path=${encodeURIComponent(path)}`)),
|
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) =>
|
subtitles: (path: string) =>
|
||||||
client.get<SubtitleTrack[]>(withRoot(`/file-browser/subtitles?path=${encodeURIComponent(path)}`)),
|
client.get<SubtitleTrack[]>(withRoot(`/file-browser/subtitles?path=${encodeURIComponent(path)}`)),
|
||||||
|
|
||||||
@@ -148,8 +156,16 @@ export type DownloadVideoStatus = {
|
|||||||
filename?: string;
|
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 SubtitleTrack = { id: number; codec: string; lang: string; title: string };
|
||||||
|
export type AudioMeta = { title: string; artist: string; duration: number; hasLyrics: boolean };
|
||||||
|
|
||||||
export type FolderTrackGroup = {
|
export type FolderTrackGroup = {
|
||||||
signature: string;
|
signature: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user