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:
Jonathan Sykes
2026-09-20 05:59:50 +08:00
parent 9c1ec4ac51
commit b983c6ca3c
14 changed files with 963 additions and 25 deletions

View File

@@ -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(() => {});
}