diff --git a/podcast.js b/podcast.js index 92662ab..f569101 100644 --- a/podcast.js +++ b/podcast.js @@ -116,7 +116,7 @@ async function handle(req, res) { /** For transcripts: where an episode came from (its feed, its id in that feed, its file). Anyone may ask about the shows on their own list only. */ async function feedInfo(me, itemId) { if (!BASE || !/^[\w-]+$/.test(String(itemId))) return null; - const f = (o, k) => o && (o[k] !== undefined ? o[k] : o[k.toLowerCase()]); + const f = (o, k) => { if (!o) return undefined; const hit = Object.keys(o).find(x => x.toLowerCase() === k.toLowerCase()); return hit === undefined ? undefined : o[hit]; }; // (Podgrab's field names, whatever their capitals) const it = await (await podgrab('/podcastitems/' + itemId)).json().catch(() => null); if (!it) return null; const pid = f(it, 'PodcastID') || f(it, 'podcastId'); if (!pid || !(await mySubs(me)).includes(pid)) return null; const sh = await (await podgrab('/podcasts/' + pid)).json().catch(() => null); diff --git a/public/lyrics-view.js b/public/lyrics-view.js index 0841b21..a0c4a37 100644 --- a/public/lyrics-view.js +++ b/public/lyrics-view.js @@ -48,7 +48,7 @@ async function lyrSpoken(t, start) { 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') return { found: false, none: true, canTranscribe: r.canTranscribe }; + if (r.state === 'none') 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.

`; await new Promise(ok => setTimeout(ok, 4000)); } @@ -114,7 +114,7 @@ function lyrDraw() { 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.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.

'}`; + 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; diff --git a/public/sw.js b/public/sw.js index 3066cf8..c31744a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v30'; +const CACHE = 'hub-v31'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/tools/transcript-test.js b/tools/transcript-test.js index b068f1b..245cf6a 100644 --- a/tools/transcript-test.js +++ b/tools/transcript-test.js @@ -33,7 +33,8 @@ let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m); const feed = `g1g2`; ok(tr.transcriptLinks(feed, 'g1', '')[0].url === 'https://a/b.srt?x=1&y=2', 'a transcript link is found by the episode guid (and & unescaped)'); ok(tr.transcriptLinks(feed, 'nope', 'https://f/2.mp3')[0].type === 'text/vtt', 'or by the audio file address'); - ok(tr.transcriptLinks(feed, 'zzz', '').length === 0, 'another episode has none'); + ok(tr.transcriptLinks(feed, 'zzz', '') === null, 'an episode that is not in the feed is told apart from one with no transcript'); + ok(tr.transcriptLinks('q', 'q', '').length === 0, 'an episode with no transcript tag gives an empty list'); for (const u of ['http://127.0.0.1:1/x', 'http://localhost/x', 'http://192.168.1.5/x', 'http://10.0.0.2/x', 'ftp://example.com/x', 'http://[::1]/x']) { let e = null; try { await tr.getText(u); } catch (x) { e = x; } ok(e && /not allowed/.test(e.message), 'refuses ' + u); diff --git a/transcripts.js b/transcripts.js index 4aa64fb..a2cda4c 100644 --- a/transcripts.js +++ b/transcripts.js @@ -50,18 +50,23 @@ function transcriptLinks(feedXml, guid, fileUrl) { const unwrap = x => String(x || '').replace(//g, '').trim(); const item = items.find(b => { const g = /]*>([\s\S]*?)<\/guid>/i.exec(b); return g && guid && unwrap(g[1]) === guid; }) || items.find(b => { const e = /]*>/i.exec(b); return e && fileUrl && attr(e[0], 'url') === fileUrl; }); - if (!item) return []; + if (!item) return null; return [...item.matchAll(/]*?)\/?>/gi)].map(m => ({ url: attr(m[1], 'url'), type: attr(m[1], 'type').toLowerCase() })).filter(x => x.url); } +/** { transcript } when the show publishes one we can read, otherwise { why }: the reason, so a missing transcript can be explained. */ async function nativeTranscript(info) { - if (!info || !info.feedUrl) return null; - const links = transcriptLinks(await getText(info.feedUrl), info.guid, info.fileUrl); + if (!info) return { why: 'Could not read this episode from the podcast service.' }; + if (!info.feedUrl) return { why: 'The podcast service did not give the show\'s feed address.' }; + let xml; try { xml = await getText(info.feedUrl); } catch (e) { return { why: 'Could not download the show\'s feed (' + e.message + ').' }; } + const links = transcriptLinks(xml, info.guid, info.fileUrl); + if (!links) return { why: 'Could not find this episode in the show\'s feed (looked for ' + (info.guid ? 'id ' + String(info.guid).slice(0, 60) : 'its audio address') + ').' }; + if (!links.length) return { why: 'The show\'s feed lists no transcript for this episode.' }; const rank = l => /json/.test(l.type) ? 0 : /vtt/.test(l.type) ? 1 : /subrip|srt/.test(l.type) ? 2 : 9; for (const l of links.sort((a, b) => rank(a) - rank(b))) { if (rank(l) > 2) continue; // (plain HTML or text has no times: not worth showing as a transcript) - try { const t = await getText(l.url), lines = /json/.test(l.type) ? parseJson(t) : parseCues(t); const r = shape(lines, 'Published by the show'); if (r) return r; } catch {} + try { const t = await getText(l.url), lines = /json/.test(l.type) ? parseJson(t) : parseCues(t); const r = shape(lines, 'Published by the show'); if (r) return { transcript: r }; } catch {} } - return null; + return { why: 'The show lists a transcript (' + links.map(l => l.type || 'unknown type').join(', ') + ') but not in a timed format this can follow, or it could not be downloaded.' }; } // ---- speech to text on this server @@ -110,7 +115,7 @@ function startJob(key, src, cookie) { } const SRC_OK = [/^\/api\/podcast\/podcastitems\/[\w-]+\/file$/, /^\/api\/ab\/books\/[^?#\s]+$/]; // only audio this hub already serves to the person -const nativeMiss = new Map(); // key -> time: a show with no transcript is not asked again for a day +const nativeMiss = new Map(); // key -> { t, why }: a show with no transcript is not asked again for a day async function handle(req, res) { const u = new URL(req.url, 'http://x'), q = u.searchParams, key0 = String(q.get('key') || ''), src = String(q.get('src') || ''); const kind = key0.startsWith('pod:') ? 'pod' : key0.startsWith('ab:') ? 'ab' : ''; @@ -124,12 +129,17 @@ async function handle(req, res) { if (!cfg.api) return json(res, 400, { error: 'Transcribing is not set up on this server (see the whisper service in docker-compose.yml)' }); startJob(key, src, req.headers.cookie); return json(res, 200, { state: 'running', since: Date.now(), ...base }); } - if (kind === 'pod' && !(nativeMiss.get(key) > Date.now() - 864e5)) { - let r = null; try { r = await nativeTranscript(await podcast.feedInfo(me, key0.slice(4))); } catch {} - if (r) { atomicWrite(fileOf(key), JSON.stringify(r)); return json(res, 200, { state: 'ready', ...base, transcript: r }); } - nativeMiss.set(key, Date.now()); + let why = ''; + if (kind === 'pod') { + const miss = nativeMiss.get(key); + if (miss && miss.t > Date.now() - 6 * 3600e3) why = miss.why; + else { + let r; try { r = await nativeTranscript(await podcast.feedInfo(me, key0.slice(4))); } catch (e) { r = { why: 'Checking the show\'s feed failed (' + e.message + ').' }; } + if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify(r.transcript)); return json(res, 200, { state: 'ready', ...base, transcript: r.transcript }); } + why = r.why; nativeMiss.set(key, { t: Date.now(), why }); + } } - return json(res, 200, { state: 'none', ...base }); + return json(res, 200, { state: 'none', why, ...base }); } catch (e) { json(res, e.status || 500, { error: e.message }); } } module.exports = { init, handle, parseCues, parseJson, transcriptLinks, shape, whisper, getText, nativeTranscript, enabled: () => true };