@quran-lab/tajweedformer 2.0.0 → 2.0.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.
@@ -1,606 +1,670 @@
1
- // The shared sessions on their way to R2 (the Swift SDK's ContributionOutbox and ShareFiles, with
2
- // PLATFORM_CLIENT.md sections 8 to 10). Each session is a folder of files in a storage:
3
- // session.json the start's body capture.json { preSkip }
4
- // seg-NNNNN.opus a segment's Ogg pages seg-NNNNN.json { n, bytes, sha256, granule }: closed
5
- // end.json { durationMs, completed } item.json the outbox's record (only the outbox writes it)
6
- // The upload starts once the first segment is closed; every closed segment is PUT straight to its
7
- // presigned URL, at most 2 at a time; the next batch of URLs is asked for when 5 unused remain, fresh
8
- // ones on a 403. A session the page was closed in is recovered on the next load: the open segment is
9
- // cut back to its last whole page and the session ends as not completed (its stream has no end page).
10
-
11
- import { segmentName, wholePages } from './oggopus.js';
12
- import { freshUrl, parseUrls, SharingError } from './uploader.js';
13
-
14
- export const FILES = Object.freeze({ session: 'session.json', capture: 'capture.json', end: 'end.json', item: 'item.json' });
15
- const DAY_MS = 86400000;
16
- /** Unsent sessions older than this are deleted (PLATFORM_CLIENT 10.4). */
17
- export const MAX_AGE_MS = 7 * DAY_MS;
18
- const PARALLEL = 2;
19
-
20
- const enc = new TextEncoder(), dec = new TextDecoder();
21
- const isId = (s) => /^[0-9a-f]{32}$/.test(s);
22
-
23
- export async function sha256Hex(bytes) {
24
- const d = await globalThis.crypto.subtle.digest('SHA-256', bytes);
25
- return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, '0')).join('');
26
- }
27
-
28
- // ------------------------------------------------------------------------------------------------
29
- // Storages: the same async interface over IndexedDB (browsers), a directory (Node) and memory (tests)
30
-
31
- /** In memory (tests; `snapshot()` copies it, as a disk at the moment an app is killed). */
32
- export class MemoryStorage {
33
- constructor(state) { this.files = new Map(state ? state.files : []); this.global = new Map(state ? state.global : []); }
34
- snapshot() { return new MemoryStorage({ files: [...this.files].map(([k, v]) => [k, v.slice()]), global: [...this.global] }); }
35
- async sessions() { return [...new Set([...this.files.keys()].map((k) => k.split('/')[0]))].filter(isId); }
36
- async list(id) { return [...this.files.keys()].filter((k) => k.startsWith(id + '/')).map((k) => k.slice(id.length + 1)); }
37
- async read(id, name) { const v = this.files.get(`${id}/${name}`); return v ? v.slice() : null; }
38
- async write(id, name, bytes) { this.files.set(`${id}/${name}`, Uint8Array.from(bytes)); }
39
- async append(id, name, bytes) {
40
- const k = `${id}/${name}`, old = this.files.get(k);
41
- const n = new Uint8Array((old ? old.length : 0) + bytes.length);
42
- if (old) n.set(old);
43
- n.set(bytes, old ? old.length : 0);
44
- this.files.set(k, n);
45
- }
46
- async truncate(id, name, len) { const v = this.files.get(`${id}/${name}`); if (v) this.files.set(`${id}/${name}`, v.slice(0, len)); }
47
- async remove(id, name) { this.files.delete(`${id}/${name}`); }
48
- async removeSession(id) { for (const k of [...this.files.keys()]) if (k.startsWith(id + '/')) this.files.delete(k); }
49
- async getGlobal(k) { return this.global.has(k) ? this.global.get(k) : null; }
50
- async setGlobal(k, v) { if (v === null) this.global.delete(k); else this.global.set(k, v); }
51
- }
52
-
53
- /** A directory (Node): <root>/outbox/<id>/<file>, <root>/sharing.json for the contributor id. */
54
- export async function dirStorage(root) {
55
- const fs = await import('node:fs/promises');
56
- const path = await import('node:path');
57
- const box = path.join(root, 'outbox');
58
- await fs.mkdir(box, { recursive: true });
59
- const f = (id, name) => path.join(box, id, name);
60
- const globalFile = path.join(root, 'sharing.json');
61
- let tmpSeq = 0;
62
- // Windows refuses a rename over a file another handle has open (a concurrent read, an antivirus scan)
63
- // with EPERM/EACCES/EBUSY for a moment: retry briefly, as graceful-fs does
64
- const rename = async (a, b) => {
65
- for (let i = 0; ; i++) {
66
- try { return await fs.rename(a, b); } catch (e) {
67
- if (!['EPERM', 'EACCES', 'EBUSY'].includes(e.code) || i >= 20) throw e;
68
- await new Promise((r) => setTimeout(r, 5 * (i + 1)));
69
- }
70
- }
71
- };
72
- const readGlobal = async () => { try { return JSON.parse(await fs.readFile(globalFile, 'utf8')); } catch (_) { return {}; } };
73
- return {
74
- root,
75
- async sessions() { return (await fs.readdir(box).catch(() => [])).filter(isId); },
76
- async list(id) { return fs.readdir(path.join(box, id)).catch(() => []); },
77
- async read(id, name) { try { return new Uint8Array(await fs.readFile(f(id, name))); } catch (_) { return null; } },
78
- async write(id, name, bytes) {
79
- await fs.mkdir(path.join(box, id), { recursive: true });
80
- // a unique temp name per write: two writes of the same file never share one (they raced before)
81
- const tmp = `${f(id, name)}.${process.pid}.${++tmpSeq}.tmp`;
82
- await fs.writeFile(tmp, bytes);
83
- try { await rename(tmp, f(id, name)); } catch (e) {
84
- await fs.rm(tmp, { force: true }).catch(() => {});
85
- if (e.code !== 'ENOENT') throw e; // the session was removed meanwhile (sharing off, uploaded): nothing to keep
86
- }
87
- },
88
- async append(id, name, bytes) { await fs.mkdir(path.join(box, id), { recursive: true }); await fs.appendFile(f(id, name), bytes); },
89
- async truncate(id, name, len) { await fs.truncate(f(id, name), len).catch(() => {}); },
90
- async remove(id, name) { await fs.rm(f(id, name), { force: true }); },
91
- async removeSession(id) { await fs.rm(path.join(box, id), { recursive: true, force: true }); },
92
- async getGlobal(k) { const g = await readGlobal(); return k in g ? g[k] : null; },
93
- async setGlobal(k, v) {
94
- const g = await readGlobal();
95
- if (v === null) delete g[k]; else g[k] = v;
96
- const tmp = `${globalFile}.${process.pid}.${++tmpSeq}.tmp`;
97
- await fs.writeFile(tmp, JSON.stringify(g));
98
- await rename(tmp, globalFile);
99
- },
100
- };
101
- }
102
-
103
- /**
104
- * IndexedDB (browsers), per origin: database `quranlab-sharing`, store `files` keyed [id, name, part]
105
- * (an append is one new record, so a page is never torn), store `global`.
106
- */
107
- export async function indexedDbStorage(dbName = 'quranlab-sharing', idb = globalThis.indexedDB) {
108
- if (!idb) throw new Error('IndexedDB is not available');
109
- const db = await new Promise((resolve, reject) => {
110
- const r = idb.open(dbName, 1);
111
- r.onupgradeneeded = () => { r.result.createObjectStore('files'); r.result.createObjectStore('global'); };
112
- r.onsuccess = () => resolve(r.result);
113
- r.onerror = () => reject(r.error);
114
- });
115
- const tx = (stores, mode, fn) => new Promise((resolve, reject) => {
116
- const t = db.transaction(stores, mode);
117
- let out;
118
- Promise.resolve(fn(t)).then((v) => { out = v; });
119
- t.oncomplete = () => resolve(out);
120
- t.onerror = () => reject(t.error);
121
- t.onabort = () => reject(t.error);
122
- });
123
- const req = (r) => new Promise((resolve, reject) => { r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
124
- const range = (id, name) => (name === undefined
125
- ? IDBKeyRange.bound([id], [id, []]) : IDBKeyRange.bound([id, name, 0], [id, name, Infinity]));
126
- const keys = (id, name) => tx(['files'], 'readonly', (t) => req(t.objectStore('files').getAllKeys(range(id, name))));
127
- return {
128
- async sessions() {
129
- const all = await tx(['files'], 'readonly', (t) => req(t.objectStore('files').getAllKeys()));
130
- return [...new Set(all.map((k) => k[0]))].filter(isId);
131
- },
132
- async list(id) { return [...new Set((await keys(id)).map((k) => k[1]))]; },
133
- async read(id, name) {
134
- const parts = await tx(['files'], 'readonly', (t) => req(t.objectStore('files').getAll(range(id, name))));
135
- if (!parts.length) return null;
136
- const n = parts.reduce((k, p) => k + p.byteLength, 0);
137
- const out = new Uint8Array(n);
138
- let at = 0;
139
- for (const p of parts) { out.set(new Uint8Array(p), at); at += p.byteLength; }
140
- return out;
141
- },
142
- async write(id, name, bytes) {
143
- await tx(['files'], 'readwrite', (t) => {
144
- const s = t.objectStore('files');
145
- s.delete(range(id, name));
146
- s.put(Uint8Array.from(bytes).buffer, [id, name, 0]);
147
- });
148
- },
149
- async append(id, name, bytes) {
150
- await tx(['files'], 'readwrite', async (t) => {
151
- const s = t.objectStore('files');
152
- const ks = await req(s.getAllKeys(range(id, name)));
153
- const part = ks.length ? ks[ks.length - 1][2] + 1 : 0;
154
- s.put(Uint8Array.from(bytes).buffer, [id, name, part]);
155
- });
156
- },
157
- async truncate(id, name, len) { const b = await this.read(id, name); if (b) await this.write(id, name, b.slice(0, len)); },
158
- async remove(id, name) { await tx(['files'], 'readwrite', (t) => { t.objectStore('files').delete(range(id, name)); }); },
159
- async removeSession(id) { await tx(['files'], 'readwrite', (t) => { t.objectStore('files').delete(range(id)); }); },
160
- async getGlobal(k) { const v = await tx(['global'], 'readonly', (t) => req(t.objectStore('global').get(k))); return v === undefined ? null : v; },
161
- async setGlobal(k, v) { await tx(['global'], 'readwrite', (t) => { if (v === null) t.objectStore('global').delete(k); else t.objectStore('global').put(v, k); }); },
162
- };
163
- }
164
-
165
- /** The default storage: IndexedDB in a browser, `<cwd>/.quranlab` in Node, else memory. */
166
- export async function defaultStorage() {
167
- if (globalThis.indexedDB) return indexedDbStorage();
168
- if (globalThis.process?.versions?.node) {
169
- const path = await import('node:path');
170
- return dirStorage(path.join(globalThis.process.cwd(), '.quranlab'));
171
- }
172
- return new MemoryStorage();
173
- }
174
-
175
- export const readJson = async (s, id, name) => { const b = await s.read(id, name); if (!b) return null; try { return JSON.parse(dec.decode(b)); } catch (_) { return null; } };
176
- export const writeJson = (s, id, name, v) => s.write(id, name, enc.encode(JSON.stringify(v)));
177
-
178
- /** The closed segments' markers, in order. */
179
- export async function markers(s, id) {
180
- const names = (await s.list(id)).filter((n) => /^seg-\d{5,}\.json$/.test(n));
181
- const out = [];
182
- for (const n of names) { const m = await readJson(s, id, n); if (m) out.push(m); }
183
- return out.sort((a, b) => a.n - b.n);
184
- }
185
-
186
- /** Closes segment n: its marker, from the bytes as they are stored. */
187
- export async function mark(s, id, n, granule) {
188
- const b = await s.read(id, segmentName(n) + '.opus');
189
- if (!b) throw new Error(`no ${segmentName(n)}.opus`);
190
- await writeJson(s, id, segmentName(n) + '.json', { n, bytes: b.length, sha256: await sha256Hex(b), granule });
191
- }
192
-
193
- /** A session the page was closed in: open segments cut back to their whole pages and closed, the
194
- * session ended as not completed. False when there is nothing to share (it is then removed). */
195
- export async function recover(s, id) {
196
- const cap = await readJson(s, id, FILES.capture);
197
- if (!cap) { await s.removeSession(id); return false; }
198
- const have = new Set((await markers(s, id)).map((m) => m.n));
199
- const open = (await s.list(id)).filter((n) => /^seg-\d{5,}\.opus$/.test(n)).map((n) => Number(n.slice(4, -5)))
200
- .filter((n) => !have.has(n)).sort((a, b) => a - b);
201
- for (const n of open) {
202
- const name = segmentName(n) + '.opus';
203
- const d = (await s.read(id, name)) || new Uint8Array(0);
204
- const { keep, granule } = wholePages(d);
205
- if (keep === 0) { await s.remove(id, name); continue; }
206
- if (keep < d.length) await s.truncate(id, name, keep);
207
- await mark(s, id, n, granule);
208
- }
209
- const ms = await markers(s, id);
210
- if (!ms.length) { await s.removeSession(id); return false; }
211
- const last = ms[ms.length - 1];
212
- await writeJson(s, id, FILES.end, { durationMs: Math.trunc(Math.max(0, last.granule - cap.preSkip) / 48), completed: false });
213
- return true;
214
- }
215
-
216
- /** Session ids being captured in this page or process (never taken for crashed). */
217
- export const activeCaptures = new Set();
218
-
219
- /**
220
- * The outbox.
221
- * @param {object} o
222
- * @param {object} o.storage
223
- * @param {import('./uploader.js').ContributionClient} o.client
224
- * @param {() => Promise<{token: string, attested?: boolean}>} o.tokenProvider
225
- * @param {() => object|null} [o.flags] the signed config's flags (contributions, upload_trust, capped_limits)
226
- * @param {() => boolean} [o.enabled] sharing is on (the app's option and the learner's consent)
227
- * @param {() => Promise<string|null>} [o.turnstile] web, capped tier with Turnstile on: a fresh token
228
- * @param {string} [o.platform] 'web' (upload_trust key)
229
- * @param {() => Promise<string>} o.contributor
230
- * @param {() => number} [o.clock] ms
231
- * @param {(item: object) => void} [o.onChange]
232
- */
233
- export class Outbox {
234
- constructor({ storage, client, tokenProvider, flags = () => null, enabled = () => true, turnstile = null, platform = 'web',
235
- contributor, clock = () => Date.now(), onChange = null, onError = null, random = Math.random }) {
236
- Object.assign(this, { storage, client, tokenProvider, flags, enabled, turnstile, platform, contributor, clock, onChange, onError, random });
237
- this.records = new Map();
238
- this.inFlight = new Map(); // "<id>/<file>" -> AbortController
239
- this.running = null;
240
- this.again = false;
241
- this.stopped = false;
242
- this.wakeTimer = null;
243
- this.seqTop = 0;
244
- }
245
-
246
- // ---- public ----
247
-
248
- /** Every session, oldest first: { id, segments, bytes, durationMs, day, completed, status, failCode, attempts, lastError }. */
249
- async items() {
250
- await this._scan();
251
- const out = [];
252
- for (const r of [...this.records.values()].sort((a, b) => a.seq - b.seq)) out.push(await this._snapshot(r));
253
- return out;
254
- }
255
-
256
- async item(id) { await this._scan(); const r = this.records.get(id); return r ? this._snapshot(r) : null; }
257
-
258
- /** Sessions not yet fully uploaded. */
259
- async pending() { return (await this.items()).filter((x) => x.status === 'queued'); }
260
-
261
- /** Starts an upload pass (also at load, when a segment closes and when a retry is due). */
262
- resume() { this.stopped = false; return this.drain(); }
263
-
264
- /** Sharing was turned off: in-flight PUTs aborted, every session not fully uploaded deleted at
265
- * once (a session being captured is abandoned by its capture). */
266
- async stop() {
267
- this.stopped = true;
268
- for (const c of this.inFlight.values()) c.abort();
269
- this.inFlight.clear();
270
- if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
271
- if (this.running) await this.running.catch(() => {});
272
- await this._scan();
273
- for (const r of [...this.records.values()]) {
274
- if (r.state === 'active' && !activeCaptures.has(r.id)) { await this.storage.removeSession(r.id); this.records.delete(r.id); }
275
- }
276
- }
277
-
278
- /** Takes a session out before the server has it. */
279
- async cancel(id) {
280
- await this._scan();
281
- const r = this.records.get(id);
282
- if (!r) throw new Error(`no outbox item ${id}`);
283
- if (r.state !== 'active' || r.started || activeCaptures.has(id)) throw new Error(`item ${id} is already uploading`);
284
- await this.storage.removeSession(id);
285
- this.records.delete(id);
286
- }
287
-
288
- /** One upload pass, after any pass already running. */
289
- async drain({ ignoringBackoff = false } = {}) {
290
- if (this.running) {
291
- this.again = true;
292
- while (this.running) await this.running.catch(() => {});
293
- if (!this.again) return;
294
- }
295
- this.again = false;
296
- this.running = (async () => {
297
- try { await this._pass(ignoringBackoff); } finally { this._scheduleWake(); }
298
- })();
299
- try { await this.running; } finally { this.running = null; }
300
- if (this.again) await this.drain({ ignoringBackoff });
301
- }
302
-
303
- /** Whether this install may upload now: the app/learner switch, the config's contributions flag and
304
- * this platform's upload trust (web: `capped` only; PLATFORM_CLIENT 9). */
305
- uploadAllowed() {
306
- if (this.stopped || !this.enabled()) return { ok: false, why: 'sharing is off' };
307
- const f = this.flags() || {};
308
- if (f.contributions === false) return { ok: false, why: 'sharing is turned off for this app' };
309
- const trust = f.upload_trust && f.upload_trust[this.platform];
310
- // owner decision (11-07): web and desktop upload at the lower trust tier (`capped`: per-key and
311
- // per-contributor caps, sessions marked attested=false); an absent entry means that tier for them
312
- const tier = trust || (['ios', 'macos', 'android'].includes(this.platform) ? 'attested' : 'capped');
313
- if (tier === 'off') return { ok: false, why: `uploads are off for ${this.platform} (upload_trust)` };
314
- if (tier === 'attested' && !['ios', 'macos', 'android'].includes(this.platform)) {
315
- return { ok: false, why: `${this.platform} has no attestation and the config asks for it (upload_trust attested)` };
316
- }
317
- return { ok: true, tier };
318
- }
319
-
320
- // ---- the pass ----
321
-
322
- async _pass(ignoringBackoff) {
323
- await this._scan();
324
- await this._expire();
325
- const allowed = this.uploadAllowed();
326
- const now = this.clock();
327
- const due = [...this.records.values()].filter((r) => r.state === 'active' && (ignoringBackoff || !r.nextAttempt || r.nextAttempt <= now))
328
- .sort((a, b) => a.seq - b.seq).map((r) => r.id);
329
- if (!due.length) return;
330
- if (!allowed.ok) { for (const id of due) await this._note(id, `${allowed.why}; waiting`); return; }
331
- for (const id of due) {
332
- if (this.stopped) return;
333
- let token;
334
- try { token = await this.tokenProvider(); } catch (e) {
335
- await this._retry(id, `token: ${e && e.message || e}`);
336
- return;
337
- }
338
- if (!token || !token.token) { await this._retry(id, 'no token'); return; }
339
- if (allowed.tier === 'attested' && !token.attested) {
340
- await this._retry(id, 'attestation_required: this install has no attested token; sharing paused', { long: true });
341
- continue;
342
- }
343
- if (!(await this._step(id, token.token))) return;
344
- }
345
- }
346
-
347
- // Moves one session on as far as it goes now. False when the pass should stop (offline).
348
- async _step(id, token) {
349
- const s = this.storage;
350
- const ms = await markers(s, id);
351
- const end = await readJson(s, id, FILES.end);
352
- let rec = this.records.get(id);
353
- if (!rec) return true;
354
- if (!rec.started) {
355
- if (!ms.length) return true;
356
- const session = await s.read(id, FILES.session);
357
- if (!session) return true;
358
- rec.attempts++;
359
- await this._save(rec);
360
- const turnstile = this.turnstile && (this.flags() || {}).capped_limits?.turnstile_web ? await this.turnstile() : null;
361
- const r = await this.client.start({ id, session: dec.decode(session), token, turnstile });
362
- const urls = await this._answer(id, r);
363
- if (!urls) return r.status !== 0;
364
- rec = this.records.get(id);
365
- rec.started = true;
366
- rec.urls = merge(rec.urls, urls);
367
- await this._save(rec);
368
- }
369
- // every closed segment, in order, at most PARALLEL PUTs at a time
370
- const todo = ms.filter((m) => !rec.uploaded.includes(segmentName(m.n) + '.opus'));
371
- for (let i = 0; i < todo.length; i += PARALLEL) {
372
- const group = [];
373
- for (const m of todo.slice(i, i + PARALLEL)) {
374
- rec = this.records.get(id);
375
- if (!rec || rec.state !== 'active') return true;
376
- if (!(rec.urls || []).some((u) => u.n === m.n && freshUrl(u, this.clock()))) {
377
- const ok = await this._fetchUrls(id, m.n, token);
378
- if (!ok.ok) return !ok.offline;
379
- rec = this.records.get(id);
380
- if (!rec || rec.state !== 'active') return true;
381
- }
382
- const u = (rec.urls || []).find((x) => x.n === m.n);
383
- if (!u) return true;
384
- group.push(this._put(id, m, u));
385
- }
386
- const results = await Promise.all(group);
387
- if (results.includes('offline')) return false;
388
- // refused (a retry is scheduled, at once for a 403): this pass stops here for the session
389
- if (results.includes('stop') || results.includes('retry')) return true;
390
- }
391
- rec = this.records.get(id);
392
- if (!rec || rec.state !== 'active') return true;
393
- // while the session runs: the next batch once 5 unused URLs remain
394
- const lastUrl = Math.max(0, ...(rec.urls || []).map((u) => u.n));
395
- if (!end && lastUrl > 0 && lastUrl - (ms.length ? ms[ms.length - 1].n : 0) <= 5) {
396
- const ok = await this._fetchUrls(id, lastUrl + 1, token);
397
- if (!ok.ok) return !ok.offline;
398
- rec = this.records.get(id);
399
- }
400
- if (!end || !rec.started || !ms.length || !ms.every((m) => rec.uploaded.includes(segmentName(m.n) + '.opus'))) return true;
401
- rec.state = 'uploaded';
402
- rec.lastError = null;
403
- rec.nextAttempt = null;
404
- rec.urls = null;
405
- await this._save(rec);
406
- for (const name of await s.list(id)) {
407
- if (name !== FILES.item && name !== FILES.end && name !== FILES.session && !/^seg-\d{5,}\.json$/.test(name)) await s.remove(id, name);
408
- }
409
- return true;
410
- }
411
-
412
- // one PUT: 'ok', 'retry' (refused; retry scheduled), 'offline', 'stop'
413
- async _put(id, m, u) {
414
- const name = segmentName(m.n) + '.opus';
415
- const key = `${id}/${name}`;
416
- const bytes = await this.storage.read(id, name);
417
- if (!bytes) return 'stop';
418
- const ctrl = new AbortController();
419
- this.inFlight.set(key, ctrl);
420
- let r;
421
- try { r = await this.client.put({ url: u.url, bytes, signal: ctrl.signal }); } catch (e) {
422
- return 'stop'; // aborted: sharing went off
423
- } finally { this.inFlight.delete(key); }
424
- const rec = this.records.get(id);
425
- if (!rec || rec.state !== 'active' || this.stopped) return 'stop';
426
- if (r.status === 0) { await this._retry(id, `PUT ${name}: ${r.error}`); return 'offline'; }
427
- if (r.status >= 200 && r.status < 300) {
428
- if (!rec.uploaded.includes(name)) rec.uploaded.push(name);
429
- rec.urls = (rec.urls || []).filter((x) => x.n !== m.n);
430
- rec.lastError = null;
431
- await this._save(rec);
432
- await this.storage.remove(id, name);
433
- return 'ok';
434
- }
435
- rec.attempts++;
436
- if (r.status === 403) rec.urls = (rec.urls || []).filter((x) => x.n !== m.n);
437
- await this._save(rec);
438
- await this._retry(id, `PUT ${name}: HTTP ${r.status}`, { immediately: r.status === 403 && rec.attempts < 5 });
439
- return 'retry';
440
- }
441
-
442
- // URLs for segments from..from+19, merged into the item
443
- async _fetchUrls(id, from, token) {
444
- const contributor = await this.contributor();
445
- const r = await this.client.urls({ id, from, contributor, token });
446
- const urls = await this._answer(id, r);
447
- const rec = this.records.get(id);
448
- if (!urls) {
449
- if (rec && rec.state === 'active') { rec.attempts++; await this._save(rec); }
450
- return { ok: false, offline: r.status === 0 };
451
- }
452
- if (!rec) return { ok: false, offline: false };
453
- rec.urls = merge(rec.urls, urls);
454
- await this._save(rec);
455
- return { ok: true };
456
- }
457
-
458
- // A Worker answer: the URLs on 2xx; otherwise the item is retried, paused, capped or failed
459
- // (PLATFORM_CLIENT 8) and null comes back.
460
- async _answer(id, r) {
461
- if (r.status === 0) { await this._retry(id, r.error || 'offline'); return null; }
462
- if (r.status >= 200 && r.status < 300) {
463
- const u = parseUrls(r.body, id);
464
- if (u) return u;
465
- await this._retry(id, 'no upload URLs in the answer');
466
- return null;
467
- }
468
- const code = (r.body && r.body.error) || `http_${r.status}`;
469
- const message = (r.body && r.body.message) || '';
470
- if (code === 'contributor_revoked' || code === 'contributor_key_mismatch') {
471
- // the contributor id this session was recorded under can never upload again: the app's
472
- // controller makes a new contributor, key and recovery code (SharingError.code)
473
- await this._fail(id, code, message);
474
- if (this.onError) this.onError(new SharingError(code, r.status, message));
475
- return null;
476
- }
477
- if (code === 'volume_limit' || code === 'capped_limit') {
478
- // retry after the next UTC midnight
479
- const rec = this.records.get(id);
480
- if (rec) {
481
- const now = this.clock();
482
- rec.lastError = `HTTP ${r.status} ${code}: retry tomorrow`;
483
- rec.nextAttempt = (Math.floor(now / DAY_MS) + 1) * DAY_MS + Math.floor(this.random() * 3600000);
484
- await this._save(rec);
485
- }
486
- if (this.onError) this.onError(new SharingError(code, r.status, message));
487
- return null;
488
- }
489
- if (r.status === 401) await this._retry(id, `HTTP 401 ${code}`, { immediately: (this.records.get(id)?.attempts || 0) < 3, invalidate: true });
490
- else if (r.status >= 500 || [408, 425, 429].includes(r.status)) await this._retry(id, `HTTP ${r.status} ${code}`);
491
- else if (code === 'contributions_disabled' || code === 'attestation_required') await this._retry(id, `HTTP ${r.status} ${code}: paused`, { long: true });
492
- else if (code === 'turnstile_required' || code === 'turnstile_failed') await this._retry(id, `HTTP ${r.status} ${code}`, { immediately: (this.records.get(id)?.attempts || 0) < 2 });
493
- else if (code === 'segment_cap') await this._capped(id);
494
- else if (r.status === 404 || r.status === 410) await this._drop(id, code);
495
- else await this._fail(id, code, message);
496
- if (r.status === 401 && this.tokenProvider.invalidate) await this.tokenProvider.invalidate();
497
- return null;
498
- }
499
-
500
- // ---- states ----
501
-
502
- async _retry(id, why, { immediately = false, long = false } = {}) {
503
- const rec = this.records.get(id);
504
- if (!rec) return;
505
- rec.lastError = why;
506
- const n = Math.max(1, rec.attempts);
507
- const base = long ? 6 * 3600 : Math.min(30 * 2 ** Math.min(n - 1, 16), 6 * 3600);
508
- const delay = immediately ? 0 : base * (0.8 + 0.4 * this.random());
509
- rec.nextAttempt = this.clock() + delay * 1000;
510
- await this._save(rec);
511
- }
512
-
513
- async _fail(id, code, message) {
514
- const rec = this.records.get(id);
515
- if (!rec) return;
516
- Object.assign(rec, { state: 'failed', failCode: code, failMessage: message, nextAttempt: null, urls: null });
517
- await this._save(rec);
518
- for (const name of await this.storage.list(id)) if (name !== FILES.item) await this.storage.remove(id, name);
519
- }
520
-
521
- // the capped tier's per-session limit was reached: what went up stays valid; nothing more is sent
522
- async _capped(id) {
523
- const rec = this.records.get(id);
524
- if (!rec) return;
525
- Object.assign(rec, { state: 'uploaded', capped: true, nextAttempt: null, urls: null, lastError: 'segment_cap' });
526
- await this._save(rec);
527
- for (const name of await this.storage.list(id)) if (/\.opus$/.test(name)) await this.storage.remove(id, name);
528
- }
529
-
530
- // the server does not know it (404) or deleted it (410): drop it locally
531
- async _drop(id, code) {
532
- await this.storage.removeSession(id);
533
- this.records.delete(id);
534
- if (this.onChange) this.onChange({ id, status: 'dropped', failCode: code });
535
- }
536
-
537
- async _note(id, why) {
538
- const rec = this.records.get(id);
539
- if (!rec || rec.lastError === why) return;
540
- rec.lastError = why;
541
- await this._save(rec);
542
- }
543
-
544
- async _expire() {
545
- const now = this.clock();
546
- for (const r of [...this.records.values()]) {
547
- if (r.state === 'active' && !activeCaptures.has(r.id) && now - (r.createdAt || now) > MAX_AGE_MS) await this._drop(r.id, 'expired');
548
- }
549
- }
550
-
551
- _scheduleWake() {
552
- if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
553
- if (this.stopped) return;
554
- const next = Math.min(...[...this.records.values()].filter((r) => r.state === 'active' && r.nextAttempt).map((r) => r.nextAttempt));
555
- if (!Number.isFinite(next)) return;
556
- const delay = Math.min(Math.max(1000, next - this.clock()), 2 ** 31 - 1);
557
- this.wakeTimer = setTimeout(() => { this.wakeTimer = null; this.drain().catch(() => {}); }, delay);
558
- if (this.wakeTimer.unref) this.wakeTimer.unref();
559
- }
560
-
561
- // ---- storage ----
562
-
563
- // Picks up sessions the captures made and recovers the ones a closed page left.
564
- async _scan() {
565
- const s = this.storage;
566
- for (const id of await s.sessions()) {
567
- if (this.records.has(id)) continue;
568
- const rec = await readJson(s, id, FILES.item);
569
- if (rec && rec.id === id) { this.records.set(id, rec); this.seqTop = Math.max(this.seqTop, rec.seq); continue; }
570
- const capturing = activeCaptures.has(id);
571
- if (capturing && !(await markers(s, id)).length) continue;
572
- if (!capturing && !(await readJson(s, id, FILES.end)) && !(await recover(s, id))) continue;
573
- const fresh = { id, seq: ++this.seqTop, state: 'active', started: false, urls: null, uploaded: [], failCode: null,
574
- failMessage: null, attempts: 0, lastError: null, nextAttempt: null, createdAt: this.clock() };
575
- await this._save(fresh);
576
- }
577
- for (const r of [...this.records.values()]) {
578
- if (r.state !== 'active' || activeCaptures.has(r.id)) continue;
579
- if (!(await readJson(s, r.id, FILES.end)) && !(await recover(s, r.id))) this.records.delete(r.id);
580
- }
581
- }
582
-
583
- async _save(rec) {
584
- this.records.set(rec.id, rec);
585
- await writeJson(this.storage, rec.id, FILES.item, rec);
586
- if (this.onChange) this.onChange(await this._snapshot(rec));
587
- }
588
-
589
- async _snapshot(r) {
590
- const ms = await markers(this.storage, r.id);
591
- const end = await readJson(this.storage, r.id, FILES.end);
592
- const ses = await readJson(this.storage, r.id, FILES.session);
593
- return {
594
- id: r.id, segments: ms.length, bytes: ms.reduce((k, m) => k + m.bytes, 0), durationMs: end ? end.durationMs : 0,
595
- day: ses ? ses.day : '', completed: end ? end.completed : null,
596
- status: r.state === 'active' ? 'queued' : r.state, capped: !!r.capped, failCode: r.failCode || null,
597
- failMessage: r.failMessage || null, attempts: r.attempts, lastError: r.lastError,
598
- };
599
- }
600
- }
601
-
602
- function merge(old, add) {
603
- const by = new Map((old || []).map((u) => [u.n, u]));
604
- for (const u of add) by.set(u.n, u);
605
- return [...by.values()].sort((a, b) => a.n - b.n);
606
- }
1
+ // The shared sessions on their way to R2 (the Swift SDK's ContributionOutbox and ShareFiles, with
2
+ // PLATFORM_CLIENT.md sections 8 to 10). Each session is a folder of files in a storage:
3
+ // session.json the start's body capture.json { preSkip, bitrates: { n: bit/s } } (set as segments open)
4
+ // seg-NNNNN.opus a segment's Ogg pages seg-NNNNN.json { n, bytes, sha256, granule, bitrate }: closed
5
+ // end.json { durationMs, completed } item.json the outbox's record (only the outbox writes it)
6
+ // The upload starts once the first segment is closed; every closed segment is PUT straight to its
7
+ // presigned URL, at most 2 at a time; the next batch of URLs is asked for when 5 unused remain, fresh
8
+ // ones on a 403. A session the page was closed in is recovered on the next load: the open segment is
9
+ // cut back to its last whole page and the session ends as not completed (its stream has no end page).
10
+
11
+ import { segmentName, wholePages } from './oggopus.js';
12
+ import { freshUrl, parseUrls, SharingError } from './uploader.js';
13
+ import { isNetworkFailure } from './bitrate.js';
14
+
15
+ export const FILES = Object.freeze({ session: 'session.json', capture: 'capture.json', end: 'end.json', item: 'item.json' });
16
+ const DAY_MS = 86400000;
17
+ /** Unsent sessions older than this are deleted (PLATFORM_CLIENT 10.4). */
18
+ export const MAX_AGE_MS = 7 * DAY_MS;
19
+ // One segment PUT at a time, in segment order, as the Android, Apple and C SDKs do. Two in parallel (2.0.1 before
20
+ // this fix) let the second segment start first whenever its bytes were read from storage first, so after an outage
21
+ // or a relaunch a session could arrive as seg-00002, seg-00001, ... (the server accepts any order, but Quran Lab wants
22
+ // segments in order). Tried and rejected: reading every segment of a group before starting its PUTs (the requests
23
+ // still race on the wire).
24
+ const PARALLEL = 1;
25
+
26
+ const enc = new TextEncoder(), dec = new TextDecoder();
27
+ const isId = (s) => /^[0-9a-f]{32}$/.test(s);
28
+
29
+ export async function sha256Hex(bytes) {
30
+ const d = await globalThis.crypto.subtle.digest('SHA-256', bytes);
31
+ return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, '0')).join('');
32
+ }
33
+
34
+ // ------------------------------------------------------------------------------------------------
35
+ // Storages: the same async interface over IndexedDB (browsers), a directory (Node) and memory (tests)
36
+
37
+ /** In memory (tests; `snapshot()` copies it, as a disk at the moment an app is killed). */
38
+ export class MemoryStorage {
39
+ constructor(state) { this.files = new Map(state ? state.files : []); this.global = new Map(state ? state.global : []); }
40
+ snapshot() { return new MemoryStorage({ files: [...this.files].map(([k, v]) => [k, v.slice()]), global: [...this.global] }); }
41
+ async sessions() { return [...new Set([...this.files.keys()].map((k) => k.split('/')[0]))].filter(isId); }
42
+ async list(id) { return [...this.files.keys()].filter((k) => k.startsWith(id + '/')).map((k) => k.slice(id.length + 1)); }
43
+ async read(id, name) { const v = this.files.get(`${id}/${name}`); return v ? v.slice() : null; }
44
+ async write(id, name, bytes) { this.files.set(`${id}/${name}`, Uint8Array.from(bytes)); }
45
+ async append(id, name, bytes) {
46
+ const k = `${id}/${name}`, old = this.files.get(k);
47
+ const n = new Uint8Array((old ? old.length : 0) + bytes.length);
48
+ if (old) n.set(old);
49
+ n.set(bytes, old ? old.length : 0);
50
+ this.files.set(k, n);
51
+ }
52
+ async truncate(id, name, len) { const v = this.files.get(`${id}/${name}`); if (v) this.files.set(`${id}/${name}`, v.slice(0, len)); }
53
+ async remove(id, name) { this.files.delete(`${id}/${name}`); }
54
+ async removeSession(id) { for (const k of [...this.files.keys()]) if (k.startsWith(id + '/')) this.files.delete(k); }
55
+ async getGlobal(k) { return this.global.has(k) ? this.global.get(k) : null; }
56
+ async setGlobal(k, v) { if (v === null) this.global.delete(k); else this.global.set(k, v); }
57
+ }
58
+
59
+ /** A directory (Node): <root>/outbox/<id>/<file>, <root>/sharing.json for the contributor id. */
60
+ export async function dirStorage(root) {
61
+ const fs = await import('node:fs/promises');
62
+ const path = await import('node:path');
63
+ const box = path.join(root, 'outbox');
64
+ await fs.mkdir(box, { recursive: true });
65
+ const f = (id, name) => path.join(box, id, name);
66
+ const globalFile = path.join(root, 'sharing.json');
67
+ let tmpSeq = 0;
68
+ // Windows refuses a rename over a file another handle has open (a concurrent read, an antivirus scan)
69
+ // with EPERM/EACCES/EBUSY for a moment: retry briefly, as graceful-fs does
70
+ const rename = async (a, b) => {
71
+ for (let i = 0; ; i++) {
72
+ try { return await fs.rename(a, b); } catch (e) {
73
+ if (!['EPERM', 'EACCES', 'EBUSY'].includes(e.code) || i >= 20) throw e;
74
+ await new Promise((r) => setTimeout(r, 5 * (i + 1)));
75
+ }
76
+ }
77
+ };
78
+ const readGlobal = async () => { try { return JSON.parse(await fs.readFile(globalFile, 'utf8')); } catch (_) { return {}; } };
79
+ return {
80
+ root,
81
+ async sessions() { return (await fs.readdir(box).catch(() => [])).filter(isId); },
82
+ async list(id) { return fs.readdir(path.join(box, id)).catch(() => []); },
83
+ async read(id, name) { try { return new Uint8Array(await fs.readFile(f(id, name))); } catch (_) { return null; } },
84
+ async write(id, name, bytes) {
85
+ await fs.mkdir(path.join(box, id), { recursive: true });
86
+ // a unique temp name per write: two writes of the same file never share one (they raced before)
87
+ const tmp = `${f(id, name)}.${process.pid}.${++tmpSeq}.tmp`;
88
+ await fs.writeFile(tmp, bytes);
89
+ try { await rename(tmp, f(id, name)); } catch (e) {
90
+ await fs.rm(tmp, { force: true }).catch(() => {});
91
+ if (e.code !== 'ENOENT') throw e; // the session was removed meanwhile (sharing off, uploaded): nothing to keep
92
+ }
93
+ },
94
+ async append(id, name, bytes) { await fs.mkdir(path.join(box, id), { recursive: true }); await fs.appendFile(f(id, name), bytes); },
95
+ async truncate(id, name, len) { await fs.truncate(f(id, name), len).catch(() => {}); },
96
+ async remove(id, name) { await fs.rm(f(id, name), { force: true }); },
97
+ async removeSession(id) { await fs.rm(path.join(box, id), { recursive: true, force: true }); },
98
+ async getGlobal(k) { const g = await readGlobal(); return k in g ? g[k] : null; },
99
+ async setGlobal(k, v) {
100
+ const g = await readGlobal();
101
+ if (v === null) delete g[k]; else g[k] = v;
102
+ const tmp = `${globalFile}.${process.pid}.${++tmpSeq}.tmp`;
103
+ await fs.writeFile(tmp, JSON.stringify(g));
104
+ await rename(tmp, globalFile);
105
+ },
106
+ };
107
+ }
108
+
109
+ /**
110
+ * IndexedDB (browsers), per origin: database `quranlab-sharing`, store `files` keyed [id, name, part]
111
+ * (an append is one new record, so a page is never torn), store `global`.
112
+ */
113
+ export async function indexedDbStorage(dbName = 'quranlab-sharing', idb = globalThis.indexedDB) {
114
+ if (!idb) throw new Error('IndexedDB is not available');
115
+ const db = await new Promise((resolve, reject) => {
116
+ const r = idb.open(dbName, 1);
117
+ r.onupgradeneeded = () => { r.result.createObjectStore('files'); r.result.createObjectStore('global'); };
118
+ r.onsuccess = () => resolve(r.result);
119
+ r.onerror = () => reject(r.error);
120
+ });
121
+ const tx = (stores, mode, fn) => new Promise((resolve, reject) => {
122
+ const t = db.transaction(stores, mode);
123
+ let out;
124
+ Promise.resolve(fn(t)).then((v) => { out = v; });
125
+ t.oncomplete = () => resolve(out);
126
+ t.onerror = () => reject(t.error);
127
+ t.onabort = () => reject(t.error);
128
+ });
129
+ const req = (r) => new Promise((resolve, reject) => { r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
130
+ const range = (id, name) => (name === undefined
131
+ ? IDBKeyRange.bound([id], [id, []]) : IDBKeyRange.bound([id, name, 0], [id, name, Infinity]));
132
+ const keys = (id, name) => tx(['files'], 'readonly', (t) => req(t.objectStore('files').getAllKeys(range(id, name))));
133
+ return {
134
+ async sessions() {
135
+ const all = await tx(['files'], 'readonly', (t) => req(t.objectStore('files').getAllKeys()));
136
+ return [...new Set(all.map((k) => k[0]))].filter(isId);
137
+ },
138
+ async list(id) { return [...new Set((await keys(id)).map((k) => k[1]))]; },
139
+ async read(id, name) {
140
+ const parts = await tx(['files'], 'readonly', (t) => req(t.objectStore('files').getAll(range(id, name))));
141
+ if (!parts.length) return null;
142
+ const n = parts.reduce((k, p) => k + p.byteLength, 0);
143
+ const out = new Uint8Array(n);
144
+ let at = 0;
145
+ for (const p of parts) { out.set(new Uint8Array(p), at); at += p.byteLength; }
146
+ return out;
147
+ },
148
+ async write(id, name, bytes) {
149
+ await tx(['files'], 'readwrite', (t) => {
150
+ const s = t.objectStore('files');
151
+ s.delete(range(id, name));
152
+ s.put(Uint8Array.from(bytes).buffer, [id, name, 0]);
153
+ });
154
+ },
155
+ async append(id, name, bytes) {
156
+ await tx(['files'], 'readwrite', async (t) => {
157
+ const s = t.objectStore('files');
158
+ const ks = await req(s.getAllKeys(range(id, name)));
159
+ const part = ks.length ? ks[ks.length - 1][2] + 1 : 0;
160
+ s.put(Uint8Array.from(bytes).buffer, [id, name, part]);
161
+ });
162
+ },
163
+ async truncate(id, name, len) { const b = await this.read(id, name); if (b) await this.write(id, name, b.slice(0, len)); },
164
+ async remove(id, name) { await tx(['files'], 'readwrite', (t) => { t.objectStore('files').delete(range(id, name)); }); },
165
+ async removeSession(id) { await tx(['files'], 'readwrite', (t) => { t.objectStore('files').delete(range(id)); }); },
166
+ async getGlobal(k) { const v = await tx(['global'], 'readonly', (t) => req(t.objectStore('global').get(k))); return v === undefined ? null : v; },
167
+ async setGlobal(k, v) { await tx(['global'], 'readwrite', (t) => { if (v === null) t.objectStore('global').delete(k); else t.objectStore('global').put(v, k); }); },
168
+ };
169
+ }
170
+
171
+ /** The default storage: IndexedDB in a browser, `<cwd>/.quranlab` in Node, else memory. */
172
+ export async function defaultStorage() {
173
+ if (globalThis.indexedDB) return indexedDbStorage();
174
+ if (globalThis.process?.versions?.node) {
175
+ const path = await import('node:path');
176
+ return dirStorage(path.join(globalThis.process.cwd(), '.quranlab'));
177
+ }
178
+ return new MemoryStorage();
179
+ }
180
+
181
+ export const readJson = async (s, id, name) => { const b = await s.read(id, name); if (!b) return null; try { return JSON.parse(dec.decode(b)); } catch (_) { return null; } };
182
+ export const writeJson = (s, id, name, v) => s.write(id, name, enc.encode(JSON.stringify(v)));
183
+
184
+ /** The closed segments' markers, in order. */
185
+ export async function markers(s, id) {
186
+ const names = (await s.list(id)).filter((n) => /^seg-\d{5,}\.json$/.test(n));
187
+ const out = [];
188
+ for (const n of names) { const m = await readJson(s, id, n); if (m) out.push(m); }
189
+ return out.sort((a, b) => a.n - b.n);
190
+ }
191
+
192
+ /** Closes segment n: its marker, from the bytes as they are stored, with the bit rate it was coded at. */
193
+ export async function mark(s, id, n, granule, bitrate = null) {
194
+ const b = await s.read(id, segmentName(n) + '.opus');
195
+ if (!b) throw new Error(`no ${segmentName(n)}.opus`);
196
+ const m = { n, bytes: b.length, sha256: await sha256Hex(b), granule };
197
+ if (Number.isInteger(bitrate)) m.bitrate = bitrate;
198
+ await writeJson(s, id, segmentName(n) + '.json', m);
199
+ }
200
+
201
+ /** Seconds of audio per closed segment, from the markers' granule positions (segment 1 from 0). */
202
+ export function markerSeconds(ms) {
203
+ let prev = 0;
204
+ return ms.map((m) => { const sec = Math.max(0, m.granule - prev) / 48000; prev = m.granule; return sec; });
205
+ }
206
+
207
+ /** A session the page was closed in: open segments cut back to their whole pages and closed, the
208
+ * session ended as not completed. False when there is nothing to share (it is then removed). */
209
+ export async function recover(s, id) {
210
+ const cap = await readJson(s, id, FILES.capture);
211
+ if (!cap) { await s.removeSession(id); return false; }
212
+ const have = new Set((await markers(s, id)).map((m) => m.n));
213
+ const open = (await s.list(id)).filter((n) => /^seg-\d{5,}\.opus$/.test(n)).map((n) => Number(n.slice(4, -5)))
214
+ .filter((n) => !have.has(n)).sort((a, b) => a - b);
215
+ for (const n of open) {
216
+ const name = segmentName(n) + '.opus';
217
+ const d = (await s.read(id, name)) || new Uint8Array(0);
218
+ const { keep, granule } = wholePages(d);
219
+ if (keep === 0) { await s.remove(id, name); continue; }
220
+ if (keep < d.length) await s.truncate(id, name, keep);
221
+ // the bit rate the capture recorded when the segment opened
222
+ const bitrate = cap.bitrates && Number.isInteger(cap.bitrates[n]) ? cap.bitrates[n] : null;
223
+ await mark(s, id, n, granule, bitrate);
224
+ }
225
+ const ms = await markers(s, id);
226
+ if (!ms.length) { await s.removeSession(id); return false; }
227
+ const last = ms[ms.length - 1];
228
+ await writeJson(s, id, FILES.end, { durationMs: Math.trunc(Math.max(0, last.granule - cap.preSkip) / 48), completed: false });
229
+ return true;
230
+ }
231
+
232
+ /** Session ids being captured in this page or process (never taken for crashed). */
233
+ export const activeCaptures = new Set();
234
+
235
+ /**
236
+ * The outbox.
237
+ * @param {object} o
238
+ * @param {object} o.storage
239
+ * @param {import('./uploader.js').ContributionClient} o.client
240
+ * @param {() => Promise<{token: string, attested?: boolean}>} o.tokenProvider
241
+ * @param {() => object|null} [o.flags] the signed config's flags (contributions, upload_trust, capped_limits)
242
+ * @param {() => boolean} [o.enabled] sharing is on (the app's option and the learner's consent)
243
+ * @param {() => Promise<string|null>} [o.turnstile] web, capped tier with Turnstile on: a fresh token
244
+ * @param {string} [o.platform] 'web' (upload_trust key)
245
+ * @param {() => Promise<string>} o.contributor
246
+ * @param {() => number} [o.clock] ms
247
+ * @param {(item: object) => void} [o.onChange]
248
+ * @param {import('./bitrate.js').BitrateLadder} [o.ladder] told about network failures, successful PUTs and the backlog
249
+ */
250
+ export class Outbox {
251
+ constructor({ storage, client, tokenProvider, flags = () => null, enabled = () => true, turnstile = null, platform = 'web',
252
+ contributor, clock = () => Date.now(), onChange = null, onError = null, random = Math.random, ladder = null }) {
253
+ Object.assign(this, { storage, client, tokenProvider, flags, enabled, turnstile, platform, contributor, clock, onChange, onError, random, ladder });
254
+ // closed segments not yet uploaded, per session: id -> Map(n -> seconds of audio); kept in memory so a
255
+ // capture can read the backlog at the moment it closes a segment (the markers are written after)
256
+ this.waiting = new Map();
257
+ this.records = new Map();
258
+ this.inFlight = new Map(); // "<id>/<file>" -> AbortController
259
+ this.running = null;
260
+ this.again = false;
261
+ this.stopped = false;
262
+ this.wakeTimer = null;
263
+ this.seqTop = 0;
264
+ }
265
+
266
+ // ---- public ----
267
+
268
+ /** Every session, oldest first: { id, segments, bytes, durationMs, segmentBitrates, day, completed, status, failCode, attempts, lastError }. */
269
+ async items() {
270
+ await this._scan();
271
+ const out = [];
272
+ for (const r of [...this.records.values()].sort((a, b) => a.seq - b.seq)) out.push(await this._snapshot(r));
273
+ return out;
274
+ }
275
+
276
+ async item(id) { await this._scan(); const r = this.records.get(id); return r ? this._snapshot(r) : null; }
277
+
278
+ /** Sessions not yet fully uploaded. */
279
+ async pending() { return (await this.items()).filter((x) => x.status === 'queued'); }
280
+
281
+ /** Seconds of audio in closed segments not yet uploaded, every session. */
282
+ backlogSeconds() {
283
+ let t = 0;
284
+ for (const w of this.waiting.values()) for (const sec of w.values()) t += sec;
285
+ return t;
286
+ }
287
+
288
+ /** A capture closed segment n of session id (seconds of audio): counted in the backlog at once, and the
289
+ * bitrate ladder told (a long backlog lowers the next segment's bitrate). */
290
+ segmentClosed(id, n, seconds) {
291
+ if (!this.waiting.has(id)) this.waiting.set(id, new Map());
292
+ this.waiting.get(id).set(n, seconds);
293
+ if (this.ladder) this.ladder.segmentClosed(this.backlogSeconds());
294
+ }
295
+
296
+ _networkResult(status) { if (this.ladder && isNetworkFailure(status)) this.ladder.uploadFailed(); }
297
+
298
+ // the backlog of a session first seen in storage (a page closed earlier, or a session found by a scan)
299
+ async _countWaiting(rec) {
300
+ if (rec.state !== 'active') { this.waiting.delete(rec.id); return; }
301
+ const ms = await markers(this.storage, rec.id);
302
+ const secs = markerSeconds(ms);
303
+ const w = this.waiting.get(rec.id) || new Map();
304
+ ms.forEach((m, i) => {
305
+ if ((rec.uploaded || []).includes(segmentName(m.n) + '.opus')) w.delete(m.n);
306
+ else if (!w.has(m.n)) w.set(m.n, secs[i]);
307
+ });
308
+ this.waiting.set(rec.id, w);
309
+ }
310
+
311
+ /** Starts an upload pass (also at load, when a segment closes and when a retry is due). */
312
+ resume() { this.stopped = false; return this.drain(); }
313
+
314
+ /** Sharing was turned off: in-flight PUTs aborted, every session not fully uploaded deleted at
315
+ * once (a session being captured is abandoned by its capture). */
316
+ async stop() {
317
+ this.stopped = true;
318
+ for (const c of this.inFlight.values()) c.abort();
319
+ this.inFlight.clear();
320
+ if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
321
+ if (this.running) await this.running.catch(() => {});
322
+ await this._scan();
323
+ for (const r of [...this.records.values()]) {
324
+ if (r.state === 'active' && !activeCaptures.has(r.id)) { await this.storage.removeSession(r.id); this.records.delete(r.id); }
325
+ }
326
+ this.waiting.clear();
327
+ }
328
+
329
+ /** Takes a session out before the server has it. */
330
+ async cancel(id) {
331
+ await this._scan();
332
+ const r = this.records.get(id);
333
+ if (!r) throw new Error(`no outbox item ${id}`);
334
+ if (r.state !== 'active' || r.started || activeCaptures.has(id)) throw new Error(`item ${id} is already uploading`);
335
+ await this.storage.removeSession(id);
336
+ this.records.delete(id);
337
+ this.waiting.delete(id);
338
+ }
339
+
340
+ /** One upload pass, after any pass already running. */
341
+ async drain({ ignoringBackoff = false } = {}) {
342
+ if (this.running) {
343
+ this.again = true;
344
+ while (this.running) await this.running.catch(() => {});
345
+ if (!this.again) return;
346
+ }
347
+ this.again = false;
348
+ this.running = (async () => {
349
+ try { await this._pass(ignoringBackoff); } finally { this._scheduleWake(); }
350
+ })();
351
+ try { await this.running; } finally { this.running = null; }
352
+ if (this.again) await this.drain({ ignoringBackoff });
353
+ }
354
+
355
+ /** Whether this install may upload now: the app/learner switch, the config's contributions flag and
356
+ * this platform's upload trust (web: `capped` only; PLATFORM_CLIENT 9). */
357
+ uploadAllowed() {
358
+ if (this.stopped || !this.enabled()) return { ok: false, why: 'sharing is off' };
359
+ const f = this.flags() || {};
360
+ if (f.contributions === false) return { ok: false, why: 'sharing is turned off for this app' };
361
+ const trust = f.upload_trust && f.upload_trust[this.platform];
362
+ // owner decision (11-07): web and desktop upload at the lower trust tier (`capped`: per-key and
363
+ // per-contributor caps, sessions marked attested=false); an absent entry means that tier for them
364
+ const tier = trust || (['ios', 'macos', 'android'].includes(this.platform) ? 'attested' : 'capped');
365
+ if (tier === 'off') return { ok: false, why: `uploads are off for ${this.platform} (upload_trust)` };
366
+ if (tier === 'attested' && !['ios', 'macos', 'android'].includes(this.platform)) {
367
+ return { ok: false, why: `${this.platform} has no attestation and the config asks for it (upload_trust attested)` };
368
+ }
369
+ return { ok: true, tier };
370
+ }
371
+
372
+ // ---- the pass ----
373
+
374
+ async _pass(ignoringBackoff) {
375
+ await this._scan();
376
+ await this._expire();
377
+ const allowed = this.uploadAllowed();
378
+ const now = this.clock();
379
+ const due = [...this.records.values()].filter((r) => r.state === 'active' && (ignoringBackoff || !r.nextAttempt || r.nextAttempt <= now))
380
+ .sort((a, b) => a.seq - b.seq).map((r) => r.id);
381
+ if (!due.length) return;
382
+ if (!allowed.ok) { for (const id of due) await this._note(id, `${allowed.why}; waiting`); return; }
383
+ for (const id of due) {
384
+ if (this.stopped) return;
385
+ let token;
386
+ try { token = await this.tokenProvider(); } catch (e) {
387
+ await this._retry(id, `token: ${e && e.message || e}`);
388
+ return;
389
+ }
390
+ if (!token || !token.token) { await this._retry(id, 'no token'); return; }
391
+ if (allowed.tier === 'attested' && !token.attested) {
392
+ await this._retry(id, 'attestation_required: this install has no attested token; sharing paused', { long: true });
393
+ continue;
394
+ }
395
+ if (!(await this._step(id, token.token))) return;
396
+ }
397
+ }
398
+
399
+ // Moves one session on as far as it goes now. False when the pass should stop (offline).
400
+ async _step(id, token) {
401
+ const s = this.storage;
402
+ const ms = await markers(s, id);
403
+ const end = await readJson(s, id, FILES.end);
404
+ let rec = this.records.get(id);
405
+ if (!rec) return true;
406
+ if (!rec.started) {
407
+ if (!ms.length) return true;
408
+ const session = await s.read(id, FILES.session);
409
+ if (!session) return true;
410
+ rec.attempts++;
411
+ await this._save(rec);
412
+ const turnstile = this.turnstile && (this.flags() || {}).capped_limits?.turnstile_web ? await this.turnstile() : null;
413
+ const r = await this.client.start({ id, session: dec.decode(session), token, turnstile });
414
+ const urls = await this._answer(id, r);
415
+ if (!urls) return r.status !== 0;
416
+ rec = this.records.get(id);
417
+ rec.started = true;
418
+ rec.urls = merge(rec.urls, urls);
419
+ await this._save(rec);
420
+ }
421
+ // every closed segment, in order, one PUT at a time (PARALLEL)
422
+ const todo = ms.filter((m) => !rec.uploaded.includes(segmentName(m.n) + '.opus'));
423
+ for (let i = 0; i < todo.length; i += PARALLEL) {
424
+ const group = [];
425
+ for (const m of todo.slice(i, i + PARALLEL)) {
426
+ rec = this.records.get(id);
427
+ if (!rec || rec.state !== 'active') return true;
428
+ if (!(rec.urls || []).some((u) => u.n === m.n && freshUrl(u, this.clock()))) {
429
+ const ok = await this._fetchUrls(id, m.n, token);
430
+ if (!ok.ok) return !ok.offline;
431
+ rec = this.records.get(id);
432
+ if (!rec || rec.state !== 'active') return true;
433
+ }
434
+ const u = (rec.urls || []).find((x) => x.n === m.n);
435
+ if (!u) return true;
436
+ group.push(this._put(id, m, u));
437
+ }
438
+ const results = await Promise.all(group);
439
+ if (results.includes('offline')) return false;
440
+ // refused (a retry is scheduled, at once for a 403): this pass stops here for the session
441
+ if (results.includes('stop') || results.includes('retry')) return true;
442
+ }
443
+ rec = this.records.get(id);
444
+ if (!rec || rec.state !== 'active') return true;
445
+ // while the session runs: the next batch once 5 unused URLs remain
446
+ const lastUrl = Math.max(0, ...(rec.urls || []).map((u) => u.n));
447
+ if (!end && lastUrl > 0 && lastUrl - (ms.length ? ms[ms.length - 1].n : 0) <= 5) {
448
+ const ok = await this._fetchUrls(id, lastUrl + 1, token);
449
+ if (!ok.ok) return !ok.offline;
450
+ rec = this.records.get(id);
451
+ }
452
+ if (!end || !rec.started || !ms.length || !ms.every((m) => rec.uploaded.includes(segmentName(m.n) + '.opus'))) return true;
453
+ rec.state = 'uploaded';
454
+ this.waiting.delete(id);
455
+ rec.lastError = null;
456
+ rec.nextAttempt = null;
457
+ rec.urls = null;
458
+ await this._save(rec);
459
+ for (const name of await s.list(id)) {
460
+ if (name !== FILES.item && name !== FILES.end && name !== FILES.session && !/^seg-\d{5,}\.json$/.test(name)) await s.remove(id, name);
461
+ }
462
+ return true;
463
+ }
464
+
465
+ // one PUT: 'ok', 'retry' (refused; retry scheduled), 'offline', 'stop'
466
+ async _put(id, m, u) {
467
+ const name = segmentName(m.n) + '.opus';
468
+ const key = `${id}/${name}`;
469
+ const bytes = await this.storage.read(id, name);
470
+ if (!bytes) return 'stop';
471
+ const ctrl = new AbortController();
472
+ this.inFlight.set(key, ctrl);
473
+ let r;
474
+ try { r = await this.client.put({ url: u.url, bytes, signal: ctrl.signal }); } catch (e) {
475
+ return 'stop'; // aborted: sharing went off
476
+ } finally { this.inFlight.delete(key); }
477
+ const rec = this.records.get(id);
478
+ if (!rec || rec.state !== 'active' || this.stopped) return 'stop';
479
+ this._networkResult(r.status);
480
+ if (r.status === 0) { await this._retry(id, `PUT ${name}: ${r.error}`); return 'offline'; }
481
+ if (r.status >= 200 && r.status < 300) {
482
+ if (!rec.uploaded.includes(name)) rec.uploaded.push(name);
483
+ const w = this.waiting.get(id);
484
+ if (w) w.delete(m.n);
485
+ if (this.ladder) this.ladder.uploadOk(this.backlogSeconds());
486
+ rec.urls = (rec.urls || []).filter((x) => x.n !== m.n);
487
+ rec.lastError = null;
488
+ await this._save(rec);
489
+ await this.storage.remove(id, name);
490
+ return 'ok';
491
+ }
492
+ rec.attempts++;
493
+ if (r.status === 403) rec.urls = (rec.urls || []).filter((x) => x.n !== m.n);
494
+ await this._save(rec);
495
+ await this._retry(id, `PUT ${name}: HTTP ${r.status}`, { immediately: r.status === 403 && rec.attempts < 5 });
496
+ return 'retry';
497
+ }
498
+
499
+ // URLs for segments from..from+19, merged into the item
500
+ async _fetchUrls(id, from, token) {
501
+ const contributor = await this.contributor();
502
+ const r = await this.client.urls({ id, from, contributor, token });
503
+ const urls = await this._answer(id, r);
504
+ const rec = this.records.get(id);
505
+ if (!urls) {
506
+ if (rec && rec.state === 'active') { rec.attempts++; await this._save(rec); }
507
+ return { ok: false, offline: r.status === 0 };
508
+ }
509
+ if (!rec) return { ok: false, offline: false };
510
+ rec.urls = merge(rec.urls, urls);
511
+ await this._save(rec);
512
+ return { ok: true };
513
+ }
514
+
515
+ // A Worker answer: the URLs on 2xx; otherwise the item is retried, paused, capped or failed
516
+ // (PLATFORM_CLIENT 8) and null comes back.
517
+ async _answer(id, r) {
518
+ this._networkResult(r.status);
519
+ if (r.status === 0) { await this._retry(id, r.error || 'offline'); return null; }
520
+ if (r.status >= 200 && r.status < 300) {
521
+ const u = parseUrls(r.body, id);
522
+ if (u) return u;
523
+ await this._retry(id, 'no upload URLs in the answer');
524
+ return null;
525
+ }
526
+ const code = (r.body && r.body.error) || `http_${r.status}`;
527
+ const message = (r.body && r.body.message) || '';
528
+ if (code === 'contributor_revoked' || code === 'contributor_key_mismatch') {
529
+ // the contributor id this session was recorded under can never upload again: the app's
530
+ // controller makes a new contributor, key and recovery code (SharingError.code)
531
+ await this._fail(id, code, message);
532
+ if (this.onError) this.onError(new SharingError(code, r.status, message));
533
+ return null;
534
+ }
535
+ if (code === 'volume_limit' || code === 'capped_limit') {
536
+ // retry after the next UTC midnight
537
+ const rec = this.records.get(id);
538
+ if (rec) {
539
+ const now = this.clock();
540
+ rec.lastError = `HTTP ${r.status} ${code}: retry tomorrow`;
541
+ rec.nextAttempt = (Math.floor(now / DAY_MS) + 1) * DAY_MS + Math.floor(this.random() * 3600000);
542
+ await this._save(rec);
543
+ }
544
+ if (this.onError) this.onError(new SharingError(code, r.status, message));
545
+ return null;
546
+ }
547
+ if (r.status === 401) await this._retry(id, `HTTP 401 ${code}`, { immediately: (this.records.get(id)?.attempts || 0) < 3, invalidate: true });
548
+ else if (r.status >= 500 || [408, 425, 429].includes(r.status)) await this._retry(id, `HTTP ${r.status} ${code}`);
549
+ else if (code === 'contributions_disabled' || code === 'attestation_required') await this._retry(id, `HTTP ${r.status} ${code}: paused`, { long: true });
550
+ else if (code === 'turnstile_required' || code === 'turnstile_failed') await this._retry(id, `HTTP ${r.status} ${code}`, { immediately: (this.records.get(id)?.attempts || 0) < 2 });
551
+ else if (code === 'segment_cap') await this._capped(id);
552
+ else if (r.status === 404 || r.status === 410) await this._drop(id, code);
553
+ else await this._fail(id, code, message);
554
+ if (r.status === 401 && this.tokenProvider.invalidate) await this.tokenProvider.invalidate();
555
+ return null;
556
+ }
557
+
558
+ // ---- states ----
559
+
560
+ async _retry(id, why, { immediately = false, long = false } = {}) {
561
+ const rec = this.records.get(id);
562
+ if (!rec) return;
563
+ rec.lastError = why;
564
+ const n = Math.max(1, rec.attempts);
565
+ const base = long ? 6 * 3600 : Math.min(30 * 2 ** Math.min(n - 1, 16), 6 * 3600);
566
+ const delay = immediately ? 0 : base * (0.8 + 0.4 * this.random());
567
+ rec.nextAttempt = this.clock() + delay * 1000;
568
+ await this._save(rec);
569
+ }
570
+
571
+ async _fail(id, code, message) {
572
+ const rec = this.records.get(id);
573
+ if (!rec) return;
574
+ Object.assign(rec, { state: 'failed', failCode: code, failMessage: message, nextAttempt: null, urls: null });
575
+ this.waiting.delete(id);
576
+ await this._save(rec);
577
+ for (const name of await this.storage.list(id)) if (name !== FILES.item) await this.storage.remove(id, name);
578
+ }
579
+
580
+ // the capped tier's per-session limit was reached: what went up stays valid; nothing more is sent
581
+ async _capped(id) {
582
+ const rec = this.records.get(id);
583
+ if (!rec) return;
584
+ Object.assign(rec, { state: 'uploaded', capped: true, nextAttempt: null, urls: null, lastError: 'segment_cap' });
585
+ this.waiting.delete(id);
586
+ await this._save(rec);
587
+ for (const name of await this.storage.list(id)) if (/\.opus$/.test(name)) await this.storage.remove(id, name);
588
+ }
589
+
590
+ // the server does not know it (404) or deleted it (410): drop it locally
591
+ async _drop(id, code) {
592
+ await this.storage.removeSession(id);
593
+ this.records.delete(id);
594
+ this.waiting.delete(id);
595
+ if (this.onChange) this.onChange({ id, status: 'dropped', failCode: code });
596
+ }
597
+
598
+ async _note(id, why) {
599
+ const rec = this.records.get(id);
600
+ if (!rec || rec.lastError === why) return;
601
+ rec.lastError = why;
602
+ await this._save(rec);
603
+ }
604
+
605
+ async _expire() {
606
+ const now = this.clock();
607
+ for (const r of [...this.records.values()]) {
608
+ if (r.state === 'active' && !activeCaptures.has(r.id) && now - (r.createdAt || now) > MAX_AGE_MS) await this._drop(r.id, 'expired');
609
+ }
610
+ }
611
+
612
+ _scheduleWake() {
613
+ if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
614
+ if (this.stopped) return;
615
+ const next = Math.min(...[...this.records.values()].filter((r) => r.state === 'active' && r.nextAttempt).map((r) => r.nextAttempt));
616
+ if (!Number.isFinite(next)) return;
617
+ const delay = Math.min(Math.max(1000, next - this.clock()), 2 ** 31 - 1);
618
+ this.wakeTimer = setTimeout(() => { this.wakeTimer = null; this.drain().catch(() => {}); }, delay);
619
+ if (this.wakeTimer.unref) this.wakeTimer.unref();
620
+ }
621
+
622
+ // ---- storage ----
623
+
624
+ // Picks up sessions the captures made and recovers the ones a closed page left.
625
+ async _scan() {
626
+ const s = this.storage;
627
+ for (const id of await s.sessions()) {
628
+ if (this.records.has(id)) continue;
629
+ const rec = await readJson(s, id, FILES.item);
630
+ if (rec && rec.id === id) { this.records.set(id, rec); this.seqTop = Math.max(this.seqTop, rec.seq); await this._countWaiting(rec); continue; }
631
+ const capturing = activeCaptures.has(id);
632
+ if (capturing && !(await markers(s, id)).length) continue;
633
+ if (!capturing && !(await readJson(s, id, FILES.end)) && !(await recover(s, id))) continue;
634
+ const fresh = { id, seq: ++this.seqTop, state: 'active', started: false, urls: null, uploaded: [], failCode: null,
635
+ failMessage: null, attempts: 0, lastError: null, nextAttempt: null, createdAt: this.clock() };
636
+ await this._save(fresh);
637
+ await this._countWaiting(fresh);
638
+ }
639
+ for (const r of [...this.records.values()]) {
640
+ if (r.state !== 'active' || activeCaptures.has(r.id)) continue;
641
+ if (!(await readJson(s, r.id, FILES.end)) && !(await recover(s, r.id))) { this.records.delete(r.id); this.waiting.delete(r.id); }
642
+ }
643
+ }
644
+
645
+ async _save(rec) {
646
+ this.records.set(rec.id, rec);
647
+ await writeJson(this.storage, rec.id, FILES.item, rec);
648
+ if (this.onChange) this.onChange(await this._snapshot(rec));
649
+ }
650
+
651
+ async _snapshot(r) {
652
+ const ms = await markers(this.storage, r.id);
653
+ const end = await readJson(this.storage, r.id, FILES.end);
654
+ const ses = await readJson(this.storage, r.id, FILES.session);
655
+ return {
656
+ id: r.id, segments: ms.length, bytes: ms.reduce((k, m) => k + m.bytes, 0), durationMs: end ? end.durationMs : 0,
657
+ // the bit rate each segment was coded at, in segment order (null for a segment recorded before 2.0.1)
658
+ segmentBitrates: ms.map((m) => (Number.isInteger(m.bitrate) ? m.bitrate : null)),
659
+ day: ses ? ses.day : '', completed: end ? end.completed : null,
660
+ status: r.state === 'active' ? 'queued' : r.state, capped: !!r.capped, failCode: r.failCode || null,
661
+ failMessage: r.failMessage || null, attempts: r.attempts, lastError: r.lastError,
662
+ };
663
+ }
664
+ }
665
+
666
+ function merge(old, add) {
667
+ const by = new Map((old || []).map((u) => [u.n, u]));
668
+ for (const u of add) by.set(u.n, u);
669
+ return [...by.values()].sort((a, b) => a.n - b.n);
670
+ }