Add upload library management and ops diagnostics

This commit is contained in:
Jonathan Sykes
2026-10-14 03:09:12 +08:00
parent 5177a4d441
commit 27c694901d
15 changed files with 467 additions and 36 deletions

View File

@@ -234,7 +234,9 @@ export async function initDb() {
size INTEGER NOT NULL DEFAULT 0,
art TEXT, -- 'embedded' | 'file' | NULL
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
plays INTEGER NOT NULL DEFAULT 0
plays INTEGER NOT NULL DEFAULT 0,
gen INTEGER NOT NULL DEFAULT 1,
art_gen INTEGER NOT NULL DEFAULT 1
);
-- "This lyric line is wrong" reports from service mode. One open row per
@@ -294,6 +296,9 @@ export async function initDb() {
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ }
}
try { await db.execute('ALTER TABLE uploads ADD COLUMN gen INTEGER NOT NULL DEFAULT 1'); } catch { /* already migrated */ }
try { await db.execute('ALTER TABLE uploads ADD COLUMN art_gen INTEGER NOT NULL DEFAULT 1'); } catch { /* already migrated */ }
await db.execute(`CREATE TABLE IF NOT EXISTS upload_owner_quotas (owner TEXT PRIMARY KEY,max_bytes INTEGER NOT NULL CHECK(max_bytes>=0),updated_at INTEGER NOT NULL)`);
// Integrated loudness (EBU R128 LUFS) measured by devices in the browser and
// shared, so each song is measured once for everyone.
await db.execute(`CREATE TABLE IF NOT EXISTS loudness (
@@ -956,12 +961,13 @@ const uploadRow = (r) => ({
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,
owner: r.owner || null, listed: r.listed === undefined || r.listed === null ? true : Number(r.listed) === 1,
gen: Number(r.gen) || 1, artGen: Number(r.art_gen) || 1,
});
export async function createUpload(u) {
await db.execute({
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, owner, listed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`,
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, owner, listed, gen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 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,
u.owner || null, u.listed === false ? 0 : 1],
});
@@ -980,7 +986,7 @@ export async function sharedUploadUsage(owner) {
}
export async function setUploadArt(id, art) {
await db.execute({ sql: 'UPDATE uploads SET art = ? WHERE id = ?', args: [art, id] });
await db.execute({ sql: 'UPDATE uploads SET art = ?, art_gen = art_gen + 1 WHERE id = ?', args: [art, id] });
}
export async function getUpload(id) {
@@ -988,6 +994,45 @@ export async function getUpload(id) {
return r.rows[0] ? uploadRow(r.rows[0]) : null;
}
export async function updateUploadMeta(id, patch = {}) {
const fields = ['title', 'artist', 'album', 'listed'].filter(k => patch[k] !== undefined);
if (!fields.length) return !!(await getUpload(id));
const values = fields.map(k => k === 'listed' ? (patch[k] ? 1 : 0) : String(patch[k] ?? '').slice(0, k === 'title' ? 300 : 200) || null);
const result = await db.execute({ sql: `UPDATE uploads SET ${fields.map(k => `${k}=?`).join(',')} WHERE id=?`, args: [...values, id] });
return !!result.rowsAffected;
}
export async function replaceUploadFile(id, upload) {
const result = await db.execute({ sql: `UPDATE uploads SET kind=?,title=?,artist=?,album=?,duration=?,ext=?,mime=?,size=?,art=?,gen=gen+1 WHERE id=?`,
args: [upload.kind, upload.title, upload.artist || null, upload.album || null, upload.duration || 0, upload.ext, upload.mime, upload.size || 0, upload.art || null, id] });
return !!result.rowsAffected;
}
export async function listAdminUploads({ q = '', limit = 100, offset = 0 } = {}) {
const args = [], where = q ? "WHERE lower(title || ' ' || ifnull(artist,'') || ' ' || ifnull(album,'')) LIKE ?" : '';
if (q) args.push(`%${String(q).toLowerCase().replace(/[\\%_]/g, '\\$&')}%`);
const rows = await db.execute({ sql: `SELECT * FROM uploads ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, args: [...args, Math.max(1, Math.min(200, limit)), Math.max(0, offset)] });
const count = await db.execute({ sql: `SELECT COUNT(*) AS n FROM uploads ${where}`, args });
const owners = await db.execute("SELECT ifnull(owner,'(admin)') AS owner,COUNT(*) AS count,COALESCE(SUM(size),0) AS bytes FROM uploads GROUP BY owner ORDER BY bytes DESC");
return { uploads: rows.rows.map(uploadRow), total: Number(count.rows[0]?.n) || 0, owners: owners.rows.map(r => ({ owner: r.owner, count: Number(r.count), bytes: Number(r.bytes) })) };
}
export async function listUploadFileNames() {
const r = await db.execute('SELECT id,ext FROM uploads');
return r.rows.flatMap(row => [`${row.id}.${row.ext}`, `${row.id}.art.jpg`]);
}
export async function listUploadQuotas() {
const r = await db.execute('SELECT owner,max_bytes,updated_at FROM upload_owner_quotas ORDER BY owner');
return r.rows.map(x => ({ owner: x.owner, maxBytes: Number(x.max_bytes), updatedAt: Number(x.updated_at) }));
}
export async function getUploadQuota(owner) {
const r = await db.execute({ sql: 'SELECT max_bytes FROM upload_owner_quotas WHERE owner=?', args: [owner] });
return r.rows[0] ? Number(r.rows[0].max_bytes) : null;
}
export async function setUploadQuota(owner, maxBytes) {
if (maxBytes == null || Number(maxBytes) <= 0) return db.execute({ sql: 'DELETE FROM upload_owner_quotas WHERE owner=?', args: [owner] });
return db.execute({ sql: 'INSERT INTO upload_owner_quotas(owner,max_bytes,updated_at) VALUES(?,?,unixepoch()) ON CONFLICT(owner) DO UPDATE SET max_bytes=excluded.max_bytes,updated_at=excluded.updated_at', args: [owner, Math.floor(Number(maxBytes))] });
}
// Newest first; `q` matches title/artist/album (case-insensitive).
// `listedOnly`: public search shows admin uploads, never device-shared ones.
export async function listUploads({ q = '', limit = 100, listedOnly = false } = {}) {