Support hour-long uploads and videos: raise body limit, stream uploads to disk, cache up to 3 h, add deferred-ideas note

This commit is contained in:
Claude
2026-10-01 18:48:37 +00:00
parent 9d74769029
commit 08e95814e5
8 changed files with 232 additions and 66 deletions

View File

@@ -713,6 +713,10 @@ export async function createUpload(u) {
});
}
export async function setUploadArt(id, art) {
await db.execute({ sql: 'UPDATE uploads SET art = ? WHERE id = ?', args: [art, id] });
}
export async function getUpload(id) {
const r = await db.execute({ sql: 'SELECT * FROM uploads WHERE id = ?', args: [id] });
return r.rows[0] ? uploadRow(r.rows[0]) : null;

View File

@@ -162,7 +162,7 @@ export function createMediaCache({
ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', nice = 'nice',
maxBytes = 10 * 1024 ** 3,
minFreeBytes = 5 * 1024 ** 3,
autoMaxSeconds = 3600,
autoMaxSeconds = 3 * 3600,
saveMaxSeconds = 3 * 3600,
transcode = { enabled: true, codec: 'hevc', crf: 28, preset: 'medium', threads: 2, maxSeconds: 3600 },
freeBytes = () => { const s = statfsSync(dir); return s.bavail * s.bsize; },

View File

@@ -1105,7 +1105,7 @@ const media = createMediaCache({
ffprobe: process.env.FFPROBE_PATH || 'ffprobe',
maxBytes: envNum('MEDIA_CACHE_MAX_BYTES', 10 * 1024 ** 3),
minFreeBytes: envNum('MEDIA_MIN_FREE_BYTES', 5 * 1024 ** 3),
autoMaxSeconds: envNum('MEDIA_AUTO_MAX_SECONDS', 3600),
autoMaxSeconds: envNum('MEDIA_AUTO_MAX_SECONDS', 3 * 3600),
saveMaxSeconds: MAX_SAVE_SECONDS,
transcode: {
enabled: process.env.MEDIA_TRANSCODE !== '0',
@@ -2156,6 +2156,10 @@ async function main() {
// 0 disables the idle timeout entirely (verified with a 300s request);
// runaway downloads are bounded by MAX_SAVE_SECONDS + kill-on-disconnect.
idleTimeout: 0,
// Bun's default request-body cap is 128 MB, which silently rejected every
// upload / device intake of a long video with a 413. Streaming routes
// enforce their own per-route limits (uploads 4 GB, intake P2P_INTAKE_MAX_BYTES).
maxRequestBodySize: 5 * 1024 ** 3,
});
console.log(`[ytplayer] Listening → http://localhost:${PORT}`);

View File

@@ -23,7 +23,7 @@
* ========================================================================== */
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
@@ -92,6 +92,63 @@ export function registerUploadRoutes(app, deps) {
return JSON.parse(out);
}
// Probe a file already on disk, extract cover/lyrics and register the row.
// Shared by the multipart route (small files) and the streaming route.
async function ingest({ id, ext, target, size, filename, fields = {}, artFile = null }) {
const probe = await probeFile(target);
const info = describeProbe(probe, filename);
if (!info.hasAudio && info.kind === 'audio') throw new Error('no audio or video streams found in this file');
// Cover art: an uploaded image wins, else the embedded picture.
let art = null;
if (artFile && typeof artFile !== 'string' && artFile.size) {
const tmp = join(uploadDir, `${id}.art.src`);
await Bun.write(tmp, artFile);
try {
await run(ffmpeg, ['-v', 'error', '-y', '-i', tmp, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", artPath(id)]);
art = 'file';
} catch { /* unusable image — ignore */ }
try { unlinkSync(tmp); } catch { /* gone */ }
}
if (!art && info.coverIndex !== null) {
try {
await run(ffmpeg, ['-v', 'error', '-y', '-i', target, '-map', `0:${info.coverIndex}`, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", artPath(id)]);
art = 'embedded';
} catch { /* cover we can't decode */ }
}
const row = {
id, kind: info.kind, ext, mime: MIME[ext] || (info.kind === 'video' ? 'video/mp4' : 'audio/mpeg'),
size, art,
title: String(fields.title || info.title || baseName(filename)).slice(0, 300),
artist: String(fields.artist || info.artist || '').slice(0, 200),
album: String(fields.album || info.album || '').slice(0, 200),
duration: info.duration,
};
await db.createUpload(row);
// Embedded lyrics → the song's shared lyrics (synced if they are LRC).
let lyricLines = 0;
if (info.lyrics) {
const lines = parseLrc(info.lyrics);
if (lines.length) {
const synced = lines.some((l) => l.t !== null);
const data = sanitizeLyrics({ lines, tags: [synced ? 'from the file (synced)' : 'from the file'], offset: 0 });
await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: 'upload', force: true });
lyricLines = data.lines.length;
}
}
return { upload: await db.getUpload(id), lyricLines };
}
const extError = (ext) => `unsupported file type ".${ext}" — video: ${[...VIDEO_EXT].join(', ')}; audio: ${[...AUDIO_EXT].join(', ')}`;
const cleanup = (id, target) => {
try { unlinkSync(target); } catch { /* not written */ }
try { unlinkSync(artPath(id)); } catch { /* none */ }
};
// Multipart upload — fine for small files and scripts, but the whole body is
// parsed in memory, so the admin page uses the streaming route below.
app.post('/api/admin/uploads', requireAdminOrToken, async (c) => {
let body;
try { body = await c.req.parseBody(); } catch { return c.json({ ok: false, error: 'send the file as multipart/form-data' }, 400); }
@@ -99,65 +156,76 @@ export function registerUploadRoutes(app, deps) {
if (!file || typeof file === 'string' || !file.name) return c.json({ ok: false, error: 'no file' }, 400);
if (file.size > MAX_BYTES) return c.json({ ok: false, error: 'file is larger than 4 GB' }, 413);
const ext = extOf(file.name);
if (!VIDEO_EXT.has(ext) && !AUDIO_EXT.has(ext)) {
return c.json({ ok: false, error: `unsupported file type ".${ext}" — video: ${[...VIDEO_EXT].join(', ')}; audio: ${[...AUDIO_EXT].join(', ')}` }, 415);
}
if (!VIDEO_EXT.has(ext) && !AUDIO_EXT.has(ext)) return c.json({ ok: false, error: extError(ext) }, 415);
const id = 'upl_' + randomBytes(6).toString('hex');
const target = join(uploadDir, `${id}.${ext}`);
try {
await Bun.write(target, file);
const probe = await probeFile(target);
const info = describeProbe(probe, file.name);
if (!info.hasAudio && info.kind === 'audio') throw new Error('no audio or video streams found in this file');
// Cover art: an uploaded image wins, else the embedded picture.
let art = null;
const artFile = body.art;
if (artFile && typeof artFile !== 'string' && artFile.size) {
const tmp = join(uploadDir, `${id}.art.src`);
await Bun.write(tmp, artFile);
try {
await run(ffmpeg, ['-v', 'error', '-y', '-i', tmp, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", artPath(id)]);
art = 'file';
} catch { /* unusable image — ignore */ }
try { unlinkSync(tmp); } catch { /* gone */ }
}
if (!art && info.coverIndex !== null) {
try {
await run(ffmpeg, ['-v', 'error', '-y', '-i', target, '-map', `0:${info.coverIndex}`, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", artPath(id)]);
art = 'embedded';
} catch { /* cover we can't decode */ }
}
const row = {
id, kind: info.kind, ext, mime: MIME[ext] || (info.kind === 'video' ? 'video/mp4' : 'audio/mpeg'),
size: file.size, art,
title: String(body.title || info.title || baseName(file.name)).slice(0, 300),
artist: String(body.artist || info.artist || '').slice(0, 200),
album: String(body.album || info.album || '').slice(0, 200),
duration: info.duration,
};
await db.createUpload(row);
// Embedded lyrics → the song's shared lyrics (synced if they are LRC).
let lyricLines = 0;
if (info.lyrics) {
const lines = parseLrc(info.lyrics);
if (lines.length) {
const synced = lines.some((l) => l.t !== null);
const data = sanitizeLyrics({ lines, tags: [synced ? 'from the file (synced)' : 'from the file'], offset: 0 });
await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: 'upload', force: true });
lyricLines = data.lines.length;
}
}
return c.json({ ok: true, upload: await db.getUpload(id), lyricLines });
const r = await ingest({ id, ext, target, size: file.size, filename: file.name, fields: body, artFile: body.art });
return c.json({ ok: true, ...r });
} catch (err) {
try { unlinkSync(target); } catch { /* not written */ }
try { unlinkSync(artPath(id)); } catch { /* none */ }
cleanup(id, target);
return c.json({ ok: false, error: err.message }, 500);
}
});
// Streaming upload: the raw file is the request body, copied to disk chunk by
// chunk so a multi-GB (hour-long) video never sits in memory.
// PUT /api/admin/uploads/stream?name=talk.mp4&title=&artist=&album=
app.put('/api/admin/uploads/stream', requireAdminOrToken, async (c) => {
const name = String(c.req.query('name') || '');
const ext = extOf(name);
if (!VIDEO_EXT.has(ext) && !AUDIO_EXT.has(ext)) return c.json({ ok: false, error: extError(ext) }, 415);
const declared = Number(c.req.header('content-length'));
if (declared > MAX_BYTES) return c.json({ ok: false, error: 'file is larger than 4 GB' }, 413);
const reader = c.req.raw.body ? c.req.raw.body.getReader() : null;
if (!reader) return c.json({ ok: false, error: 'empty body' }, 400);
const id = 'upl_' + randomBytes(6).toString('hex');
const target = join(uploadDir, `${id}.${ext}`);
try {
let got = 0;
const out = createWriteStream(target);
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
got += value.byteLength;
if (got > MAX_BYTES) throw Object.assign(new Error('file is larger than 4 GB'), { status: 413 });
if (!out.write(value)) await new Promise((r) => out.once('drain', r));
}
} finally {
await new Promise((r) => out.end(r));
}
if (!got) throw Object.assign(new Error('empty file'), { status: 400 });
if (declared > 0 && got !== declared) throw Object.assign(new Error(`upload cut short (${got} of ${declared} bytes)`), { status: 400 });
const q = (k) => String(c.req.query(k) || '');
const r = await ingest({ id, ext, target, size: got, filename: name, fields: { title: q('title'), artist: q('artist'), album: q('album') } });
return c.json({ ok: true, ...r });
} catch (err) {
cleanup(id, target);
return c.json({ ok: false, error: err.message }, err.status || 500);
}
});
// Attach / replace the cover of an existing upload (raw image body).
app.put('/api/admin/uploads/:id/art', requireAdminOrToken, async (c) => {
const id = c.req.param('id');
if (!UPLOAD_ID_RE.test(id) || !(await db.getUpload(id))) return c.json({ ok: false, error: 'not found' }, 404);
const buf = await c.req.arrayBuffer();
if (!buf.byteLength || buf.byteLength > 20 * 1024 * 1024) return c.json({ ok: false, error: 'send an image under 20 MB' }, 400);
const tmp = join(uploadDir, `${id}.art.src`);
try {
await Bun.write(tmp, buf);
await run(ffmpeg, ['-v', 'error', '-y', '-i', tmp, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", artPath(id)]);
await db.setUploadArt(id, 'file');
return c.json({ ok: true });
} catch (err) {
return c.json({ ok: false, error: 'unusable image: ' + err.message }, 400);
} finally {
try { unlinkSync(tmp); } catch { /* gone */ }
}
});
app.delete('/api/admin/uploads/:id', requireAdminOrToken, async (c) => {
const id = c.req.param('id');
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);

View File

@@ -131,3 +131,37 @@ describe('uploads', () => {
expect((await app.request(`/api/uploads/${audioId}`)).status).toBe(404);
});
});
describe('streaming upload (large / long files)', () => {
const put = (path, body, headers = { 'x-admin': 'yes' }) => app.request(path, { method: 'PUT', headers, body });
test('raw body is streamed to disk and registered', async () => {
const buf = readFileSync(fx('clip.mp4'));
const r = await put('/api/admin/uploads/stream?name=Long%20Talk.mp4&title=Long%20Talk', buf);
const j = await r.json();
expect(r.status).toBe(200);
expect(j.upload).toMatchObject({ kind: 'video', title: 'Long Talk', size: buf.length });
expect(existsSync(join(root, 'uploads', `${j.upload.id}.mp4`))).toBe(true);
});
test('needs the admin, a known extension and a non-empty body', async () => {
const buf = readFileSync(fx('clip.mp4'));
expect((await put('/api/admin/uploads/stream?name=a.mp4', buf, {})).status).toBe(401);
expect((await put('/api/admin/uploads/stream?name=a.exe', buf)).status).toBe(415);
expect((await put('/api/admin/uploads/stream?name=a.mp4', '')).status).toBe(400);
});
test('a file that is not media is rejected and leaves nothing behind', async () => {
const before = (await db.listUploads({ limit: 500 })).length;
const r = await put('/api/admin/uploads/stream?name=junk.mp4', new Uint8Array(5000).fill(7));
expect(r.status).toBe(500);
expect((await db.listUploads({ limit: 500 })).length).toBe(before);
});
test('cover image can be attached afterwards', async () => {
const up = await (await put('/api/admin/uploads/stream?name=song.mp3', readFileSync(fx('song.mp3')))).json();
const r = await put(`/api/admin/uploads/${up.upload.id}/art`, readFileSync(fx('cover.png')));
expect((await r.json()).ok).toBe(true);
expect((await db.getUpload(up.upload.id)).art).toBeTruthy();
});
});