Add gapless and crossfade playlist transitions with a mode toggle, and a waveform seek bar for audio-only playback
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
* GET /api/play?v=<id>&f=<fmt> same-origin playback proxy (Range-aware) → media bytes
|
||||
* GET /api/download/:videoId server-cached copy (fetched by a background job) → binary
|
||||
* GET /api/media/:id?g=<gen>[&a=1] Range-aware server-cached mp4 (or m4a audio sidecar)
|
||||
* GET /api/media/:id/peaks loudness envelope (400 buckets, 0..100) for the waveform seek bar
|
||||
* GET /api/media/:id/status { status, size, height, optimized, error }
|
||||
* POST /api/media/:id/redownload "Broken" button: drop the copy, fetch it again
|
||||
* GET /api/media/stats cache totals, budget, free disk, queue
|
||||
@@ -1066,6 +1067,59 @@ app.get('/api/media/:id', async (c) => {
|
||||
});
|
||||
|
||||
// GET /api/media/:id/status
|
||||
// GET /api/media/:id/peaks — loudness envelope of a server-cached copy for
|
||||
// the waveform seek bar: PEAKS_N RMS buckets scaled 0..100. Computed once per
|
||||
// file with ffmpeg (mono, 2 kHz is plenty for an envelope) and kept in memory;
|
||||
// the file path carries the gen, so a replaced copy is recomputed.
|
||||
const PEAKS_N = 400;
|
||||
const PEAKS_RATE = 2000;
|
||||
const peaksCache = new Map();
|
||||
let peaksRunning = 0;
|
||||
function computePeaks(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(FFMPEG, ['-v', 'error', '-i', path, '-vn', '-ac', '1', '-ar', String(PEAKS_RATE), '-f', 's16le', '-'],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const chunks = [];
|
||||
let err = '';
|
||||
child.stdout.on('data', (d) => chunks.push(d));
|
||||
child.stderr.on('data', (d) => { err = (err + d).slice(-2000); });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) return reject(new Error(err.trim() || `ffmpeg exited ${code}`));
|
||||
const buf = Buffer.concat(chunks);
|
||||
const n = Math.floor(buf.length / 2);
|
||||
if (!n) return reject(new Error('no audio'));
|
||||
const per = Math.max(1, Math.ceil(n / PEAKS_N));
|
||||
const rms = [];
|
||||
for (let b = 0; b * per < n; b++) {
|
||||
let sum = 0, cnt = 0;
|
||||
for (let i = b * per; i < Math.min(n, (b + 1) * per); i++) { const v = buf.readInt16LE(i * 2); sum += v * v; cnt++; }
|
||||
rms.push(Math.sqrt(sum / Math.max(1, cnt)));
|
||||
}
|
||||
const max = Math.max(...rms) || 1;
|
||||
// ^0.7 lifts quiet passages so verses stay visible next to a loud bridge.
|
||||
resolve({ duration: n / PEAKS_RATE, peaks: rms.map((v) => Math.round(Math.pow(v / max, 0.7) * 100)) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/api/media/:id/peaks', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
const path = (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4'));
|
||||
if (!path) return c.json({ ok: false, error: 'not cached on the server' }, 404);
|
||||
let hit = peaksCache.get(path);
|
||||
if (!hit) {
|
||||
if (peaksRunning >= 2) return c.json({ ok: false, error: 'busy — retry shortly' }, 503);
|
||||
peaksRunning++;
|
||||
try { hit = await computePeaks(path); } catch (err) { return c.json({ ok: false, error: err.message }, 500); }
|
||||
finally { peaksRunning--; }
|
||||
peaksCache.set(path, hit);
|
||||
if (peaksCache.size > 300) peaksCache.delete(peaksCache.keys().next().value);
|
||||
}
|
||||
return c.json({ ok: true, ...hit }, 200, { 'Cache-Control': 'public, max-age=3600' });
|
||||
});
|
||||
|
||||
app.get('/api/media/:id/status', async (c) => {
|
||||
try {
|
||||
return c.json({ ok: true, ...(await media.status(c.req.param('id'))) }, 200, { 'Cache-Control': 'no-store' });
|
||||
|
||||
Reference in New Issue
Block a user