Transcripts: say why a show's own transcript was not found; read Podgrab fields whatever their capitals
build-container / build (push) Successful in 4m2s
build-container / build (push) Successful in 4m2s
This commit is contained in:
1 parent
ae00f61711
commit
747002924d
5 files changed
+27
-16
No files matched your search
+1
-1
@@ -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);
|
||||
|
||||
@@ -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 = `<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));
|
||||
}
|
||||
@@ -114,7 +114,7 @@ function lyrDraw() {
|
||||
if (!d || !d.found) {
|
||||
src.textContent = '';
|
||||
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;
|
||||
}
|
||||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return;
|
||||
|
||||
+1
-1
@@ -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'];
|
||||
|
||||
|
||||
@@ -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&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, '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']) {
|
||||
let e = null; try { await tr.getText(u); } catch (x) { e = x; } ok(e && /not allowed/.test(e.message), 'refuses ' + u);
|
||||
|
||||
+21
-11
@@ -50,18 +50,23 @@ function transcriptLinks(feedXml, guid, fileUrl) {
|
||||
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; })
|
||||
|| 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);
|
||||
}
|
||||
/** { 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 };
|
||||
Reference in new issue
Block a user