Transcripts: new and existing episodes are transcribed in the background (published transcript first, else whisper), with a status line in Health
build-container / build (push) Successful in 3m29s
build-container / build (push) Successful in 3m29s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e7bd24bbaa
commit
1c089c4a10
6 files changed
+64
-8
No files matched your search
@@ -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
|
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).
|
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.
|
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.
|
||||||
+23
-3
@@ -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) {
|
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) => { 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 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 || (me && !(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);
|
||||||
return { guid: f(it, 'GUID') || f(it, 'guid') || '', fileUrl: f(it, 'FileURL') || f(it, 'fileUrl') || '', feedUrl: (sh && (f(sh, 'URL') || f(sh, 'url'))) || '' };
|
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 };
|
||||||
@@ -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.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) : '')
|
+ (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)
|
+ 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);
|
+ 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 = '<div class="row"><small>Could not read the health report</small></div>'; });
|
}).catch(() => { const e = $('#health'); if (e) e.innerHTML = '<div class="row"><small>Could not read the health report</small></div>'; });
|
||||||
if (isAdmin) jfetch('/api/system').then(i => {
|
if (isAdmin) jfetch('/api/system').then(i => {
|
||||||
|
|||||||
+1
-1
@@ -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-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 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'];
|
||||||
|
|
||||||
|
|||||||
@@ -501,6 +501,7 @@ function storageInfo() {
|
|||||||
const STARTED = Date.now();
|
const STARTED = Date.now();
|
||||||
push.start();
|
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.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 || '' });
|
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 || '' });
|
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}`);
|
{ 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();
|
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 }; } })();
|
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 = []; }
|
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));
|
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())); }
|
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
|
||||||
|
|||||||
+33
-3
@@ -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');
|
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. */
|
/** 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) {
|
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) }); }
|
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)})`); }
|
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')); });
|
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 }));
|
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() });
|
jobs.set(key, { state: 'running', since: Date.now() });
|
||||||
chain = chain.then(async () => {
|
chain = chain.then(async () => {
|
||||||
const tmp = path.join(os.tmpdir(), 'nehub-tr-' + key);
|
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(); }
|
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 }, () => {}); }
|
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 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
|
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) {
|
||||||
@@ -142,4 +172,4 @@ async function handle(req, res) {
|
|||||||
return json(res, 200, { state: 'none', why, ...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, startAuto, status, handle, parseCues, parseJson, transcriptLinks, shape, whisper, getText, nativeTranscript, enabled: () => true };
|
||||||
Reference in new issue
Block a user