video player: server-side audio track selector
Add /file-browser/audio-tracks (list) and raw?audio=N (serve the video with a chosen audio track via a cached -c copy remux, byte-range seekable). The player shows an always-visible selector outside the controls; switching reloads the source and restores the playhead. Works in Chromium, which doesn't expose the audioTracks API. Temp remux file keeps the real extension (a .tmp suffix breaks ffmpeg's muxer selection); concurrent requests for a track share one remux. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,56 @@ function resolveUserPath(rootDir: string, relPath: string): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// mkv `title` / mp4 `handler_name` hold a track's name; the generic "…Handler" defaults are ignored.
|
||||
function trackName(tags?: { title?: string; handler_name?: string }): string {
|
||||
const handler = tags?.handler_name ?? '';
|
||||
return tags?.title || (handler && !/Handler$/.test(handler) ? handler : '');
|
||||
}
|
||||
|
||||
// Serve a video with a chosen audio track selected: fast `-c copy` remux (video untouched, other
|
||||
// audio dropped) cached under the user's data dir, so it streams with byte-range seeking like /raw.
|
||||
// In-flight remuxes are shared so concurrent requests for the same track don't race on the temp file.
|
||||
const audioRemuxInFlight = new Map<string, Promise<string>>();
|
||||
|
||||
async function ensureAudioRemux(email: string, absPath: string, relPath: string, track: number): Promise<string> {
|
||||
const parsed = parsePath(relPath);
|
||||
const ext = (parsed.ext.slice(1) || 'mp4').toLowerCase();
|
||||
const sub = parsed.dir ? `${parsed.dir}/` : '';
|
||||
// ffmpeg picks the output muxer from the file extension, so both the final and temp names must
|
||||
// keep the real extension (a ".tmp" suffix makes ffmpeg fail with "unable to choose format").
|
||||
const base = resolve(getUserDataDir(email), `cache/audio/${sub}${parsed.name}.a${track}`);
|
||||
const cacheAbs = `${base}.${ext}`;
|
||||
if (existsSync(cacheAbs)) return cacheAbs;
|
||||
|
||||
const pending = audioRemuxInFlight.get(cacheAbs);
|
||||
if (pending) return pending;
|
||||
|
||||
const job = (async () => {
|
||||
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||
const tmp = `${base}.tmp.${ext}`;
|
||||
const movflags = ext === 'mp4' || ext === 'mov' || ext === 'm4v' ? ['-movflags', '+faststart'] : [];
|
||||
const proc = Bun.spawn(
|
||||
['ffmpeg', '-v', 'error', '-i', absPath, '-map', '0:v', '-map', `0:a:${track}`, '-c', 'copy', '-dn', '-sn', ...movflags, '-y', tmp],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
);
|
||||
const code = await proc.exited;
|
||||
if (code !== 0) {
|
||||
const err = await new Response(proc.stderr).text();
|
||||
await rm(tmp, { force: true }).catch(() => {});
|
||||
throw errors.BAD_REQUEST(err.trim() || 'Audio track remux failed');
|
||||
}
|
||||
await rename(tmp, cacheAbs);
|
||||
return cacheAbs;
|
||||
})();
|
||||
|
||||
audioRemuxInFlight.set(cacheAbs, job);
|
||||
try {
|
||||
return await job;
|
||||
} finally {
|
||||
audioRemuxInFlight.delete(cacheAbs);
|
||||
}
|
||||
}
|
||||
|
||||
// List directory entries
|
||||
router.get('/ls', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -227,9 +277,17 @@ router.get('/raw', async (ctx) => {
|
||||
const s = await stat(absPath);
|
||||
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot serve a directory');
|
||||
|
||||
const file = Bun.file(absPath);
|
||||
// Optional: serve with a specific audio track selected (track 0 is the default → serve raw).
|
||||
let fileAbs = absPath;
|
||||
const audioParam = ctx.req.query('audio');
|
||||
if (audioParam) {
|
||||
const track = parseInt(audioParam, 10);
|
||||
if (Number.isInteger(track) && track > 0) fileAbs = await ensureAudioRemux(user.email, absPath, relPath, track);
|
||||
}
|
||||
|
||||
const file = Bun.file(fileAbs);
|
||||
const contentType = file.type || 'application/octet-stream';
|
||||
const total = s.size;
|
||||
const total = file.size;
|
||||
|
||||
const rangeHeader = ctx.req.header('range');
|
||||
if (rangeHeader) {
|
||||
@@ -323,6 +381,40 @@ router.get('/subtitle-vtt', async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
// List a video's audio tracks (for the external audio-track selector; served via raw?audio=N)
|
||||
router.get('/audio-tracks', 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', 'a', '-show_entries', 'stream=channels,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 ProbeAudio = { channels?: number; codec_name?: string; tags?: { language?: string; title?: string; handler_name?: string } };
|
||||
let streams: ProbeAudio[] = [];
|
||||
try {
|
||||
streams = (JSON.parse(out).streams as ProbeAudio[]) ?? [];
|
||||
} catch {
|
||||
streams = [];
|
||||
}
|
||||
|
||||
const tracks = streams.map((s, id) => ({
|
||||
id,
|
||||
codec: s.codec_name ?? '',
|
||||
channels: s.channels ?? 0,
|
||||
lang: s.tags?.language ?? '',
|
||||
title: trackName(s.tags),
|
||||
}));
|
||||
|
||||
return ctx.json(tracks);
|
||||
});
|
||||
|
||||
// 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