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:
@@ -92,7 +92,7 @@
|
||||
}
|
||||
.btn:hover { background: rgba(255,255,255,.1); }
|
||||
.btn:active { transform: scale(.97); }
|
||||
.btn.pri { border: 0; background: var(--grad); color: #fff; box-shadow: 0 10px 26px -10px var(--a1); }
|
||||
.btn.pri { border: 0; background: #6143cf; color: #fff; box-shadow: none; }
|
||||
.btn.pri:hover { filter: brightness(1.1); }
|
||||
.btn.ok { border: 0; background: var(--ok); color: #06210f; }
|
||||
.btn.dng { color: var(--bad); border-color: rgba(255,107,107,.35); background: rgba(255,107,107,.07); }
|
||||
@@ -164,6 +164,13 @@
|
||||
.song-row small { color: var(--text-dim); font-size: 12px; }
|
||||
.song-row .st { flex: none; font: 700 11px var(--ui); padding: 3px 9px; border-radius: 99px; border: 1px solid var(--line-2); color: var(--text-dim); }
|
||||
.song-row .st.has { color: var(--ok); border-color: rgba(74,222,128,.4); }
|
||||
.song-open { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; padding: 0; border: 0; background: none; color: inherit; text-align: left; }
|
||||
.whisper-request { min-height: 44px; flex: none; }
|
||||
.whisper-draft { margin-block: 14px; }
|
||||
.whisper-draft pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; font: 400 14px/1.55 var(--ui); color: var(--text); }
|
||||
#whisperStatus { color: var(--text-dim); font-size: 14px; margin-block: 10px; }
|
||||
.song { flex-wrap: wrap; }
|
||||
@media (max-width: 480px) { .song-row { flex-wrap: wrap; } .song-open { flex-basis: 100%; min-height: 44px; } }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||||
.chip { height: 34px; padding: 0 13px; border-radius: 99px; border: 1px solid var(--line-2); background: rgba(255,255,255,.04); font-size: 13px; font-weight: 600; color: var(--text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chip:hover { color: var(--text); border-color: var(--a1); }
|
||||
@@ -211,7 +218,7 @@
|
||||
}
|
||||
.tc:hover { border-color: var(--a2); color: var(--text); }
|
||||
.ln.untimed .tc { color: var(--warn); border-style: dashed; border-color: rgba(255,180,84,.55); }
|
||||
.ln-text { flex: 1 1 auto; min-width: 0; height: 38px; padding: 0 8px; border: 0; border-radius: 9px; background: transparent; font-size: 16px; }
|
||||
.ln-text { flex: 1 1 auto; min-width: 0; min-height: 38px; height: auto; padding: 7px 8px; resize: vertical; overflow: hidden; line-height: 1.3; border: 0; border-radius: 9px; background: transparent; font-size: 16px; }
|
||||
.ln.section .ln-text { font-weight: 800; text-transform: uppercase; letter-spacing: .06em; font-size: 13px; color: var(--a2); }
|
||||
.ln.cue .ln-text { font-style: italic; color: var(--warn); }
|
||||
.ln.now { background: rgba(124,92,255,.16); border-color: rgba(124,92,255,.55); box-shadow: 0 0 0 1px rgba(124,92,255,.25), 0 10px 30px -16px var(--a1); }
|
||||
@@ -257,6 +264,26 @@
|
||||
.tap small { display: block; font-weight: 600; opacity: .8; font-size: 11px; letter-spacing: 0; max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tap:active { transform: scale(.93); }
|
||||
|
||||
.analytics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 20px 0; }
|
||||
.metric { padding: 18px; background: var(--bg-2); border: 1px solid var(--line-2); border-radius: 14px; }
|
||||
.metric b { display: block; font-size: 26px; margin: 4px 0; }
|
||||
.metric small, .metric span { color: var(--text-2); }
|
||||
.analytics-form { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; }
|
||||
.analytics-form label { display: grid; gap: 5px; min-width: 0; }
|
||||
.analytics-form .query { flex: 1 1 250px; }
|
||||
.analytics-form .field { width: 100%; min-width: 0; }
|
||||
.analytics-form button { min-height: 44px; }
|
||||
.analytics-table { overflow-x: auto; margin-top: 12px; }
|
||||
.analytics-table table { width: 100%; }
|
||||
.collection-job { padding: 14px 0; border-bottom: 1px solid var(--line-2); overflow-wrap: anywhere; }
|
||||
.collection-job p { margin: 4px 0; color: var(--text-2); }
|
||||
#metadataDetail { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 400px; overflow: auto; font-size: 13px; }
|
||||
@media (max-width: 480px) { .analytics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .metric { padding: 12px; } .metric b { font-size: 22px; } }
|
||||
@media (min-width: 761px) and (max-width: 1100px) {
|
||||
.top { flex-wrap: wrap; }
|
||||
.tabs { order: 2; width: 100%; margin-left: 0; overflow-x: auto; }
|
||||
.tabs .tab { flex: none; }
|
||||
}
|
||||
.nav { display: none; }
|
||||
@media (max-width: 760px) {
|
||||
.tabs, .top .ghost.desk { display: none; }
|
||||
@@ -347,6 +374,7 @@
|
||||
<div class="ring" id="ring"><span id="ringTxt">0/0</span></div>
|
||||
<div style="min-width:0"><h1 id="songTitle"></h1><small id="songMeta"></small></div>
|
||||
<span class="grow"></span>
|
||||
<button class="btn sm whisper-request" id="whisperBtn" type="button">Transcribe with Whisper</button>
|
||||
<button class="ghost" id="closeSong" type="button">Change song</button>
|
||||
</div>
|
||||
|
||||
@@ -386,8 +414,15 @@
|
||||
</div>
|
||||
|
||||
<div id="msg" class="msg"></div>
|
||||
<p id="whisperStatus" role="status" aria-live="polite"></p>
|
||||
<div id="whisperResult" class="card whisper-draft hidden">
|
||||
<h2 style="font-size:18px;margin:0 0 8px">Whisper transcript ready</h2>
|
||||
<p class="muted">Saved lyrics stay unchanged until you review and save this draft.</p>
|
||||
<pre id="whisperPreview"></pre>
|
||||
<button id="whisperUse" class="btn" type="button">Use transcript in editor</button>
|
||||
</div>
|
||||
<div id="lines" class="lines"></div>
|
||||
<textarea id="raw" class="hidden" spellcheck="false"></textarea>
|
||||
<textarea id="raw" aria-label="Lyrics as text; prefix continuation lines with vertical bar and space" class="hidden" spellcheck="false"></textarea>
|
||||
<div class="row" style="margin-top:14px">
|
||||
<button class="btn sm" id="addLine" type="button">+ Add line</button>
|
||||
<span class="muted" style="font-size:12.5px">Shortcuts: <code>Space</code> stamp (Tap-sync) · <code>S</code> stamp selected · <code>K</code> play · <code>↑↓</code> select · <code>⌘/Ctrl S</code> save</span>
|
||||
@@ -480,6 +515,35 @@
|
||||
</section>
|
||||
|
||||
<!-- ================= ACCESS ================= -->
|
||||
<section id="p-analytics" class="panel">
|
||||
<h1 class="page">Server analytics</h1>
|
||||
<p class="lead">Video storage, listening activity and the metadata library.</p>
|
||||
<button class="btn" type="button" id="analyticsRefresh">Refresh analytics</button>
|
||||
<p id="analyticsStatus" role="status" aria-live="polite"></p>
|
||||
<div class="analytics-grid" id="analyticsMetrics"></div>
|
||||
<div class="card"><h2>Remaining disk space</h2><div id="analyticsVolumes"></div><p class="muted">Volumes on the same device share the reported free space. Stored media totals exclude temporary files and filesystem overhead.</p></div>
|
||||
<div class="card"><h2>Metadata collector</h2>
|
||||
<p class="muted">Search and save descriptive video metadata and thumbnails. Video and audio files are not downloaded.</p>
|
||||
<form id="collectorForm" class="analytics-form">
|
||||
<label class="query">Search<input id="collectorQuery" class="field" type="search" maxlength="200" required placeholder="Cathedral of Praise Worship" /></label>
|
||||
<label>Total video limit<input id="collectorLimit" class="field" type="number" min="1" max="500" value="30" required /></label>
|
||||
<label>Related-search depth<select id="collectorDepth" class="field"><option value="0">0 · Search only</option><option value="1">1 · Channels and topics</option><option value="2">2 · Two related rounds</option><option value="3">3 · Three related rounds</option></select></label>
|
||||
<button class="btn pri" type="submit" id="collectorStart">Collect metadata</button>
|
||||
</form>
|
||||
<p class="muted">The limit applies across every round. Depth follows channels and tags found in video metadata, with at most 24 searches. A collection can return fewer videos when results repeat or sources are unavailable. Thumbnails follow the server’s storage budget.</p>
|
||||
<p id="collectorStatus" role="status" aria-live="polite"></p><div id="collectionJobs"></div>
|
||||
</div>
|
||||
<div class="card"><h2>Metadata library</h2>
|
||||
<form id="metadataFilterForm" class="analytics-form"><label class="query">Filter saved metadata<input id="metadataFilter" class="field" type="search" maxlength="200" placeholder="Title, channel or tag" /></label><button class="btn" type="submit">Filter</button></form>
|
||||
<p id="metadataCount" class="muted"></p>
|
||||
<div class="analytics-table"><table id="metadataTable"></table></div>
|
||||
<div class="row"><button id="metadataPrev" class="btn" type="button">Previous</button><button id="metadataNext" class="btn" type="button">Next</button></div>
|
||||
<details id="metadataDetails" class="hidden"><summary>Video metadata</summary><pre id="metadataDetail"></pre></details>
|
||||
</div>
|
||||
<div class="card"><h2>Discovery sources</h2><div class="analytics-table"><table id="analyticsSources"></table></div></div>
|
||||
<div class="card"><h2>Most played</h2><div id="analyticsTop"></div></div>
|
||||
</section>
|
||||
|
||||
<section id="p-access" class="panel">
|
||||
<h1 class="page">API access</h1>
|
||||
<p class="lead">Tokens let scripts write shared lyrics and chapters. A token is shown once — store it in your secrets vault.</p>
|
||||
@@ -505,6 +569,7 @@
|
||||
<nav class="nav" id="tabsBottom" role="tablist"></nav>
|
||||
</div>
|
||||
|
||||
<script src="lyrics-core.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -537,9 +602,10 @@
|
||||
library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
|
||||
peers: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>',
|
||||
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
|
||||
analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>',
|
||||
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
|
||||
};
|
||||
const TABS = [['editor', 'Editor'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['access', 'API']];
|
||||
const TABS = [['editor', 'Editor'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['analytics', 'Stats'], ['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('');
|
||||
@@ -558,6 +624,7 @@
|
||||
if (name === 'peers') loadP2p();
|
||||
if (name === 'library') loadUploads();
|
||||
if (name === 'access') loadTokens();
|
||||
if (name === 'analytics') loadAnalytics();
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
function setBadge(n) {
|
||||
@@ -596,6 +663,67 @@
|
||||
});
|
||||
$('logoutBtn').addEventListener('click', async () => { await api('/api/admin/logout', { method: 'POST' }); showLogin(); });
|
||||
|
||||
/* ---------------- server analytics / metadata collection ---------------- */
|
||||
let analyticsTimer = null, metadataOffset = 0;
|
||||
const bytes = value => { let size = Number(value) || 0, unit = 0; const units = ['B','KB','MB','GB','TB']; while (size >= 1024 && unit < 4) { size /= 1024; unit++; } return `${size.toFixed(unit ? 1 : 0)} ${units[unit]}`; };
|
||||
async function loadAnalytics() {
|
||||
$('analyticsStatus').textContent = 'Loading server analytics…';
|
||||
try {
|
||||
const j = await api('/api/admin/analytics');
|
||||
if (!j.ok) throw Error(j.error || 'Analytics unavailable.');
|
||||
const ready = j.media.find(row => row.status === 'ready') || {}, uploadBytes = j.uploads.reduce((total, row) => total + Number(row.bytes), 0);
|
||||
const metrics = [
|
||||
['Stored media', bytes(Number(ready.bytes || 0) + uploadBytes), `${ready.count || 0} cached videos · ${j.uploads.reduce((sum, row) => sum + Number(row.count), 0)} uploads`],
|
||||
['Metadata videos', Number(j.catalog.videos).toLocaleString(), `${j.details.videos} with detailed extractor metadata`],
|
||||
['Saved thumbnails', Number(j.thumbnails.saved || 0).toLocaleString(), `${bytes(j.thumbnails.bytes)} · ${j.thumbnails.pending || 0} awaiting download`],
|
||||
['Recorded plays', Number(j.listening.plays).toLocaleString(), `${j.listening.videos} different videos`],
|
||||
['Cached video size', bytes(ready.bytes), `${Math.round(Number(ready.seconds || 0) / 3600)} hours of audio/video`],
|
||||
['Upload size', bytes(uploadBytes), 'Server media library'],
|
||||
['Metadata size', bytes(Number(j.catalog.bytes) + Number(j.details.bytes)), 'JSON payloads; database indexes excluded'],
|
||||
['Cache queue', j.media.filter(row => row.status !== 'ready').reduce((sum, row) => sum + Number(row.count), 0), j.media.filter(row => row.status !== 'ready').map(row => `${row.status}: ${row.count}`).join(' · ') || 'No pending downloads'],
|
||||
];
|
||||
$('analyticsMetrics').innerHTML = metrics.map(([label, value, detail]) => `<div class="metric"><span>${esc(label)}</span><b>${esc(value)}</b><small>${esc(detail)}</small></div>`).join('');
|
||||
$('analyticsVolumes').innerHTML = j.volumes.map(v => `<p><b>${esc(v.label)}</b> · ${v.unavailable ? 'Storage unavailable' : `${bytes(v.free)} available of ${bytes(v.total)} · device ${esc(v.device)}`}</p>`).join('');
|
||||
$('analyticsSources').innerHTML = '<tr><th>Source</th><th>Videos</th><th>Discoveries</th></tr>' + j.sources.map(row => `<tr><td>${esc(row.source)}</td><td>${esc(row.videos)}</td><td>${esc(row.discoveries)}</td></tr>`).join('');
|
||||
$('analyticsTop').innerHTML = j.topPlayed.length ? j.topPlayed.map(row => `<p>${esc(row.title)} · <b>${esc(row.plays)} plays</b></p>`).join('') : '<p class="muted">No listening activity recorded yet.</p>';
|
||||
$('analyticsStatus').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
await Promise.all([loadCollections(), loadMetadata()]);
|
||||
} catch (error) { $('analyticsStatus').textContent = error.message || 'Could not load server analytics.'; }
|
||||
}
|
||||
async function loadCollections() {
|
||||
try {
|
||||
const j = await api('/api/admin/collections'); if (!j.ok) return;
|
||||
$('collectionJobs').innerHTML = j.jobs.length ? j.jobs.map(job => `<div class="collection-job"><b>${esc(job.query)}</b> · ${esc(job.status)}<p>${job.collected}/${job.maxVideos} videos collected · ${job.enriched} enriched · ${job.failed} failed · ${job.searches} searches · depth ${job.depth}</p>${job.current ? `<p>Working on ${esc(job.current)}</p>` : ''}${job.error ? `<p>${esc(job.error)}</p>` : ''}${job.errors?.length ? `<details><summary>Extraction errors</summary>${job.errors.map(e => `<p>${esc(e.id)}: ${esc(e.error)}</p>`).join('')}</details>` : ''}${['queued','running'].includes(job.status) ? `<button class="btn" type="button" data-cancel-collection="${esc(job.id)}">Stop collection</button>` : ''}</div>`).join('') : '<p class="muted">No collections yet.</p>';
|
||||
clearTimeout(analyticsTimer); analyticsTimer = null;
|
||||
if (tab === 'analytics' && j.jobs.some(job => ['queued','running'].includes(job.status))) analyticsTimer = setTimeout(loadCollections, 4000);
|
||||
} catch { $('collectorStatus').textContent = 'Connection interrupted. Collection continues on the server.'; }
|
||||
}
|
||||
async function loadMetadata() {
|
||||
const j = await api(`/api/admin/metadata?q=${encodeURIComponent($('metadataFilter').value)}&offset=${metadataOffset}`); if (!j.ok) return;
|
||||
$('metadataCount').textContent = `${j.total} saved videos · ${j.total ? metadataOffset + 1 : 0}–${metadataOffset + j.videos.length}`;
|
||||
$('metadataTable').innerHTML = '<tr><th>Video</th><th>Metadata</th><th>Thumbnail</th></tr>' + j.videos.map(v => `<tr><td><button class="btn sm" type="button" data-metadata-id="${esc(v.id)}">${esc(v.title)}</button><br><small>${esc(v.channel)} · ${Number(v.duration) > 0 ? Math.round(Number(v.duration)) + 's' : 'Duration unavailable'}</small></td><td>${v.enriched ? 'Detailed' : 'Search card'}</td><td>${v.thumbnailBytes ? bytes(v.thumbnailBytes) : 'Pending / unavailable'}</td></tr>`).join('');
|
||||
$('metadataPrev').disabled = metadataOffset === 0; $('metadataNext').disabled = metadataOffset + 50 >= j.total;
|
||||
}
|
||||
$('analyticsRefresh').addEventListener('click', loadAnalytics);
|
||||
$('collectorForm').addEventListener('submit', async e => {
|
||||
e.preventDefault(); $('collectorStart').disabled = true; $('collectorStatus').textContent = 'Queuing collection…';
|
||||
try {
|
||||
const j = await api('/api/admin/collections', { method: 'POST', body: { query: $('collectorQuery').value, maxVideos: Number($('collectorLimit').value), depth: Number($('collectorDepth').value) } });
|
||||
$('collectorStatus').textContent = j.ok ? 'Collection queued. You can leave this page while it runs.' : j.error || 'Could not start collection.';
|
||||
if (j.ok) await loadCollections();
|
||||
} catch { $('collectorStatus').textContent = 'Could not reach the server.'; }
|
||||
finally { $('collectorStart').disabled = false; }
|
||||
});
|
||||
$('collectionJobs').addEventListener('click', async e => { const btn = e.target.closest('[data-cancel-collection]'); if (!btn) return; await api('/api/admin/collections/' + btn.dataset.cancelCollection + '/cancel', { method: 'POST', body: {} }); await loadCollections(); });
|
||||
$('metadataFilterForm').addEventListener('submit', e => { e.preventDefault(); metadataOffset = 0; loadMetadata(); });
|
||||
$('metadataPrev').addEventListener('click', () => { metadataOffset = Math.max(0, metadataOffset - 50); loadMetadata(); });
|
||||
$('metadataNext').addEventListener('click', () => { metadataOffset += 50; loadMetadata(); });
|
||||
$('metadataTable').addEventListener('click', async e => {
|
||||
const btn = e.target.closest('[data-metadata-id]'); if (!btn) return;
|
||||
const j = await api('/api/admin/metadata/' + btn.dataset.metadataId); if (!j.ok) return;
|
||||
$('metadataDetail').textContent = JSON.stringify(j.metadata, null, 2); $('metadataDetails').classList.remove('hidden'); $('metadataDetails').open = true;
|
||||
});
|
||||
|
||||
/* ---------------- tokens / inject ---------------- */
|
||||
async function loadTokens() {
|
||||
const j = await api('/api/admin/tokens');
|
||||
@@ -776,13 +904,18 @@
|
||||
function paintSongs() {
|
||||
const q = $('songFilter').value.trim().toLowerCase();
|
||||
const rows = SONGS.filter((r) => !q || (r.title + ' ' + (r.sub || '')).toLowerCase().includes(q));
|
||||
$('songList').innerHTML = rows.length ? rows.map((r) => `<button type="button" class="song-row" data-open="${esc(r.id)}">
|
||||
$('songList').innerHTML = rows.length ? rows.map((r) => `<div class="song-row"><button type="button" class="song-open" data-open="${esc(r.id)}">
|
||||
<span style="min-width:0;flex:1"><b>${esc(r.title)}</b><small>${esc(r.sub || '')}</small></span>
|
||||
<span class="st ${r.lines > 0 ? 'has' : ''}">${r.lines > 0 ? `${r.lines} lines` : r.lines === 0 ? 'no lyrics' : 'open'}</span></button>`).join('')
|
||||
<span class="st ${r.lines > 0 ? 'has' : ''}">${r.lines > 0 ? `${r.lines} lines` : r.lines === 0 ? 'no lyrics' : 'open'}</span></button>
|
||||
<button type="button" class="btn sm whisper-request" data-whisper="${esc(r.id)}" aria-label="Transcribe ${esc(r.title)} with Whisper">Whisper</button></div>`).join('')
|
||||
: '<div class="muted" style="padding:10px">Nothing matches — paste a link above to open any YouTube song.</div>';
|
||||
paintWhisper();
|
||||
}
|
||||
$('songFilter').addEventListener('input', paintSongs);
|
||||
$('songList').addEventListener('click', (e) => { const b = e.target.closest('[data-open]'); if (b) leOpen(b.dataset.open); });
|
||||
$('songList').addEventListener('click', (e) => {
|
||||
const whisper = e.target.closest('[data-whisper]'); if (whisper) { startWhisper(whisper.dataset.whisper); return; }
|
||||
const b = e.target.closest('[data-open]'); if (b) leOpen(b.dataset.open);
|
||||
});
|
||||
$('recentChips').addEventListener('click', (e) => { const b = e.target.closest('[data-open]'); if (b) leOpen(b.dataset.open); });
|
||||
|
||||
/* ---------------- p2p ---------------- */
|
||||
@@ -907,6 +1040,67 @@
|
||||
$('save').disabled = !on;
|
||||
$('discard').disabled = !on;
|
||||
}
|
||||
|
||||
const whisperJobs = new Map(), whisperStarting = new Set();
|
||||
let whisperTimer = null;
|
||||
const activeWhisper = job => job && (job.status === 'queued' || job.status === 'running');
|
||||
function paintWhisper() {
|
||||
document.querySelectorAll('[data-whisper]').forEach(btn => {
|
||||
const job = whisperJobs.get(btn.dataset.whisper);
|
||||
btn.disabled = whisperStarting.has(btn.dataset.whisper) || activeWhisper(job);
|
||||
btn.textContent = job?.status === 'running' ? 'Transcribing…' : job?.status === 'queued' ? 'Queued' : 'Whisper';
|
||||
});
|
||||
const job = whisperJobs.get(LE.id);
|
||||
$('whisperBtn').disabled = whisperStarting.has(LE.id) || activeWhisper(job);
|
||||
$('whisperBtn').textContent = activeWhisper(job) ? (job.status === 'queued' ? 'Whisper queued' : 'Whisper transcribing…') : 'Transcribe with Whisper';
|
||||
const labels = { 'loading-model': 'Whisper is loading its model…', 'downloading-audio': 'Whisper is reading the song’s audio…', transcribing: 'Whisper is transcribing the vocals. This can take several minutes.' };
|
||||
$('whisperStatus').textContent = job?.status === 'queued' ? 'Queued for Whisper. Waiting for the lyrics worker.'
|
||||
: job?.status === 'running' ? labels[job.stage] || 'Whisper is working…'
|
||||
: job?.status === 'failed' ? 'Whisper could not finish: ' + (job.error || 'Try again.') : '';
|
||||
const ready = job?.status === 'complete' && job.result?.lines?.length;
|
||||
$('whisperResult').classList.toggle('hidden', !ready);
|
||||
$('whisperPreview').textContent = ready ? job.result.lines.map(l => l.text).join('\n') : '';
|
||||
}
|
||||
async function refreshWhisper(id) {
|
||||
try {
|
||||
const result = await api('/api/admin/transcriptions/' + encodeURIComponent(id));
|
||||
if (result.ok) whisperJobs.set(id, result.job);
|
||||
paintWhisper(); scheduleWhisper();
|
||||
} catch { /* a reconnect or another poll can recover the durable job */ }
|
||||
}
|
||||
function scheduleWhisper() {
|
||||
if (whisperTimer || ![...whisperJobs.values()].some(activeWhisper)) return;
|
||||
whisperTimer = setTimeout(async () => {
|
||||
whisperTimer = null;
|
||||
for (const [id, job] of whisperJobs) if (activeWhisper(job)) await refreshWhisper(id);
|
||||
scheduleWhisper();
|
||||
}, 5000);
|
||||
}
|
||||
async function startWhisper(id) {
|
||||
if (!id || whisperStarting.has(id) || activeWhisper(whisperJobs.get(id))) return;
|
||||
whisperStarting.add(id); paintWhisper();
|
||||
try {
|
||||
if (LE.id !== id || !LE.loaded) { const opened = await leOpen(id); if (opened === false) return; }
|
||||
const result = await api('/api/admin/transcriptions/' + encodeURIComponent(id), { method: 'POST', body: {} });
|
||||
if (!result.ok) { say(result.error || 'Whisper could not start.', 'err'); return; }
|
||||
whisperJobs.set(id, result.job); paintWhisper(); scheduleWhisper();
|
||||
} catch { say('Could not reach Whisper. Try again when connected.', 'err'); }
|
||||
finally { whisperStarting.delete(id); paintWhisper(); }
|
||||
}
|
||||
$('whisperBtn').addEventListener('click', () => startWhisper(LE.id));
|
||||
$('whisperUse').addEventListener('click', () => {
|
||||
const job = whisperJobs.get(LE.id); if (job?.status !== 'complete' || !job.result?.lines?.length) return;
|
||||
if (LE.dirty && !confirm('Replace your unsaved editor text with the Whisper draft? Undo can restore it.')) return;
|
||||
edit(() => {
|
||||
LE.lines = job.result.lines.map(l => ({ ...l })); LE.offset = 0;
|
||||
LE.tags = [...LE.tags.filter(t => !/^(from |auto-transcribed)/i.test(t)), ...job.result.tags];
|
||||
LE.raw = false; $('raw').classList.add('hidden'); $('lines').classList.remove('hidden');
|
||||
$('rawBtn').textContent = '✎ Edit as text'; $('tags').value = LE.tags.join(', ');
|
||||
});
|
||||
render(); draw(); syncBars();
|
||||
$('whisperResult').classList.add('hidden');
|
||||
say('Whisper draft loaded. Check the words and timing, then Save to publish; Undo restores the previous text.', 'ok');
|
||||
});
|
||||
function progress() {
|
||||
const timed = LE.lines.filter((l) => l.kind === 'line' && l.t != null).length;
|
||||
const all = LE.lines.filter((l) => l.kind === 'line').length;
|
||||
@@ -919,10 +1113,10 @@
|
||||
}
|
||||
|
||||
/* undo / redo — whole-document snapshots, typing is coalesced */
|
||||
const snap = () => JSON.stringify({ l: LE.lines, t: LE.tags });
|
||||
const snap = () => JSON.stringify({ l: LE.lines, t: LE.tags, o: LE.offset });
|
||||
function pushUndo() { LE.undo.push(snap()); if (LE.undo.length > 100) LE.undo.shift(); LE.redo = []; undoBtns(); }
|
||||
function undoBtns() { $('bUndo').disabled = !LE.undo.length; $('bRedo').disabled = !LE.redo.length; }
|
||||
function restore(s) { const o = JSON.parse(s); LE.lines = o.l; LE.tags = o.t; $('tags').value = LE.tags.join(', '); render(); setDirty(true); undoBtns(); }
|
||||
function restore(s) { const o = JSON.parse(s); LE.lines = o.l; LE.tags = o.t; LE.offset = o.o ?? LE.offset; $('tags').value = LE.tags.join(', '); render(); setDirty(true); undoBtns(); }
|
||||
function doUndo() { if (!LE.undo.length) return; LE.redo.push(snap()); restore(LE.undo.pop()); say('Undone'); }
|
||||
function doRedo() { if (!LE.redo.length) return; LE.undo.push(snap()); restore(LE.redo.pop()); say('Redone'); }
|
||||
let typing = null;
|
||||
@@ -957,6 +1151,7 @@
|
||||
$('raw').classList.add('hidden'); $('lines').classList.remove('hidden'); $('rawBtn').textContent = '✎ Edit as text';
|
||||
await loadFlags();
|
||||
render(); setDirty(false); undoBtns(); syncBars();
|
||||
paintWhisper(); refreshWhisper(id);
|
||||
say('');
|
||||
if (!keepAudio) initAudio(id, streams);
|
||||
draw();
|
||||
@@ -993,7 +1188,7 @@
|
||||
row.innerHTML = `
|
||||
<div class="ln-main">
|
||||
<button class="tc" data-act="chip" type="button"></button>
|
||||
<input class="ln-text" value="${esc(line.text)}" placeholder="Type the line…" aria-label="Line text" />
|
||||
<textarea class="ln-text" rows="1" placeholder="Type the line… (Shift+Enter for a line break)" aria-label="Line text">${esc(line.text)}</textarea>
|
||||
<span class="flagchip hidden" data-flag><svg viewBox="0 0 24 24"><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></svg><b></b></span>
|
||||
<button class="more" data-act="more" type="button" aria-label="Line options">⋯</button>
|
||||
</div>
|
||||
@@ -1005,9 +1200,12 @@
|
||||
<button class="btn" data-act="nudge" data-d="-0.2" type="button">−0.2</button>
|
||||
<button class="btn" data-act="nudge" data-d="0.2" type="button">+0.2</button>
|
||||
<select class="mini" data-kind aria-label="Line type"><option value="line">Line</option><option value="section">Section</option><option value="cue">Cue</option></select>
|
||||
<button class="btn" data-act="break" type="button" title="Same timestamp, both lines highlight together">↵ Line break</button>
|
||||
<button class="btn" data-act="add" type="button">+ Below</button>
|
||||
<button class="btn dng" data-act="del" type="button">Delete</button>
|
||||
</div>`;
|
||||
const text = row.querySelector('.ln-text');
|
||||
text.rows = Math.max(1, line.text.split('\n').length);
|
||||
row.querySelector('[data-kind]').value = line.kind;
|
||||
return row;
|
||||
}
|
||||
@@ -1036,6 +1234,9 @@
|
||||
LE.rows = LE.lines.map((l, i) => { const r = rowEl(l, i); box.appendChild(r); return r; });
|
||||
if (!LE.lines.length) box.innerHTML = '<div class="empty">No lines yet — press “+ Add line”, paste text with ✎ Edit as text, or pull them from LRCLIB (⋯ tools).</div>';
|
||||
LE.lines.forEach((_, i) => paintRow(i));
|
||||
const fields = LE.rows.map(row => row.querySelector('.ln-text'));
|
||||
const heights = fields.map(field => field.scrollHeight);
|
||||
fields.forEach((field, i) => { field.style.height = heights[i] + 'px'; });
|
||||
paintFlags(); progress(); lastIdx = -2; tick(); tapLabel();
|
||||
$('songMeta').textContent = $('songMeta').textContent.replace(/\d+ lines/, `${LE.lines.length} lines`);
|
||||
}
|
||||
@@ -1109,6 +1310,13 @@
|
||||
else if (act === 'set') stamp(i);
|
||||
else if (act === 'seek') { seek((LE.lines[i].t || 0) + LE.offset); if (LE.audio && LE.audio.paused) LE.audio.play().catch(() => {}); }
|
||||
else if (act === 'nudge') { if (LE.lines[i].t == null) return; edit(() => { LE.lines[i].t = r2(Math.max(0, LE.lines[i].t + Number(btn.dataset.d))); }); paintRow(i); draw(); }
|
||||
else if (act === 'break') {
|
||||
const text = LE.rows[i].querySelector('.ln-text');
|
||||
const pos = text.selectionStart;
|
||||
edit(() => { LE.lines[i].text = text.value.slice(0, pos) + '\n' + text.value.slice(text.selectionEnd); });
|
||||
render(); select(i);
|
||||
const target = LE.rows[i].querySelector('.ln-text'); target.focus(); target.setSelectionRange(pos + 1, pos + 1);
|
||||
}
|
||||
else if (act === 'add') { edit(() => LE.lines.splice(i + 1, 0, { t: null, text: '', kind: 'line' })); render(); select(i + 1); LE.rows[i + 1].querySelector('.ln-text').focus(); }
|
||||
else if (act === 'del') { edit(() => LE.lines.splice(i, 1)); LE.sel = -1; render(); draw(); }
|
||||
else if (act === 'resolve') {
|
||||
@@ -1118,7 +1326,7 @@
|
||||
});
|
||||
$('lines').addEventListener('input', (e) => {
|
||||
const row = e.target.closest('.ln'); if (!row) return; const i = Number(row.dataset.i);
|
||||
if (e.target.classList.contains('ln-text')) { typed(); LE.lines[i].text = e.target.value; setDirty(true); tapLabel(); }
|
||||
if (e.target.classList.contains('ln-text')) { typed(); LE.lines[i].text = e.target.value; e.target.rows = Math.max(1, e.target.value.split('\n').length); e.target.style.height = 'auto'; e.target.style.height = e.target.scrollHeight + 'px'; setDirty(true); tapLabel(); }
|
||||
});
|
||||
$('lines').addEventListener('change', (e) => {
|
||||
const row = e.target.closest('.ln'); if (!row) return; const i = Number(row.dataset.i);
|
||||
@@ -1128,6 +1336,7 @@
|
||||
$('lines').addEventListener('focusin', (e) => { const row = e.target.closest('.ln'); if (row && Number(row.dataset.i) !== LE.sel) select(Number(row.dataset.i)); });
|
||||
$('lines').addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' || !e.target.classList.contains('ln-text')) return;
|
||||
if (e.shiftKey) return; // Native textarea break stays in the current timed cue.
|
||||
e.preventDefault();
|
||||
const i = Number(e.target.closest('.ln').dataset.i);
|
||||
edit(() => LE.lines.splice(i + 1, 0, { t: null, text: '', kind: LE.lines[i].kind === 'section' ? 'line' : LE.lines[i].kind }));
|
||||
@@ -1199,17 +1408,10 @@
|
||||
$('raw').classList.toggle('hidden', !LE.raw); $('lines').classList.toggle('hidden', LE.raw);
|
||||
$('rawBtn').textContent = LE.raw ? '≡ Back to lines' : '✎ Edit as text';
|
||||
if (LE.raw) {
|
||||
$('raw').value = LE.lines.map((l) => (l.t == null ? '' : `[${fmtT(l.t)}] `) + (l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '') + l.text).join('\n');
|
||||
$('raw').value = LyricsCore.toText({ lines: LE.lines });
|
||||
} else {
|
||||
pushUndo();
|
||||
LE.lines = $('raw').value.split('\n').map((raw) => {
|
||||
let rest = raw.trim(), t = null;
|
||||
const m = rest.match(/^\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]\s*/);
|
||||
if (m) { t = r2(Number(m[1]) * 60 + Number(m[2].replace(':', '.'))); rest = rest.slice(m[0].length); }
|
||||
let kind = 'line';
|
||||
if (rest.startsWith('# ')) { kind = 'section'; rest = rest.slice(2); } else if (rest.startsWith('! ')) { kind = 'cue'; rest = rest.slice(2); }
|
||||
return { t, text: rest, kind };
|
||||
}).filter((l) => l.text);
|
||||
LE.lines = LyricsCore.fromText($('raw').value).lines;
|
||||
setDirty(true); render(); draw();
|
||||
}
|
||||
syncBars();
|
||||
@@ -1242,12 +1444,12 @@
|
||||
leOpen(id);
|
||||
});
|
||||
function leOpen(id, focusText) {
|
||||
if (LE.dirty && id !== LE.id && !confirm('Discard the unsaved changes to the song you have open?')) return;
|
||||
if (LE.dirty && id !== LE.id && !confirm('Discard the unsaved changes to the song you have open?')) return false;
|
||||
showTab('editor');
|
||||
if (id === LE.id && LE.loaded && focusText) {
|
||||
const i = LE.lines.findIndex((l) => l.text === focusText); if (i >= 0) select(i, true, true); return;
|
||||
}
|
||||
leLoad(id, false, focusText);
|
||||
return leLoad(id, false, focusText);
|
||||
}
|
||||
|
||||
function renderDocs() {
|
||||
|
||||
Reference in New Issue
Block a user