Still-picture songs save and play as audio plus one picture
This commit is contained in:
@@ -1404,9 +1404,20 @@ function cachedStreamsPayload(videoId, row) {
|
||||
}],
|
||||
serverCached: true,
|
||||
cid: row.sha256 || null,
|
||||
// Album-art song: the picture never changes, so the app plays the audio
|
||||
// and shows this instead of streaming video (additive, web-only).
|
||||
...(row.still === 1 && existsSync(`${MEDIA_DIR}/${videoId}.${row.gen}.jpg`)
|
||||
? { still: true, art: `/api/media/${videoId}/art?g=${row.gen}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// A still-picture copy whose m4a a device may save instead of the video.
|
||||
function stillAudioPath(videoId, row) {
|
||||
if (!row || row.still !== 1 || !row.audio_sha256) return null;
|
||||
const p = `${MEDIA_DIR}/${videoId}.${row.gen}.m4a`;
|
||||
return existsSync(p) && existsSync(`${MEDIA_DIR}/${videoId}.${row.gen}.jpg`) ? p : null;
|
||||
}
|
||||
|
||||
// Serve a file with byte-Range support (the <video> element seeks with it).
|
||||
// `etag` (strong, quoted) lets a resuming client send If-Range: when the file
|
||||
// changed under it, the Range is ignored and the whole new file comes back
|
||||
@@ -1470,6 +1481,17 @@ app.get('/api/media/:id', async (c) => {
|
||||
g ? 'public, max-age=31536000, immutable' : 'no-cache');
|
||||
});
|
||||
|
||||
// GET /api/media/:id/art?g=<gen> — the picture of a still-picture song
|
||||
// (one frame, ≤720p JPEG), shown while its audio plays.
|
||||
app.get('/api/media/:id/art', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const g = c.req.query('g');
|
||||
const path = await media.filePath(id, g, 'jpg');
|
||||
if (!path) return c.json({ ok: false, error: 'no picture' }, 404);
|
||||
return new Response(Bun.file(path), { headers: { 'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': g ? 'public, max-age=31536000, immutable' : 'no-cache' } });
|
||||
});
|
||||
|
||||
// GET /api/export/:id?name=<file name>[&hevc=1] — the server's copy as a file
|
||||
// download (Content-Disposition: attachment), Range-aware so a big file can
|
||||
// resume. Serves a cached YouTube copy or one of the server's own uploads, so a
|
||||
@@ -1719,6 +1741,18 @@ const DOWNLOAD_EXPOSE = 'X-Content-SHA256, Content-Range, Content-Length, ETag,
|
||||
function cachedDownloadResponse(c, videoId, fp, row) {
|
||||
if (fp && !c.req.header('range')) recordVideoAccess(fp, { id: videoId }).catch(() => {});
|
||||
media.touch(videoId);
|
||||
const stillAudio = c.req.query('still') === '1' ? stillAudioPath(videoId, row) : null;
|
||||
if (stillAudio) {
|
||||
return rangeFileResponse(c, stillAudio, 'audio/mp4', 'no-store', {
|
||||
etag: `"${videoId}.${row.gen}.a"`,
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${videoId}.m4a"`,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Expose-Headers': DOWNLOAD_EXPOSE,
|
||||
'X-Content-SHA256': row.audio_sha256,
|
||||
},
|
||||
});
|
||||
}
|
||||
return rangeFileResponse(c, `${MEDIA_DIR}/${videoId}.${row.gen}.mp4`, 'video/mp4', 'no-store', {
|
||||
etag: `"${videoId}.${row.gen}"`,
|
||||
headers: {
|
||||
@@ -1761,6 +1795,14 @@ app.get('/api/download/:videoId/prepare', async (c) => {
|
||||
const hevcOk = c.req.query('hevc') === '1';
|
||||
const ready = await media.getReady(videoId);
|
||||
if (ready) {
|
||||
// ?still=1: the device would rather keep the audio + picture of an
|
||||
// album-art song than a video of one unchanging frame.
|
||||
const stillAudio = c.req.query('still') === '1' ? stillAudioPath(videoId, ready) : null;
|
||||
if (stillAudio) {
|
||||
media.touch(videoId);
|
||||
return c.json({ ok: true, state: 'ready', gen: ready.gen, size: Bun.file(stillAudio).size, sha256: ready.audio_sha256,
|
||||
etag: `"${videoId}.${ready.gen}.a"`, ext: 'm4a', still: true, art: `/api/media/${videoId}/art?g=${ready.gen}` }, 200, nocache);
|
||||
}
|
||||
if (!servableTo(ready, hevcOk)) return c.json({ ok: true, state: 'legacy', reason: 'hevc' }, 200, nocache);
|
||||
media.touch(videoId);
|
||||
// The row's size can differ from the mp4 on disk (it is not the file the
|
||||
@@ -1839,7 +1881,9 @@ app.get('/api/download/:videoId', async (c) => {
|
||||
const row = await media.ensureCached(videoId, { priority: HIGH });
|
||||
// A device that can't decode HEVC must not save the HEVC copy — give it
|
||||
// the legacy H.264 save instead (the ?mux=1 path included).
|
||||
if (servableTo(row, c.req.query('hevc') === '1')) return cachedDownloadResponse(c, videoId, fp, row);
|
||||
if (servableTo(row, c.req.query('hevc') === '1') || stillAudioPath(videoId, row) && c.req.query('still') === '1') {
|
||||
return cachedDownloadResponse(c, videoId, fp, row);
|
||||
}
|
||||
cacheErr = Object.assign(new Error('cached copy is HEVC; client did not ask for it'), { code: 'SKIPPED' });
|
||||
} catch (err) {
|
||||
cacheErr = err;
|
||||
|
||||
Reference in New Issue
Block a user