@khorsheed/dsh-reader 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.en.md +26 -121
- package/README.i18n.yaml +3 -3
- package/README.md +26 -121
- package/cordis.patch.yml +7 -0
- package/lib/client.js +29105 -6692
- package/lib/index.js +3500 -321
- package/lib/typert.host.js +2145 -259
- package/lib/typert.remote-client.d.ts +97 -3
- package/lib/typert.remote-client.js +1590 -250
- package/lib/types/captions.d.ts +99 -0
- package/lib/types/captions.js +236 -0
- package/lib/types/client/MediaPlayer.d.ts +55 -0
- package/lib/types/client/MediaPlayer.js +106 -0
- package/lib/types/client/NotesView.d.ts +57 -0
- package/lib/types/client/NotesView.js +124 -0
- package/lib/types/client/ReaderPane.d.ts +20 -3
- package/lib/types/client/ReaderPane.js +860 -164
- package/lib/types/client/SpeakerLegend.d.ts +18 -0
- package/lib/types/client/SpeakerLegend.js +26 -0
- package/lib/types/client/canvas-sync.d.ts +84 -0
- package/lib/types/client/canvas-sync.js +59 -0
- package/lib/types/client/contract.d.ts +130 -3
- package/lib/types/client/extract-article.d.ts +38 -1
- package/lib/types/client/extract-article.js +561 -33
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/client/icons.js +1 -1
- package/lib/types/client/index.js +81 -2
- package/lib/types/client/locales.d.ts +279 -11
- package/lib/types/client/locales.js +278 -10
- package/lib/types/client/notes.d.ts +142 -0
- package/lib/types/client/notes.js +334 -0
- package/lib/types/client/parse-rss.js +40 -0
- package/lib/types/client/preset-visibility.d.ts +93 -0
- package/lib/types/client/preset-visibility.js +115 -0
- package/lib/types/client/selectors.d.ts +5 -0
- package/lib/types/client/selectors.js +5 -0
- package/lib/types/client/session.d.ts +21 -1
- package/lib/types/client/session.js +12 -0
- package/lib/types/client/store.js +4 -1
- package/lib/types/client/translate.d.ts +26 -1
- package/lib/types/client/translate.js +29 -12
- package/lib/types/client/useLocalTranscript.d.ts +43 -0
- package/lib/types/client/useLocalTranscript.js +129 -0
- package/lib/types/client/useMedia.d.ts +68 -0
- package/lib/types/client/useMedia.js +299 -0
- package/lib/types/client/useMediaStates.d.ts +9 -0
- package/lib/types/client/useMediaStates.js +43 -0
- package/lib/types/client/useNotes.d.ts +110 -0
- package/lib/types/client/useNotes.js +423 -0
- package/lib/types/client/usePolish.d.ts +47 -0
- package/lib/types/client/usePolish.js +162 -0
- package/lib/types/document-fetch.d.ts +106 -0
- package/lib/types/document-fetch.js +100 -0
- package/lib/types/index.d.ts +15 -0
- package/lib/types/index.js +3 -0
- package/lib/types/local-transcribe.d.ts +171 -0
- package/lib/types/local-transcribe.js +473 -0
- package/lib/types/media-host.d.ts +91 -0
- package/lib/types/media-host.js +230 -0
- package/lib/types/media.d.ts +100 -0
- package/lib/types/media.js +207 -0
- package/lib/types/notes-types.d.ts +158 -0
- package/lib/types/notes-types.js +8 -0
- package/lib/types/notes.d.ts +213 -0
- package/lib/types/notes.js +718 -0
- package/lib/types/polish.d.ts +41 -0
- package/lib/types/polish.js +136 -0
- package/lib/types/remote.d.ts +118 -0
- package/lib/types/remote.js +151 -1
- package/lib/types/service.d.ts +81 -6
- package/lib/types/service.js +286 -65
- package/lib/types/skill.d.ts +20 -0
- package/lib/types/skill.js +78 -0
- package/lib/types/store.js +30 -8
- package/lib/types/tool.d.ts +23 -0
- package/lib/types/tool.js +40 -0
- package/lib/types/tools.d.ts +69 -0
- package/lib/types/tools.js +400 -0
- package/lib/types/types.d.ts +90 -2
- package/lib/types/types.js +66 -0
- package/lib/types/xiaoyuzhou.d.ts +43 -0
- package/lib/types/xiaoyuzhou.js +134 -0
- package/lib/types/ytdlp.d.ts +111 -0
- package/lib/types/ytdlp.js +269 -0
- package/package.json +33 -20
- package/lib/invariant.js +0 -38
- package/lib/types/invariant.d.ts +0 -29
- package/lib/types/invariant.js +0 -38
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local transcription of a video or audio entry without captions, on the
|
|
3
|
+
* optional `transcribe` engine (@khorsheed/dsh-transcribe).
|
|
4
|
+
*
|
|
5
|
+
* Opening such an entry starts a job (2026-10-20 decision: automatic on open,
|
|
6
|
+
* never on feed refresh): the audio is fetched into a temporary directory —
|
|
7
|
+
* yt-dlp for a video, curl for an audio URL — handed to the engine, and the
|
|
8
|
+
* finished stretches render as transcript paragraphs while it runs. A job
|
|
9
|
+
* still QUEUED when the reader leaves is withdrawn; a RUNNING one finishes and
|
|
10
|
+
* is stored either way (`cancel` with `force`, the 字幕 switch and the 取消
|
|
11
|
+
* button, stops it). The engine's first use downloads its models: the job
|
|
12
|
+
* waits for one confirmation (`needs-models`) and then runs.
|
|
13
|
+
*
|
|
14
|
+
* The engine is restated here structurally (no code edge between the
|
|
15
|
+
* packages) and probed on every start.
|
|
16
|
+
*
|
|
17
|
+
* @module @khorsheed/dsh-reader/local-transcribe
|
|
18
|
+
*/
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
20
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { mergeCues, renderTranscript } from "./captions.js";
|
|
23
|
+
/** Sentence ends in either script. */
|
|
24
|
+
const SENTENCE_END = /[。!?!?…]$|[^.\d]\.$/;
|
|
25
|
+
/** A run-on sentence (SenseVoice punctuates English sparsely) closes at a comma past this long (s)… */
|
|
26
|
+
const LONG_AT_COMMA_SEC = 20;
|
|
27
|
+
/** …at a pause between words past this long… */
|
|
28
|
+
const LONG_AT_PAUSE_SEC = 30;
|
|
29
|
+
/** …and in any case by this. */
|
|
30
|
+
const LONGEST_CUE_SEC = 45;
|
|
31
|
+
/**
|
|
32
|
+
* Engine stretches as caption-like cues: one per sentence (the engine's
|
|
33
|
+
* stretches are cut at pauses, not sentence ends), so the transcript merges
|
|
34
|
+
* into the same paragraphs platform captions do. A sentence that runs on
|
|
35
|
+
* without a period is closed at a comma or a pause once it is long, or a
|
|
36
|
+
* single cue would make a minute-and-a-half paragraph (measured 2026-10-20).
|
|
37
|
+
*
|
|
38
|
+
* @param segments - the engine's segments, in order.
|
|
39
|
+
*/
|
|
40
|
+
export function cuesFromSegments(segments, speakers) {
|
|
41
|
+
const cues = [];
|
|
42
|
+
let text = '';
|
|
43
|
+
let start;
|
|
44
|
+
let end = 0;
|
|
45
|
+
let speaker;
|
|
46
|
+
const flush = () => {
|
|
47
|
+
const trimmed = text.replace(/\s+/g, ' ').trim();
|
|
48
|
+
if (start !== undefined && trimmed !== '')
|
|
49
|
+
cues.push({ start, end, text: trimmed, ...(speaker === undefined ? {} : { speaker }) });
|
|
50
|
+
text = '';
|
|
51
|
+
start = undefined;
|
|
52
|
+
};
|
|
53
|
+
const tokens = segments.flatMap(segment => segment.tokens);
|
|
54
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
55
|
+
const token = tokens[index];
|
|
56
|
+
// A new voice starts a new cue (and so a new paragraph).
|
|
57
|
+
const voice = speakers?.[index];
|
|
58
|
+
if (voice !== undefined && voice !== speaker) {
|
|
59
|
+
flush();
|
|
60
|
+
speaker = voice;
|
|
61
|
+
}
|
|
62
|
+
start ??= token.start;
|
|
63
|
+
text += token.text;
|
|
64
|
+
end = token.end;
|
|
65
|
+
const length = end - start;
|
|
66
|
+
const next = tokens[index + 1];
|
|
67
|
+
if (SENTENCE_END.test(text.trimEnd())
|
|
68
|
+
|| (length >= LONG_AT_COMMA_SEC && /[,,、;;]$/.test(token.text.trim()))
|
|
69
|
+
|| (length >= LONG_AT_PAUSE_SEC && next !== undefined && next.start - token.end >= 0.4)
|
|
70
|
+
|| length >= LONGEST_CUE_SEC)
|
|
71
|
+
flush();
|
|
72
|
+
}
|
|
73
|
+
flush();
|
|
74
|
+
return cues;
|
|
75
|
+
}
|
|
76
|
+
/** A run of one voice shorter than this is the diarizer flickering, not a turn (s). */
|
|
77
|
+
const MIN_TURN_SEC = 2.5;
|
|
78
|
+
/**
|
|
79
|
+
* Each token's speaker, as an index in order of first appearance (A, B, …):
|
|
80
|
+
* the turn covering the token's middle, else the nearest one; then runs of a
|
|
81
|
+
* voice shorter than {@link MIN_TURN_SEC} are folded into the voice before
|
|
82
|
+
* them — a diarizer's boundary wobble would otherwise split sentences.
|
|
83
|
+
*
|
|
84
|
+
* @param segments - the transcript's segments.
|
|
85
|
+
* @param turns - the engine's turns.
|
|
86
|
+
*/
|
|
87
|
+
export function speakerPerToken(segments, turns) {
|
|
88
|
+
const sorted = [...turns].sort((a, b) => a.start - b.start);
|
|
89
|
+
const order = new Map();
|
|
90
|
+
const tokens = segments.flatMap(segment => segment.tokens);
|
|
91
|
+
const raw = tokens.map(token => {
|
|
92
|
+
const at = (token.start + token.end) / 2;
|
|
93
|
+
let best;
|
|
94
|
+
let distance = Infinity;
|
|
95
|
+
for (const turn of sorted) {
|
|
96
|
+
const d = at < turn.start ? turn.start - at : at > turn.end ? at - turn.end : 0;
|
|
97
|
+
if (d < distance) {
|
|
98
|
+
distance = d;
|
|
99
|
+
best = turn;
|
|
100
|
+
}
|
|
101
|
+
if (turn.start > at + distance)
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
return best?.cluster ?? -1;
|
|
105
|
+
});
|
|
106
|
+
// Fold flickers: a run shorter than the minimum takes the previous voice.
|
|
107
|
+
const out = [...raw];
|
|
108
|
+
let runStart = 0;
|
|
109
|
+
for (let index = 1; index <= out.length; index++) {
|
|
110
|
+
if (index < out.length && out[index] === out[runStart])
|
|
111
|
+
continue;
|
|
112
|
+
const length = (tokens[index - 1]?.end ?? 0) - (tokens[runStart]?.start ?? 0);
|
|
113
|
+
if (runStart > 0 && length < MIN_TURN_SEC)
|
|
114
|
+
for (let k = runStart; k < index; k++)
|
|
115
|
+
out[k] = out[runStart - 1];
|
|
116
|
+
runStart = index;
|
|
117
|
+
}
|
|
118
|
+
// Punctuation belongs to the word before it, never opens the next voice's paragraph.
|
|
119
|
+
for (let index = 1; index < out.length; index++) {
|
|
120
|
+
if (/^\s*[\p{P}]+\s*$/u.test(tokens[index].text))
|
|
121
|
+
out[index] = out[index - 1];
|
|
122
|
+
}
|
|
123
|
+
return out.map(cluster => {
|
|
124
|
+
if (!order.has(cluster))
|
|
125
|
+
order.set(cluster, order.size);
|
|
126
|
+
return order.get(cluster);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Escape text for HTML content. */
|
|
130
|
+
function escapeText(text) {
|
|
131
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The transcript with its speakers: paragraphs never cross a change of voice,
|
|
135
|
+
* and each carries `data-spk` (the speaker's index) beside its `data-t`.
|
|
136
|
+
*/
|
|
137
|
+
export function renderSpeakerTranscript(segments, turns) {
|
|
138
|
+
const cues = cuesFromSegments(segments, speakerPerToken(segments, turns));
|
|
139
|
+
const out = [];
|
|
140
|
+
let run = [];
|
|
141
|
+
const flush = () => {
|
|
142
|
+
const speaker = run[0]?.speaker;
|
|
143
|
+
for (const paragraph of mergeCues(run)) {
|
|
144
|
+
out.push(`<p ${LOCAL_MARK} data-spk="${String(speaker ?? 0)}" data-t="${paragraph.start.toFixed(1)}">${escapeText(paragraph.text)}</p>`);
|
|
145
|
+
}
|
|
146
|
+
run = [];
|
|
147
|
+
};
|
|
148
|
+
for (const cue of cues) {
|
|
149
|
+
if (run.length > 0 && cue.speaker !== run[0].speaker)
|
|
150
|
+
flush();
|
|
151
|
+
run.push(cue);
|
|
152
|
+
}
|
|
153
|
+
flush();
|
|
154
|
+
return out.join('\n');
|
|
155
|
+
}
|
|
156
|
+
/** The marker a locally transcribed paragraph carries (the 来源 line reads it). */
|
|
157
|
+
export const LOCAL_MARK = 'data-src="local"';
|
|
158
|
+
/** The engine's segments as transcript markup, each paragraph marked local. */
|
|
159
|
+
export function renderLocalTranscript(segments) {
|
|
160
|
+
return renderTranscript(mergeCues(cuesFromSegments(segments))).replace(/<p data-t=/g, `<p ${LOCAL_MARK} data-t=`);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The language to ask for: always detection (SenseVoice). Measured
|
|
164
|
+
* 2026-10-20: a platform says nothing reliable about the spoken language — a
|
|
165
|
+
* Bilibili upload of an English interview came out of the Chinese model as
|
|
166
|
+
* lower-case English with Chinese punctuation — and on Chinese speech
|
|
167
|
+
* SenseVoice reads about as well as Paraformer (a 90 s sample: each missed
|
|
168
|
+
* two or three characters the other got right).
|
|
169
|
+
*/
|
|
170
|
+
export function languageFor(_media) {
|
|
171
|
+
return 'auto';
|
|
172
|
+
}
|
|
173
|
+
/** Fetch a URL to a file with curl (it honors the deployment's proxy environment). */
|
|
174
|
+
function curlTo(url, file, signal) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
execFile('curl', ['-fsSL', '--retry', '2', '-o', file, url], { signal, timeout: 30 * 60_000 }, (error) => {
|
|
177
|
+
if (error === null)
|
|
178
|
+
resolve();
|
|
179
|
+
else
|
|
180
|
+
reject(new Error(`下载音频失败:${error.message.split('\n')[0] ?? ''}`));
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** A model download larger than this asks first; a small addition (the 2 MB VAD) just happens. */
|
|
185
|
+
const CONFIRM_BYTES = 20_000_000;
|
|
186
|
+
/** Share of the progress bar the audio download takes. */
|
|
187
|
+
const FETCH_SHARE = 0.1;
|
|
188
|
+
/** How often a running job writes its finished segments down (ms). */
|
|
189
|
+
const SAVE_EVERY_MS = 15_000;
|
|
190
|
+
/** How long a restart waits for the engine to be provided before giving up on resuming (ms). */
|
|
191
|
+
const RESUME_WAIT_MS = 10 * 60_000;
|
|
192
|
+
function readJson(path) {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function writeJson(path, value) {
|
|
201
|
+
try {
|
|
202
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(value));
|
|
203
|
+
renameSync(`${path}.tmp`, path);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// A record that cannot be written only costs the resume.
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** The reader's local transcription jobs, by entry. */
|
|
210
|
+
export class LocalTranscriber {
|
|
211
|
+
deps;
|
|
212
|
+
jobs = new Map();
|
|
213
|
+
/** Jobs a previous process left mid-way, waiting for the engine. */
|
|
214
|
+
resumable = [];
|
|
215
|
+
resumeTimer;
|
|
216
|
+
disposing = false;
|
|
217
|
+
constructor(deps) {
|
|
218
|
+
this.deps = deps;
|
|
219
|
+
try {
|
|
220
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
221
|
+
for (const name of readdirSync(deps.tmpRoot)) {
|
|
222
|
+
if (!name.startsWith('audio-'))
|
|
223
|
+
continue;
|
|
224
|
+
const dir = join(deps.tmpRoot, name);
|
|
225
|
+
// Audio a killed process left behind (an hour of it is tens of MB): gone after a day.
|
|
226
|
+
if (statSync(dir).mtimeMs < cutoff) {
|
|
227
|
+
rmSync(dir, { recursive: true, force: true });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const record = readJson(join(dir, 'job.json'));
|
|
231
|
+
if (record?.entryId !== undefined && record.media !== undefined)
|
|
232
|
+
this.resumable.push({ dir, record });
|
|
233
|
+
else
|
|
234
|
+
rmSync(dir, { recursive: true, force: true });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// No tmp directory yet.
|
|
239
|
+
}
|
|
240
|
+
if (this.resumable.length > 0) {
|
|
241
|
+
// The engine is another plugin: it may be provided after this one boots.
|
|
242
|
+
const since = Date.now();
|
|
243
|
+
this.resumeTimer = setInterval(() => {
|
|
244
|
+
const engine = this.deps.engine();
|
|
245
|
+
if (engine === undefined && Date.now() - since < RESUME_WAIT_MS)
|
|
246
|
+
return;
|
|
247
|
+
clearInterval(this.resumeTimer);
|
|
248
|
+
this.resumeTimer = undefined;
|
|
249
|
+
for (const { dir, record } of this.resumable.splice(0)) {
|
|
250
|
+
if (engine === undefined || this.jobs.has(record.entryId)) {
|
|
251
|
+
rmSync(dir, { recursive: true, force: true });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const job = { state: 'queued', progress: FETCH_SHARE, segments: [], rendered: 0, abort: new AbortController() };
|
|
255
|
+
this.jobs.set(record.entryId, job);
|
|
256
|
+
void this.execute(record, engine, 'auto', job, dir);
|
|
257
|
+
}
|
|
258
|
+
}, this.deps.resumePollMs ?? 2000);
|
|
259
|
+
this.resumeTimer.unref?.();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** An entry's job as the browser sees it. */
|
|
263
|
+
status(entryId) {
|
|
264
|
+
const job = this.jobs.get(entryId);
|
|
265
|
+
if (job === undefined)
|
|
266
|
+
return { entryId, state: 'idle' };
|
|
267
|
+
if (job.rendered !== job.segments.length && job.state !== 'done') {
|
|
268
|
+
job.html = renderLocalTranscript(job.segments);
|
|
269
|
+
job.rendered = job.segments.length;
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
entryId,
|
|
273
|
+
state: job.state,
|
|
274
|
+
progress: job.progress,
|
|
275
|
+
...(job.ahead === undefined ? {} : { ahead: job.ahead }),
|
|
276
|
+
...(job.html === undefined ? {} : { html: job.html }),
|
|
277
|
+
...(job.language === undefined ? {} : { language: job.language }),
|
|
278
|
+
...(job.error === undefined ? {} : { error: job.error }),
|
|
279
|
+
...(job.speakers === undefined ? {} : { speakers: job.speakers }),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Start (or rejoin) an entry's transcription.
|
|
284
|
+
*
|
|
285
|
+
* @param request - the entry, its media, and whether the model download was confirmed.
|
|
286
|
+
*/
|
|
287
|
+
async start(request) {
|
|
288
|
+
const current = this.jobs.get(request.entryId);
|
|
289
|
+
if (current !== undefined && current.state !== 'failed' && current.state !== 'done')
|
|
290
|
+
return this.status(request.entryId);
|
|
291
|
+
const engine = this.deps.engine();
|
|
292
|
+
if (engine === undefined)
|
|
293
|
+
return { entryId: request.entryId, state: 'unavailable' };
|
|
294
|
+
const language = languageFor(request.media);
|
|
295
|
+
const bytes = await engine.missingBytes(engine.modelsFor({ language, diarize: false }));
|
|
296
|
+
if (bytes > CONFIRM_BYTES && request.confirmModels !== true)
|
|
297
|
+
return { entryId: request.entryId, state: 'needs-models', bytes };
|
|
298
|
+
const job = { state: 'fetching', progress: 0, segments: [], rendered: 0, abort: new AbortController() };
|
|
299
|
+
this.jobs.set(request.entryId, job);
|
|
300
|
+
const record = { entryId: request.entryId, url: request.url, media: request.media, ...(request.title === undefined ? {} : { title: request.title }), phase: 'transcribe' };
|
|
301
|
+
void this.execute(record, engine, language, job);
|
|
302
|
+
return this.status(request.entryId);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Leave or stop an entry's job: a queued (or still fetching) job is
|
|
306
|
+
* withdrawn either way; a running one only with `force`.
|
|
307
|
+
*/
|
|
308
|
+
cancel(entryId, force) {
|
|
309
|
+
const job = this.jobs.get(entryId);
|
|
310
|
+
if (job === undefined)
|
|
311
|
+
return { entryId, state: 'idle' };
|
|
312
|
+
// Leaving during the speakers pass lets it finish too (the transcript is stored already).
|
|
313
|
+
if (force || job.state === 'queued' || job.state === 'fetching') {
|
|
314
|
+
job.abort.abort();
|
|
315
|
+
this.jobs.delete(entryId);
|
|
316
|
+
return { entryId, state: 'idle' };
|
|
317
|
+
}
|
|
318
|
+
return this.status(entryId);
|
|
319
|
+
}
|
|
320
|
+
/** Whether the engine is installed right now. */
|
|
321
|
+
engineAvailable() {
|
|
322
|
+
return this.deps.engine() !== undefined;
|
|
323
|
+
}
|
|
324
|
+
/** Stop everything (plugin disposal) — keeping each job's record, so the next process resumes it. */
|
|
325
|
+
dispose() {
|
|
326
|
+
this.disposing = true;
|
|
327
|
+
if (this.resumeTimer !== undefined)
|
|
328
|
+
clearInterval(this.resumeTimer);
|
|
329
|
+
for (const job of this.jobs.values())
|
|
330
|
+
job.abort.abort();
|
|
331
|
+
this.jobs.clear();
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Run (or resume) one job: fetch the audio unless the record has it,
|
|
335
|
+
* transcribe from where the saved segments end, store, then the speakers.
|
|
336
|
+
*
|
|
337
|
+
* @param resumeDir - a previous process's directory for this job.
|
|
338
|
+
*/
|
|
339
|
+
async execute(record, engine, language, job, resumeDir) {
|
|
340
|
+
const signal = job.abort.signal;
|
|
341
|
+
mkdirSync(this.deps.tmpRoot, { recursive: true });
|
|
342
|
+
const dir = resumeDir ?? mkdtempSync(join(this.deps.tmpRoot, 'audio-'));
|
|
343
|
+
const recordPath = join(dir, 'job.json');
|
|
344
|
+
const segmentsPath = join(dir, 'segments.json');
|
|
345
|
+
writeJson(recordPath, record);
|
|
346
|
+
let lastSave = 0;
|
|
347
|
+
const saveSegments = (force) => {
|
|
348
|
+
if (!force && Date.now() - lastSave < SAVE_EVERY_MS)
|
|
349
|
+
return;
|
|
350
|
+
lastSave = Date.now();
|
|
351
|
+
writeJson(segmentsPath, job.segments);
|
|
352
|
+
};
|
|
353
|
+
try {
|
|
354
|
+
let file = record.file !== undefined && existsSync(record.file) ? record.file : undefined;
|
|
355
|
+
if (file === undefined) {
|
|
356
|
+
// No audio yet (or it went): whatever was transcribed from it is not trusted either.
|
|
357
|
+
job.segments = [];
|
|
358
|
+
if (record.media.kind === 'video') {
|
|
359
|
+
file = await this.deps.videoAudio(record.url, dir, { signal, onProgress: (fraction) => { job.progress = FETCH_SHARE * fraction; } });
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
file = join(dir, 'audio');
|
|
363
|
+
await curlTo(record.media.ref, file, signal);
|
|
364
|
+
}
|
|
365
|
+
record.file = file;
|
|
366
|
+
record.phase = 'transcribe';
|
|
367
|
+
writeJson(recordPath, record);
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
job.segments = readJson(segmentsPath) ?? [];
|
|
371
|
+
}
|
|
372
|
+
job.progress = FETCH_SHARE;
|
|
373
|
+
let segments;
|
|
374
|
+
let detected = record.language;
|
|
375
|
+
if (record.phase === 'transcribe') {
|
|
376
|
+
job.state = 'queued';
|
|
377
|
+
// A resumed job picks up where its saved segments end.
|
|
378
|
+
const earlier = [...job.segments];
|
|
379
|
+
const fromSec = earlier[earlier.length - 1]?.end ?? 0;
|
|
380
|
+
const result = await engine.run({
|
|
381
|
+
input: { files: [file] },
|
|
382
|
+
...(fromSec > 0 ? { fromSec } : {}),
|
|
383
|
+
language,
|
|
384
|
+
diarize: false,
|
|
385
|
+
owner: '@khorsheed/dsh-reader',
|
|
386
|
+
...(record.title === undefined ? {} : { label: record.title }),
|
|
387
|
+
}, {
|
|
388
|
+
signal,
|
|
389
|
+
onProgress: (progress) => {
|
|
390
|
+
if (progress.stage === 'queued') {
|
|
391
|
+
job.state = 'queued';
|
|
392
|
+
if (progress.ahead === undefined)
|
|
393
|
+
delete job.ahead;
|
|
394
|
+
else
|
|
395
|
+
job.ahead = progress.ahead;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
job.state = 'transcribing';
|
|
399
|
+
delete job.ahead;
|
|
400
|
+
job.progress = FETCH_SHARE + (1 - FETCH_SHARE) * progress.progress;
|
|
401
|
+
},
|
|
402
|
+
onSegments: (more) => {
|
|
403
|
+
job.segments.push(...more);
|
|
404
|
+
saveSegments(false);
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
segments = [...earlier, ...result.segments];
|
|
408
|
+
detected = result.language ?? detected;
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
segments = job.segments;
|
|
412
|
+
}
|
|
413
|
+
const html = renderLocalTranscript(segments);
|
|
414
|
+
if (html === '')
|
|
415
|
+
throw new Error('没有识别出语音');
|
|
416
|
+
if (detected !== undefined)
|
|
417
|
+
job.language = detected;
|
|
418
|
+
job.segments = segments;
|
|
419
|
+
saveSegments(true);
|
|
420
|
+
if (record.phase === 'transcribe') {
|
|
421
|
+
await this.deps.store(record.entryId, record.url, html, detected);
|
|
422
|
+
record.phase = 'speakers';
|
|
423
|
+
if (detected !== undefined)
|
|
424
|
+
record.language = detected;
|
|
425
|
+
writeJson(recordPath, record);
|
|
426
|
+
}
|
|
427
|
+
job.html = html;
|
|
428
|
+
job.rendered = segments.length;
|
|
429
|
+
job.progress = 1;
|
|
430
|
+
// Then who spoke when, on the audio still at hand. The transcript is
|
|
431
|
+
// already stored and readable; this pass only re-cuts its paragraphs.
|
|
432
|
+
if (engine.speakers !== undefined) {
|
|
433
|
+
job.state = 'speakers';
|
|
434
|
+
try {
|
|
435
|
+
const voices = await engine.speakers({
|
|
436
|
+
input: { files: [file] },
|
|
437
|
+
speech: segments.map(segment => ({ start: segment.start, end: segment.end })),
|
|
438
|
+
owner: '@khorsheed/dsh-reader',
|
|
439
|
+
...(record.title === undefined ? {} : { label: record.title }),
|
|
440
|
+
}, { signal });
|
|
441
|
+
const count = voices.single ? 1 : new Set(voices.turns.map(turn => turn.cluster)).size;
|
|
442
|
+
if (count >= 2) {
|
|
443
|
+
const split = renderSpeakerTranscript(segments, voices.turns);
|
|
444
|
+
await this.deps.store(record.entryId, record.url, split, detected, count);
|
|
445
|
+
job.html = split;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
await this.deps.store(record.entryId, record.url, html, detected, 1);
|
|
449
|
+
}
|
|
450
|
+
job.speakers = count;
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
if (signal.aborted)
|
|
454
|
+
return;
|
|
455
|
+
// The transcript stands; only the speakers are missing.
|
|
456
|
+
job.speakersError = error instanceof Error ? error.message : String(error);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
job.state = 'done';
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
if (signal.aborted)
|
|
463
|
+
return;
|
|
464
|
+
job.state = 'failed';
|
|
465
|
+
job.error = error instanceof Error ? error.message : String(error);
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
// A shutdown keeps the record for the next process; anything else ends the job.
|
|
469
|
+
if (!this.disposing)
|
|
470
|
+
rmSync(dir, { recursive: true, force: true });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type ReaderMedia } from './media.ts';
|
|
2
|
+
import type { XiaoyuzhouEpisode } from './xiaoyuzhou.ts';
|
|
3
|
+
import { YtDlp, type YtDlpInfo } from './ytdlp.ts';
|
|
4
|
+
/** What a video's captions came to, remembered per entry. */
|
|
5
|
+
export interface CaptionRecord {
|
|
6
|
+
/** `local`: transcribed on this machine (the platform had no captions). */
|
|
7
|
+
readonly state: 'platform' | 'none' | 'local';
|
|
8
|
+
readonly language?: string;
|
|
9
|
+
readonly checkedAt: string;
|
|
10
|
+
/** How many voices a local transcript has (1: the probe heard one; ≥ 2: paragraphs carry `data-spk`). */
|
|
11
|
+
readonly speakers?: number;
|
|
12
|
+
/** The reader's names for the speakers, by index (`0` → 主持人). */
|
|
13
|
+
readonly names?: Readonly<Record<string, string>>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The synthetic page a media link is stored as.
|
|
17
|
+
*
|
|
18
|
+
* @param page - what the page says.
|
|
19
|
+
*/
|
|
20
|
+
export declare function syntheticPage(page: {
|
|
21
|
+
title: string;
|
|
22
|
+
media: ReaderMedia;
|
|
23
|
+
descriptionText?: string;
|
|
24
|
+
descriptionHtml?: string;
|
|
25
|
+
author?: string;
|
|
26
|
+
publishedAt?: string;
|
|
27
|
+
}): string;
|
|
28
|
+
/**
|
|
29
|
+
* A video's synthetic page from its yt-dlp metadata.
|
|
30
|
+
*
|
|
31
|
+
* @param info - the metadata.
|
|
32
|
+
* @param provider - which platform.
|
|
33
|
+
* @param id - the platform's video id.
|
|
34
|
+
*/
|
|
35
|
+
export declare function videoPage(info: YtDlpInfo, provider: 'youtube' | 'bilibili', id: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* A Xiaoyuzhou episode's synthetic page.
|
|
38
|
+
*
|
|
39
|
+
* @param episode - the parsed episode.
|
|
40
|
+
*/
|
|
41
|
+
export declare function episodePage(episode: XiaoyuzhouEpisode): string;
|
|
42
|
+
/**
|
|
43
|
+
* A YouTube channel's newest videos as an RSS document (YouTube's own channel
|
|
44
|
+
* feed answers 404 — measured 2026-10-20). Each item links the watch page, so
|
|
45
|
+
* the browser half reads it as a video; the thumbnail rides `media:thumbnail`.
|
|
46
|
+
*
|
|
47
|
+
* @param channel - the yt-dlp flat listing.
|
|
48
|
+
* @param pageUrl - the channel URL.
|
|
49
|
+
*/
|
|
50
|
+
export declare function channelRss(channel: {
|
|
51
|
+
title?: string;
|
|
52
|
+
entries: readonly YtDlpInfo[];
|
|
53
|
+
}, pageUrl: string): string;
|
|
54
|
+
/** A synthetic page's display name for the source: its author (the channel / podcast), else its title. */
|
|
55
|
+
export declare function syntheticLabel(raw: string): string | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* Where a share short link leads (b23.tv → bilibili.com/video/BV…). curl
|
|
58
|
+
* follows the redirect chain and honors the deployment's proxy environment.
|
|
59
|
+
*
|
|
60
|
+
* @param url - the short link.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveShortLink(url: string): Promise<string>;
|
|
63
|
+
/** The host's media helper: yt-dlp plus the caption record. */
|
|
64
|
+
export declare class ReaderMediaHost {
|
|
65
|
+
private readonly stateRoot;
|
|
66
|
+
readonly ytdlp: YtDlp;
|
|
67
|
+
private readonly file;
|
|
68
|
+
private records;
|
|
69
|
+
/**
|
|
70
|
+
* @param stateRoot - the reader's state root.
|
|
71
|
+
* @param ytdlpPath - a deployment-configured yt-dlp, which wins over the managed one.
|
|
72
|
+
*/
|
|
73
|
+
constructor(stateRoot: string, ytdlpPath?: string);
|
|
74
|
+
/** The caption records, read once. */
|
|
75
|
+
private load;
|
|
76
|
+
/** What is known about an entry's captions. */
|
|
77
|
+
captions(entryId: string): CaptionRecord | undefined;
|
|
78
|
+
/** Remember what an entry's captions came to. */
|
|
79
|
+
remember(entryId: string, record: CaptionRecord): void;
|
|
80
|
+
/**
|
|
81
|
+
* A video's transcript from its platform captions.
|
|
82
|
+
*
|
|
83
|
+
* @param url - the video page.
|
|
84
|
+
* @returns the transcript markup and its language, or `none` when the video has no captions.
|
|
85
|
+
*/
|
|
86
|
+
transcript(url: string): Promise<{
|
|
87
|
+
html: string;
|
|
88
|
+
language: string;
|
|
89
|
+
} | 'none'>;
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=media-host.d.ts.map
|