Files
ytplayer/server/uploads.js
Jonathan Sykes 11ddb8a970 Uploads library on the USB drive with a nightly stable-disk copy; Topic channels resolve by name
- UPLOAD_DIR moves to the USB drive; reads fall back to UPLOAD_BACKUP_DIR on the
  ytplayer-data volume, and while the drive is offline new uploads land there
- scripts/ops/uploads-backup.sh (homelab cron, 03:30) copies both ways, never
  deletes; the server removes a deleted upload from both places
- /api/channel resolves a bare channel name ('Artist - Topic') to its id via a
  search, and the client falls back to the channel name for old saved entries
2026-10-02 22:38:05 +08:00

302 lines
15 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;
// 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 }) {
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,
};
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);
}
});
// 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) };
}