Fetch lyrics from LRCLIB (synced when available) and serve an admin media library of uploaded video and audio with cover art and embedded lyrics
This commit is contained in:
61
server/db.js
61
server/db.js
@@ -142,6 +142,23 @@ export async function initDb() {
|
||||
CREATE INDEX IF NOT EXISTS idx_note_revs_time
|
||||
ON video_note_revs (created_at DESC);
|
||||
|
||||
-- Admin uploads: the server's own video/audio library, searched next to
|
||||
-- YouTube. Files live in UPLOAD_DIR as <id>.<ext> (+ <id>.art.jpg).
|
||||
CREATE TABLE IF NOT EXISTS uploads (
|
||||
id TEXT PRIMARY KEY, -- upl_<hex>
|
||||
kind TEXT NOT NULL, -- video | audio
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT,
|
||||
album TEXT,
|
||||
duration REAL NOT NULL DEFAULT 0,
|
||||
ext TEXT NOT NULL,
|
||||
mime TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
art TEXT, -- 'embedded' | 'file' | NULL
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
plays INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- API tokens for scripts (lyrics injection etc.). Only a SHA-256 of the
|
||||
-- token is stored; the plaintext is shown once when it is created.
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
@@ -561,3 +578,47 @@ export async function getUserData(fingerprint) {
|
||||
|
||||
return { lastVersion, playlists, history };
|
||||
}
|
||||
|
||||
// ---- Uploads (the server's own media library) ---------------------------------
|
||||
|
||||
const uploadRow = (r) => ({
|
||||
id: r.id, kind: r.kind, title: r.title, artist: r.artist || '', album: r.album || '',
|
||||
duration: Number(r.duration) || 0, ext: r.ext, mime: r.mime, size: Number(r.size) || 0,
|
||||
art: r.art || null, createdAt: Number(r.created_at), plays: Number(r.plays) || 0,
|
||||
});
|
||||
|
||||
export async function createUpload(u) {
|
||||
await db.execute({
|
||||
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`,
|
||||
args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUpload(id) {
|
||||
const r = await db.execute({ sql: 'SELECT * FROM uploads WHERE id = ?', args: [id] });
|
||||
return r.rows[0] ? uploadRow(r.rows[0]) : null;
|
||||
}
|
||||
|
||||
// Newest first; `q` matches title/artist/album (case-insensitive).
|
||||
export async function listUploads({ q = '', limit = 100 } = {}) {
|
||||
const like = `%${String(q).toLowerCase()}%`;
|
||||
const r = q
|
||||
? await db.execute({
|
||||
sql: `SELECT * FROM uploads
|
||||
WHERE lower(title) LIKE ? OR lower(ifnull(artist, '')) LIKE ? OR lower(ifnull(album, '')) LIKE ?
|
||||
ORDER BY created_at DESC LIMIT ?`,
|
||||
args: [like, like, like, limit],
|
||||
})
|
||||
: await db.execute({ sql: 'SELECT * FROM uploads ORDER BY created_at DESC LIMIT ?', args: [limit] });
|
||||
return r.rows.map(uploadRow);
|
||||
}
|
||||
|
||||
export async function deleteUpload(id) {
|
||||
const r = await db.execute({ sql: 'DELETE FROM uploads WHERE id = ?', args: [id] });
|
||||
return (r.rowsAffected || 0) > 0;
|
||||
}
|
||||
|
||||
export async function touchUpload(id) {
|
||||
db.execute({ sql: 'UPDATE uploads SET plays = plays + 1 WHERE id = ?', args: [id] }).catch(() => {});
|
||||
}
|
||||
|
||||
134
server/notes.js
134
server/notes.js
@@ -24,6 +24,7 @@
|
||||
* GET /api/notes/:id/:kind/revs/:rev → one revision's data
|
||||
* GET /api/notes/:id/captions → YouTube captions as lyric lines (preview, no save)
|
||||
* POST /api/notes/:id/lyrics/auto { overwrite? } token/admin: captions → saved lyrics
|
||||
* POST /api/notes/:id/lyrics/web { overwrite? } token/admin: LRCLIB (synced when available)
|
||||
* POST /api/admin/login | /api/admin/logout, GET /api/admin/me
|
||||
* GET|POST /api/admin/tokens, DELETE /api/admin/tokens/:id
|
||||
* GET /api/admin/notes/recent, GET /api/admin/notes/export
|
||||
@@ -38,7 +39,8 @@ import { join } from 'node:path';
|
||||
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
|
||||
|
||||
export const NOTE_KINDS = new Set(['lyrics', 'chapters']);
|
||||
const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;
|
||||
// A YouTube id or one of the server's own uploads (see uploads.js).
|
||||
const VIDEO_ID_RE = /^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/;
|
||||
|
||||
const MAX_LINES = 1000;
|
||||
const MAX_LINE_CHARS = 300;
|
||||
@@ -179,6 +181,91 @@ export function pickCaptionTrack(info) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Lyrics from the web (LRCLIB) ---------------------------------------------
|
||||
// LRCLIB (lrclib.net) is a free, key-less, crowd-sourced lyrics database made
|
||||
// for music players; it often has SYNCED lyrics, which is what this app wants.
|
||||
// Titles from YouTube carry a lot of noise ("(Official Video)", "| Lyrics",
|
||||
// "Karaoke | Minus-One"), so they are cleaned before the lookup.
|
||||
const NOISE = /\b(official|video|audio|lyrics?|lyric|hd|hq|4k|live|mv|karaoke|minus[\s-]?one|instrumental|cover|remaster(ed)?|visualizer|performance|version)\b/gi;
|
||||
|
||||
// NOISE is global (used with .replace), so testing needs its own stateless
|
||||
// copy — a /g regex remembers lastIndex between .test() calls.
|
||||
const hasNoise = (s) => new RegExp(NOISE.source, 'i').test(s);
|
||||
|
||||
export function cleanTitle(raw) {
|
||||
let t = String(raw || '');
|
||||
t = t.split('|')[0]; // "Song | Channel extras"
|
||||
t = t.replace(/\([^)]*\)|\[[^\]]*\]/g, (m) => (hasNoise(m) ? ' ' : m)); // drop noisy brackets only
|
||||
t = t.replace(NOISE, ' ');
|
||||
t = t.replace(/\(\s*\)|\[\s*\]/g, ' ').replace(/[-–—]\s*$/, ''); // leftovers like "[ ]"
|
||||
return t.replace(/\s{2,}/g, ' ').replace(/^[\s\-–—,]+|[\s\-–—,]+$/g, '').trim();
|
||||
}
|
||||
export function cleanArtist(raw) {
|
||||
return String(raw || '').replace(/\s*-\s*Topic$/i, '').replace(/VEVO$/i, '').replace(NOISE, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
}
|
||||
|
||||
// "[mm:ss.xx] words" → timed lines; plain text → untimed lines.
|
||||
export function parseLrc(text) {
|
||||
const out = [];
|
||||
for (const raw of String(text || '').replace(/\r/g, '').split('\n')) {
|
||||
let rest = raw.trim();
|
||||
if (!rest) continue;
|
||||
if (/^\[[a-z]+:[^\]]*\]$/i.test(rest)) continue; // [ar:…] [length:…] headers
|
||||
const stamps = [];
|
||||
let m;
|
||||
while ((m = rest.match(/^\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/))) {
|
||||
stamps.push(Number(m[1]) * 60 + Number(m[2]) + (m[3] ? Number('0.' + m[3]) : 0));
|
||||
rest = rest.slice(m[0].length).trim();
|
||||
}
|
||||
if (!rest) continue;
|
||||
if (!stamps.length) out.push({ t: null, text: rest, kind: 'line' });
|
||||
for (const t of stamps) out.push({ t: Math.round(t * 100) / 100, text: rest, kind: 'line' });
|
||||
}
|
||||
if (out.length && out.every((l) => l.t !== null)) out.sort((a, b) => a.t - b.t);
|
||||
return out;
|
||||
}
|
||||
|
||||
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Pick the entry whose title matches and whose duration is closest (± 6 s).
|
||||
export function pickLrclib(list, { title, duration }) {
|
||||
const want = norm(title);
|
||||
const scored = (Array.isArray(list) ? list : [])
|
||||
.filter((x) => x && (x.syncedLyrics || x.plainLyrics) && !x.instrumental)
|
||||
.map((x) => {
|
||||
const t = norm(x.trackName);
|
||||
const dd = duration && x.duration ? Math.abs(x.duration - duration) : 99;
|
||||
const titleHit = t === want ? 2 : t.includes(want) || want.includes(t) ? 1 : 0;
|
||||
return { x, score: titleHit * 10 + (x.syncedLyrics ? 3 : 0) - Math.min(9, dd), dd, titleHit };
|
||||
})
|
||||
.filter((c) => c.titleHit > 0 && (!duration || c.dd <= 6))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
return scored.length ? scored[0].x : null;
|
||||
}
|
||||
|
||||
async function lrclibLookup({ title, artist, album, duration }) {
|
||||
const get = async (url) => {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': 'ytplayer (https://worship.hesed.sbs)' } });
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`LRCLIB HTTP ${res.status}`);
|
||||
return res.json();
|
||||
};
|
||||
const q = new URLSearchParams({ track_name: title, artist_name: artist || '' });
|
||||
if (album) q.set('album_name', album);
|
||||
if (duration) q.set('duration', String(Math.round(duration)));
|
||||
let hit = null;
|
||||
try { hit = await get(`https://lrclib.net/api/get?${q}`); } catch { /* fall through to search */ }
|
||||
if (!hit) {
|
||||
const list = await get(`https://lrclib.net/api/search?q=${encodeURIComponent(`${title} ${artist || ''}`.trim())}`);
|
||||
hit = pickLrclib(list, { title, duration });
|
||||
}
|
||||
if (!hit) return null;
|
||||
const synced = hit.syncedLyrics && parseLrc(hit.syncedLyrics);
|
||||
if (synced && synced.length) return { lines: synced, synced: true, meta: hit };
|
||||
const plain = hit.plainLyrics && parseLrc(hit.plainLyrics);
|
||||
return plain && plain.length ? { lines: plain, synced: false, meta: hit } : null;
|
||||
}
|
||||
|
||||
// ---- Tokens / admin cookie ------------------------------------------------------
|
||||
|
||||
export function hashToken(token) {
|
||||
@@ -331,6 +418,50 @@ export function registerNoteRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// POST /api/notes/:id/lyrics/web — look the song up on LRCLIB (timed lyrics
|
||||
// when they exist) and save them. Never overwrites existing lyrics unless
|
||||
// asked. Token/admin only; title & artist come from the cached media info
|
||||
// unless the caller passes them.
|
||||
app.post('/api/notes/:id/lyrics/web', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (badId(id)) return c.json({ ok: false, error: 'invalid video id' }, 400);
|
||||
let body = {};
|
||||
try { body = await c.req.json(); } catch { /* optional */ }
|
||||
const who = await resolveWriter(c, body);
|
||||
if (!who || who.invalid) return c.json({ ok: false, error: 'link an online profile (or use an API token) to fetch lyrics' }, 401);
|
||||
try {
|
||||
const existing = (await db.getNotes(id)).lyrics;
|
||||
if (existing && existing.data.lines.length && !body.overwrite) {
|
||||
return c.json({ ok: false, error: 'lyrics already exist — pass {"overwrite":true} to replace them', rev: existing.rev }, 409);
|
||||
}
|
||||
let meta = {};
|
||||
let row = null;
|
||||
if (id.startsWith('upl_')) {
|
||||
const u = await db.getUpload(id);
|
||||
if (u) meta = { title: u.title, channel: u.artist, album: u.album, duration: u.duration };
|
||||
} else {
|
||||
try { meta = JSON.parse((await db.getMedia(id) || {}).meta || '{}'); } catch { /* none */ }
|
||||
row = await db.getMedia(id);
|
||||
}
|
||||
const title = cleanTitle(body.title || meta.title || '');
|
||||
const artist = cleanArtist(body.artist || meta.channel || meta.uploader || '');
|
||||
const duration = Number(body.duration || (row && row.duration) || meta.duration || 0) || 0;
|
||||
if (!title) return c.json({ ok: false, error: 'no title known for this video — pass {"title":"…","artist":"…"}' }, 400);
|
||||
const hit = await lrclibLookup({ title, artist, album: body.album || meta.album, duration });
|
||||
if (!hit) return c.json({ ok: false, error: `no lyrics on LRCLIB for “${title}”${artist ? ` by ${artist}` : ''}` }, 404);
|
||||
const data = sanitizeLyrics({
|
||||
lines: hit.lines,
|
||||
tags: [hit.synced ? 'from LRCLIB (synced)' : 'from LRCLIB (plain text)'],
|
||||
offset: 0,
|
||||
});
|
||||
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
|
||||
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced, match: { track: hit.meta.trackName, artist: hit.meta.artistName, duration: hit.meta.duration } });
|
||||
} catch (err) {
|
||||
return c.json({ ok: false, error: err.message }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/notes/:id/:kind', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const kind = c.req.param('kind');
|
||||
@@ -511,6 +642,7 @@ export function registerNoteRoutes(app, deps) {
|
||||
}
|
||||
}
|
||||
return {
|
||||
requireAdminOrToken,
|
||||
startBackups() {
|
||||
setTimeout(writeBackup, 60_000);
|
||||
setInterval(writeBackup, 24 * 3600_000).unref?.();
|
||||
|
||||
@@ -106,6 +106,36 @@ describe('pure helpers', () => {
|
||||
expect(N.pickCaptionTrack({ automatic_captions: { es: [{ ext: 'vtt', url: 's' }] } })).toBeNull();
|
||||
});
|
||||
|
||||
test('cleanTitle / cleanArtist strip YouTube noise', () => {
|
||||
expect(N.cleanTitle('Hosanna - Hillsong Worship (Official Live Video) [HD]')).toBe('Hosanna - Hillsong Worship');
|
||||
expect(N.cleanTitle('Kay Buti-Buti Mo Panginoon Karaoke | Minus-One | Instrumental')).toBe('Kay Buti-Buti Mo Panginoon');
|
||||
expect(N.cleanTitle('Still (Lyrics)')).toBe('Still');
|
||||
expect(N.cleanArtist('Hillsong Worship - Topic')).toBe('Hillsong Worship');
|
||||
expect(N.cleanArtist('BethelVEVO')).toBe('Bethel');
|
||||
});
|
||||
|
||||
test('parseLrc reads synced and plain lyrics', () => {
|
||||
const synced = N.parseLrc('[ar:Someone]\n[00:12.50]first line here\n[00:20.00][01:05.25]repeated line\n');
|
||||
expect(synced).toEqual([
|
||||
{ t: 12.5, text: 'first line here', kind: 'line' },
|
||||
{ t: 20, text: 'repeated line', kind: 'line' },
|
||||
{ t: 65.25, text: 'repeated line', kind: 'line' },
|
||||
]);
|
||||
const plain = N.parseLrc('one line\n\nanother line');
|
||||
expect(plain).toEqual([{ t: null, text: 'one line', kind: 'line' }, { t: null, text: 'another line', kind: 'line' }]);
|
||||
});
|
||||
|
||||
test('pickLrclib prefers a title match, synced lyrics and the closest duration', () => {
|
||||
const list = [
|
||||
{ trackName: 'As The Deer', duration: 120, plainLyrics: 'x' },
|
||||
{ trackName: 'As The Deer', duration: 249, syncedLyrics: '[00:01.00]x', plainLyrics: 'x' },
|
||||
{ trackName: 'As The Deer (Instrumental)', duration: 248, instrumental: true, plainLyrics: 'x' },
|
||||
{ trackName: 'Something else', duration: 250, syncedLyrics: '[00:01.00]y' },
|
||||
];
|
||||
expect(N.pickLrclib(list, { title: 'As the Deer', duration: 249 }).duration).toBe(249);
|
||||
expect(N.pickLrclib(list, { title: 'No such song', duration: 249 })).toBeNull();
|
||||
});
|
||||
|
||||
test('admin cookie signature and expiry', () => {
|
||||
const v = N.signAdminCookie('k', 2000);
|
||||
expect(N.verifyAdminCookie('k', v, 1000)).toBe(true);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"start": "bun server.js",
|
||||
"dev": "bun --hot server.js",
|
||||
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js && bun test ./party.test.js"
|
||||
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.14.0",
|
||||
|
||||
@@ -43,9 +43,10 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP
|
||||
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js';
|
||||
import { createMediaCache, HIGH, LOW } from './media-cache.js';
|
||||
import * as notesDb from './db.js';
|
||||
import { registerNoteRoutes } from './notes.js';
|
||||
import { registerNoteRoutes, parseLrc, sanitizeLyrics } from './notes.js';
|
||||
import { createRemoteHub } from './remote.js';
|
||||
import { createPartyHub } from './party.js';
|
||||
import { registerUploadRoutes } from './uploads.js';
|
||||
import QRCode from 'qrcode';
|
||||
import { dirname, join as pathJoin } from 'node:path';
|
||||
|
||||
@@ -58,6 +59,7 @@ const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const APP_VERSION = process.env.APP_VERSION || '1.0.0';
|
||||
const YTDLP = process.env.YTDLP_PATH || 'yt-dlp';
|
||||
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
|
||||
const FFPROBE = process.env.FFPROBE_PATH || 'ffprobe';
|
||||
// Cap for server-side SAVE downloads. yt-dlp with -N 4 pinned the homelab's
|
||||
// whole downlink (~7.6 MB/s measured), and since /api/play fetches its own
|
||||
// googlevideo slices over the same link, one long save starved every
|
||||
@@ -356,14 +358,19 @@ app.get('/api/search', async (c) => {
|
||||
const q = (c.req.query('q') || '').trim();
|
||||
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
|
||||
|
||||
// This server's own library first — and it still answers when YouTube
|
||||
// (yt-dlp) is unreachable or rate-limited.
|
||||
let mine = [];
|
||||
try { mine = (await notesDb.listUploads({ q, limit: 20 })).map(uploads.card); } catch { /* library optional */ }
|
||||
try {
|
||||
const out = await runYtdlpResilient([
|
||||
`ytsearch${SEARCH_LIMIT}:${q}`,
|
||||
'--dump-json', '--flat-playlist',
|
||||
'--no-warnings', '--ignore-errors',
|
||||
]);
|
||||
return c.json({ ok: true, results: parseCards(out) });
|
||||
return c.json({ ok: true, results: [...mine, ...parseCards(out)] });
|
||||
} catch (err) {
|
||||
if (mine.length) return c.json({ ok: true, results: mine, youtubeError: err.message });
|
||||
return c.json({ ok: false, error: err.message }, 500);
|
||||
}
|
||||
});
|
||||
@@ -509,6 +516,13 @@ app.get('/api/streams', async (c) => {
|
||||
const videoId = (c.req.query('v') || '').replace(/[/\\:?<>|*"]/g, '').trim();
|
||||
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
|
||||
|
||||
// An upload from the server's own library — no yt-dlp, no media cache.
|
||||
if (isUpload(videoId)) {
|
||||
const u = await notesDb.getUpload(videoId);
|
||||
if (!u) return c.json({ ok: false, error: 'upload not found' }, 404);
|
||||
return c.json({ ok: true, data: uploads.streamsPayload(u) });
|
||||
}
|
||||
|
||||
// Server already holds a validated copy → answer from the DB alone, no
|
||||
// yt-dlp round trip. ?nocache=1 (the client's fallback when a cached copy
|
||||
// won't play on its device) forces the YouTube path below.
|
||||
@@ -1070,6 +1084,23 @@ app.get('/api/media/:id', async (c) => {
|
||||
});
|
||||
|
||||
// GET /api/media/:id/status
|
||||
// Where the bytes for an id live: the validated YouTube copy in the media
|
||||
// cache, or one of the server's own uploads.
|
||||
async function audioSourcePath(id) {
|
||||
if (isUpload(id)) {
|
||||
const u = await notesDb.getUpload(id);
|
||||
return u ? uploads.filePath(u) : null;
|
||||
}
|
||||
return (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4'));
|
||||
}
|
||||
async function videoSourcePath(id) {
|
||||
if (isUpload(id)) {
|
||||
const u = await notesDb.getUpload(id);
|
||||
return u && u.kind === 'video' ? uploads.filePath(u) : null;
|
||||
}
|
||||
return media.filePath(id, null, 'mp4');
|
||||
}
|
||||
|
||||
// GET /api/media/:id/peaks — loudness envelope of a server-cached copy for
|
||||
// the waveform seek bar: PEAKS_N RMS buckets scaled 0..100. Computed once per
|
||||
// file with ffmpeg (mono, 2 kHz is plenty for an envelope) and kept in memory;
|
||||
@@ -1108,8 +1139,8 @@ function computePeaks(path) {
|
||||
|
||||
app.get('/api/media/:id/peaks', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
const path = (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4'));
|
||||
if (!/^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
const path = await audioSourcePath(id);
|
||||
if (!path) return c.json({ ok: false, error: 'not cached on the server' }, 404);
|
||||
let hit = peaksCache.get(path);
|
||||
if (!hit) {
|
||||
@@ -1130,11 +1161,11 @@ const gifCache = new Map();
|
||||
let gifRunning = 0;
|
||||
app.get('/api/media/:id/gif', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
if (!/^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
const t = Math.max(0, Number(c.req.query('t')) || 0);
|
||||
const d = Math.min(6, Math.max(1, Number(c.req.query('d')) || 3));
|
||||
const w = Math.min(640, Math.max(240, Math.round((Number(c.req.query('w')) || 480) / 2) * 2));
|
||||
const path = await media.filePath(id, null, 'mp4');
|
||||
const path = await videoSourcePath(id);
|
||||
if (!path) return c.json({ ok: false, error: 'this video is not cached on the server yet — play it once, then try again' }, 404);
|
||||
const key = `${path}|${t.toFixed(1)}|${d}|${w}`;
|
||||
let gif = gifCache.get(key);
|
||||
@@ -1177,12 +1208,12 @@ app.get('/api/media/:id/gif', async (c) => {
|
||||
// (.m4r = AAC in an iPod MP4 container, ≤ 40 s — Apple's ringtone limit).
|
||||
app.get('/api/media/:id/clip', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
if (!/^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||
const fmt = c.req.query('fmt') === 'm4r' ? 'm4r' : 'mp3';
|
||||
const start = Math.max(0, Number(c.req.query('start')) || 0);
|
||||
const maxLen = fmt === 'm4r' ? 40 : 60;
|
||||
const len = Math.min(maxLen, Math.max(1, (Number(c.req.query('end')) || start + 20) - start));
|
||||
const path = (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4'));
|
||||
const path = await audioSourcePath(id);
|
||||
if (!path) return c.json({ ok: false, error: 'this video is not cached on the server yet — play it once, then try again' }, 404);
|
||||
const fade = Math.min(0.5, len / 4);
|
||||
const af = `afade=t=in:st=0:d=${fade},afade=t=out:st=${(len - fade).toFixed(2)}:d=${fade}`;
|
||||
@@ -1263,6 +1294,17 @@ function cachedDownloadResponse(videoId, fp, row) {
|
||||
app.get('/api/download/:videoId', async (c) => {
|
||||
const videoId = (c.req.param('videoId') || '').replace(/[/\\:?<>|*"]/g, '').trim();
|
||||
if (!videoId) return c.json({ ok: false, error: 'missing videoId' }, 400);
|
||||
|
||||
// Uploads are already a single file on disk — hand it over as-is.
|
||||
if (isUpload(videoId)) {
|
||||
const u = await notesDb.getUpload(videoId);
|
||||
if (!u) return c.json({ ok: false, error: 'upload not found' }, 404);
|
||||
const f = Bun.file(uploads.filePath(u));
|
||||
return new Response(f, { status: 200, headers: {
|
||||
'Content-Type': u.mime, 'Content-Length': String(f.size),
|
||||
'Content-Disposition': `attachment; filename="${u.id}.${u.ext}"`, 'Cache-Control': 'no-store',
|
||||
} });
|
||||
}
|
||||
const fp = c.req.query('fp');
|
||||
|
||||
// ?edit=1&keep=s-e,s-e — "Edit & download" path: download the source, then
|
||||
@@ -1772,6 +1814,22 @@ const notes = registerNoteRoutes(app, {
|
||||
workerToken: process.env.LYRICS_WORKER_TOKEN || '',
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Uploads — the server's own searchable media library (see uploads.js)
|
||||
// ============================================================================
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'uploads');
|
||||
const uploads = registerUploadRoutes(app, {
|
||||
db: notesDb,
|
||||
ffmpeg: FFMPEG,
|
||||
ffprobe: FFPROBE,
|
||||
uploadDir: UPLOAD_DIR,
|
||||
rangeFileResponse,
|
||||
requireAdminOrToken: notes.requireAdminOrToken,
|
||||
parseLrc,
|
||||
sanitizeLyrics,
|
||||
});
|
||||
const isUpload = (id) => uploads.isUploadId(id);
|
||||
|
||||
// ============================================================================
|
||||
// GET /sw.js — serve the service worker with BUILD_TAG injected
|
||||
//
|
||||
|
||||
215
server/uploads.js
Normal file
215
server/uploads.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/* ============================================================================
|
||||
* 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 { 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);
|
||||
}
|
||||
|
||||
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: `unsupported file type ".${ext}" — video: ${[...VIDEO_EXT].join(', ')}; audio: ${[...AUDIO_EXT].join(', ')}` }, 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 });
|
||||
} catch (err) {
|
||||
try { unlinkSync(target); } catch { /* not written */ }
|
||||
try { unlinkSync(artPath(id)); } catch { /* none */ }
|
||||
return c.json({ ok: false, error: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
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) };
|
||||
}
|
||||
133
server/uploads.test.js
Normal file
133
server/uploads.test.js
Normal file
@@ -0,0 +1,133 @@
|
||||
// Admin uploads: real ffmpeg-made files through the real routes.
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), 'ytp-uploads-test-'));
|
||||
process.env.DB_PATH = join(root, 'test.db');
|
||||
const db = await import('./db.js');
|
||||
const { registerUploadRoutes, describeProbe } = await import('./uploads.js');
|
||||
const { parseLrc, sanitizeLyrics } = await import('./notes.js');
|
||||
|
||||
const ff = (...args) => {
|
||||
const r = spawnSync('ffmpeg', ['-v', 'error', '-y', ...args]);
|
||||
if (r.status !== 0) throw new Error('ffmpeg: ' + r.stderr);
|
||||
};
|
||||
const fx = (n) => join(root, n);
|
||||
let app, uploads;
|
||||
|
||||
beforeAll(async () => {
|
||||
await db.initDb();
|
||||
ff('-f', 'lavfi', '-i', 'color=c=orange:s=240x240:d=1', '-frames:v', '1', fx('cover.png'));
|
||||
// Audio with cover art + embedded synced lyrics (made-up words).
|
||||
ff('-f', 'lavfi', '-i', 'sine=frequency=440:duration=5', '-i', fx('cover.png'),
|
||||
'-map', '0:a', '-map', '1:v', '-c:v', 'mjpeg', '-disposition:v', 'attached_pic',
|
||||
'-metadata', 'title=Test Song', '-metadata', 'artist=Test Artist', '-metadata', 'album=Test Album',
|
||||
'-metadata', 'lyrics=[00:00.50]first test line\n[00:02.00]second test line',
|
||||
'-id3v2_version', '3', fx('song.mp3'));
|
||||
ff('-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=15:duration=3', '-f', 'lavfi', '-i', 'sine=frequency=330:duration=3',
|
||||
'-shortest', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', fx('clip.mp4'));
|
||||
app = new Hono();
|
||||
uploads = registerUploadRoutes(app, {
|
||||
db, uploadDir: join(root, 'uploads'), parseLrc, sanitizeLyrics,
|
||||
requireAdminOrToken: async (c, next) => { if (c.req.header('x-admin') === 'yes') await next(); else return c.json({ ok: false, error: 'admin only' }, 401); },
|
||||
rangeFileResponse: (c, path, type) => {
|
||||
const body = readFileSync(path);
|
||||
const range = c.req.header('range');
|
||||
if (!range) return c.body(body, 200, { 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Content-Length': String(body.length) });
|
||||
const [s, e] = range.replace('bytes=', '').split('-');
|
||||
const start = Number(s), end = e ? Number(e) : body.length - 1;
|
||||
return c.body(body.subarray(start, end + 1), 206, { 'Content-Type': type, 'Content-Range': `bytes ${start}-${end}/${body.length}` });
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
const upload = async (file, name, extra = {}) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', new File([readFileSync(fx(file))], name));
|
||||
for (const [k, v] of Object.entries(extra)) fd.append(k, typeof v === 'string' ? v : new File([readFileSync(fx(v.file))], v.name));
|
||||
const res = await app.request('/api/admin/uploads', { method: 'POST', headers: { 'x-admin': 'yes' }, body: fd });
|
||||
return { status: res.status, body: await res.json() };
|
||||
};
|
||||
|
||||
describe('uploads', () => {
|
||||
let audioId, videoId;
|
||||
|
||||
test('describeProbe separates a real video from an attached cover picture', () => {
|
||||
const probe = {
|
||||
format: { duration: '5.0', tags: { title: 'T', artist: 'A', LYRICS: '[00:01.00]x' } },
|
||||
streams: [{ codec_type: 'audio', index: 0 }, { codec_type: 'video', index: 1, disposition: { attached_pic: 1 } }],
|
||||
};
|
||||
const info = describeProbe(probe, 'file.mp3');
|
||||
expect(info).toMatchObject({ kind: 'audio', hasAudio: true, coverIndex: 1, title: 'T', artist: 'A' });
|
||||
expect(info.lyrics).toBe('[00:01.00]x');
|
||||
const vid = describeProbe({ format: { duration: '3' }, streams: [{ codec_type: 'video', index: 0 }, { codec_type: 'audio', index: 1 }] }, 'My Clip.mp4');
|
||||
expect(vid).toMatchObject({ kind: 'video', title: 'My Clip' });
|
||||
});
|
||||
|
||||
test('refuses anything but an admin/token, and unsupported types', async () => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', new File(['x'], 'a.mp3'));
|
||||
expect((await app.request('/api/admin/uploads', { method: 'POST', body: fd })).status).toBe(401);
|
||||
const bad = await upload('cover.png', 'cover.png');
|
||||
expect(bad.status).toBe(415);
|
||||
});
|
||||
|
||||
test('audio upload: tags, embedded cover art and embedded synced lyrics', async () => {
|
||||
const r = await upload('song.mp3', 'song.mp3');
|
||||
expect(r.status).toBe(200);
|
||||
const u = r.body.upload;
|
||||
audioId = u.id;
|
||||
expect(u).toMatchObject({ kind: 'audio', title: 'Test Song', artist: 'Test Artist', album: 'Test Album', art: 'embedded', ext: 'mp3' });
|
||||
expect(Math.round(u.duration)).toBe(5);
|
||||
expect(r.body.lyricLines).toBe(2);
|
||||
const notes = await db.getNotes(audioId);
|
||||
expect(notes.lyrics.data.lines[0]).toEqual({ t: 0.5, text: 'first test line', kind: 'line' });
|
||||
expect(notes.lyrics.data.tags).toEqual(['from the file (synced)']);
|
||||
const art = await app.request(`/api/uploads/${audioId}/art`);
|
||||
expect(art.status).toBe(200);
|
||||
expect(art.headers.get('content-type')).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
test('video upload with an uploaded cover; manual title/artist win', async () => {
|
||||
const r = await upload('clip.mp4', 'clip.mp4', { title: 'Manual title', artist: 'Manual artist', art: { file: 'cover.png', name: 'cover.png' } });
|
||||
expect(r.status).toBe(200);
|
||||
videoId = r.body.upload.id;
|
||||
expect(r.body.upload).toMatchObject({ kind: 'video', title: 'Manual title', artist: 'Manual artist', art: 'file' });
|
||||
expect(r.body.lyricLines).toBe(0);
|
||||
});
|
||||
|
||||
test('listing, search and the streams/card shapes', async () => {
|
||||
const all = await (await app.request('/api/uploads')).json();
|
||||
expect(all.uploads.length).toBe(2);
|
||||
const hit = await (await app.request('/api/uploads?q=test+art')).json();
|
||||
expect(hit.uploads.map((u) => u.id)).toEqual([audioId]);
|
||||
const u = await db.getUpload(audioId);
|
||||
expect(uploads.streamsPayload(u)).toMatchObject({
|
||||
audioUrl: `/api/uploads/${audioId}`, kind: 'audio', upload: true, art: `/api/uploads/${audioId}/art`, qualities: [],
|
||||
});
|
||||
expect(uploads.card(u)).toMatchObject({ id: audioId, title: 'Test Song', channel: 'Test Artist', kind: 'audio' });
|
||||
const v = uploads.streamsPayload(await db.getUpload(videoId));
|
||||
expect(v.qualities[0]).toMatchObject({ label: 'Original', hasAudio: true, url: `/api/uploads/${videoId}` });
|
||||
});
|
||||
|
||||
test('serving supports Range, and delete removes the files', async () => {
|
||||
const full = await app.request(`/api/uploads/${audioId}`);
|
||||
expect(full.status).toBe(200);
|
||||
expect(full.headers.get('content-type')).toBe('audio/mpeg');
|
||||
const part = await app.request(`/api/uploads/${audioId}`, { headers: { Range: 'bytes=0-99' } });
|
||||
expect(part.status).toBe(206);
|
||||
expect((await part.arrayBuffer()).byteLength).toBe(100);
|
||||
const u = await db.getUpload(audioId);
|
||||
const path = uploads.filePath(u);
|
||||
expect(existsSync(path)).toBe(true);
|
||||
expect((await app.request(`/api/admin/uploads/${audioId}`, { method: 'DELETE', headers: { 'x-admin': 'yes' } })).status).toBe(200);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
expect(await db.getUpload(audioId)).toBeNull();
|
||||
expect((await app.request(`/api/uploads/${audioId}`)).status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user