video player: subtitle track support with a selector
Add /file-browser/subtitles (list text subtitle tracks) and /subtitle-vtt (extract one as WebVTT on demand); the VideoRenderer fetches the list, renders <track> elements, and shows a CC selector to switch/turn off subs. Track labels prefer title, then handler_name (mp4), then a real language, falling back to Track N — so untagged tracks aren't shown as "und". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -261,6 +261,68 @@ router.get('/raw', async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
// List a video's text-based subtitle tracks (for the in-browser player's selector)
|
||||
const TEXT_SUBTITLE_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'mov_text', 'webvtt', 'text', 'subviewer', 'microdvd']);
|
||||
|
||||
router.get('/subtitles', 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);
|
||||
|
||||
const proc = Bun.spawn(
|
||||
['ffprobe', '-v', 'error', '-select_streams', 's', '-show_entries', 'stream=codec_name: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_name?: string; tags?: { language?: string; title?: string; handler_name?: string } };
|
||||
let streams: ProbeStream[] = [];
|
||||
try {
|
||||
streams = (JSON.parse(out).streams as ProbeStream[]) ?? [];
|
||||
} catch {
|
||||
streams = [];
|
||||
}
|
||||
|
||||
// mkv stores the track name in `title`; mp4/mov stores it in `handler_name` (default names like
|
||||
// "SubtitleHandler" are generic and ignored).
|
||||
const trackName = (tags: ProbeStream['tags']) => {
|
||||
const handler = tags?.handler_name ?? '';
|
||||
return tags?.title || (handler && !/Handler$/.test(handler) ? handler : '');
|
||||
};
|
||||
|
||||
// `id` is the subtitle-relative index among ALL subtitle streams (what `-map 0:s:id` expects),
|
||||
// so it is assigned before filtering out image-based tracks that can't become WebVTT.
|
||||
const tracks = streams
|
||||
.map((s, id) => ({ id, s }))
|
||||
.filter(({ s }) => TEXT_SUBTITLE_CODECS.has((s.codec_name ?? '').toLowerCase()))
|
||||
.map(({ id, s }) => ({ id, codec: s.codec_name ?? '', lang: s.tags?.language ?? '', title: trackName(s.tags) }));
|
||||
|
||||
return ctx.json(tracks);
|
||||
});
|
||||
|
||||
// Extract one subtitle track as WebVTT for a <track> element
|
||||
router.get('/subtitle-vtt', 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 track = parseInt(ctx.req.query('track') ?? '', 10);
|
||||
if (!Number.isInteger(track) || track < 0) throw errors.BAD_REQUEST('valid track is required');
|
||||
const absPath = resolveUserPath(rootDir, relPath);
|
||||
|
||||
const proc = Bun.spawn(['ffmpeg', '-v', 'error', '-i', absPath, '-map', `0:s:${track}`, '-f', 'webvtt', 'pipe:1'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
|
||||
return new Response(proc.stdout as ReadableStream, {
|
||||
headers: { 'Content-Type': 'text/vtt; charset=utf-8' },
|
||||
});
|
||||
});
|
||||
|
||||
// Save a cached result (ocr/tts/transcriptions/audio) next to the original file
|
||||
const CACHE_PREFIXES = ['cache/ocr/', 'cache/tts/', 'cache/transcriptions/', 'cache/audio/'];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user