diff --git a/README.md b/README.md index 9a35f1c..5c51160 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,7 @@ The lyrics screen shows a **Transcript** for podcast episodes and audiobooks. If straight away. Otherwise a **Transcribe on this server** button makes one with speech-to-text: the bundled `whisper` service in `docker-compose.yml` (faster-whisper behind an OpenAI-compatible API) is reached at `TRANSCRIBE_API_URL` (default `http://127.0.0.1:8000`), model `TRANSCRIBE_MODEL` (default `Systran/faster-whisper-small`, downloaded on first use). CPU only: about a quarter of the running time or more. One job at a time; results are kept in `/data/transcripts`. Remove the service and the variable to switch the button off. + +In the background the server also keeps transcripts up to date by itself: every ten minutes it looks at the newest `TRANSCRIBE_KEEP` (default 5) episodes of every show in Podgrab, uses the show's +published transcript when there is one, and otherwise transcribes the episode (one at a time, at most two new ones per round, a failing one retried a few times). `TRANSCRIBE_AUTO=0` switches this off; +playing an episode or pressing the button still works. Settings -> Health shows how many are saved and what it is working on. diff --git a/podcast.js b/podcast.js index f569101..444ae4d 100644 --- a/podcast.js +++ b/podcast.js @@ -113,13 +113,33 @@ 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). With a person, only the shows on their own list; without one (the server's own background work), any. */ async function feedInfo(me, itemId) { if (!BASE || !/^[\w-]+$/.test(String(itemId))) return null; 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 pid = f(it, 'PodcastID') || f(it, 'podcastId'); if (!pid || (me && !(await mySubs(me)).includes(pid))) return null; const sh = await (await podgrab('/podcasts/' + pid)).json().catch(() => null); return { guid: f(it, 'GUID') || f(it, 'guid') || '', fileUrl: f(it, 'FileURL') || f(it, 'fileUrl') || '', feedUrl: (sh && (f(sh, 'URL') || f(sh, 'url'))) || '' }; } -module.exports = { handle, enabled, feedInfo }; +/** The newest `n` episodes of every show in Podgrab, newest first overall: [{ id, title, show, guid, fileUrl, feedUrl }]. */ +async function recentEpisodes(n) { + if (!BASE) return []; + const f = (o, k) => { const hit = Object.keys(o || {}).find(x => x.toLowerCase() === k.toLowerCase()); return hit === undefined ? undefined : o[hit]; }; + const shows = await (await podgrab('/podcasts')).json().catch(() => []), out = []; + for (const sh of Array.isArray(shows) ? shows : []) { + const sid = idOf(sh); if (!sid) continue; + const items = await (await podgrab('/podcasts/' + sid + '/items')).json().catch(() => []); + for (const it of (Array.isArray(items) ? items : []).sort((x, y) => String(f(y, 'PubDate')).localeCompare(String(f(x, 'PubDate')))).slice(0, n)) + out.push({ id: String(idOf(it)), title: f(it, 'Title') || '', show: f(sh, 'Title') || '', date: String(f(it, 'PubDate') || ''), guid: f(it, 'GUID') || '', fileUrl: f(it, 'FileURL') || '', feedUrl: f(sh, 'URL') || '' }); + } + return out.sort((x, y) => y.date.localeCompare(x.date)); +} +/** The audio of one episode, straight from Podgrab (for the server's own background work). */ +async function episodeAudio(itemId) { + if (!BASE || !/^[\w-]+$/.test(String(itemId))) throw new Error('Bad episode'); + const r = await podgrab('/podcastitems/' + itemId + '/file'), loc = r.headers.get('location'); + if (r.status >= 300 && r.status < 400 && loc && /^https?:\/\//i.test(loc)) return fetch(loc, { signal: AbortSignal.timeout(2 * 3600e3) }); // (Podgrab has no copy of its own: the original) + return r; +} +module.exports = { handle, enabled, feedInfo, recentEpisodes, episodeAudio }; diff --git a/public/settings.js b/public/settings.js index 3c9af7b..52632e7 100644 --- a/public/settings.js +++ b/public/settings.js @@ -138,6 +138,7 @@ function viewSettings() { + (h.mail.length ? h.mail.map(m => row('Mail: ' + esc(m.address), m.ok ? 'Connected (' + m.ms + ' ms)' : esc(m.error || 'Could not connect'), !m.ok)).join('') : row('Mail', 'No account connected', false)) + (h.disk ? row('Disk', gb(h.disk.free) + ' free of ' + gb(h.disk.total), h.disk.free / h.disk.total < .1 || h.disk.free < 2e9) : '') + row('Last backup download', h.backup ? days(h.backup.t) + ' day' + (days(h.backup.t) === 1 ? '' : 's') + ' ago (by ' + esc(h.backup.who || '?') + ')' : 'Never. Use Download a backup below.', !h.backup || days(h.backup.t) > 30) + + (h.transcripts && h.transcripts.enabled ? row('Transcripts', `${h.transcripts.saved} saved · ${h.transcripts.working ? 'working on ' + h.transcripts.working : 'idle'}${h.transcripts.failed ? ' · ' + h.transcripts.failed + ' failed recently' : ''} · ${h.transcripts.auto ? 'automatic for the newest ' + h.transcripts.keep + ' of each show' : 'automatic transcribing is off'}`, false) : '') + row('Server', 'Up ' + up + ' · Node ' + esc(h.node) + ' · lyrics ' + (h.lyrics ? 'on' : 'off') + ' · Signal ' + (h.signal ? 'on' : 'off'), false); }).catch(() => { const e = $('#health'); if (e) e.innerHTML = '
Could not read the health report
'; }); if (isAdmin) jfetch('/api/system').then(i => { diff --git a/public/sw.js b/public/sw.js index b6039bc..1c5cd3a 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-v32'; +const CACHE = 'hub-v33'; 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/server.js b/server.js index 6666405..f3c5da1 100644 --- a/server.js +++ b/server.js @@ -501,6 +501,7 @@ function storageInfo() { const STARTED = Date.now(); push.start(); transcripts.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.TRANSCRIBE_API_URL || '', model: process.env.TRANSCRIBE_MODEL || '', port: PORT }); +transcripts.startAuto({ on: process.env.TRANSCRIBE_AUTO !== '0', keep: process.env.TRANSCRIBE_KEEP }); // new and existing episodes are transcribed in the background (TRANSCRIBE_AUTO=0 turns it off; TRANSCRIBE_KEEP = how many of each show's newest) lyrics.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.LYRICS_API_URL || '' }); signal.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.SIGNAL_API_URL || '' }); { const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`); @@ -614,7 +615,7 @@ async function handleRequest(req, res) { const ps = plexLoad(); out.plex = !ps.srv ? { configured: false } : await (async () => { const t = Date.now(); try { const r = await fetch(ps.srv + '/identity', { headers: { 'X-Plex-Token': ps.stk || ps.token || '', Accept: 'application/json' }, signal: AbortSignal.timeout(6000) }); return { configured: true, ok: r.ok, ms: Date.now() - t, user: ps.user ? ps.user.title : '' }; } catch (e) { return { configured: true, ok: false, error: e.name === 'TimeoutError' ? 'No answer in 6 seconds' : e.message }; } })(); try { out.mail = await mail.health(); } catch { out.mail = []; } - out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); + out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); out.transcripts = transcripts.status(); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out)); } if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); } diff --git a/transcripts.js b/transcripts.js index a2cda4c..89d8d31 100644 --- a/transcripts.js +++ b/transcripts.js @@ -96,6 +96,12 @@ async function whisper(file, name) { return shape((j.segments || []).map(s => ({ t: Math.round(+s.start * 1000), text: clean(s.text) })).filter(x => x.text), 'Transcribed on your server'); } /** Bring the audio over from this hub (as the person who asked, with their sign-in) into a temp file. */ +async function saveAudio(r, tmp) { + if (!r.ok || !r.body) throw new Error('Could not read the audio (' + r.status + ')'); + if ((+r.headers.get('content-length') || 0) > MAX_AUDIO) throw new Error('That audio file is too large to transcribe'); + let n = 0; const body = Readable.fromWeb(r.body); body.on('data', c => { n += c.length; if (n > MAX_AUDIO) body.destroy(new Error('That audio file is too large to transcribe')); }); + await pipeline(body, fs.createWriteStream(tmp, { mode: 0o600 })); +} async function fetchAudio(src, cookie, tmp) { let r; try { r = await fetch('http://127.0.0.1:' + cfg.port + src, { headers: { cookie: cookie || '' }, redirect: 'follow', signal: AbortSignal.timeout(2 * 3600e3) }); } catch (e) { throw new Error(`Could not read the audio from this server (${why(e)})`); } @@ -104,16 +110,40 @@ async function fetchAudio(src, cookie, tmp) { let n = 0; const body = Readable.fromWeb(r.body); body.on('data', c => { n += c.length; if (n > MAX_AUDIO) body.destroy(new Error('That audio file is too large to transcribe')); }); await pipeline(body, fs.createWriteStream(tmp, { mode: 0o600 })); } -function startJob(key, src, cookie) { +function startJob(key, src, cookie, getAudio) { jobs.set(key, { state: 'running', since: Date.now() }); chain = chain.then(async () => { const tmp = path.join(os.tmpdir(), 'nehub-tr-' + key); - try { await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(r)); jobs.delete(key); } + try { if (getAudio) await saveAudio(await getAudio(), tmp); else await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(r)); jobs.delete(key); } catch (e) { jobs.set(key, { state: 'error', since: Date.now(), error: e.message }); setTimeout(() => jobs.delete(key), 10 * 60e3).unref(); } finally { fs.rm(tmp, { force: true }, () => {}); } }); } +// ---- in the background: new episodes as they are pulled, and the ones already in the library that have no transcript yet +const auto = { on: false, keep: 5, tried: new Map(), last: 0, timer: null }; +const podSrc = id => '/api/podcast/podcastitems/' + id + '/file'; +async function autoTick() { + if (!cfg.api || !auto.on || [...jobs.values()].some(j => j.state === 'running')) return; // (one job at a time: the next round looks again when this one is done) + let queued = 0; const feeds = new Map(); + for (const ep of await podcast.recentEpisodes(auto.keep)) { + if (queued >= 2) break; + const key = keyFor('pod', 'pod:' + ep.id, podSrc(ep.id)), t = auto.tried.get(key) || { n: 0, at: 0 }; + if (fs.existsSync(fileOf(key)) || jobs.has(key) || t.n >= 3 || Date.now() - t.at < 6 * 3600e3 * t.n) continue; // (a failing one is retried a few times, further apart each time) + auto.tried.set(key, { n: t.n + 1, at: Date.now() }); + let r = { why: '' }; try { r = await nativeTranscript(ep); } catch {} + if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify(r.transcript)); continue; } // (the show publishes one: no need to make one) + startJob(key, '', '', () => podcast.episodeAudio(ep.id)); queued++; + } +} +function startAuto({ on, keep }) { + auto.on = !!on && !!cfg.api; auto.keep = Math.max(1, Math.min(50, +keep || 5)); + if (auto.timer || !auto.on) return; + auto.timer = setInterval(() => autoTick().catch(e => console.warn('[transcripts]', e.message)), 10 * 60e3); auto.timer.unref(); + setTimeout(() => autoTick().catch(e => console.warn('[transcripts]', e.message)), 90e3).unref(); +} +function status() { let saved = 0; try { saved = fs.readdirSync(cfg.dir).filter(x => x.endsWith('.json')).length; } catch {} return { enabled: !!cfg.api, auto: auto.on, keep: auto.keep, saved, working: [...jobs.values()].filter(j => j.state === 'running').length, failed: [...jobs.values()].filter(j => j.state === 'error').length }; } + 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 -> { t, why }: a show with no transcript is not asked again for a day async function handle(req, res) { @@ -142,4 +172,4 @@ async function handle(req, res) { 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 }; +module.exports = { init, startAuto, status, handle, parseCues, parseJson, transcriptLinks, shape, whisper, getText, nativeTranscript, enabled: () => true };