284 lines
14 KiB
JavaScript
284 lines
14 KiB
JavaScript
/* ============================================================================
|
|
* uploads.js — the server's own media library (admin uploads)
|
|
*
|
|
* An admin uploads a video or an audio file; it is then searchable and
|
|
* playable next to YouTube results. Files live in UPLOAD_DIR as
|
|
* <id>.<ext> (+ <id>.art.jpg for cover art), the row in the `uploads` table.
|
|
*
|
|
* On upload the file is probed once with ffprobe and everything useful is
|
|
* taken from it:
|
|
* - title / artist / album tags (falling back to the file name),
|
|
* - duration, and whether there is a REAL video stream (an attached cover
|
|
* picture also shows up as a video stream — `disposition.attached_pic`),
|
|
* - embedded cover art → extracted to <id>.art.jpg (or an uploaded image),
|
|
* - embedded lyrics tags (LYRICS / lyrics-eng / UNSYNCEDLYRICS / ©lyr) →
|
|
* saved as the song's shared lyrics, synced when they are in LRC form.
|
|
*
|
|
* Endpoints:
|
|
* POST /api/admin/uploads (admin or token) multipart: file, art?, title?, artist?
|
|
* DELETE /api/admin/uploads/:id (admin or token)
|
|
* GET /api/uploads?q=&limit= public list / search
|
|
* GET /api/uploads/:id the media file (Range-aware)
|
|
* GET /api/uploads/:id/art cover image
|
|
* ========================================================================== */
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { randomBytes } from 'node:crypto';
|
|
|
|
export const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/;
|
|
const MAX_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB
|
|
const VIDEO_EXT = new Set(['mp4', 'webm', 'mkv', 'mov', 'm4v', 'avi']);
|
|
const AUDIO_EXT = new Set(['mp3', 'm4a', 'aac', 'flac', 'ogg', 'opus', 'wav', 'wma', 'mp4a']);
|
|
const MIME = {
|
|
mp4: 'video/mp4', m4v: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska', mov: 'video/quicktime', avi: 'video/x-msvideo',
|
|
mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', ogg: 'audio/ogg', opus: 'audio/ogg', wav: 'audio/wav', wma: 'audio/x-ms-wma',
|
|
};
|
|
|
|
const extOf = (name) => (String(name).toLowerCase().match(/\.([a-z0-9]{1,5})$/) || [, ''])[1];
|
|
const baseName = (name) => String(name).replace(/\.[^.]+$/, '').replace(/[_]+/g, ' ').trim();
|
|
|
|
function run(bin, args) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let out = '', err = '';
|
|
child.stdout.on('data', (d) => { out += d; });
|
|
child.stderr.on('data', (d) => { err = (err + d).slice(-2000); });
|
|
child.on('error', reject);
|
|
child.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(err.trim() || `${bin} exited ${code}`))));
|
|
});
|
|
}
|
|
|
|
// Lyrics can be tagged in many ways depending on the container/tagger.
|
|
function lyricsFromTags(probe) {
|
|
const pools = [probe.format && probe.format.tags, ...(probe.streams || []).map((s) => s.tags)].filter(Boolean);
|
|
for (const tags of pools) {
|
|
for (const [k, v] of Object.entries(tags)) {
|
|
if (/^(lyrics|unsyncedlyrics|usltext|©lyr|syncedlyrics)/i.test(k) && typeof v === 'string' && v.trim().length > 3) return v;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export function describeProbe(probe, fallbackName) {
|
|
const tags = (probe.format && probe.format.tags) || {};
|
|
const pick = (...keys) => { for (const k of keys) { const hit = Object.keys(tags).find((t) => t.toLowerCase() === k); if (hit && String(tags[hit]).trim()) return String(tags[hit]).trim(); } return ''; };
|
|
const streams = probe.streams || [];
|
|
const realVideo = streams.find((s) => s.codec_type === 'video' && !(s.disposition && s.disposition.attached_pic));
|
|
const cover = streams.find((s) => s.codec_type === 'video' && s.disposition && s.disposition.attached_pic);
|
|
const audio = streams.find((s) => s.codec_type === 'audio');
|
|
return {
|
|
kind: realVideo ? 'video' : 'audio',
|
|
hasAudio: !!audio,
|
|
coverIndex: cover ? cover.index : null,
|
|
title: pick('title') || baseName(fallbackName),
|
|
artist: pick('artist', 'album_artist', 'composer'),
|
|
album: pick('album'),
|
|
duration: Math.max(0, Number(probe.format && probe.format.duration) || 0),
|
|
lyrics: lyricsFromTags(probe),
|
|
};
|
|
}
|
|
|
|
export function registerUploadRoutes(app, deps) {
|
|
const { db, ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', uploadDir, rangeFileResponse, requireAdminOrToken, parseLrc, sanitizeLyrics } = deps;
|
|
mkdirSync(uploadDir, { recursive: true });
|
|
|
|
const filePath = (u) => join(uploadDir, `${u.id}.${u.ext}`);
|
|
const artPath = (id) => join(uploadDir, `${id}.art.jpg`);
|
|
|
|
async function probeFile(path) {
|
|
const out = await run(ffprobe, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path]);
|
|
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); }
|
|
const file = body.file;
|
|
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: extError(ext) }, 415);
|
|
const id = 'upl_' + randomBytes(6).toString('hex');
|
|
const target = join(uploadDir, `${id}.${ext}`);
|
|
try {
|
|
await Bun.write(target, file);
|
|
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) {
|
|
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);
|
|
const u = await db.getUpload(id);
|
|
if (!u) return c.json({ ok: false, error: 'not found' }, 404);
|
|
await db.deleteUpload(id);
|
|
try { unlinkSync(filePath(u)); } catch { /* gone */ }
|
|
try { unlinkSync(artPath(id)); } catch { /* none */ }
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
app.get('/api/uploads', async (c) => {
|
|
const q = (c.req.query('q') || '').trim();
|
|
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 100));
|
|
return c.json({ ok: true, uploads: await db.listUploads({ q, limit }) });
|
|
});
|
|
|
|
app.get('/api/uploads/:id', async (c) => {
|
|
const id = c.req.param('id');
|
|
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
|
const u = await db.getUpload(id);
|
|
if (!u || !existsSync(filePath(u))) return c.json({ ok: false, error: 'not found' }, 404);
|
|
db.touchUpload(id);
|
|
return rangeFileResponse(c, filePath(u), u.mime, 'public, max-age=31536000, immutable');
|
|
});
|
|
|
|
app.get('/api/uploads/:id/art', async (c) => {
|
|
const id = c.req.param('id');
|
|
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
|
const p = artPath(id);
|
|
if (!existsSync(p)) return c.json({ ok: false, error: 'no cover art' }, 404);
|
|
return rangeFileResponse(c, p, 'image/jpeg', 'public, max-age=31536000, immutable');
|
|
});
|
|
|
|
// /api/streams payload for an upload: one "Original" quality for video,
|
|
// audio + cover art for audio (the app shows the art where the video goes).
|
|
function streamsPayload(u) {
|
|
const art = u.art ? `/api/uploads/${u.id}/art` : '';
|
|
const url = `/api/uploads/${u.id}`;
|
|
return {
|
|
meta: { id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', duration: u.duration, thumbnail: art },
|
|
audioUrl: u.kind === 'audio' ? url : '',
|
|
qualities: u.kind === 'video' ? [{ label: 'Original', height: 0, hasAudio: true, url }] : [],
|
|
upload: true,
|
|
kind: u.kind,
|
|
art,
|
|
};
|
|
}
|
|
const card = (u) => ({
|
|
id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '',
|
|
duration: u.duration, thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', upload: true, kind: u.kind,
|
|
});
|
|
|
|
return { streamsPayload, card, filePath, artPath, isUploadId: (id) => UPLOAD_ID_RE.test(id) };
|
|
}
|