@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.
- package/CHANGELOG.md +27 -0
- package/dist/tajweedformer-opus.mjs +1 -1
- package/dist/tajweedformer-opus.wasm +0 -0
- package/docs/api.md +2 -2
- package/package.json +1 -1
- package/web/consent/copy.js +17 -9
- package/web/consent/decks.js +144 -56
- package/web/consent/element.js +21 -15
- package/web/consent/index.d.ts +76 -13
- package/web/consent/index.js +103 -18
- package/web/consent/state.js +91 -29
- package/web/consent/theme.js +31 -0
- package/web/consent/wording.js +126 -0
- package/web/index.d.ts +1 -1
- package/web/platform/client.js +484 -366
- package/web/platform/index.d.ts +39 -5
- package/web/platform/index.js +3 -2
- package/web/platform/telemetry.js +128 -13
- package/web/platform/update-notice.js +60 -0
- package/web/sharing/bitrate.js +69 -0
- package/web/sharing/encoder.js +26 -5
- package/web/sharing/index.d.ts +53 -6
- package/web/sharing/index.js +287 -257
- package/web/sharing/oggopus.js +325 -294
- package/web/sharing/outbox.js +670 -606
- package/web/sharing/uploader.js +10 -3
- package/web/tajweed/index.d.ts +2 -2
- package/web/tajweed/manifest.js +1 -1
package/web/sharing/outbox.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
await
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
*/
|
|
233
|
-
export
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
await this.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
async
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
rec
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (
|
|
450
|
-
|
|
451
|
-
}
|
|
452
|
-
if (!rec)
|
|
453
|
-
rec.
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
const
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
return
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
if (
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
for (const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
+
}
|