@khorsheed/dsh-reader 0.2.1 → 0.3.1

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.en.md +26 -121
  3. package/README.i18n.yaml +3 -3
  4. package/README.md +26 -121
  5. package/cordis.patch.yml +7 -0
  6. package/lib/client.js +30580 -6892
  7. package/lib/index.js +4189 -621
  8. package/lib/typert.host.js +2265 -319
  9. package/lib/typert.remote-client.d.ts +99 -3
  10. package/lib/typert.remote-client.js +1618 -240
  11. package/lib/types/captions.d.ts +99 -0
  12. package/lib/types/captions.js +236 -0
  13. package/lib/types/client/MediaPlayer.d.ts +55 -0
  14. package/lib/types/client/MediaPlayer.js +106 -0
  15. package/lib/types/client/NotesView.d.ts +57 -0
  16. package/lib/types/client/NotesView.js +124 -0
  17. package/lib/types/client/ReaderPane.d.ts +20 -3
  18. package/lib/types/client/ReaderPane.js +905 -164
  19. package/lib/types/client/SpeakerLegend.d.ts +18 -0
  20. package/lib/types/client/SpeakerLegend.js +26 -0
  21. package/lib/types/client/canvas-sync.d.ts +84 -0
  22. package/lib/types/client/canvas-sync.js +59 -0
  23. package/lib/types/client/contract.d.ts +138 -3
  24. package/lib/types/client/extract-article.d.ts +38 -1
  25. package/lib/types/client/extract-article.js +501 -28
  26. package/lib/types/client/icons.d.ts +1 -1
  27. package/lib/types/client/icons.js +1 -1
  28. package/lib/types/client/index.js +82 -2
  29. package/lib/types/client/locales.d.ts +279 -11
  30. package/lib/types/client/locales.js +278 -10
  31. package/lib/types/client/notes.d.ts +142 -0
  32. package/lib/types/client/notes.js +334 -0
  33. package/lib/types/client/parse-rss.js +40 -0
  34. package/lib/types/client/preset-visibility.d.ts +93 -0
  35. package/lib/types/client/preset-visibility.js +115 -0
  36. package/lib/types/client/selectors.d.ts +5 -0
  37. package/lib/types/client/selectors.js +5 -0
  38. package/lib/types/client/session.d.ts +21 -1
  39. package/lib/types/client/session.js +12 -0
  40. package/lib/types/client/store.js +4 -1
  41. package/lib/types/client/translate.d.ts +26 -1
  42. package/lib/types/client/translate.js +29 -12
  43. package/lib/types/client/useLocalTranscript.d.ts +43 -0
  44. package/lib/types/client/useLocalTranscript.js +129 -0
  45. package/lib/types/client/useMedia.d.ts +68 -0
  46. package/lib/types/client/useMedia.js +299 -0
  47. package/lib/types/client/useMediaStates.d.ts +9 -0
  48. package/lib/types/client/useMediaStates.js +43 -0
  49. package/lib/types/client/useNotes.d.ts +110 -0
  50. package/lib/types/client/useNotes.js +423 -0
  51. package/lib/types/client/usePolish.d.ts +47 -0
  52. package/lib/types/client/usePolish.js +162 -0
  53. package/lib/types/image-fetch.d.ts +81 -0
  54. package/lib/types/image-fetch.js +385 -0
  55. package/lib/types/index.d.ts +6 -0
  56. package/lib/types/index.js +3 -0
  57. package/lib/types/local-transcribe.d.ts +171 -0
  58. package/lib/types/local-transcribe.js +473 -0
  59. package/lib/types/media-host.d.ts +91 -0
  60. package/lib/types/media-host.js +230 -0
  61. package/lib/types/media.d.ts +100 -0
  62. package/lib/types/media.js +207 -0
  63. package/lib/types/notes-types.d.ts +158 -0
  64. package/lib/types/notes-types.js +8 -0
  65. package/lib/types/notes.d.ts +213 -0
  66. package/lib/types/notes.js +718 -0
  67. package/lib/types/polish.d.ts +41 -0
  68. package/lib/types/polish.js +136 -0
  69. package/lib/types/remote.d.ts +127 -1
  70. package/lib/types/remote.js +162 -1
  71. package/lib/types/service.d.ts +100 -1
  72. package/lib/types/service.js +320 -59
  73. package/lib/types/skill.d.ts +20 -0
  74. package/lib/types/skill.js +78 -0
  75. package/lib/types/store.js +30 -8
  76. package/lib/types/tool.d.ts +23 -0
  77. package/lib/types/tool.js +40 -0
  78. package/lib/types/tools.d.ts +69 -0
  79. package/lib/types/tools.js +400 -0
  80. package/lib/types/types.d.ts +106 -0
  81. package/lib/types/types.js +66 -0
  82. package/lib/types/xiaoyuzhou.d.ts +43 -0
  83. package/lib/types/xiaoyuzhou.js +134 -0
  84. package/lib/types/ytdlp.d.ts +111 -0
  85. package/lib/types/ytdlp.js +269 -0
  86. package/package.json +29 -20
  87. package/lib/invariant.js +0 -38
  88. package/lib/types/invariant.d.ts +0 -29
  89. package/lib/types/invariant.js +0 -38
@@ -0,0 +1,81 @@
1
+ import type { LookupFunction } from 'node:net';
2
+ /** The outcome of one image fetch: the bytes and their type, or the reason not. */
3
+ export type ImageFetchOutcome = {
4
+ readonly ok: true;
5
+ readonly url: string;
6
+ readonly mime: string;
7
+ readonly base64: string;
8
+ } | {
9
+ readonly ok: false;
10
+ readonly url: string;
11
+ readonly error: string;
12
+ };
13
+ /** One resolved host address (the `dns.lookup` all-mode shape). */
14
+ export interface ImageResolvedAddress {
15
+ readonly address: string;
16
+ readonly family: number;
17
+ }
18
+ /** Injectable resolver seam — the POLICY half's DNS (defaults to the system resolver). */
19
+ export type ImageHostResolver = (hostname: string) => Promise<readonly ImageResolvedAddress[]>;
20
+ /** The transport half's address supply — node's own `lookup` callback contract. */
21
+ export type ImageTransportLookup = LookupFunction;
22
+ /**
23
+ * The two seams a test injects: `resolve` answers what the POLICY validates
24
+ * (a public address), `lookup` answers what the CONNECTION dials (the fixture
25
+ * server's loopback). In production both default to the system resolver and
26
+ * the lookup is the validated answer set — that identity IS the pin.
27
+ */
28
+ export interface ImageFetchSeams {
29
+ readonly resolve?: ImageHostResolver;
30
+ readonly lookup?: ImageTransportLookup;
31
+ }
32
+ /** Numeric limits, overridable in tests. */
33
+ export interface ImageFetchLimits {
34
+ readonly maxBytes?: number;
35
+ readonly timeoutMs?: number;
36
+ readonly maxRedirects?: number;
37
+ }
38
+ /** An article figure is a few hundred KB; 8 MiB leaves room for print-resolution PNGs. */
39
+ export declare const IMAGE_FETCH_MAX_BYTES: number;
40
+ /** One image fetch never hangs the rescue: the pane's picture stays broken instead. */
41
+ export declare const IMAGE_FETCH_TIMEOUT_MS = 15000;
42
+ /** Redirect hops per image, each re-validated end to end (the classic SSRF hop). */
43
+ export declare const IMAGE_FETCH_MAX_REDIRECTS = 3;
44
+ /**
45
+ * The byte budget of the in-memory rescue cache (wire size — base64 is what
46
+ * the map actually holds). A figure-heavy article is a few MB; the budget
47
+ * covers several of them, and the oldest entry leaves first.
48
+ */
49
+ export declare const IMAGE_CACHE_BUDGET_BYTES: number;
50
+ /**
51
+ * The rescue cache: a pane remount (the sidebar unmounts wholesale when
52
+ * another panel takes over) re-fails every CORP-blocked image, and this map is
53
+ * what keeps that a memory read instead of a dozen outbound requests. LRU by
54
+ * insertion refresh, bounded by bytes.
55
+ */
56
+ export declare class ImageFetchCache {
57
+ private readonly budgetBytes;
58
+ private readonly entries;
59
+ private bytes;
60
+ constructor(budgetBytes?: number);
61
+ /** The cached bytes for one URL, refreshing its recency. */
62
+ get(url: string): {
63
+ mime: string;
64
+ base64: string;
65
+ } | undefined;
66
+ /** Cache one fetched image, evicting the oldest entries until it fits. */
67
+ set(url: string, mime: string, base64: string): void;
68
+ }
69
+ /**
70
+ * Fetch one image, policy-checked end to end.
71
+ *
72
+ * Errors are values: the pane's only answer to a failed rescue is leaving the
73
+ * broken image as it was, so the reason string is for logs, not for display.
74
+ *
75
+ * @param rawUrl - the image URL the pane failed on.
76
+ * @param seams - resolver/lookup overrides (tests only; production pins).
77
+ * @param limits - byte/timeout/redirect overrides (tests only).
78
+ * @returns the image bytes base64-encoded with their declared type, or why not.
79
+ */
80
+ export declare function fetchImageBytes(rawUrl: string, seams?: ImageFetchSeams, limits?: ImageFetchLimits): Promise<ImageFetchOutcome>;
81
+ //# sourceMappingURL=image-fetch.d.ts.map
@@ -0,0 +1,385 @@
1
+ /** An article figure is a few hundred KB; 8 MiB leaves room for print-resolution PNGs. */
2
+ export const IMAGE_FETCH_MAX_BYTES = 8 * 1024 * 1024;
3
+ /** One image fetch never hangs the rescue: the pane's picture stays broken instead. */
4
+ export const IMAGE_FETCH_TIMEOUT_MS = 15_000;
5
+ /** Redirect hops per image, each re-validated end to end (the classic SSRF hop). */
6
+ export const IMAGE_FETCH_MAX_REDIRECTS = 3;
7
+ /** An honest agent line — bot walls are reported, never impersonated around. */
8
+ const IMAGE_FETCH_USER_AGENT = 'dsh-reader image rescue (+https://github.com/Khorsheed/dsh-plugins)';
9
+ /**
10
+ * The raster/vector types a rescued image may declare. The pane re-points the
11
+ * failed `<img>` at these bytes, so an SVG rides an image context (which never
12
+ * scripts); anything else the origin answers — HTML above all — is refused
13
+ * before the body is read.
14
+ */
15
+ const IMAGE_MIME = /^image\/(?:png|jpe?g|gif|webp|avif|svg\+xml|bmp|x-icon|vnd\.microsoft\.icon)$/i;
16
+ /**
17
+ * The byte budget of the in-memory rescue cache (wire size — base64 is what
18
+ * the map actually holds). A figure-heavy article is a few MB; the budget
19
+ * covers several of them, and the oldest entry leaves first.
20
+ */
21
+ export const IMAGE_CACHE_BUDGET_BYTES = 32 * 1024 * 1024;
22
+ /**
23
+ * The rescue cache: a pane remount (the sidebar unmounts wholesale when
24
+ * another panel takes over) re-fails every CORP-blocked image, and this map is
25
+ * what keeps that a memory read instead of a dozen outbound requests. LRU by
26
+ * insertion refresh, bounded by bytes.
27
+ */
28
+ export class ImageFetchCache {
29
+ budgetBytes;
30
+ entries = new Map();
31
+ bytes = 0;
32
+ constructor(budgetBytes = IMAGE_CACHE_BUDGET_BYTES) {
33
+ this.budgetBytes = budgetBytes;
34
+ }
35
+ /** The cached bytes for one URL, refreshing its recency. */
36
+ get(url) {
37
+ const hit = this.entries.get(url);
38
+ if (hit === undefined)
39
+ return undefined;
40
+ this.entries.delete(url);
41
+ this.entries.set(url, hit);
42
+ return hit;
43
+ }
44
+ /** Cache one fetched image, evicting the oldest entries until it fits. */
45
+ set(url, mime, base64) {
46
+ if (base64.length > this.budgetBytes)
47
+ return;
48
+ const existing = this.entries.get(url);
49
+ if (existing !== undefined) {
50
+ this.bytes -= existing.base64.length;
51
+ this.entries.delete(url);
52
+ }
53
+ while (this.bytes + base64.length > this.budgetBytes) {
54
+ const oldest = this.entries.keys().next();
55
+ if (oldest.done)
56
+ break;
57
+ const dropped = this.entries.get(oldest.value);
58
+ this.entries.delete(oldest.value);
59
+ this.bytes -= dropped?.base64.length ?? 0;
60
+ }
61
+ this.entries.set(url, { mime, base64 });
62
+ this.bytes += base64.length;
63
+ }
64
+ }
65
+ /**
66
+ * Fetch one image, policy-checked end to end.
67
+ *
68
+ * Errors are values: the pane's only answer to a failed rescue is leaving the
69
+ * broken image as it was, so the reason string is for logs, not for display.
70
+ *
71
+ * @param rawUrl - the image URL the pane failed on.
72
+ * @param seams - resolver/lookup overrides (tests only; production pins).
73
+ * @param limits - byte/timeout/redirect overrides (tests only).
74
+ * @returns the image bytes base64-encoded with their declared type, or why not.
75
+ */
76
+ export async function fetchImageBytes(rawUrl, seams = {}, limits = {}) {
77
+ const maxBytes = limits.maxBytes ?? IMAGE_FETCH_MAX_BYTES;
78
+ const timeoutMs = limits.timeoutMs ?? IMAGE_FETCH_TIMEOUT_MS;
79
+ const maxRedirects = limits.maxRedirects ?? IMAGE_FETCH_MAX_REDIRECTS;
80
+ let current = rawUrl;
81
+ for (let hop = 0; hop <= maxRedirects; hop++) {
82
+ const gate = await checkImageTarget(current, seams.resolve);
83
+ if (!gate.ok)
84
+ return { ok: false, url: rawUrl, error: gate.error };
85
+ const response = await requestOnce(gate.url, gate.addresses, timeoutMs, seams.lookup);
86
+ if (!response.ok)
87
+ return { ok: false, url: rawUrl, error: response.error };
88
+ const { message } = response;
89
+ const status = message.statusCode ?? 0;
90
+ if (status >= 300 && status < 400) {
91
+ message.resume();
92
+ const location = message.headers['location'];
93
+ if (typeof location !== 'string' || location.length === 0) {
94
+ return { ok: false, url: rawUrl, error: `HTTP ${status} without a location` };
95
+ }
96
+ try {
97
+ current = new URL(location, current).toString();
98
+ }
99
+ catch {
100
+ return { ok: false, url: rawUrl, error: `HTTP ${status} to an unparseable location` };
101
+ }
102
+ continue;
103
+ }
104
+ if (status < 200 || status >= 300) {
105
+ message.resume();
106
+ return { ok: false, url: rawUrl, error: `HTTP ${status}` };
107
+ }
108
+ const mime = (message.headers['content-type'] ?? '').replace(/;.*$/s, '').trim().toLowerCase();
109
+ if (!IMAGE_MIME.test(mime)) {
110
+ message.resume();
111
+ return { ok: false, url: rawUrl, error: `the response is not an image (content-type "${mime || 'unknown'}")` };
112
+ }
113
+ const declared = Number(message.headers['content-length'] ?? Number.NaN);
114
+ if (Number.isFinite(declared) && declared > maxBytes) {
115
+ message.resume();
116
+ return { ok: false, url: rawUrl, error: `the image exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MiB cap` };
117
+ }
118
+ const body = await readBody(message, maxBytes);
119
+ if (!body.ok)
120
+ return { ok: false, url: rawUrl, error: body.error };
121
+ return { ok: true, url: rawUrl, mime, base64: body.base64 };
122
+ }
123
+ return { ok: false, url: rawUrl, error: `more than ${maxRedirects} redirects` };
124
+ }
125
+ /**
126
+ * The syntax gate plus the address gate, in the order capture's navigation
127
+ * policy runs them. The address set travels with the verdict: the transport
128
+ * dials exactly these, so the hostname is never resolved twice.
129
+ */
130
+ async function checkImageTarget(raw, resolve) {
131
+ let url;
132
+ try {
133
+ url = new URL(raw);
134
+ }
135
+ catch {
136
+ return { ok: false, error: 'invalid-url' };
137
+ }
138
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
139
+ return { ok: false, error: `only http(s) images can be rescued, not ${url.protocol}` };
140
+ }
141
+ if (url.username !== '' || url.password !== '') {
142
+ return { ok: false, error: 'URLs with embedded credentials are never fetched' };
143
+ }
144
+ const hostname = url.hostname.replace(/\.$/, '');
145
+ if (hostname === '')
146
+ return { ok: false, error: 'invalid-url' };
147
+ if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
148
+ return { ok: false, error: 'localhost is never fetched' };
149
+ }
150
+ if (isIpLiteral(hostname)) {
151
+ const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
152
+ const cls = classifyAddress(bare);
153
+ if (cls !== 'public')
154
+ return { ok: false, error: `${bare} is a ${cls} address` };
155
+ return { ok: true, url, addresses: [{ address: bare, family: bare.includes(':') ? 6 : 4 }] };
156
+ }
157
+ const resolver = resolve ?? defaultResolve;
158
+ let addresses;
159
+ try {
160
+ addresses = await resolver(hostname.replace(/^\[(.*)\]$/, '$1'));
161
+ }
162
+ catch (error) {
163
+ return { ok: false, error: `cannot resolve ${hostname}: ${error instanceof Error ? error.message : String(error)}` };
164
+ }
165
+ if (addresses.length === 0)
166
+ return { ok: false, error: `cannot resolve ${hostname}: no addresses` };
167
+ for (const { address } of addresses) {
168
+ const cls = classifyAddress(address);
169
+ if (cls !== 'public')
170
+ return { ok: false, error: `${hostname} resolves to a ${cls} address` };
171
+ }
172
+ return { ok: true, url, addresses };
173
+ }
174
+ /**
175
+ * Issue one GET against the validated address set.
176
+ *
177
+ * The `lookup` handed to the transport answers with the validated addresses
178
+ * and nothing else — DNS is consulted once, by the gate, and the connection
179
+ * cannot re-resolve the hostname into a private network (the rebinding window
180
+ * a plain fetch leaves open). Node still verifies TLS against the HOSTNAME;
181
+ * the lookup only maps it to an address.
182
+ */
183
+ function requestOnce(url, addresses, timeoutMs, lookup) {
184
+ return new Promise((resolvePromise) => {
185
+ void (async () => {
186
+ const transport = url.protocol === 'https:'
187
+ ? await import('node:https')
188
+ : await import('node:http');
189
+ const request = transport.request({
190
+ protocol: url.protocol,
191
+ // Bracketed IPv6 literals are a URL spelling, not a host option.
192
+ hostname: url.hostname.replace(/^\[(.*)\]$/, '$1'),
193
+ port: url.port,
194
+ path: `${url.pathname}${url.search}`,
195
+ method: 'GET',
196
+ headers: {
197
+ accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*,*/*;q=0.8',
198
+ 'accept-encoding': 'identity',
199
+ 'user-agent': IMAGE_FETCH_USER_AGENT,
200
+ },
201
+ lookup: lookup ?? pinnedLookup(addresses),
202
+ }, (message) => resolvePromise({ ok: true, message }));
203
+ request.setTimeout(timeoutMs, () => {
204
+ request.destroy(new Error(`the image fetch timed out after ${timeoutMs} ms`));
205
+ });
206
+ request.on('error', (error) => resolvePromise({ ok: false, error: error.message }));
207
+ request.end();
208
+ })().catch((error) => resolvePromise({ ok: false, error: error instanceof Error ? error.message : String(error) }));
209
+ });
210
+ }
211
+ /** The lookup that pins a request to the gate's validated answers. */
212
+ function pinnedLookup(addresses) {
213
+ return (_hostname, options, callback) => {
214
+ if (options.all === true) {
215
+ callback(null, addresses.map(({ address, family }) => ({ address, family })));
216
+ return;
217
+ }
218
+ const first = addresses[0];
219
+ callback(null, first.address, first.family);
220
+ };
221
+ }
222
+ /** The body, capped; over the cap is a refusal, not a truncation. */
223
+ function readBody(message, maxBytes) {
224
+ return new Promise((resolvePromise) => {
225
+ const chunks = [];
226
+ let received = 0;
227
+ message.on('data', (chunk) => {
228
+ received += chunk.byteLength;
229
+ if (received > maxBytes) {
230
+ message.destroy();
231
+ resolvePromise({ ok: false, error: `the image exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MiB cap` });
232
+ return;
233
+ }
234
+ chunks.push(chunk);
235
+ });
236
+ message.on('end', () => resolvePromise({ ok: true, base64: Buffer.concat(chunks).toString('base64') }));
237
+ message.on('error', (error) => resolvePromise({ ok: false, error: error.message }));
238
+ });
239
+ }
240
+ /** The system resolver, bound lazily so importing this module never touches `node:dns`. */
241
+ async function defaultResolve(hostname) {
242
+ const dns = await import('node:dns');
243
+ return dns.promises.lookup(hostname, { all: true, verbatim: true });
244
+ }
245
+ /** Parse a canonical IPv4 string into its 32-bit value, or undefined. */
246
+ function parseIpv4(address) {
247
+ const parts = address.split('.');
248
+ if (parts.length !== 4)
249
+ return undefined;
250
+ let value = 0;
251
+ for (const part of parts) {
252
+ if (!/^\d{1,3}$/.test(part))
253
+ return undefined;
254
+ if (part.length > 1 && part.startsWith('0'))
255
+ return undefined; // legacy octal-looking octets: fail closed
256
+ const octet = Number(part);
257
+ if (octet > 255)
258
+ return undefined;
259
+ value = value * 256 + octet;
260
+ }
261
+ return value >>> 0;
262
+ }
263
+ /** Expand an IPv6 string into its eight 16-bit groups, or undefined when malformed. */
264
+ function parseIpv6Groups(address) {
265
+ // Strip a zone id (link-local literals like fe80::1%eth0).
266
+ const zoneless = address.split('%')[0];
267
+ if (!/^[0-9a-fA-F:.]+$/.test(zoneless))
268
+ return undefined;
269
+ const halves = zoneless.split('::');
270
+ if (halves.length > 2)
271
+ return undefined;
272
+ const parseHalf = (half) => {
273
+ if (half === '')
274
+ return [];
275
+ const groups = [];
276
+ const parts = half.split(':');
277
+ for (const [index, part] of parts.entries()) {
278
+ // An embedded dotted-quad tail (deprecated compatible / mapped forms).
279
+ if (part.includes('.')) {
280
+ if (index !== parts.length - 1)
281
+ return undefined;
282
+ const v4 = parseIpv4(part);
283
+ if (v4 === undefined)
284
+ return undefined;
285
+ groups.push((v4 >>> 16) & 0xffff, v4 & 0xffff);
286
+ continue;
287
+ }
288
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part))
289
+ return undefined;
290
+ groups.push(parseInt(part, 16));
291
+ }
292
+ return groups;
293
+ };
294
+ const head = parseHalf(halves[0]);
295
+ if (head === undefined)
296
+ return undefined;
297
+ if (halves.length === 1)
298
+ return head.length === 8 ? head : undefined;
299
+ const tail = parseHalf(halves[1]);
300
+ if (tail === undefined)
301
+ return undefined;
302
+ const missing = 8 - head.length - tail.length;
303
+ if (missing < 0 || (missing === 0 && halves[1] !== ''))
304
+ return undefined;
305
+ return [...head, ...Array(missing).fill(0), ...tail];
306
+ }
307
+ /** The embedded IPv4 value of an IPv4-mapped, NAT64, or 6to4 IPv6 address. */
308
+ function embeddedIpv4(groups) {
309
+ // ::ffff:a.b.c.d (IPv4-mapped): groups 0-4 zero, group 5 0xffff.
310
+ if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff) {
311
+ return ((groups[6] << 16) | groups[7]) >>> 0;
312
+ }
313
+ // 64:ff9b::/96 (NAT64 well-known prefix): the v4 address is the last 32 bits.
314
+ if (groups[0] === 0x0064 && groups[1] === 0xff9b && groups.slice(2, 6).every((g) => g === 0)) {
315
+ return ((groups[6] << 16) | groups[7]) >>> 0;
316
+ }
317
+ // 2002::/16 (6to4): the v4 address is groups 1-2.
318
+ if (groups[0] === 0x2002) {
319
+ return ((groups[1] << 16) | groups[2]) >>> 0;
320
+ }
321
+ return undefined;
322
+ }
323
+ /** Classify a canonical dotted-quad IPv4 value. */
324
+ function classifyIpv4(value) {
325
+ const a = (value >>> 24) & 0xff;
326
+ const b = (value >>> 16) & 0xff;
327
+ if (a === 0)
328
+ return 'unspecified'; // 0.0.0.0/8 "this host"
329
+ if (a === 127)
330
+ return 'loopback';
331
+ if (a === 10)
332
+ return 'private';
333
+ if (a === 172 && b >= 16 && b <= 31)
334
+ return 'private';
335
+ if (a === 192 && b === 168)
336
+ return 'private';
337
+ if (a === 169 && b === 254)
338
+ return 'link-local'; // incl. 169.254.169.254 cloud metadata
339
+ if (a === 100 && b >= 64 && b <= 127)
340
+ return 'private'; // CGNAT shared space
341
+ if (a === 192 && b === 0)
342
+ return 'reserved'; // 192.0.0.0/24 protocol assignments, 192.0.2.0/24 documentation
343
+ if (a === 198 && (b === 18 || b === 19))
344
+ return 'reserved'; // benchmarking
345
+ if (a === 198 && b === 51)
346
+ return 'reserved'; // documentation
347
+ if (a === 203 && b === 0)
348
+ return 'reserved'; // documentation
349
+ if (a >= 224 && a <= 239)
350
+ return 'multicast';
351
+ if (a >= 240)
352
+ return 'reserved';
353
+ return 'public';
354
+ }
355
+ /** Classify one resolved address (IPv4 or IPv6, canonical or transition form). */
356
+ function classifyAddress(address) {
357
+ const v4 = parseIpv4(address);
358
+ if (v4 !== undefined)
359
+ return classifyIpv4(v4);
360
+ const groups = parseIpv6Groups(address);
361
+ if (groups === undefined)
362
+ return 'reserved'; // unparseable is never public
363
+ const embedded = embeddedIpv4(groups);
364
+ if (embedded !== undefined)
365
+ return classifyIpv4(embedded);
366
+ const first = groups[0];
367
+ if (groups.every((g) => g === 0))
368
+ return 'unspecified'; // ::
369
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
370
+ return 'loopback'; // ::1
371
+ if ((first & 0xfe00) === 0xfc00)
372
+ return 'private'; // fc00::/7 unique local
373
+ if ((first & 0xffc0) === 0xfe80)
374
+ return 'link-local'; // fe80::/10
375
+ if ((first & 0xff00) === 0xff00)
376
+ return 'multicast'; // ff00::/8
377
+ if (first === 0x2001 && groups[1] === 0x0db8)
378
+ return 'reserved'; // documentation
379
+ return 'public';
380
+ }
381
+ /** Whether one hostname string is already an IP literal (bracketed v6 or dotted v4). */
382
+ function isIpLiteral(hostname) {
383
+ const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
384
+ return parseIpv4(bare) !== undefined || parseIpv6Groups(bare) !== undefined;
385
+ }
@@ -13,17 +13,21 @@
13
13
  */
14
14
  import type { Context } from '@deepseek-ai/cordis';
15
15
  import { ReaderService } from './service.ts';
16
+ import { ReaderNotesStore } from './notes.ts';
16
17
  export { ReaderService, classifyPayload, normalizeUrl } from './service.ts';
17
18
  export { ReaderRemoteService } from './remote.ts';
18
19
  export type { ReaderRemoteConfig } from './remote.ts';
19
20
  export { MAX_CACHED_BODIES, MAX_TAGS, ReaderStore, ReaderStoreError, boundAnnotations, boundPayloads, emptyStateDoc, normalizeStateDoc, pruneOrphanTags, resolveReaderStateRoot, serializeStateDoc, } from './store.ts';
20
21
  export type { ReaderStoreRead } from './store.ts';
22
+ export * from './notes.ts';
21
23
  export * from './schedule.ts';
22
24
  export * from './types.ts';
23
25
  declare module '@deepseek-ai/cordis' {
24
26
  interface Context {
25
27
  /** The reader plugin's service core. */
26
28
  reader: ReaderService;
29
+ /** The reader's notes, discussions and article snapshots. */
30
+ readerNotes: ReaderNotesStore;
27
31
  }
28
32
  }
29
33
  /** Cordis plugin name used by loader diagnostics. */
@@ -32,6 +36,8 @@ export declare const name = "reader";
32
36
  export interface ReaderConfig {
33
37
  /** Directory holding `state.json`; defaults to `$DSH_HOME/state/dsh-reader`. */
34
38
  readonly stateRoot?: string;
39
+ /** A yt-dlp binary to use instead of the reader's managed copy (video metadata and captions). */
40
+ readonly ytdlpPath?: string;
35
41
  }
36
42
  /**
37
43
  * Plugin body: provide the service core, then mount the Remote data face.
@@ -1,8 +1,10 @@
1
1
  import { ReaderService } from "./service.js";
2
+ import { ReaderNotesStore } from "./notes.js";
2
3
  import { ReaderRemoteService } from "./remote.js";
3
4
  export { ReaderService, classifyPayload, normalizeUrl } from "./service.js";
4
5
  export { ReaderRemoteService } from "./remote.js";
5
6
  export { MAX_CACHED_BODIES, MAX_TAGS, ReaderStore, ReaderStoreError, boundAnnotations, boundPayloads, emptyStateDoc, normalizeStateDoc, pruneOrphanTags, resolveReaderStateRoot, serializeStateDoc, } from "./store.js";
7
+ export * from "./notes.js";
6
8
  export * from "./schedule.js";
7
9
  export * from "./types.js";
8
10
  /** Cordis plugin name used by loader diagnostics. */
@@ -21,5 +23,6 @@ export const name = 'reader';
21
23
  export function apply(ctx, config = {}) {
22
24
  const service = new ReaderService(ctx, config);
23
25
  ctx.provide('reader', service);
26
+ ctx.provide('readerNotes', new ReaderNotesStore(config));
24
27
  ctx.plugin(ReaderRemoteService, {});
25
28
  }
@@ -0,0 +1,171 @@
1
+ import { type Cue } from './captions.ts';
2
+ import type { ReaderMedia } from './media.ts';
3
+ import type { LocalTranscriptStatus } from './types.ts';
4
+ /** The part of the `transcribe` service the reader calls. */
5
+ export interface TranscribeEngine {
6
+ modelsFor(request: {
7
+ language?: 'zh' | 'en' | 'auto';
8
+ diarize?: false;
9
+ }): string[];
10
+ missingBytes(ids: readonly string[]): Promise<number>;
11
+ run(request: {
12
+ input: {
13
+ files: string[];
14
+ };
15
+ fromSec?: number;
16
+ language: 'zh' | 'en' | 'auto';
17
+ diarize: false;
18
+ owner: string;
19
+ label?: string;
20
+ }, options: {
21
+ signal?: AbortSignal;
22
+ onProgress?: (progress: {
23
+ stage: string;
24
+ progress: number;
25
+ ahead?: number;
26
+ }) => void;
27
+ onSegments?: (segments: readonly EngineSegment[]) => void;
28
+ }): Promise<{
29
+ segments: EngineSegment[];
30
+ language?: string;
31
+ }>;
32
+ /** Who spoke when (an engine older than this has none: transcripts stay one voice). */
33
+ speakers?(request: {
34
+ input: {
35
+ files: string[];
36
+ };
37
+ speech?: {
38
+ start: number;
39
+ end: number;
40
+ }[];
41
+ owner: string;
42
+ label?: string;
43
+ }, options: {
44
+ signal?: AbortSignal;
45
+ onProgress?: (progress: {
46
+ stage: string;
47
+ progress: number;
48
+ ahead?: number;
49
+ }) => void;
50
+ }): Promise<{
51
+ single: boolean;
52
+ turns: {
53
+ cluster: number;
54
+ start: number;
55
+ end: number;
56
+ }[];
57
+ }>;
58
+ }
59
+ /** A speaker turn as the engine reports it. */
60
+ export interface EngineTurn {
61
+ readonly cluster: number;
62
+ readonly start: number;
63
+ readonly end: number;
64
+ }
65
+ /** One finished stretch as the engine reports it. */
66
+ export interface EngineSegment {
67
+ readonly start: number;
68
+ readonly end: number;
69
+ readonly tokens: readonly {
70
+ readonly text: string;
71
+ readonly start: number;
72
+ readonly end: number;
73
+ }[];
74
+ }
75
+ /**
76
+ * Engine stretches as caption-like cues: one per sentence (the engine's
77
+ * stretches are cut at pauses, not sentence ends), so the transcript merges
78
+ * into the same paragraphs platform captions do. A sentence that runs on
79
+ * without a period is closed at a comma or a pause once it is long, or a
80
+ * single cue would make a minute-and-a-half paragraph (measured 2026-10-20).
81
+ *
82
+ * @param segments - the engine's segments, in order.
83
+ */
84
+ export declare function cuesFromSegments(segments: readonly EngineSegment[], speakers?: readonly (number | undefined)[]): (Cue & {
85
+ speaker?: number;
86
+ })[];
87
+ /**
88
+ * Each token's speaker, as an index in order of first appearance (A, B, …):
89
+ * the turn covering the token's middle, else the nearest one; then runs of a
90
+ * voice shorter than {@link MIN_TURN_SEC} are folded into the voice before
91
+ * them — a diarizer's boundary wobble would otherwise split sentences.
92
+ *
93
+ * @param segments - the transcript's segments.
94
+ * @param turns - the engine's turns.
95
+ */
96
+ export declare function speakerPerToken(segments: readonly EngineSegment[], turns: readonly EngineTurn[]): number[];
97
+ /**
98
+ * The transcript with its speakers: paragraphs never cross a change of voice,
99
+ * and each carries `data-spk` (the speaker's index) beside its `data-t`.
100
+ */
101
+ export declare function renderSpeakerTranscript(segments: readonly EngineSegment[], turns: readonly EngineTurn[]): string;
102
+ /** The marker a locally transcribed paragraph carries (the 来源 line reads it). */
103
+ export declare const LOCAL_MARK = "data-src=\"local\"";
104
+ /** The engine's segments as transcript markup, each paragraph marked local. */
105
+ export declare function renderLocalTranscript(segments: readonly EngineSegment[]): string;
106
+ /**
107
+ * The language to ask for: always detection (SenseVoice). Measured
108
+ * 2026-10-20: a platform says nothing reliable about the spoken language — a
109
+ * Bilibili upload of an English interview came out of the Chinese model as
110
+ * lower-case English with Chinese punctuation — and on Chinese speech
111
+ * SenseVoice reads about as well as Paraformer (a 90 s sample: each missed
112
+ * two or three characters the other got right).
113
+ */
114
+ export declare function languageFor(_media: ReaderMedia): 'auto';
115
+ /** What the transcriber needs from the reader. */
116
+ export interface LocalTranscriberDeps {
117
+ /** The engine, probed per start. */
118
+ readonly engine: () => TranscribeEngine | undefined;
119
+ /** Where temporary audio goes. */
120
+ readonly tmpRoot: string;
121
+ /** Fetch a video's audio track into `dir`. */
122
+ readonly videoAudio: (url: string, dir: string, options: {
123
+ signal: AbortSignal;
124
+ onProgress: (fraction: number) => void;
125
+ }) => Promise<string>;
126
+ /** How often a restart checks for the engine before resuming jobs (ms; default 2 s). */
127
+ readonly resumePollMs?: number;
128
+ /** Keep the finished transcript as the entry's body (`speakers`: how many voices, once known). */
129
+ readonly store: (entryId: string, url: string, html: string, language: string | undefined, speakers?: number) => Promise<void>;
130
+ }
131
+ /** The reader's local transcription jobs, by entry. */
132
+ export declare class LocalTranscriber {
133
+ private readonly deps;
134
+ private readonly jobs;
135
+ /** Jobs a previous process left mid-way, waiting for the engine. */
136
+ private readonly resumable;
137
+ private resumeTimer;
138
+ private disposing;
139
+ constructor(deps: LocalTranscriberDeps);
140
+ /** An entry's job as the browser sees it. */
141
+ status(entryId: string): LocalTranscriptStatus;
142
+ /**
143
+ * Start (or rejoin) an entry's transcription.
144
+ *
145
+ * @param request - the entry, its media, and whether the model download was confirmed.
146
+ */
147
+ start(request: {
148
+ entryId: string;
149
+ url: string;
150
+ media: ReaderMedia;
151
+ title?: string;
152
+ confirmModels?: boolean;
153
+ }): Promise<LocalTranscriptStatus>;
154
+ /**
155
+ * Leave or stop an entry's job: a queued (or still fetching) job is
156
+ * withdrawn either way; a running one only with `force`.
157
+ */
158
+ cancel(entryId: string, force: boolean): LocalTranscriptStatus;
159
+ /** Whether the engine is installed right now. */
160
+ engineAvailable(): boolean;
161
+ /** Stop everything (plugin disposal) — keeping each job's record, so the next process resumes it. */
162
+ dispose(): void;
163
+ /**
164
+ * Run (or resume) one job: fetch the audio unless the record has it,
165
+ * transcribe from where the saved segments end, store, then the speakers.
166
+ *
167
+ * @param resumeDir - a previous process's directory for this job.
168
+ */
169
+ private execute;
170
+ }
171
+ //# sourceMappingURL=local-transcribe.d.ts.map