Add searchable lyrics index
This commit is contained in:
@@ -558,6 +558,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p-lyric-search" class="panel">
|
||||
<h1 class="page">Lyrics search</h1>
|
||||
<p class="lead">Search the saved lyric text, then open a matching song directly in the editor.</p>
|
||||
<div class="card"><form id="lyricsSearchForm" class="analytics-form"><label class="query">Words in lyrics<input class="field" id="lyricsSearchQuery" type="search" maxlength="120" placeholder="grace will lead me home" /></label><label>Lyrics<select class="field" id="lyricsSearchHasLyrics"><option value="yes">With lyrics</option><option value="no">Without lyrics · title search</option><option value="all">All</option></select></label><label>Chapters<select class="field" id="lyricsSearchHasChapters"><option value="all">Any</option><option value="yes">Has chapters</option><option value="no">No chapters</option></select></label><button class="btn pri" type="submit">Search</button><button class="btn" id="lyricsSearchRebuild" type="button">Rebuild text index</button></form>
|
||||
<p id="lyricsSearchStatus" class="msg" role="status" aria-live="polite"></p><div class="scroll"><table class="tbl" id="lyricsSearchTable"></table></div><div class="row"><button class="btn" id="lyricsSearchPrev" type="button">Previous</button><button class="btn" id="lyricsSearchNext" type="button">Next</button><span class="grow"></span><span class="muted" id="lyricsSearchCount"></span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p-cache" class="panel">
|
||||
<h1 class="page">Cache health</h1>
|
||||
<p class="lead">Review server copies by state and recency. Pinned copies stay out of automatic eviction.</p>
|
||||
@@ -778,6 +786,7 @@
|
||||
editor: '<path d="M4 20h4L19 9a2.8 2.8 0 00-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>',
|
||||
reports: '<path d="M5 21V4M5 4h12l-2.5 4L17 12H5"/>',
|
||||
quality: '<path d="M4 5h16M4 12h16M4 19h10"/><path d="m17 18 2 2 4-5"/>',
|
||||
'lyric-search': '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5M8 10.5h5M10.5 8v5"/>',
|
||||
cache: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
|
||||
index: '<path d="M4 19V5M4 19h16M8 15l3-4 3 2 5-7"/><circle cx="19" cy="6" r="1.5"/>',
|
||||
library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
|
||||
@@ -789,7 +798,7 @@
|
||||
audit: '<path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/>',
|
||||
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
|
||||
};
|
||||
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
|
||||
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
|
||||
let tab = 'editor';
|
||||
function buildTabs() {
|
||||
const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join('');
|
||||
@@ -805,6 +814,7 @@
|
||||
syncBars();
|
||||
if (name === 'reports') loadReports();
|
||||
if (name === 'quality') loadLyricsQuality();
|
||||
if (name === 'lyric-search') loadLyricsSearch();
|
||||
if (name === 'cache') loadCacheHealth();
|
||||
if (name === 'index') loadIndexHealth();
|
||||
if (name === 'history') loadRevs();
|
||||
@@ -1066,6 +1076,34 @@
|
||||
} catch (error) { $('qualityStatus').className = 'msg err'; $('qualityStatus').textContent = error.message; }
|
||||
});
|
||||
|
||||
/* ---------------- full-text lyrics search ---------------- */
|
||||
let lyricsSearchOffset = 0;
|
||||
function lyricsSearchQuery() {
|
||||
const params = new URLSearchParams({ q: $('lyricsSearchQuery').value.trim(), hasLyrics: $('lyricsSearchHasLyrics').value, hasChapters: $('lyricsSearchHasChapters').value, limit: '30', offset: String(lyricsSearchOffset) });
|
||||
return params.toString();
|
||||
}
|
||||
const safeSnippet = value => esc(value || '').replaceAll('\u0001', '<mark>').replaceAll('\u0002', '</mark>');
|
||||
async function loadLyricsSearch() {
|
||||
const table = $('lyricsSearchTable'); table.innerHTML = '<tr><td class="muted">Searching saved lyrics…</td></tr>';
|
||||
try {
|
||||
const result = await api('/api/admin/lyrics/search?' + lyricsSearchQuery()); if (!result.ok) throw Error(result.error || 'Lyrics search failed.');
|
||||
table.innerHTML = result.results.length ? `<tr><th>Song</th><th>Match</th><th>Chapters</th><th></th></tr>${result.results.map(row => `<tr><td><b>${esc(row.title || row.videoId)}</b><br><small>${esc(row.channel || row.videoId)}</small></td><td>${safeSnippet(row.snippet)}</td><td>${row.hasChapters ? 'Yes' : 'No'}</td><td><button class="btn sm pri" data-lyrics-search-open="${esc(row.videoId)}">Open editor</button></td></tr>`).join('')}` : '<tr><td class="muted">No matching notes found.</td></tr>';
|
||||
$('lyricsSearchCount').textContent = `${result.total.toLocaleString()} matches · ${result.results.length ? lyricsSearchOffset + 1 : 0}–${lyricsSearchOffset + result.results.length}`;
|
||||
$('lyricsSearchPrev').disabled = lyricsSearchOffset === 0; $('lyricsSearchNext').disabled = lyricsSearchOffset + result.results.length >= result.total;
|
||||
$('lyricsSearchStatus').textContent = result.query ? `Searching “${result.query}” · ${result.hasLyrics} lyrics · ${result.hasChapters} chapters` : 'Showing saved lyrics. Enter words to search inside them.';
|
||||
} catch (error) { table.innerHTML = `<tr><td class="err">${esc(error.message)}</td></tr>`; }
|
||||
}
|
||||
$('lyricsSearchForm').addEventListener('submit', event => { event.preventDefault(); lyricsSearchOffset = 0; loadLyricsSearch(); });
|
||||
$('lyricsSearchPrev').addEventListener('click', () => { lyricsSearchOffset = Math.max(0, lyricsSearchOffset - 30); loadLyricsSearch(); });
|
||||
$('lyricsSearchNext').addEventListener('click', () => { lyricsSearchOffset += 30; loadLyricsSearch(); });
|
||||
$('lyricsSearchTable').addEventListener('click', event => { const button = event.target.closest('[data-lyrics-search-open]'); if (button) leOpen(button.dataset.lyricsSearchOpen); });
|
||||
$('lyricsSearchRebuild').addEventListener('click', async () => {
|
||||
$('lyricsSearchStatus').textContent = 'Rebuilding the lyric text index…';
|
||||
const result = await api('/api/admin/lyrics/search/rebuild', { method: 'POST', body: {} });
|
||||
$('lyricsSearchStatus').textContent = result.ok ? `Indexed ${result.indexed} saved lyric documents.` : result.error || 'Index rebuild failed.';
|
||||
if (result.ok) loadLyricsSearch();
|
||||
});
|
||||
|
||||
/* ---------------- cache health and pinning ---------------- */
|
||||
let cacheOffset = 0;
|
||||
async function loadCacheHealth() {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
const classic = require('./playwright.classic.config');
|
||||
module.exports = defineConfig({ ...classic, testMatch: /admin-(audit|quality|cache|index|reports|backups)\.spec\.js/ });
|
||||
module.exports = defineConfig({ ...classic, testMatch: /admin-(audit|quality|cache|index|reports|backups|lyrics-search)\.spec\.js/ });
|
||||
|
||||
@@ -156,6 +156,52 @@ export function registerAdminToolsRoutes(app, { db, adminAuth, media = null, bac
|
||||
return c.json({ ok: true, minLines, category, offset, limit, total: number(count.rows[0]?.total), songs }, 200, { 'Cache-Control': 'no-store' });
|
||||
});
|
||||
|
||||
app.get('/api/admin/lyrics/search', adminAuth, async c => {
|
||||
const query = String(c.req.query('q') || '').trim().slice(0, 120);
|
||||
const hasLyrics = ['yes', 'no', 'all'].includes(c.req.query('hasLyrics')) ? c.req.query('hasLyrics') : 'yes';
|
||||
const hasChapters = ['yes', 'no', 'all'].includes(c.req.query('hasChapters')) ? c.req.query('hasChapters') : 'all';
|
||||
const limit = Math.max(1, Math.min(50, Number(c.req.query('limit')) || 30));
|
||||
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
|
||||
let hits = [], total = 0;
|
||||
if (query && hasLyrics !== 'no') {
|
||||
const chapterOptions = { hasChapters: hasChapters === 'yes', withoutChapters: hasChapters === 'no' };
|
||||
hits = await db.searchNoteLyrics(query, { limit, offset, ...chapterOptions });
|
||||
total = await db.countNoteLyrics(query, chapterOptions);
|
||||
}
|
||||
const terms = query.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
|
||||
if (!query || hasLyrics === 'no') {
|
||||
const parts = [];
|
||||
const args = [];
|
||||
if (hasLyrics === 'yes') parts.push("l.video_id IS NOT NULL AND l.data<>'{}'");
|
||||
if (hasLyrics === 'no') parts.push('l.video_id IS NULL');
|
||||
if (hasChapters === 'yes') parts.push("EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters')");
|
||||
if (hasChapters === 'no') parts.push("NOT EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters')");
|
||||
for (const term of terms) { parts.push('lower(m.hay) LIKE ?'); args.push(`%${term}%`); }
|
||||
const where = parts.length ? `WHERE ${parts.join(' AND ')}` : '';
|
||||
const count = await sql.execute({ sql: `SELECT COUNT(*) AS n FROM video_meta m LEFT JOIN video_notes l ON l.video_id=m.id AND l.kind='lyrics' ${where}`, args });
|
||||
const rows = await sql.execute({ sql: `SELECT m.id,m.card,l.data AS lyrics,l.rev,EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters') AS chapters FROM video_meta m LEFT JOIN video_notes l ON l.video_id=m.id AND l.kind='lyrics' ${where} ORDER BY m.updated_at DESC LIMIT ? OFFSET ?`, args: [...args, limit, offset] });
|
||||
total = number(count.rows[0]?.n);
|
||||
hits = rows.rows.map(row => ({ videoId: row.id, snippet: parse(row.lyrics).lines?.slice(0, 3).map(line => line.text).join(' … ') || parse(row.card).title || row.id, rev: number(row.rev), hasChapters: !!row.chapters }));
|
||||
}
|
||||
if (hits.length && query && hasLyrics !== 'no') {
|
||||
const ids = hits.map(item => item.videoId);
|
||||
const rows = await sql.execute({ sql: `SELECT n.video_id,n.data,n.rev,m.card,EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=n.video_id AND ch.kind='chapters') AS chapters FROM video_notes n LEFT JOIN video_meta m ON m.id=n.video_id WHERE n.kind='lyrics' AND n.video_id IN (${ids.map(() => '?').join(',')})`, args: ids });
|
||||
const byId = new Map(rows.rows.map(row => [row.video_id, row]));
|
||||
hits = hits.map(hit => {
|
||||
const row = byId.get(hit.videoId), card = parse(row?.card);
|
||||
return { ...hit, title: card.title || hit.videoId, channel: card.channel || '', rev: number(row?.rev), hasChapters: !!row?.chapters };
|
||||
});
|
||||
}
|
||||
return c.json({ ok: true, query, hasLyrics, hasChapters, total, limit, offset, results: hits }, 200, { 'Cache-Control': 'no-store' });
|
||||
});
|
||||
|
||||
app.post('/api/admin/lyrics/search/rebuild', adminAuth, async c => {
|
||||
if (typeof db.rebuildLyricsSearch !== 'function') return c.json({ ok: false, error: 'Lyrics search rebuild is unavailable.' }, 503);
|
||||
const indexed = await db.rebuildLyricsSearch();
|
||||
await audit(db, 'admin', 'lyrics.search.rebuild', '', { indexed });
|
||||
return c.json({ ok: true, indexed });
|
||||
});
|
||||
|
||||
app.get('/api/admin/cache/health', adminAuth, async c => {
|
||||
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50));
|
||||
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
|
||||
|
||||
@@ -93,4 +93,23 @@ describe('lyrics quality queue', () => {
|
||||
expect(dump.status).toBe(200);
|
||||
expect(await dump.text()).toContain('CREATE TABLE');
|
||||
});
|
||||
|
||||
test('lyrics search is indexed on save, filters chapter state, and rebuilds under admin auth', async () => {
|
||||
const id = 'SearchSong01';
|
||||
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: [id, JSON.stringify({ title: 'Amazing grace sample', channel: 'Choir' }), 'amazing grace sample choir', 1, Date.now()] });
|
||||
await db.saveNote({ videoId: id, kind: 'lyrics', data: { lines: [{ t: 1, text: 'Amazing grace will lead me home' }] }, source: 'user', force: true });
|
||||
expect((await app.request('/api/admin/lyrics/search?q=amazing')).status).toBe(401);
|
||||
const headers = { 'x-admin': 'yes' };
|
||||
let result = await (await app.request('/api/admin/lyrics/search?q=amazing+grace&hasChapters=no', { headers })).json();
|
||||
expect(result.results[0]).toMatchObject({ videoId: id, title: 'Amazing grace sample', hasChapters: false });
|
||||
expect(result.results[0].snippet).toContain('\u0001');
|
||||
await db.saveNote({ videoId: id, kind: 'chapters', data: { items: [{ t: 1, title: 'Verse' }] }, source: 'user', force: true });
|
||||
result = await (await app.request('/api/admin/lyrics/search?q=amazing&hasChapters=yes', { headers })).json();
|
||||
expect(result.results[0].videoId).toBe(id);
|
||||
await db.saveNote({ videoId: id, kind: 'lyrics', data: { lines: [{ t: 1, text: 'Bright morning star' }] }, source: 'user', force: true });
|
||||
expect((await (await app.request('/api/admin/lyrics/search?q=amazing', { headers })).json()).total).toBe(0);
|
||||
const rebuilt = await (await app.request('/api/admin/lyrics/search/rebuild', { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: '{}' })).json();
|
||||
expect(rebuilt.ok).toBe(true);
|
||||
expect(rebuilt.indexed).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
76
server/db.js
76
server/db.js
@@ -20,6 +20,7 @@ const DB_PATH = process.env.DB_PATH || join(process.cwd(), 'data', 'ytplayer.db'
|
||||
export const db = createClient({
|
||||
url: 'file:' + DB_PATH,
|
||||
});
|
||||
let lyricsFtsAvailable = false;
|
||||
|
||||
// ---- Schema ----------------------------------------------------------------
|
||||
|
||||
@@ -279,6 +280,7 @@ export async function initDb() {
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor_action ON audit_log (actor, action, ts DESC);
|
||||
`);
|
||||
await migrateVideoMeta();
|
||||
await initLyricsSearch();
|
||||
try { await db.execute('ALTER TABLE media_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0'); } catch { /* already migrated */ }
|
||||
// Uploads shared from devices (PWA share target): who sent it, and whether it
|
||||
// is listed in everyone's search (admin uploads) or reachable only by link.
|
||||
@@ -307,6 +309,75 @@ export async function initDb() {
|
||||
}
|
||||
}
|
||||
|
||||
function lyricsSearchContent(value) {
|
||||
try {
|
||||
const data = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
return [...(data?.lines || []).flatMap(line => [line?.text, line?.secondary, line?.phonetic]), ...(data?.tags || [])]
|
||||
.filter(Boolean).join(' ').slice(0, 500_000);
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
async function initLyricsSearch() {
|
||||
await db.execute('CREATE TABLE IF NOT EXISTS lyrics_search_state (id INTEGER PRIMARY KEY CHECK(id=1), indexed_at INTEGER NOT NULL)');
|
||||
try {
|
||||
await db.execute("CREATE VIRTUAL TABLE IF NOT EXISTS lyrics_search_fts USING fts5(video_id UNINDEXED, content, tokenize='unicode61 remove_diacritics 2')");
|
||||
lyricsFtsAvailable = true;
|
||||
} catch {
|
||||
await db.execute('CREATE TABLE IF NOT EXISTS lyrics_search_fts (video_id TEXT PRIMARY KEY, content TEXT NOT NULL)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_lyrics_search_id ON lyrics_search_fts(video_id)');
|
||||
lyricsFtsAvailable = false;
|
||||
}
|
||||
const state = await db.execute('SELECT id FROM lyrics_search_state WHERE id=1');
|
||||
if (state.rows.length) return;
|
||||
await db.execute('DELETE FROM lyrics_search_fts');
|
||||
const rows = await db.execute("SELECT video_id,data FROM video_notes WHERE kind='lyrics'");
|
||||
for (let i = 0; i < rows.rows.length; i += 100) {
|
||||
const batch = rows.rows.slice(i, i + 100).map(row => ({
|
||||
sql: lyricsFtsAvailable ? 'INSERT INTO lyrics_search_fts(video_id,content) VALUES(?,?)' : 'INSERT OR REPLACE INTO lyrics_search_fts(video_id,content) VALUES(?,?)',
|
||||
args: [row.video_id, lyricsSearchContent(row.data)],
|
||||
}));
|
||||
if (batch.length) await db.batch(batch, 'write');
|
||||
}
|
||||
await db.execute('INSERT INTO lyrics_search_state(id,indexed_at) VALUES(1,unixepoch())');
|
||||
}
|
||||
|
||||
export async function searchNoteLyrics(query, { limit = 30, offset = 0, hasChapters = false, withoutChapters = false } = {}) {
|
||||
const words = String(query || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[\p{L}\p{N}_-]+/gu) || [];
|
||||
if (!words.length) return [];
|
||||
const boundedLimit = Math.max(1, Math.min(100, Number(limit) || 30));
|
||||
const boundedOffset = Math.max(0, Math.min(1_000_000, Number(offset) || 0));
|
||||
const chapterFilter = hasChapters ? "AND EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : withoutChapters ? "AND NOT EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : '';
|
||||
if (lyricsFtsAvailable) {
|
||||
const match = words.map(word => `"${word.replaceAll('"', '""')}"*`).join(' AND ');
|
||||
const result = await db.execute({ sql: `SELECT video_id, snippet(lyrics_search_fts,1,char(1),char(2),' … ',18) AS snippet
|
||||
FROM lyrics_search_fts WHERE lyrics_search_fts MATCH ? ${chapterFilter} ORDER BY bm25(lyrics_search_fts) LIMIT ? OFFSET ?`, args: [match, boundedLimit, boundedOffset] });
|
||||
return result.rows.map(row => ({ videoId: row.video_id, snippet: row.snippet }));
|
||||
}
|
||||
const filters = words.map(() => 'content LIKE ?').join(' AND ');
|
||||
const result = await db.execute({ sql: `SELECT video_id,substr(content,1,220) AS snippet FROM lyrics_search_fts WHERE ${filters} ORDER BY video_id LIMIT ? OFFSET ?`, args: [...words.map(word => `%${word}%`), boundedLimit, boundedOffset] });
|
||||
return result.rows.map(row => ({ videoId: row.video_id, snippet: row.snippet }));
|
||||
}
|
||||
|
||||
export async function countNoteLyrics(query, { hasChapters = false, withoutChapters = false } = {}) {
|
||||
const words = String(query || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[\p{L}\p{N}_-]+/gu) || [];
|
||||
if (!words.length) return 0;
|
||||
const chapterFilter = hasChapters ? "AND EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : withoutChapters ? "AND NOT EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : '';
|
||||
if (lyricsFtsAvailable) {
|
||||
const match = words.map(word => `"${word.replaceAll('"', '""')}"*`).join(' AND ');
|
||||
return Number((await db.execute({ sql: `SELECT COUNT(*) AS n FROM lyrics_search_fts WHERE lyrics_search_fts MATCH ? ${chapterFilter}`, args: [match] })).rows[0]?.n) || 0;
|
||||
}
|
||||
const filters = words.map(() => 'content LIKE ?').join(' AND ');
|
||||
const result = await db.execute({ sql: `SELECT COUNT(*) AS n FROM lyrics_search_fts WHERE ${filters} ${chapterFilter}`, args: words.map(word => `%${word}%`) });
|
||||
return Number(result.rows[0]?.n) || 0;
|
||||
}
|
||||
|
||||
export async function rebuildLyricsSearch() {
|
||||
await db.execute('DELETE FROM lyrics_search_fts');
|
||||
await db.execute('DELETE FROM lyrics_search_state');
|
||||
await initLyricsSearch();
|
||||
return Number((await db.execute("SELECT COUNT(*) AS n FROM video_notes WHERE kind='lyrics'")).rows[0]?.n) || 0;
|
||||
}
|
||||
|
||||
const YOUTUBE_ID = /^[\w-]{11}$/;
|
||||
const folded = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||
|
||||
@@ -395,6 +466,11 @@ export async function saveNote({ videoId, kind, data, baseRev, source, updatedBy
|
||||
VALUES (?, ?, ?, ?, ?, ?, unixepoch())`,
|
||||
args: [videoId, kind, rev, json, source, updatedBy || null],
|
||||
});
|
||||
if (kind === 'lyrics') {
|
||||
await tx.execute({ sql: 'DELETE FROM lyrics_search_fts WHERE video_id=?', args: [videoId] });
|
||||
const content = lyricsSearchContent(data);
|
||||
if (content) await tx.execute({ sql: 'INSERT INTO lyrics_search_fts(video_id,content) VALUES(?,?)', args: [videoId, content] });
|
||||
}
|
||||
await tx.commit();
|
||||
return { ok: true, rev };
|
||||
} catch (err) {
|
||||
|
||||
28
tests/admin-lyrics-search.spec.js
Normal file
28
tests/admin-lyrics-search.spec.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('lyrics full-text search highlights matches, filters, rebuilds and opens the editor', async ({ page }) => {
|
||||
const requests = [];
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request(), url = new URL(request.url()); requests.push({ path: url.pathname, query: url.search, method: request.method() });
|
||||
let body = { ok: true };
|
||||
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
|
||||
if (url.pathname === '/api/admin/lyrics/search' && request.method() === 'GET') body = { ok: true, query: url.searchParams.get('q') || '', hasLyrics: 'yes', hasChapters: 'all', total: 1, limit: 30, offset: 0, results: [{ videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', snippet: '\u0001Amazing grace\u0002 will lead me home', hasChapters: true, rev: 2 }] };
|
||||
if (url.pathname === '/api/admin/lyrics/search/rebuild') body = { ok: true, indexed: 42 };
|
||||
if (url.pathname === '/api/notes/dQw4w9WgXcQ') body = { ok: true, lyrics: { rev: 2, data: { lines: [{ t: 1, text: 'Amazing grace', kind: 'line' }] } }, chapters: null };
|
||||
if (url.pathname === '/api/streams') body = { ok: true, data: { meta: { title: 'Amazing grace', duration: 30 }, qualities: [] } };
|
||||
if (url.pathname === '/api/admin/flags') body = { ok: true, open: 0, flags: [] };
|
||||
if (url.pathname.startsWith('/api/admin/transcriptions/')) body = { ok: true, job: null };
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
await page.goto('/admin.html#lyric-search');
|
||||
await expect(page.locator('#lyricsSearchTable')).toContainText('Amazing grace');
|
||||
await expect(page.locator('#lyricsSearchTable mark')).toHaveText('Amazing grace');
|
||||
await page.locator('#lyricsSearchQuery').fill('amazing grace'); await page.locator('#lyricsSearchHasChapters').selectOption('yes');
|
||||
await page.locator('#lyricsSearchForm button[type=submit]').click();
|
||||
await expect.poll(() => requests.some(item => item.path === '/api/admin/lyrics/search' && item.query.includes('hasChapters=yes'))).toBe(true);
|
||||
await page.locator('#lyricsSearchRebuild').click();
|
||||
await expect.poll(() => requests.some(item => item.path === '/api/admin/lyrics/search/rebuild' && item.method === 'POST')).toBe(true);
|
||||
await expect(page.locator('#lyricsSearchTable')).toContainText('Amazing grace');
|
||||
await page.locator('[data-lyrics-search-open="dQw4w9WgXcQ"]').click();
|
||||
await expect(page.locator('#songTitle')).toHaveText('Amazing grace');
|
||||
});
|
||||
Reference in New Issue
Block a user