/* ============================================================================ * 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; // uploadDir may sit on a removable drive (volumeMarker = a file that only // exists on it). backupDir is the nightly second copy on the stable disk: // reads fall back to it, and while the drive is offline new uploads land // there (the nightly sync copies them back over). const { backupDir = null, volumeMarker = null } = deps; const online = () => { if (!volumeMarker) return true; try { return existsSync(volumeMarker); } catch { return false; } }; const writeDir = () => { const d = online() || !backupDir ? uploadDir : backupDir; try { mkdirSync(d, { recursive: true }); } catch { /* reported by the write itself */ } return d; }; const dirs = () => [online() ? uploadDir : null, backupDir].filter(Boolean); if (online()) mkdirSync(uploadDir, { recursive: true }); if (backupDir) mkdirSync(backupDir, { recursive: true }); const locate = (name) => { for (const d of dirs()) { const p = join(d, name); if (existsSync(p)) return p; } return join(writeDir(), name); }; const filePath = (u) => locate(`${u.id}.${u.ext}`); const artPath = (id) => locate(`${id}.art.jpg`); const newArtPath = (id) => join(writeDir(), `${id}.art.jpg`); const removeEverywhere = (name) => { for (const d of [uploadDir, backupDir].filter(Boolean)) { try { unlinkSync(join(d, name)); } catch { /* gone */ } } }; 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, owner = null, listed = true }) { 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(writeDir(), `${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", newArtPath(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", newArtPath(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, owner, listed, }; 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 */ } removeEverywhere(`${id}.art.jpg`); }; // 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(writeDir(), `${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(writeDir(), `${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); } }); // Device-shared upload (PWA share target): ANY visitor may send a file, so it // is bounded — audio/video only (ffprobe-validated by ingest), a size cap per // file, a byte quota and a daily count per device, a ceiling for all shared // uploads together, one upload at a time per device — and it is unlisted: // reachable by its id (the sender's "Shared uploads" playlist), never in // anyone else's search results. // PUT /api/uploads/shared?name=song.m4a&title=&fp= const envNum = (k, d) => (process.env[k] ? Number(process.env[k]) : d); const SHARED = { fileBytes: envNum('SHARED_UPLOAD_MAX_BYTES', 500 * 1024 ** 2), deviceBytes: envNum('SHARED_UPLOAD_DEVICE_BYTES', 2 * 1024 ** 3), perDay: envNum('SHARED_UPLOAD_PER_DAY', 20), totalBytes: envNum('SHARED_UPLOAD_TOTAL_BYTES', 50 * 1024 ** 3), }; const sharedActive = new Set(); const mb = (n) => Math.round(n / 1048576) + ' MB'; app.put('/api/uploads/shared', async (c) => { const fp = String(c.req.query('fp') || ''); if (!/^[\w-]{8,128}$/.test(fp)) return c.json({ ok: false, error: 'missing device id' }, 400); 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')) || 0; if (declared > SHARED.fileBytes) return c.json({ ok: false, error: `shared files can be up to ${mb(SHARED.fileBytes)}` }, 413); if (sharedActive.has(fp)) return c.json({ ok: false, error: 'this device is already uploading — wait for it to finish' }, 429); const use = await db.sharedUploadUsage(fp); if (use.today >= SHARED.perDay) return c.json({ ok: false, error: `limit of ${SHARED.perDay} shared uploads a day reached` }, 429); if (use.bytes + declared > SHARED.deviceBytes) return c.json({ ok: false, error: `this device's ${mb(SHARED.deviceBytes)} of shared uploads is used up` }, 413); if (use.totalShared + declared > SHARED.totalBytes) return c.json({ ok: false, error: 'the server has no room for more shared uploads' }, 507); const reader = c.req.raw.body ? c.req.raw.body.getReader() : null; if (!reader) return c.json({ ok: false, error: 'empty body' }, 400); sharedActive.add(fp); const id = 'upl_' + randomBytes(6).toString('hex'); const target = join(writeDir(), `${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 > SHARED.fileBytes || use.bytes + got > SHARED.deviceBytes) { throw Object.assign(new Error(`shared files can be up to ${mb(Math.min(SHARED.fileBytes, SHARED.deviceBytes - use.bytes))}`), { 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 }); let r; try { r = await ingest({ id, ext, target, size: got, filename: name, fields: { title: String(c.req.query('title') || '') }, owner: fp, listed: false }); } catch (err) { // ffprobe's stderr names server paths — never hand that to a visitor. throw Object.assign(new Error('that file is not a playable audio or video file'), { status: 415 }); } return c.json({ ok: true, ...r }); } catch (err) { cleanup(id, target); return c.json({ ok: false, error: err.message }, err.status || 500); } finally { sharedActive.delete(fp); } }); // 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(writeDir(), `${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", newArtPath(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); removeEverywhere(`${u.id}.${u.ext}`); removeEverywhere(`${id}.art.jpg`); 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) }; }