/* ============================================================================ * 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 * . (+ .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 .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) }; }