Transcripts: say why a show's own transcript was not found; read Podgrab fields whatever their capitals
build-container / build (push) Successful in 4m2s

This commit is contained in:
nmemmert committed 2026-10-23 11:42:43 -04:00
1 parent ae00f61711
commit 747002924d
5 files changed
+27 -16

No files matched your search

+1 -1
View File
@@ -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. */ /** 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) { async function feedInfo(me, itemId) {
if (!BASE || !/^[\w-]+$/.test(String(itemId))) return null; 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 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 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); const sh = await (await podgrab('/podcasts/' + pid)).json().catch(() => null);
+2 -2
View File
@@ -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 }; } 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 === '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 === '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 = `<p class="empty">Making a transcript on your server… ${Math.max(0, Math.round((Date.now() - r.since) / 60000))} min so far.<br><small>You can close this: it keeps going, and it is kept for next time.</small></p>`; const body = lyr.el.querySelector('#lyrbody'); if (body) body.innerHTML = `<p class="empty">Making a transcript on your server… ${Math.max(0, Math.round((Date.now() - r.since) / 60000))} min so far.<br><small>You can close this: it keeps going, and it is kept for next time.</small></p>`;
await new Promise(ok => setTimeout(ok, 4000)); await new Promise(ok => setTimeout(ok, 4000));
} }
@@ -114,7 +114,7 @@ function lyrDraw() {
if (!d || !d.found) { if (!d || !d.found) {
src.textContent = ''; src.textContent = '';
if (lyrSpokenKey(t)) { // no transcript yet: offer to make one if (lyrSpokenKey(t)) { // no transcript yet: offer to make one
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`; body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.why && !d.error ? `<p class="empty"><small>${esc(d.why)}</small></p>` : ''}${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`;
const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return; const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return;
} }
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return; body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return;
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network. // 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 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']; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+2 -1
View File
@@ -33,7 +33,8 @@ let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m);
const feed = `<item><guid>g1</guid><podcast:transcript url="https://a/b.srt?x=1&amp;y=2" type="application/x-subrip"/></item><item><guid>g2</guid><enclosure url="https://f/2.mp3" length="1"/><podcast:transcript url="https://a/c.vtt" type="text/vtt"/></item>`; const feed = `<item><guid>g1</guid><podcast:transcript url="https://a/b.srt?x=1&amp;y=2" type="application/x-subrip"/></item><item><guid>g2</guid><enclosure url="https://f/2.mp3" length="1"/><podcast:transcript url="https://a/c.vtt" type="text/vtt"/></item>`;
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, '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, '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('<item><guid>q</guid></item>', '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']) { 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); let e = null; try { await tr.getText(u); } catch (x) { e = x; } ok(e && /not allowed/.test(e.message), 'refuses ' + u);
+21 -11
View File
@@ -50,18 +50,23 @@ function transcriptLinks(feedXml, guid, fileUrl) {
const unwrap = x => String(x || '').replace(/<!\[CDATA\[|\]\]>/g, '').trim(); const unwrap = x => String(x || '').replace(/<!\[CDATA\[|\]\]>/g, '').trim();
const item = items.find(b => { const g = /<guid[^>]*>([\s\S]*?)<\/guid>/i.exec(b); return g && guid && unwrap(g[1]) === guid; }) const item = items.find(b => { const g = /<guid[^>]*>([\s\S]*?)<\/guid>/i.exec(b); return g && guid && unwrap(g[1]) === guid; })
|| items.find(b => { const e = /<enclosure[^>]*>/i.exec(b); return e && fileUrl && attr(e[0], 'url') === fileUrl; }); || items.find(b => { const e = /<enclosure[^>]*>/i.exec(b); return e && fileUrl && attr(e[0], 'url') === fileUrl; });
if (!item) return []; if (!item) return null;
return [...item.matchAll(/<podcast:transcript\s+([^>]*?)\/?>/gi)].map(m => ({ url: attr(m[1], 'url'), type: attr(m[1], 'type').toLowerCase() })).filter(x => x.url); return [...item.matchAll(/<podcast:transcript\s+([^>]*?)\/?>/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) { async function nativeTranscript(info) {
if (!info || !info.feedUrl) return null; if (!info) return { why: 'Could not read this episode from the podcast service.' };
const links = transcriptLinks(await getText(info.feedUrl), info.guid, info.fileUrl); 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; 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))) { 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) 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 // ---- 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 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) { 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 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' : ''; 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)' }); 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 }); 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 why = '';
let r = null; try { r = await nativeTranscript(await podcast.feedInfo(me, key0.slice(4))); } catch {} if (kind === 'pod') {
if (r) { atomicWrite(fileOf(key), JSON.stringify(r)); return json(res, 200, { state: 'ready', ...base, transcript: r }); } const miss = nativeMiss.get(key);
nativeMiss.set(key, Date.now()); 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 }); } } catch (e) { json(res, e.status || 500, { error: e.message }); }
} }
module.exports = { init, handle, parseCues, parseJson, transcriptLinks, shape, whisper, getText, nativeTranscript, enabled: () => true }; module.exports = { init, handle, parseCues, parseJson, transcriptLinks, shape, whisper, getText, nativeTranscript, enabled: () => true };