'use strict'; // ---- Lyrics and karaoke: lyrics from Plex when the song has them, otherwise from the Lyrica service on the server; synced ones follow the song. function lyrParseLrc(text) { const t2ms = (m, s, f) => (+m * 60 + +s) * 1000 + Math.round(+('0.' + (f || '0')) * 1000); const lines = []; for (const raw of String(text || '').split(/\r?\n/)) { const tags = [...raw.matchAll(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)]; if (!tags.length) continue; let body = raw.replace(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g, '').trim(), words = []; if (/<\d+:\d{1,2}/.test(body)) { const parts = [...body.matchAll(/<(\d+):(\d{1,2})(?:[.:](\d{1,3}))?>([^<]*)/g)]; words = parts.map(p => ({ t: t2ms(p[1], p[2], p[3]), text: p[4] })).filter(w => w.text.trim()); words.forEach((w, i) => { w.e = i + 1 < words.length ? words[i + 1].t : null; }); body = parts.map(p => p[4]).join('').replace(/\s+/g, ' ').trim(); } for (const g of tags) lines.push({ t: t2ms(g[1], g[2], g[3]), text: body, ...(words.length ? { words } : {}) }); } lines.sort((a, b) => a.t - b.t); lines.forEach((l, i) => { l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; if (l.words) { const last = l.words[l.words.length - 1]; if (last.e == null) last.e = l.e; } }); return lines; } const lyrSpokenKey = t => !!t && /^(pod|ab):/.test(String(t.key)); // a podcast episode or an audiobook: words from a transcript instead of lyrics const lyrLabel = () => lyrSpokenKey(typeof cur === 'function' ? cur() : null) ? 'Transcript' : 'Lyrics'; const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false }; const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } }; const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} }; const lyrOffset = k => { try { return +localStorage.getItem('lyr_off:' + k) || 0; } catch { return 0; } }; const lyrSetOffset = (k, v) => { try { localStorage.setItem('lyr_off:' + k, String(v)); } catch {} }; const lyrQuery = t => { try { return JSON.parse(localStorage.getItem('lyr_q:' + t.key)) || { artist: t.artist, title: t.title }; } catch { return { artist: t.artist, title: t.title }; } }; const lyrCleanTitle = s => String(s || '').replace(/\s*[\(\[](feat\.?|ft\.?|with|remaster(ed)?|live|deluxe|version|mono|stereo)[^\)\]]*[\)\]]/gi, '').replace(/\s+-\s+(remaster(ed)?|live|mono|stereo|single version).*$/i, '').trim(); async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc file next to it, or downloaded by Plex) try { const mc = await px.get('/library/metadata/' + t.key), st = ((mc.Metadata || [])[0]?.Media?.[0]?.Part?.[0]?.Stream || []).find(x => x.streamType === 4 && x.key); if (!st) return null; const r = await fetch('/plex' + st.key + (st.key.includes('?') ? '&' : '?') + 'format=' + (st.format || 'txt'), { headers: { Accept: 'text/plain, */*' } }); if (!r.ok) return null; let text = await r.text(); if (/^\s*[{<]/.test(text) && /MediaContainer|"Lyrics"/.test(text)) return null; // (not lyrics) const timed = lyrParseLrc(text); if (timed.length >= 2) return { found: true, source: 'Plex', sync: timed.some(l => l.words) ? 'word' : 'line', lines: timed.filter(l => l.text || l.words) }; const lines = text.split(/\r?\n/).map(x => ({ text: x.trim() })); return lines.some(l => l.text) ? { found: true, source: 'Plex', sync: 'none', lines } : null; } catch { return null; } } /** A transcript: the show's own if it publishes one, else (on request) one made on this server. Polls while that is being made. */ async function lyrSpoken(t, start) { const q = new URLSearchParams({ key: t.key, src: t.src || '' }); for (let n = 0; ; n++) { // (n restarts at -1 after an automatic start, so the POST goes out once) if (!lyr.el || lyr.key !== t.key) return null; let r; try { r = await jfetch('/api/transcript?' + q, start && n <= 0 ? { method: 'POST' } : undefined); } catch (e) { return { found: false, error: e.message }; } if (r.state === 'ready') { r.transcript.spoken = true; return r.transcript; } if (r.state === 'error') return { found: false, error: r.error, canTranscribe: r.canTranscribe, none: true }; if (r.state === 'none') { if (r.canTranscribe && !start && String(t.key).startsWith('pod:') && !lyrPref().noAuto) { start = true; n = -1; continue; } // nothing published: make one (an episode, not a whole book) return { found: false, none: true, canTranscribe: r.canTranscribe, why: r.why }; } const body = lyr.el.querySelector('#lyrbody'); if (body) body.innerHTML = `
Making a transcript on your server… ${Math.max(0, Math.round((Date.now() - r.since) / 60000))} min so far.
You can close this: it keeps going, and it is kept for next time.
Looking for the lyrics…
Looking for the lyrics…
'; const res = lyrSpokenKey(t) ? await lyrSpoken(t, force === 'start') : await lyrLoad(t, force); if (!res || !lyr.el || lyr.key !== t.key) return; lyr.data = res; lyrDraw(); } function lyrDraw() { const t = cur(), d = lyr.data, body = lyr.el && lyr.el.querySelector('#lyrbody'); if (!body || !t) return; const src = lyr.el.querySelector('#lyrsrc'), nudge = lyr.el.querySelector('#lyrnudge'); nudge.hidden = !(d && d.found && d.sync !== 'none'); lyr.el.querySelector('#lyroff').textContent = (lyrOffset(t.key) / 1000).toFixed(1) + ' s'; if (!d || !d.found) { src.textContent = ''; if (lyrSpokenKey(t)) { // no transcript yet: offer to make one body.innerHTML = `${d && d.error ? esc(d.error) : 'This one has no published transcript.'}
${d && d.why && !d.error ? `${esc(d.why)}
` : ''}${d && d.canTranscribe ? `Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.
` : 'An admin can add the whisper service to turn on transcribing.
'}`; const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return; } body.innerHTML = `${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}
`; return; } if (d.instrumental) { src.textContent = ''; body.innerHTML = 'This one is instrumental.
'; return; } src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced'); body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `