Add a lyrics-only service mode view with the sung line centred and auto-sized, and a free local faster-whisper script to transcribe saved songs

This commit is contained in:
Jonathan Sykes
2026-09-19 08:33:00 +08:00
parent b82f17cd46
commit 71fc51fb50
8 changed files with 457 additions and 2 deletions

View File

@@ -27,6 +27,7 @@
* 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
* GET /api/admin/media (admin or token) saved videos + lyrics status
* POST /api/admin/notes/:id/:kind/restore { rev }
* GET /admin → admin.html
* ========================================================================== */
@@ -436,6 +437,32 @@ export function registerNoteRoutes(app, deps) {
return ok ? c.json({ ok: true }) : c.json({ ok: false, error: 'not found' }, 404);
});
// Admin cookie OR an API token — for scripts (e.g. scripts/lyrics).
const requireAdminOrToken = async (c, next) => {
const who = await resolveWriter(c, {});
if (!who || who.invalid || who.via === 'user') return c.json({ ok: false, error: 'API token or admin login required' }, 401);
await next();
};
// Saved (server-cached) videos with whether each already has lyrics — the
// work list for batch lyric injection.
app.get('/api/admin/media', requireAdminOrToken, async (c) => {
const rows = (await db.listMedia()).filter((r) => r.status === 'ready');
const out = [];
for (const r of rows) {
let meta = {};
try { meta = JSON.parse(r.meta || '{}'); } catch { /* corrupt meta */ }
const n = await db.getNotes(r.video_id);
out.push({
id: r.video_id, title: meta.title || '', channel: meta.channel || meta.uploader || '',
duration: Number(r.duration) || 0, lastAccess: Number(r.last_access) || 0,
lyricsRev: n.lyrics ? n.lyrics.rev : 0, lyricsLines: n.lyrics ? n.lyrics.data.lines.length : 0,
});
}
out.sort((a, b) => b.lastAccess - a.lastAccess);
return c.json({ ok: true, media: out });
});
app.get('/api/admin/notes/recent', requireAdmin, async (c) => c.json({ ok: true, revs: await db.recentNoteRevs(150) }));
app.post('/api/admin/notes/:id/:kind/restore', requireAdmin, async (c) => {

View File

@@ -191,6 +191,10 @@ describe('routes', () => {
const recent = await (await app.request('/api/admin/notes/recent', { headers: { Cookie: cookie } })).json();
expect(recent.revs[0].rev).toBe(4);
// Media work list: token or admin only.
expect((await app.request('/api/admin/media')).status).toBe(401);
expect((await app.request('/api/admin/media', { headers: auth })).status).toBe(200);
// Revoked tokens stop working.
await app.request(`/api/admin/tokens/${created.id}`, { method: 'DELETE', headers: { Cookie: cookie } });
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: { lines: [] }, baseRev: 4 }, auth));