|
|
|
|
@@ -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);
|
|
|
|
|
|