Add admin analytics, metadata collection, and grouped lyric cues
Queue reviewable Whisper drafts from the song list and lyrics editor. Preserve line breaks within one timed cue across editing, saving, reporting, and service views. Add storage and listening analytics with a durable metadata collector, related-search depth, video limits, thumbnail storage, and a browsable metadata library.
This commit is contained in:
152
tests/admin-lyrics.spec.js
Normal file
152
tests/admin-lyrics.spec.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const id = '0gfX0dFLaBc';
|
||||
async function fixture(page) {
|
||||
const state = { job: null, starts: 0, saves: [] };
|
||||
await page.route('**/api/**', async route => {
|
||||
const req = route.request(), url = new URL(req.url()); let body = { ok: true };
|
||||
if (url.pathname === '/api/admin/me') body = { enabled: true, admin: true };
|
||||
else if (url.pathname === '/api/admin/media') body = { ok: true, media: [{ id, title: 'Because You are God', channel: 'Cathedral of Praise Worship', lyricsLines: 47 }] };
|
||||
else if (url.pathname === '/api/uploads') body.uploads = [];
|
||||
else if (url.pathname === '/api/admin/flags') body = { ok: true, flags: [], open: 0 };
|
||||
else if (url.pathname === '/api/notes/' + id) body = { lyrics: { rev: 8, data: { lines: Array.from({ length: 47 }, (_, i) => ({ t: i * 5, text: 'Original cue ' + i, kind: 'line' })), tags: ['Key G'], offset: 0 } } };
|
||||
else if (url.pathname === '/api/streams') body = { data: { meta: { title: 'Because You are God', duration: 240 }, qualities: [] } };
|
||||
else if (url.pathname === '/api/admin/transcriptions/' + id) {
|
||||
if (req.method() === 'POST') { state.starts++; state.job = { id: 'job', videoId: id, status: 'queued', baseRev: 8 }; }
|
||||
body = { ok: true, job: state.job };
|
||||
} else if (url.pathname === '/api/notes/' + id + '/lyrics' && req.method() === 'PUT') { state.saves.push(req.postDataJSON()); body.rev = 9; }
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
|
||||
});
|
||||
await page.goto('/admin.html#editor');
|
||||
await expect(page.locator('[data-open="' + id + '"]').first()).toBeVisible();
|
||||
return state;
|
||||
}
|
||||
function complete(state) {
|
||||
state.job = { ...state.job, status: 'complete', result: { lines: [{ t: 1, text: 'Because You are God', kind: 'line' }, { t: 4, text: 'You can do anything', kind: 'line' }], tags: ['auto-transcribed (whisper)'], offset: 0 } };
|
||||
}
|
||||
test('row and editor buttons queue one job and require review before saving', async ({ page }) => {
|
||||
const state = await fixture(page);
|
||||
await page.locator('[data-whisper]').click();
|
||||
await expect(page.locator('#whisperBtn')).toBeDisabled();
|
||||
expect(state.starts).toBe(1);
|
||||
await expect(page.locator('.ln')).toHaveCount(47);
|
||||
complete(state);
|
||||
await expect(page.locator('#whisperUse')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.ln')).toHaveCount(47); expect(state.saves).toHaveLength(0);
|
||||
await page.locator('#whisperUse').click();
|
||||
await expect(page.locator('.ln')).toHaveCount(2); expect(state.saves).toHaveLength(0);
|
||||
await page.locator('#bUndo').click(); await expect(page.locator('.ln')).toHaveCount(47);
|
||||
await page.locator('#bRedo').click(); await expect(page.locator('.ln')).toHaveCount(2);
|
||||
await page.locator('#save').click();
|
||||
await expect.poll(() => state.saves.length).toBe(1);
|
||||
expect(state.saves[0].baseRev).toBe(8); expect(state.saves[0].data.lines[0].text).toBe('Because You are God');
|
||||
});
|
||||
test('editor button keeps unsaved words until the draft is explicitly applied', async ({ page }) => {
|
||||
const state = await fixture(page);
|
||||
await page.locator('[data-open="' + id + '"]').first().click();
|
||||
await page.locator('.ln-text').first().fill('My correction');
|
||||
await page.locator('#whisperBtn').click(); complete(state);
|
||||
await expect(page.locator('#whisperUse')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.ln-text').first()).toHaveValue('My correction');
|
||||
page.once('dialog', dialog => dialog.dismiss()); await page.locator('#whisperUse').click();
|
||||
await expect(page.locator('.ln-text').first()).toHaveValue('My correction');
|
||||
expect(state.saves).toHaveLength(0);
|
||||
});
|
||||
test('mobile buttons remain reachable and failed requests allow retry', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await fixture(page);
|
||||
const button = page.locator('[data-whisper]'); await expect(button).toBeVisible();
|
||||
const box = await button.boundingBox(); expect(box.height).toBeGreaterThanOrEqual(44); expect(box.x + box.width).toBeLessThanOrEqual(390);
|
||||
await page.route('**/api/admin/transcriptions/' + id, route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ ok: false, error: 'Lyrics worker is not configured.' }) }));
|
||||
await button.click(); await expect(page.locator('#msg')).toContainText('not configured');
|
||||
await expect(page.locator('#whisperBtn')).toBeEnabled();
|
||||
});
|
||||
|
||||
for (const width of [390, 1440]) {
|
||||
test(`grouped cue keeps one time through text editing and save at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const state = await fixture(page);
|
||||
await page.locator('[data-open="' + id + '"]').first().click();
|
||||
const input = page.locator('.ln-text').first();
|
||||
await input.fill('Because You are God'); await input.press('End');
|
||||
if (width === 390) await page.locator('.ln').first().locator('[data-act="break"]').click();
|
||||
else await input.press('Shift+Enter');
|
||||
await input.press('ControlOrMeta+End'); await input.pressSequentially('You can do anything');
|
||||
await expect(input).toHaveValue('Because You are God\nYou can do anything');
|
||||
await expect(page.locator('.ln')).toHaveCount(47);
|
||||
await page.locator('#bTools').click(); await page.locator('#rawBtn').click();
|
||||
await expect(page.locator('#raw')).toContainText('');
|
||||
expect(await page.locator('#raw').inputValue()).toContain('| You can do anything');
|
||||
await page.locator('#rawBtn').click();
|
||||
await expect(page.locator('.ln-text').first()).toHaveValue('Because You are God\nYou can do anything');
|
||||
await page.locator('#save').click(); await expect.poll(() => state.saves.length).toBe(1);
|
||||
expect(state.saves[0].data.lines[0]).toEqual({ t: 0, text: 'Because You are God\nYou can do anything', kind: 'line' });
|
||||
});
|
||||
}
|
||||
|
||||
const { openClassic } = require('./helpers/classic-fixture');
|
||||
for (const width of [390, 1440]) {
|
||||
test(`service mode highlights the whole grouped cue with a tight internal gap at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await openClassic(page);
|
||||
await page.route('**/api/notes/' + id, route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, lyrics: { rev: 1, data: { lines: [{ t: 0, text: 'Because You are God\nYou can do anything', kind: 'line' }, { t: 30, text: 'Next lyric', kind: 'line' }], tags: [], offset: 0 } } }) }));
|
||||
await page.evaluate(async id => {
|
||||
current = { meta: { id, title: 'Because You are God' }, qualities: [], audioUrl: '' };
|
||||
await Notes.load(current.meta); enterServiceMode(); setServiceView('lyrics'); Notes.tick(0, true);
|
||||
}, id);
|
||||
const cue = page.locator('#serviceLyricsList .sl-line').first();
|
||||
await expect(cue).toHaveClass(/active/); await expect(cue).toHaveText('Because You are God\nYou can do anything');
|
||||
await expect(page.locator('#serviceLyricsList .sl-line')).toHaveCount(2);
|
||||
await page.waitForTimeout(450);
|
||||
const metrics = await cue.evaluate(el => {
|
||||
const style = getComputedStyle(el), next = el.nextElementSibling;
|
||||
return { space: style.whiteSpace, leading: parseFloat(style.lineHeight) - parseFloat(style.fontSize), gap: next.getBoundingClientRect().top - el.getBoundingClientRect().bottom, height: el.clientHeight, lineHeight: parseFloat(style.lineHeight) };
|
||||
});
|
||||
expect(metrics.space).toBe('pre-line'); expect(metrics.gap).toBeGreaterThan(metrics.leading);
|
||||
expect(metrics.height).toBeGreaterThan(metrics.lineHeight * 1.8);
|
||||
});
|
||||
}
|
||||
|
||||
for (const width of [320, 390, 820, 1440]) {
|
||||
test(`admin analytics exposes storage and collector controls at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 }); await fixture(page);
|
||||
let request = null, cancelled = false;
|
||||
await page.route('**/api/admin/analytics', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, media: [{ status: 'ready', count: 2, bytes: 1048576, seconds: 3600 }], uploads: [{ count: 1, bytes: 1024 }], catalog: { videos: 15, bytes: 3000 }, details: { videos: 4, bytes: 10000 }, thumbnails: { saved: 12, pending: 3, bytes: 1000 }, listening: { plays: 47, videos: 2 }, topPlayed: [{ title: 'Because You are God', plays: 47 }], sources: [{ source: 'search', videos: 15, discoveries: 23 }], volumes: [{ label: 'Video cache', device: '1', total: 1000000000, free: 500000000 }] }) }));
|
||||
await page.route('**/api/admin/metadata?**', route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, total: 1, videos: [{ id, title: 'Because You are God', channel: 'Cathedral of Praise Worship', enriched: true, thumbnailBytes: 1024, duration: 240 }] }) }));
|
||||
await page.route('**/api/admin/metadata/' + id, route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, metadata: { id, description: 'Stored descriptive metadata', tags: ['worship'] } }) }));
|
||||
await page.route('**/api/admin/collections', route => {
|
||||
if (route.request().method() === 'POST') request = route.request().postDataJSON();
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, id: 'job', jobs: request ? [{ id: 'job', query: request.query, maxVideos: request.maxVideos, depth: request.depth, status: cancelled ? 'cancelled' : 'queued', collected: 0, enriched: 0, failed: 0, searches: 0 }] : [] }) });
|
||||
});
|
||||
await page.route('**/api/admin/collections/job/cancel', route => { cancelled = true; return route.fulfill({ contentType: 'application/json', body: '{"ok":true}' }); });
|
||||
await page.locator('[data-tab="analytics"]:visible').click();
|
||||
await expect(page.locator('#analyticsMetrics .metric')).toHaveCount(8);
|
||||
await expect(page.locator('#analyticsVolumes')).toContainText('available');
|
||||
await page.locator('#collectorQuery').fill('Cathedral worship'); await page.locator('#collectorLimit').fill('45'); await page.locator('#collectorDepth').selectOption('2');
|
||||
await page.locator('#collectorStart').click();
|
||||
await expect(page.locator('#collectorStatus')).toContainText('queued'); expect(request).toEqual({ query: 'Cathedral worship', maxVideos: 45, depth: 2 });
|
||||
await page.getByRole('button', { name: 'Stop collection' }).click(); await expect(page.locator('#collectionJobs')).toContainText('cancelled');
|
||||
await page.locator('[data-metadata-id]').click(); await expect(page.locator('#metadataDetail')).toContainText('Stored descriptive metadata');
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1); expect(overflow).toBe(false);
|
||||
await page.locator('#analyticsRefresh').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: `/tmp/ytplayer-admin-analytics-${width}.png`, fullPage: width >= 1000 });
|
||||
if (width < 1000) {
|
||||
await page.locator('#collectorForm').evaluate(el => el.scrollIntoView({ block: 'center' }));
|
||||
await page.screenshot({ path: `/tmp/ytplayer-admin-collector-${width}.png` });
|
||||
const reached = await page.locator('#collectorStart').evaluate(el => { const r = el.getBoundingClientRect(); return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)); });
|
||||
expect(reached).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('player lyric editor groups Shift+Enter words and stamps their shared cue', async ({ page }) => {
|
||||
await openClassic(page);
|
||||
await page.route('**/api/notes/' + id, route => route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, lyrics: { rev: 1, data: { lines: [{ t: 12, text: 'Because You are God', kind: 'line' }, { t: 30, text: 'Next lyric', kind: 'line' }], tags: [], offset: 0 } } }) }));
|
||||
await page.evaluate(async id => { data.profile = { name: 'fixture' }; current = { meta: { id, title: 'Song' } }; await Notes.load(current.meta); document.getElementById('lyricsEditBtn').click(); }, id);
|
||||
const text = page.locator('.lyr-editor-text'); await expect(text).toBeVisible();
|
||||
await text.evaluate(el => { el.focus(); const pos = el.value.indexOf('\n'); el.setSelectionRange(pos, pos); });
|
||||
await text.press('Shift+Enter'); await text.pressSequentially('You can do anything');
|
||||
expect(await text.inputValue()).toContain('Because You are God\n| You can do anything');
|
||||
await page.locator('.lyr-editor [data-act="stamp"]').click();
|
||||
const document = await text.inputValue(); expect(document).toContain('[0:00.00] Because You are God\n| You can do anything');
|
||||
expect(document).not.toContain('[0:00.00] |');
|
||||
});
|
||||
Reference in New Issue
Block a user