@torrent-tv/proxy 2.9.53 → 2.9.55
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 +10 -0
- package/bin/cli.js +8 -1
- package/package.json +1 -1
- package/routes/transcode/session-file/get.js +6 -1
- package/server.js +3 -1
- package/services/hls-session-manager.js +2374 -2312
- package/services/segment-formats/fmp4.js +82 -0
- package/services/segment-formats/index.js +77 -0
- package/services/segment-formats/mp4-boxes.js +160 -0
- package/services/segment-formats/mpegts.js +60 -0
|
@@ -1,2312 +1,2374 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file HLS transcode session manager.
|
|
3
|
-
*
|
|
4
|
-
* Spawns one ffmpeg process per unique source+settings combination and
|
|
5
|
-
* streams the resulting HLS playlist and segments from a temporary directory.
|
|
6
|
-
* Sessions are expired automatically via a periodic cleanup interval, or
|
|
7
|
-
* immediately when all registered consumers release them.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { createReadStream } from "node:fs";
|
|
11
|
-
import { access, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
12
|
-
import { Readable } from "node:stream";
|
|
13
|
-
import os from "node:os";
|
|
14
|
-
import path from "node:path";
|
|
15
|
-
import { randomUUID } from "node:crypto";
|
|
16
|
-
import { spawn } from "node:child_process";
|
|
17
|
-
import { logger } from "../utils/logger.js";
|
|
18
|
-
import {
|
|
19
|
-
softwareDescriptor,
|
|
20
|
-
chooseSoftwareEncodeSettings,
|
|
21
|
-
pickSoftwarePreset,
|
|
22
|
-
TRANSCODE_FPS,
|
|
23
|
-
chooseOutputFps
|
|
24
|
-
} from "./hwaccel.js";
|
|
25
|
-
import {
|
|
26
|
-
parseFfmpegDurationSeconds,
|
|
27
|
-
parseFfmpegStartTimeSeconds,
|
|
28
|
-
parseFfmpegVideoDimensions,
|
|
29
|
-
parseFfmpegVideoFps,
|
|
30
|
-
parseFfmpegHdr
|
|
31
|
-
} from "./ffmpeg-banner.js";
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
const
|
|
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
|
-
// the
|
|
74
|
-
const
|
|
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
|
-
// below
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
//
|
|
111
|
-
const
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
const
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
const
|
|
267
|
-
if (
|
|
268
|
-
return
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
*
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
let
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
function
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
* @
|
|
633
|
-
* @property {
|
|
634
|
-
* @property {
|
|
635
|
-
* @property {
|
|
636
|
-
* @property {
|
|
637
|
-
* @property {
|
|
638
|
-
* @property {
|
|
639
|
-
* @property {number}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* @property {
|
|
646
|
-
*
|
|
647
|
-
* @property {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
* @
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
this.
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
this.
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
this.
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
this.
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
this.
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
* @param {
|
|
738
|
-
* @
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
//
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
//
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
|
|
874
|
-
//
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
//
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
//
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
//
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
//
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
//
|
|
1041
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
//
|
|
1046
|
-
|
|
1047
|
-
//
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
`
|
|
1081
|
-
`
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
|
|
1085
|
-
`
|
|
1086
|
-
|
|
1087
|
-
//
|
|
1088
|
-
|
|
1089
|
-
`${
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
"#
|
|
1137
|
-
|
|
1138
|
-
"#EXT-X-
|
|
1139
|
-
`#EXT-X-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
*
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
*
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
*
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1208
|
-
*
|
|
1209
|
-
*
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
1245
|
-
*
|
|
1246
|
-
*
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
*
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
*
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
*
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
if (
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
continue;
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
const
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
const
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
session.
|
|
1478
|
-
session.
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
const
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
*
|
|
1504
|
-
*
|
|
1505
|
-
*
|
|
1506
|
-
*
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1510
|
-
*
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
*
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
1523
|
-
*
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
//
|
|
1561
|
-
//
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
//
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
//
|
|
1601
|
-
//
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
//
|
|
1606
|
-
//
|
|
1607
|
-
//
|
|
1608
|
-
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
1612
|
-
//
|
|
1613
|
-
//
|
|
1614
|
-
//
|
|
1615
|
-
//
|
|
1616
|
-
//
|
|
1617
|
-
//
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
//
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
//
|
|
1652
|
-
//
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
"-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
"
|
|
1672
|
-
"
|
|
1673
|
-
"
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
"
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
//
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
"
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
session.
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
session
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
*
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
*
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1731
|
-
*
|
|
1732
|
-
*
|
|
1733
|
-
*
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
1736
|
-
*
|
|
1737
|
-
*
|
|
1738
|
-
*
|
|
1739
|
-
*
|
|
1740
|
-
*
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
1743
|
-
*
|
|
1744
|
-
*
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
*
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
if (
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
session.progress.
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
session.progress.
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
session.
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
if (
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
session.
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
if (
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
session.
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
//
|
|
1917
|
-
//
|
|
1918
|
-
//
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
const
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
//
|
|
1938
|
-
//
|
|
1939
|
-
//
|
|
1940
|
-
//
|
|
1941
|
-
|
|
1942
|
-
if (session.
|
|
1943
|
-
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
session.
|
|
1950
|
-
session.seekSettleTimer
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
if (
|
|
1974
|
-
session.seekTarget = null;
|
|
1975
|
-
session.seekFirstFarAt = 0;
|
|
1976
|
-
return;
|
|
1977
|
-
}
|
|
1978
|
-
//
|
|
1979
|
-
//
|
|
1980
|
-
//
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
session.
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
*
|
|
2047
|
-
*
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
if (session
|
|
2058
|
-
return
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
}
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
const
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
//
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
if (!(
|
|
2238
|
-
|
|
2239
|
-
}
|
|
2240
|
-
session.
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
);
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
session.
|
|
2278
|
-
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
*
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
}
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file HLS transcode session manager.
|
|
3
|
+
*
|
|
4
|
+
* Spawns one ffmpeg process per unique source+settings combination and
|
|
5
|
+
* streams the resulting HLS playlist and segments from a temporary directory.
|
|
6
|
+
* Sessions are expired automatically via a periodic cleanup interval, or
|
|
7
|
+
* immediately when all registered consumers release them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createReadStream } from "node:fs";
|
|
11
|
+
import { access, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
12
|
+
import { Readable } from "node:stream";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import { logger } from "../utils/logger.js";
|
|
18
|
+
import {
|
|
19
|
+
softwareDescriptor,
|
|
20
|
+
chooseSoftwareEncodeSettings,
|
|
21
|
+
pickSoftwarePreset,
|
|
22
|
+
TRANSCODE_FPS,
|
|
23
|
+
chooseOutputFps
|
|
24
|
+
} from "./hwaccel.js";
|
|
25
|
+
import {
|
|
26
|
+
parseFfmpegDurationSeconds,
|
|
27
|
+
parseFfmpegStartTimeSeconds,
|
|
28
|
+
parseFfmpegVideoDimensions,
|
|
29
|
+
parseFfmpegVideoFps,
|
|
30
|
+
parseFfmpegHdr
|
|
31
|
+
} from "./ffmpeg-banner.js";
|
|
32
|
+
import { resolveSegmentFormat } from "./segment-formats/index.js";
|
|
33
|
+
|
|
34
|
+
const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
35
|
+
const CLEANUP_INTERVAL_MS = 30_000;
|
|
36
|
+
const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
37
|
+
// How many segments ahead of the current encode head a missing-segment request
|
|
38
|
+
// is allowed to be before we restart ffmpeg at that position (server-side seek).
|
|
39
|
+
// Requests within the window are served by waiting for the running encode.
|
|
40
|
+
const MAX_LOOKAHEAD_SEGMENTS = 8;
|
|
41
|
+
// After a seek-restart, ignore competing restart requests for this long. The
|
|
42
|
+
// synthetic VOD playlist lets the player request distant segments in quick
|
|
43
|
+
// succession (stall-recovery seeks); without a cooldown ffmpeg ping-pongs
|
|
44
|
+
// between positions, restarting endlessly and producing nothing.
|
|
45
|
+
const RESTART_COOLDOWN_MS = 4_000;
|
|
46
|
+
// Encoder stall watchdog. A running ffmpeg emits `-progress` output on stdout
|
|
47
|
+
// continuously while it encodes; when it hangs mid-file (alive, but producing
|
|
48
|
+
// no output and no stderr — a deadlock, e.g. a stalled input read), that output
|
|
49
|
+
// stops and `progress.updatedAt` freezes. If a segment INSIDE the look-ahead
|
|
50
|
+
// window is being demanded but progress has not advanced for this long, the
|
|
51
|
+
// encoder is wedged (observed: the segment 503s forever). Treat it like a seek
|
|
52
|
+
// and restart ffmpeg at the demanded segment. Conservative — a slow-but-moving
|
|
53
|
+
// encode keeps advancing `updatedAt`, so this only fires on a true freeze.
|
|
54
|
+
const ENCODER_STALL_MS = 12_000;
|
|
55
|
+
// Seek debounce. A far (out-of-window) segment request is a server-side seek.
|
|
56
|
+
// Rather than restart ffmpeg on the first one, wait a short quiet period:
|
|
57
|
+
// further far requests re-arm it and update the target to the latest index, so
|
|
58
|
+
// a scrub that emits a burst of scattered requests (e.g. iOS native HLS firing
|
|
59
|
+
// 367,732,369,368,370 seconds apart) collapses to ONE restart at the position
|
|
60
|
+
// the player ended on, instead of ping-ponging ffmpeg between positions and
|
|
61
|
+
// producing nothing.
|
|
62
|
+
const SEEK_SETTLE_MS = 1_200;
|
|
63
|
+
// Hard cap on the total settle wait, measured from the first far request of a
|
|
64
|
+
// burst, so a still-moving scrubber cannot delay a genuine seek forever.
|
|
65
|
+
const SEEK_SETTLE_MAX_MS = 2_500;
|
|
66
|
+
// Grace period to wait for the PREVIOUS ffmpeg process to exit (per signal
|
|
67
|
+
// escalation step: SIGTERM, then SIGKILL) before spawning its replacement into
|
|
68
|
+
// the same session directory. See #startEncodeRun.
|
|
69
|
+
const ENCODE_RUN_TERMINATE_GRACE_MS = 2_000;
|
|
70
|
+
// A seek-restart run that exits this fast never did real work — it failed at
|
|
71
|
+
// the seek/open step itself (container demux error, bad audio frame boundary,
|
|
72
|
+
// etc.), not mid-stream. Used to tell a genuine seek failure apart from a
|
|
73
|
+
// later, unrelated crash so the circuit breaker below only counts the former.
|
|
74
|
+
const SEEK_FAST_FAIL_MS = 2_000;
|
|
75
|
+
// Circuit breaker: consecutive fast failures AT THE SAME target before we stop
|
|
76
|
+
// auto-retrying and leave the session in its terminal "failed" state (surfaced
|
|
77
|
+
// to the client as a clean, retryable error) instead of looping forever. The
|
|
78
|
+
// keyframe-snap seek (see #startEncodeRun) already fixes the dominant failure
|
|
79
|
+
// mode (an unreliable container-computed seek position); this is a safety net
|
|
80
|
+
// for whatever residual case still fails — not a second competing "fix" that
|
|
81
|
+
// blindly retries the identical command hoping for a different result.
|
|
82
|
+
const MAX_SEEK_FAILURES = 3;
|
|
83
|
+
// Idle TTL: a session is disposed this long after the last segment/playlist
|
|
84
|
+
// access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
|
|
85
|
+
// turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
|
|
86
|
+
// also backs the seamless auto-reconnect). ffmpeg stops producing at the
|
|
87
|
+
// look-ahead cap when idle, so a lingering session costs retained segments on
|
|
88
|
+
// disk, not sustained CPU. Active playback refreshes the timer on every segment
|
|
89
|
+
// fetch, so it never expires mid-watch.
|
|
90
|
+
const DEFAULT_SESSION_TTL_MS = 10 * 60 * 1000;
|
|
91
|
+
const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
92
|
+
// Realtime budget — runtime downswitch (software encoder only). Periodically
|
|
93
|
+
// check each active software-transcode session's ffmpeg `speed`; when it stays
|
|
94
|
+
// below realtime for a sustained window AND the input is not download-starved
|
|
95
|
+
// (so the limit is the encoder, not the torrent), step down one resolution rung
|
|
96
|
+
// and restart at the current segment. Conservative so it never thrashes: a long
|
|
97
|
+
// sustained window, a post-action cooldown, a step cap, and no upswitch (v1).
|
|
98
|
+
const BUDGET_CHECK_INTERVAL_MS = 5_000;
|
|
99
|
+
// Speed below this (cumulative ffmpeg average) counts as "slow"; recovery to
|
|
100
|
+
// realtime resets the slow window (hysteresis).
|
|
101
|
+
const BUDGET_SPEED_SLOW = 0.95;
|
|
102
|
+
const BUDGET_SPEED_OK = 1.0;
|
|
103
|
+
// Slow must persist this long before a downshift (absorbs warm-up + brief
|
|
104
|
+
// complex scenes; the cumulative average won't dip this long unless the host
|
|
105
|
+
// genuinely can't keep up).
|
|
106
|
+
const BUDGET_SUSTAINED_MS = 15_000;
|
|
107
|
+
// After a downshift, wait this long before another (lets the new profile settle
|
|
108
|
+
// and a fresh cumulative average build).
|
|
109
|
+
const BUDGET_ACTION_COOLDOWN_MS = 30_000;
|
|
110
|
+
// Never step down more than this many rungs below the startup choice.
|
|
111
|
+
const BUDGET_MAX_DOWNSHIFTS = 3;
|
|
112
|
+
// The input counts as "keeping up" when the torrent downloads at least this
|
|
113
|
+
// multiple of the source's average byte rate. Below it (and not yet fully
|
|
114
|
+
// downloaded), a low speed is download-bound, not CPU-bound → do NOT downscale.
|
|
115
|
+
const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
|
|
116
|
+
// Viewer-link adaptation (adaptive bitrate, part b). The browser reports its
|
|
117
|
+
// measured data-channel throughput + buffered seconds every ~10 s; when a
|
|
118
|
+
// FRESH report shows the usable link (reported × safety margin) sustainedly
|
|
119
|
+
// below the observed produced bitrate AND the viewer's buffer is low, the
|
|
120
|
+
// budget loop steps the encode one rung down — same machinery, cooldown and
|
|
121
|
+
// floor as the CPU trigger. Manual-quality sessions are inherently exempt
|
|
122
|
+
// (their budgetLadder is null).
|
|
123
|
+
const LINK_REPORT_FRESH_MS = 30_000;
|
|
124
|
+
// Usable share of the reported link (protocol overhead + measurement noise).
|
|
125
|
+
const LINK_SAFETY = 0.8;
|
|
126
|
+
// Deficit must persist this long before acting (absorbs one slow segment).
|
|
127
|
+
const LINK_SLOW_WINDOW_MS = 15_000;
|
|
128
|
+
// Only act while the viewer is actually running dry; a comfortable buffer
|
|
129
|
+
// (e.g. paused playback filling ahead) suppresses the trigger.
|
|
130
|
+
const LINK_LOW_BUFFER_SEC = 10;
|
|
131
|
+
// Observed produced bitrate: average over this many recently completed
|
|
132
|
+
// segments (the newest file on disk may still be written and is excluded).
|
|
133
|
+
const LINK_OBSERVED_SEGMENTS = 5;
|
|
134
|
+
const MICROSECONDS_PER_SECOND = 1_000_000;
|
|
135
|
+
const PROGRESS_LOG_INTERVAL_MS = 5_000;
|
|
136
|
+
// Read segment files in large blocks so the body is delivered to the data
|
|
137
|
+
// channel in few, big chunks. On a busy ARM host the in-process WebTorrent
|
|
138
|
+
// hashing starves the event loop in bursts, so fewer read iterations means
|
|
139
|
+
// far less time lost between chunks while serving the first segments.
|
|
140
|
+
const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolve after a given number of milliseconds.
|
|
144
|
+
*
|
|
145
|
+
* @param {number} ms
|
|
146
|
+
* @returns {Promise<void>}
|
|
147
|
+
*/
|
|
148
|
+
function delay(ms) {
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
setTimeout(resolve, ms);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Wait for a child process to exit, with a hard timeout fallback.
|
|
156
|
+
*
|
|
157
|
+
* @param {import("node:child_process").ChildProcess} child
|
|
158
|
+
* @param {number} [timeoutMs=2000]
|
|
159
|
+
* @returns {Promise<void>}
|
|
160
|
+
*/
|
|
161
|
+
function waitForChildExit(child, timeoutMs = 2_000) {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
let settled = false;
|
|
164
|
+
const finish = () => {
|
|
165
|
+
if (settled) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
settled = true;
|
|
169
|
+
resolve();
|
|
170
|
+
};
|
|
171
|
+
child.once("exit", finish);
|
|
172
|
+
setTimeout(finish, timeoutMs);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether a child process has genuinely exited. `ChildProcess.killed` only
|
|
178
|
+
* means `.kill()` was called — the process can stay alive well after that
|
|
179
|
+
* (blocked in I/O, ignoring/delaying the signal). `exitCode`/`signalCode` are
|
|
180
|
+
* only set once the `exit` event has actually fired, so this is the reliable
|
|
181
|
+
* check before treating a directory/file as free for a new process to use.
|
|
182
|
+
*
|
|
183
|
+
* @param {import("node:child_process").ChildProcess} child
|
|
184
|
+
* @returns {boolean}
|
|
185
|
+
*/
|
|
186
|
+
function hasChildExited(child) {
|
|
187
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Convert a bind-all host address to the loopback address so that
|
|
192
|
+
* the HLS input URL is always reachable from the same machine.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} host
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function toLoopbackHost(host) {
|
|
198
|
+
if (host === "0.0.0.0" || host === "::") {
|
|
199
|
+
return "127.0.0.1";
|
|
200
|
+
}
|
|
201
|
+
return host;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build the HTTP base URL (scheme + host + port) for the local proxy server.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} host - Bind host (may be "0.0.0.0" or "::").
|
|
208
|
+
* @param {number} port
|
|
209
|
+
* @returns {string} e.g. "http://127.0.0.1:9090"
|
|
210
|
+
*/
|
|
211
|
+
function buildHttpBaseUrl(host, port) {
|
|
212
|
+
const url = new URL("http://localhost");
|
|
213
|
+
url.hostname = toLoopbackHost(host);
|
|
214
|
+
url.port = String(port);
|
|
215
|
+
return url.origin;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Return the temporary directory path for a given HLS session.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} sessionId - UUID of the session.
|
|
222
|
+
* @returns {string}
|
|
223
|
+
*/
|
|
224
|
+
function createSessionDirPath(sessionId) {
|
|
225
|
+
return path.join(os.tmpdir(), "torrent-tv-hls", sessionId);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Guard against path traversal by validating that a session ID is a UUID.
|
|
230
|
+
*
|
|
231
|
+
* @param {unknown} value
|
|
232
|
+
* @returns {boolean}
|
|
233
|
+
*/
|
|
234
|
+
function isSafeSessionId(value) {
|
|
235
|
+
return /^[a-f0-9-]{36}$/i.test(value);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Guard against path traversal by restricting file names to the known
|
|
240
|
+
* playlist and segment patterns produced by ffmpeg. Which segment names are
|
|
241
|
+
* legal depends on the active container, so the format decides.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} fileName
|
|
244
|
+
* @param {import("./segment-formats/index.js").SegmentFormat} segmentFormat
|
|
245
|
+
* @returns {boolean}
|
|
246
|
+
*/
|
|
247
|
+
function isSafeFileName(fileName, segmentFormat) {
|
|
248
|
+
return (
|
|
249
|
+
fileName === PLAYLIST_FILE_NAME ||
|
|
250
|
+
(segmentFormat.initFileName !== null && fileName === segmentFormat.initFileName) ||
|
|
251
|
+
segmentFormat.isSegmentFileName(fileName)
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Parse an ffmpeg `HH:MM:SS.mmm` timestamp string into total seconds.
|
|
257
|
+
* Returns `null` if the value is absent or malformed.
|
|
258
|
+
*
|
|
259
|
+
* @param {string | undefined} value
|
|
260
|
+
* @returns {number | null}
|
|
261
|
+
*/
|
|
262
|
+
function parseFfmpegTimestamp(value) {
|
|
263
|
+
if (!value || typeof value !== "string") {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const parts = value.split(":");
|
|
267
|
+
if (parts.length !== 3) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const hours = Number(parts[0]);
|
|
271
|
+
const minutes = Number(parts[1]);
|
|
272
|
+
const seconds = Number(parts[2]);
|
|
273
|
+
if (![hours, minutes, seconds].every((item) => Number.isFinite(item))) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
return hours * 3600 + minutes * 60 + seconds;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Format a seconds value as `HH:MM:SS`, or `"n/a"` if not finite.
|
|
281
|
+
*
|
|
282
|
+
* @param {number} seconds
|
|
283
|
+
* @returns {string}
|
|
284
|
+
*/
|
|
285
|
+
function formatSeconds(seconds) {
|
|
286
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
287
|
+
return "n/a";
|
|
288
|
+
}
|
|
289
|
+
const total = Math.floor(seconds);
|
|
290
|
+
const hours = Math.floor(total / 3600);
|
|
291
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
292
|
+
const rest = total % 60;
|
|
293
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Compute derived progress metrics from raw ffmpeg output values.
|
|
298
|
+
*
|
|
299
|
+
* When `startPositionSeconds` is provided (seek-restart case), progress is
|
|
300
|
+
* computed relative to the remaining duration after the seek point so the
|
|
301
|
+
* percent value reflects transcoding of the requested segment, not the whole
|
|
302
|
+
* file.
|
|
303
|
+
*
|
|
304
|
+
* @param {number} processedSeconds - Output timestamp of last encoded frame.
|
|
305
|
+
* @param {number | null} totalSeconds - Total duration, or `null` if unknown.
|
|
306
|
+
* @param {number} [startPositionSeconds=0] - Seek offset used for this session.
|
|
307
|
+
* @returns {{ totalSeconds: number | null, percent: number | null, remainingSeconds: number | null, processedSeconds: number }}
|
|
308
|
+
*/
|
|
309
|
+
function computeProgressMetrics(processedSeconds, totalSeconds, startPositionSeconds = 0) {
|
|
310
|
+
const processed = Number.isFinite(processedSeconds) ? Math.max(0, processedSeconds) : 0;
|
|
311
|
+
const startOffset = Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
312
|
+
? startPositionSeconds
|
|
313
|
+
: 0;
|
|
314
|
+
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
|
|
315
|
+
return { totalSeconds: null, percent: null, remainingSeconds: null, processedSeconds: processed };
|
|
316
|
+
}
|
|
317
|
+
const safeTotal = totalSeconds;
|
|
318
|
+
const segmentDuration = Math.max(1, safeTotal - startOffset);
|
|
319
|
+
const segmentProcessed = Math.max(0, processed - startOffset);
|
|
320
|
+
const percent = Math.max(0, Math.min(100, (segmentProcessed / segmentDuration) * 100));
|
|
321
|
+
const remainingSeconds = Math.max(0, safeTotal - processed);
|
|
322
|
+
return {
|
|
323
|
+
totalSeconds: safeTotal,
|
|
324
|
+
percent,
|
|
325
|
+
remainingSeconds,
|
|
326
|
+
processedSeconds: processed
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Run a short ffmpeg probe to extract the total duration AND video resolution
|
|
332
|
+
* of a stream from the container header. Both are printed almost immediately
|
|
333
|
+
* (before any decoding), so this returns as soon as they are seen; an 8 s
|
|
334
|
+
* timeout guards the rest.
|
|
335
|
+
*
|
|
336
|
+
* @param {string} ffmpegBin - Path to the ffmpeg executable.
|
|
337
|
+
* @param {string | URL} inputUrl - URL of the stream to probe.
|
|
338
|
+
* @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
|
|
339
|
+
*/
|
|
340
|
+
async function probeInputMediaInfo(ffmpegBin, inputUrl) {
|
|
341
|
+
return new Promise((resolve) => {
|
|
342
|
+
const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
|
|
343
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
344
|
+
windowsHide: true
|
|
345
|
+
});
|
|
346
|
+
let stderr = "";
|
|
347
|
+
let settled = false;
|
|
348
|
+
const finish = () => {
|
|
349
|
+
if (settled) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
settled = true;
|
|
353
|
+
const dims = parseFfmpegVideoDimensions(stderr);
|
|
354
|
+
resolve({
|
|
355
|
+
durationSeconds: parseFfmpegDurationSeconds(stderr),
|
|
356
|
+
width: dims.width,
|
|
357
|
+
height: dims.height,
|
|
358
|
+
fps: parseFfmpegVideoFps(stderr),
|
|
359
|
+
startTime: parseFfmpegStartTimeSeconds(stderr),
|
|
360
|
+
isHdr: parseFfmpegHdr(stderr)
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
const timeoutId = setTimeout(() => {
|
|
364
|
+
if (!ffmpeg.killed) {
|
|
365
|
+
ffmpeg.kill("SIGTERM");
|
|
366
|
+
}
|
|
367
|
+
finish();
|
|
368
|
+
}, 8_000);
|
|
369
|
+
ffmpeg.stderr.on("data", (chunk) => {
|
|
370
|
+
stderr += String(chunk);
|
|
371
|
+
// The header ("Duration:" then the "Video: … WxH" stream line) is printed
|
|
372
|
+
// before any decoding. Bail as soon as both are present instead of letting
|
|
373
|
+
// `-f null -` decode the whole stream until the 8 s timeout.
|
|
374
|
+
const duration = parseFfmpegDurationSeconds(stderr);
|
|
375
|
+
const dims = parseFfmpegVideoDimensions(stderr);
|
|
376
|
+
if (duration != null && dims.width != null) {
|
|
377
|
+
clearTimeout(timeoutId);
|
|
378
|
+
if (!ffmpeg.killed) {
|
|
379
|
+
ffmpeg.kill("SIGTERM");
|
|
380
|
+
}
|
|
381
|
+
finish();
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
ffmpeg.on("error", () => {
|
|
385
|
+
clearTimeout(timeoutId);
|
|
386
|
+
finish();
|
|
387
|
+
});
|
|
388
|
+
ffmpeg.on("exit", () => {
|
|
389
|
+
clearTimeout(timeoutId);
|
|
390
|
+
finish();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Compute the actual output resolution ffmpeg will produce: the target box
|
|
397
|
+
* capped to the source (never upscaled), preserving aspect, divisible by 2.
|
|
398
|
+
* Mirrors the `scale='min(w,iw)':'min(h,ih)':force_original_aspect_ratio=decrease`
|
|
399
|
+
* filter. Returns `null` when the source size is unknown.
|
|
400
|
+
*
|
|
401
|
+
* @param {number} targetWidth
|
|
402
|
+
* @param {number} targetHeight
|
|
403
|
+
* @param {number | null} sourceWidth
|
|
404
|
+
* @param {number | null} sourceHeight
|
|
405
|
+
* @returns {{ w: number, h: number } | null}
|
|
406
|
+
*/
|
|
407
|
+
function computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight) {
|
|
408
|
+
const sw = Number.isFinite(sourceWidth) && sourceWidth > 0 ? sourceWidth : 0;
|
|
409
|
+
const sh = Number.isFinite(sourceHeight) && sourceHeight > 0 ? sourceHeight : 0;
|
|
410
|
+
if (!sw || !sh) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const tw = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : sw;
|
|
414
|
+
const th = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : sh;
|
|
415
|
+
const scale = Math.min(tw / sw, th / sh, 1);
|
|
416
|
+
let w = Math.round(sw * scale);
|
|
417
|
+
let h = Math.round(sh * scale);
|
|
418
|
+
w -= w % 2;
|
|
419
|
+
h -= h % 2;
|
|
420
|
+
return { w: Math.max(2, w), h: Math.max(2, h) };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Resolve the ffprobe binary path from the ffmpeg path (same directory / name).
|
|
425
|
+
*
|
|
426
|
+
* @param {string} ffmpegBin
|
|
427
|
+
* @returns {string}
|
|
428
|
+
*/
|
|
429
|
+
function ffprobeBinFor(ffmpegBin) {
|
|
430
|
+
if (typeof ffmpegBin !== "string" || ffmpegBin.length === 0) {
|
|
431
|
+
return "ffprobe";
|
|
432
|
+
}
|
|
433
|
+
if (/ffmpeg(\.exe)?$/i.test(ffmpegBin)) {
|
|
434
|
+
return ffmpegBin.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
|
|
435
|
+
}
|
|
436
|
+
return "ffprobe";
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Probe the source video stream's keyframe timestamps (seconds, in the source
|
|
441
|
+
* timeline) via ffprobe packet flags. Used for the video-copy path, where we
|
|
442
|
+
* cannot insert keyframes: the synthetic playlist's segment boundaries must
|
|
443
|
+
* match the source's real keyframe positions or the player sees gaps on seek.
|
|
444
|
+
*
|
|
445
|
+
* Time-bounded; returns `null` on failure/timeout (caller falls back to a
|
|
446
|
+
* uniform grid). NOTE: reading all video packets streams much of the file from
|
|
447
|
+
* the torrent, so for large files this may time out and fall back.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} ffmpegBin
|
|
450
|
+
* @param {string | URL} inputUrl
|
|
451
|
+
* @param {number} [timeoutMs]
|
|
452
|
+
* @returns {Promise<number[] | null>} Sorted keyframe times, or null.
|
|
453
|
+
*/
|
|
454
|
+
async function probeVideoKeyframeTimes(ffmpegBin, inputUrl, timeoutMs = 25_000) {
|
|
455
|
+
return new Promise((resolve) => {
|
|
456
|
+
let proc;
|
|
457
|
+
try {
|
|
458
|
+
proc = spawn(
|
|
459
|
+
ffprobeBinFor(ffmpegBin),
|
|
460
|
+
[
|
|
461
|
+
"-v", "error",
|
|
462
|
+
"-select_streams", "v:0",
|
|
463
|
+
"-show_entries", "packet=pts_time,flags",
|
|
464
|
+
"-of", "csv=p=0",
|
|
465
|
+
String(inputUrl)
|
|
466
|
+
],
|
|
467
|
+
{ stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
|
|
468
|
+
);
|
|
469
|
+
} catch {
|
|
470
|
+
resolve(null);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
let stdout = "";
|
|
474
|
+
let settled = false;
|
|
475
|
+
const finish = (value) => {
|
|
476
|
+
if (settled) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
settled = true;
|
|
480
|
+
resolve(value);
|
|
481
|
+
};
|
|
482
|
+
const timer = setTimeout(() => {
|
|
483
|
+
try {
|
|
484
|
+
if (!proc.killed) {
|
|
485
|
+
proc.kill("SIGTERM");
|
|
486
|
+
}
|
|
487
|
+
} catch {
|
|
488
|
+
// ignore
|
|
489
|
+
}
|
|
490
|
+
finish(null);
|
|
491
|
+
}, timeoutMs);
|
|
492
|
+
proc.stdout.on("data", (chunk) => {
|
|
493
|
+
stdout += String(chunk);
|
|
494
|
+
});
|
|
495
|
+
proc.on("error", () => {
|
|
496
|
+
clearTimeout(timer);
|
|
497
|
+
finish(null);
|
|
498
|
+
});
|
|
499
|
+
proc.on("exit", (code) => {
|
|
500
|
+
clearTimeout(timer);
|
|
501
|
+
if (code !== 0) {
|
|
502
|
+
finish(null);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const times = [];
|
|
506
|
+
for (const line of stdout.split("\n")) {
|
|
507
|
+
// Each line: "<pts_time>,<flags>" e.g. "12.345000,K__"
|
|
508
|
+
const comma = line.indexOf(",");
|
|
509
|
+
if (comma < 0) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const flags = line.slice(comma + 1);
|
|
513
|
+
if (!flags.includes("K")) {
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
const t = Number(line.slice(0, comma));
|
|
517
|
+
if (Number.isFinite(t)) {
|
|
518
|
+
times.push(t);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
times.sort((a, b) => a - b);
|
|
522
|
+
finish(times.length > 0 ? times : null);
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Compute segment START times (a 0-based timeline) for a session.
|
|
529
|
+
*
|
|
530
|
+
* - Re-encoded video: a uniform grid (0, segDur, 2·segDur, …) — ffmpeg's fixed
|
|
531
|
+
* GOP makes the real cuts land exactly here.
|
|
532
|
+
* - Copied video: the source's real keyframes, normalized to 0 (start time
|
|
533
|
+
* subtracted) and greedily grouped to ≥ segDur — these are exactly where
|
|
534
|
+
* `-hls_time segDur` cuts a copied stream, so the playlist matches reality.
|
|
535
|
+
*
|
|
536
|
+
* The returned array starts at 0 and ends at `durationSeconds` (so segment i
|
|
537
|
+
* spans `[boundaries[i], boundaries[i+1])`). Falls back to a uniform grid when
|
|
538
|
+
* keyframes are unavailable.
|
|
539
|
+
*
|
|
540
|
+
* @param {{ transcodeVideo: boolean, durationSeconds: number, segDur: number, keyframeTimes: number[] | null, startTime: number }} params
|
|
541
|
+
* @returns {number[]}
|
|
542
|
+
*/
|
|
543
|
+
function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, keyframeTimes, startTime }) {
|
|
544
|
+
const total = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 0;
|
|
545
|
+
const step = Number.isFinite(segDur) && segDur > 0 ? segDur : 4;
|
|
546
|
+
const uniform = () => {
|
|
547
|
+
const boundaries = [];
|
|
548
|
+
for (let t = 0; t < total - 0.001; t += step) {
|
|
549
|
+
boundaries.push(Number(t.toFixed(6)));
|
|
550
|
+
}
|
|
551
|
+
boundaries.push(total);
|
|
552
|
+
return boundaries;
|
|
553
|
+
};
|
|
554
|
+
if (transcodeVideo || !Array.isArray(keyframeTimes) || keyframeTimes.length === 0 || total <= 0) {
|
|
555
|
+
return uniform();
|
|
556
|
+
}
|
|
557
|
+
const base = Number.isFinite(startTime) ? startTime : 0;
|
|
558
|
+
const norm = keyframeTimes
|
|
559
|
+
.map((t) => t - base)
|
|
560
|
+
.filter((t) => t >= -0.001 && t < total - 0.05)
|
|
561
|
+
.sort((a, b) => a - b);
|
|
562
|
+
const boundaries = [0];
|
|
563
|
+
for (const kf of norm) {
|
|
564
|
+
if (kf >= boundaries[boundaries.length - 1] + step - 0.05) {
|
|
565
|
+
boundaries.push(Number(kf.toFixed(6)));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
boundaries.push(total);
|
|
569
|
+
// Guard against a degenerate probe (e.g. a single keyframe) — fall back.
|
|
570
|
+
return boundaries.length >= 2 ? boundaries : uniform();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The largest keyframe time that does not exceed `target`, from a SORTED
|
|
575
|
+
* (ascending) array of keyframe times such as {@link probeVideoKeyframeTimes}
|
|
576
|
+
* returns. Null when `target` is before the first keyframe or the array is
|
|
577
|
+
* empty — the caller then falls back to its unsnapped target.
|
|
578
|
+
*
|
|
579
|
+
* @param {number[]} keyframeTimes - Sorted ascending.
|
|
580
|
+
* @param {number} target
|
|
581
|
+
* @returns {number | null}
|
|
582
|
+
*/
|
|
583
|
+
function nearestKeyframeAtOrBefore(keyframeTimes, target) {
|
|
584
|
+
let result = null;
|
|
585
|
+
for (const time of keyframeTimes) {
|
|
586
|
+
if (time > target) {
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
result = time;
|
|
590
|
+
}
|
|
591
|
+
return result;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function isWarmupTimeoutError(error) {
|
|
595
|
+
if (!(error instanceof Error)) {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
return error.message === "HLS playlist is still warming up.";
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function normalizeLogFileName(fileName, fileIndex) {
|
|
602
|
+
const fallback = `file#${fileIndex}`;
|
|
603
|
+
if (typeof fileName !== "string") {
|
|
604
|
+
return fallback;
|
|
605
|
+
}
|
|
606
|
+
const value = fileName.trim();
|
|
607
|
+
if (value.length === 0) {
|
|
608
|
+
return fallback;
|
|
609
|
+
}
|
|
610
|
+
return value;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* @typedef {Object} HlsSessionManagerOptions
|
|
615
|
+
* @property {boolean} enabled - Whether HLS transcoding is enabled.
|
|
616
|
+
* @property {string} ffmpegBin - Path to the ffmpeg executable.
|
|
617
|
+
* @property {string} localBindHost - Host the proxy HTTP server is bound to.
|
|
618
|
+
* @property {number} localPort - Port the proxy HTTP server is listening on.
|
|
619
|
+
* @property {number} [segmentDurationSec] - HLS segment length in seconds.
|
|
620
|
+
* @property {number} [sessionTtlMs] - Session idle TTL in milliseconds.
|
|
621
|
+
* @property {number} [startupWaitMs] - Max time to wait for the first playlist file.
|
|
622
|
+
* @property {string} [segmentFormatId] - Output container: "fmp4" (default)
|
|
623
|
+
* or "mpegts". See `./segment-formats/index.js`.
|
|
624
|
+
*/
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* @typedef {Object} HlsSession
|
|
628
|
+
* @property {string} id - UUID of the session.
|
|
629
|
+
* @property {string} sourceMapKey - Cache key combining source + transcode settings.
|
|
630
|
+
* @property {string} fileName - Display name of the file being transcoded.
|
|
631
|
+
* @property {string} dirPath - Temp directory containing HLS output.
|
|
632
|
+
* @property {"starting" | "ready" | "failed" | "disposed"} state
|
|
633
|
+
* @property {number} startedAt - Unix ms timestamp when the session was created.
|
|
634
|
+
* @property {number} lastAccessedAt - Unix ms timestamp of the last consumer access.
|
|
635
|
+
* @property {import("node:child_process").ChildProcess} ffmpeg
|
|
636
|
+
* @property {string} lastError
|
|
637
|
+
* @property {Set<string>} consumers - Consumer IDs currently using this session.
|
|
638
|
+
* @property {object} progress - Live progress metrics updated from ffmpeg stdout.
|
|
639
|
+
* @property {number} encodeRunGeneration - Bumped on every #startEncodeRun call;
|
|
640
|
+
* lets a call that awaited the previous ffmpeg's exit detect it was superseded
|
|
641
|
+
* by a newer restart request and abort instead of spawning a second process.
|
|
642
|
+
* @property {number[] | null} keyframeTimes - Real source keyframe times
|
|
643
|
+
* (sorted seconds), or null when the probe failed/timed out. Used to snap a
|
|
644
|
+
* source seek onto a known-valid position (see #startEncodeRun).
|
|
645
|
+
* @property {number} seekFailureTarget - Segment index of the last fast seek
|
|
646
|
+
* failure, for the consecutive-failure circuit breaker (see MAX_SEEK_FAILURES).
|
|
647
|
+
* @property {number} seekFailureCount - Consecutive fast failures at seekFailureTarget.
|
|
648
|
+
*/
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Manages HLS transcode sessions backed by ffmpeg child processes.
|
|
652
|
+
*
|
|
653
|
+
* One session is created per unique (source, fileIndex, transcode settings)
|
|
654
|
+
* combination. Sessions are reused across consumers and are automatically
|
|
655
|
+
* expired after {@link HlsSessionManagerOptions.sessionTtlMs} of idle time.
|
|
656
|
+
*/
|
|
657
|
+
export class HlsSessionManager {
|
|
658
|
+
/**
|
|
659
|
+
* @param {HlsSessionManagerOptions} options
|
|
660
|
+
*/
|
|
661
|
+
constructor({
|
|
662
|
+
enabled,
|
|
663
|
+
ffmpegBin,
|
|
664
|
+
localBindHost,
|
|
665
|
+
localPort,
|
|
666
|
+
segmentDurationSec = DEFAULT_SEGMENT_DURATION_SEC,
|
|
667
|
+
sessionTtlMs = DEFAULT_SESSION_TTL_MS,
|
|
668
|
+
startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
|
|
669
|
+
videoEncoder = null,
|
|
670
|
+
softwarePresetBenchmark = null,
|
|
671
|
+
getSourceStats = null,
|
|
672
|
+
tonemapSupported = false,
|
|
673
|
+
getCachedMediaInfo = null,
|
|
674
|
+
segmentFormatId = undefined
|
|
675
|
+
}) {
|
|
676
|
+
this.enabled = Boolean(enabled);
|
|
677
|
+
this.ffmpegBin = ffmpegBin;
|
|
678
|
+
// Output container (fMP4/CMAF or MPEG-TS). Everything container-specific —
|
|
679
|
+
// muxer args, file naming, playlist header, per-segment correction — lives
|
|
680
|
+
// in this module; nothing here branches on the format.
|
|
681
|
+
this.segmentFormat = resolveSegmentFormat(segmentFormatId);
|
|
682
|
+
// Optional accessor for media info the playback planner already probed for
|
|
683
|
+
// (sourceKey, fileIndex), so session create can skip its own ffmpeg scan.
|
|
684
|
+
this.getCachedMediaInfo = typeof getCachedMediaInfo === "function" ? getCachedMediaInfo : null;
|
|
685
|
+
// Optional async accessor for a source's live download stats, used by the
|
|
686
|
+
// realtime budget to tell a CPU limit from a download-starved input:
|
|
687
|
+
// (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
|
|
688
|
+
this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
|
|
689
|
+
// Detected H.264 encoder descriptor (hardware or software). Defaults to
|
|
690
|
+
// software libx264 when no detection result is supplied. May be downgraded
|
|
691
|
+
// to software at runtime if a hardware encode fails.
|
|
692
|
+
this.videoEncoder = videoEncoder ?? softwareDescriptor();
|
|
693
|
+
// Per-preset software encode throughput (pixels/sec) measured at startup,
|
|
694
|
+
// used to pick the best preset per stream. Null when unavailable (hardware
|
|
695
|
+
// encoder, or benchmark skipped/failed).
|
|
696
|
+
this.softwarePresetBenchmark = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
|
|
697
|
+
// Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
|
|
698
|
+
// Gates the tonemap chain for HDR sources on the software path.
|
|
699
|
+
this.tonemapSupported = Boolean(tonemapSupported);
|
|
700
|
+
this.segmentDurationSec = segmentDurationSec;
|
|
701
|
+
this.sessionTtlMs = sessionTtlMs;
|
|
702
|
+
this.startupWaitMs = startupWaitMs;
|
|
703
|
+
this.localBaseUrl = buildHttpBaseUrl(localBindHost, localPort);
|
|
704
|
+
this.sessionsById = new Map();
|
|
705
|
+
this.sessionIdBySource = new Map();
|
|
706
|
+
this.cleanupTimer = setInterval(() => {
|
|
707
|
+
void this.cleanupExpired();
|
|
708
|
+
}, CLEANUP_INTERVAL_MS);
|
|
709
|
+
this.cleanupTimer.unref();
|
|
710
|
+
// Realtime-budget monitor: only meaningful for the software encoder with a
|
|
711
|
+
// benchmark (the only path that can pick/step resolution). Cheap no-op scan
|
|
712
|
+
// otherwise.
|
|
713
|
+
this.budgetTimer = setInterval(() => {
|
|
714
|
+
void this.#enforceRealtimeBudget();
|
|
715
|
+
}, BUDGET_CHECK_INTERVAL_MS);
|
|
716
|
+
this.budgetTimer.unref();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Return an existing HLS session for the given source/settings, or create
|
|
721
|
+
* one by spawning a new ffmpeg process.
|
|
722
|
+
*
|
|
723
|
+
* Throws with `error.code === "TRANSCODE_DISABLED"` when transcoding is
|
|
724
|
+
* disabled on this proxy instance.
|
|
725
|
+
*
|
|
726
|
+
* @param {object} options
|
|
727
|
+
* @param {string} options.sourceKey - Registry source key.
|
|
728
|
+
* @param {number} options.fileIndex - Zero-based file index in the torrent.
|
|
729
|
+
* @param {boolean} [options.transcodeVideo=false]
|
|
730
|
+
* @param {boolean} [options.transcodeAudio=false]
|
|
731
|
+
* @param {string} [options.consumerId=""] - Caller ID for reference counting.
|
|
732
|
+
* @param {string} [options.fileName=""] - Display name for log output.
|
|
733
|
+
* @param {number} [options.targetWidth=0] - Target video width (0 = keep source).
|
|
734
|
+
* @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
|
|
735
|
+
* @param {number} [options.startPositionSeconds=0] - Seek start position in seconds.
|
|
736
|
+
* @param {number} [options.audioTrackIndex=0] - Type-relative audio track to map (0:a:N).
|
|
737
|
+
* @param {boolean} [options.manualQuality=false] - User-forced resolution: encode the target box exactly (capped to source), no budget downscale / runtime downswitch.
|
|
738
|
+
* @returns {Promise<HlsSession>}
|
|
739
|
+
*/
|
|
740
|
+
async createOrGetSession({
|
|
741
|
+
sourceKey,
|
|
742
|
+
fileIndex,
|
|
743
|
+
transcodeVideo = false,
|
|
744
|
+
transcodeAudio = false,
|
|
745
|
+
consumerId = "",
|
|
746
|
+
fileName = "",
|
|
747
|
+
targetWidth = 0,
|
|
748
|
+
targetHeight = 0,
|
|
749
|
+
startPositionSeconds = 0,
|
|
750
|
+
audioTrackIndex = 0,
|
|
751
|
+
manualQuality = false
|
|
752
|
+
}) {
|
|
753
|
+
if (!this.enabled) {
|
|
754
|
+
const error = new Error("Audio transcoding is disabled on this proxy.");
|
|
755
|
+
error.code = "TRANSCODE_DISABLED";
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const normalizedTargetWidth = Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0;
|
|
760
|
+
const normalizedTargetHeight = Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0;
|
|
761
|
+
// Round seek position to the nearest 10 s so that two consumers seeking
|
|
762
|
+
// to similar positions can share the same ffmpeg session.
|
|
763
|
+
const normalizedStartPosition =
|
|
764
|
+
Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
765
|
+
? Math.round(startPositionSeconds / 10) * 10
|
|
766
|
+
: 0;
|
|
767
|
+
const normalizedAudioTrack =
|
|
768
|
+
Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0;
|
|
769
|
+
const forceManualQuality = manualQuality === true && transcodeVideo;
|
|
770
|
+
const sourceMapKey = [
|
|
771
|
+
sourceKey,
|
|
772
|
+
String(fileIndex),
|
|
773
|
+
transcodeVideo ? "video" : "audio",
|
|
774
|
+
transcodeAudio ? "a1" : "a0",
|
|
775
|
+
`t${normalizedAudioTrack}`,
|
|
776
|
+
String(normalizedTargetWidth),
|
|
777
|
+
String(normalizedTargetHeight),
|
|
778
|
+
forceManualQuality ? "q-manual" : "q-auto",
|
|
779
|
+
String(normalizedStartPosition)
|
|
780
|
+
].join(":");
|
|
781
|
+
const existingId = this.sessionIdBySource.get(sourceMapKey);
|
|
782
|
+
if (existingId) {
|
|
783
|
+
const existing = this.sessionsById.get(existingId);
|
|
784
|
+
if (existing && existing.state !== "failed") {
|
|
785
|
+
existing.fileName = normalizeLogFileName(fileName, fileIndex);
|
|
786
|
+
if (consumerId) {
|
|
787
|
+
existing.consumers.add(consumerId);
|
|
788
|
+
}
|
|
789
|
+
existing.lastAccessedAt = Date.now();
|
|
790
|
+
try {
|
|
791
|
+
await this.waitUntilReady(existing);
|
|
792
|
+
} catch (error) {
|
|
793
|
+
if (!isWarmupTimeoutError(error)) {
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
// Keep session reusable while ffmpeg is still warming up.
|
|
797
|
+
}
|
|
798
|
+
return existing;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const sessionId = randomUUID();
|
|
803
|
+
const createEntryMs = Date.now();
|
|
804
|
+
const sessionDir = createSessionDirPath(sessionId);
|
|
805
|
+
await mkdir(sessionDir, { recursive: true });
|
|
806
|
+
const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
|
|
807
|
+
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
808
|
+
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
809
|
+
|
|
810
|
+
// Media info (duration/resolution/fps/startTime/HDR) up front, so we can
|
|
811
|
+
// serve a complete VOD playlist (#EXT-X-ENDLIST) with the correct total
|
|
812
|
+
// duration and a fully seekable timeline before a single segment exists.
|
|
813
|
+
// Reuse the planner's probe when it is available and complete — the plan
|
|
814
|
+
// request just ran the same ffmpeg scan over the same input. Fall back to
|
|
815
|
+
// a fresh probe otherwise (proxy restarted between plan and session, or a
|
|
816
|
+
// critical field is missing).
|
|
817
|
+
const mediaInfoStartMs = Date.now();
|
|
818
|
+
const cachedMediaInfo = this.getCachedMediaInfo?.({ sourceKey, fileIndex }) ?? null;
|
|
819
|
+
const cachedUsable =
|
|
820
|
+
cachedMediaInfo &&
|
|
821
|
+
Number.isFinite(cachedMediaInfo.durationSeconds) &&
|
|
822
|
+
cachedMediaInfo.durationSeconds > 0 &&
|
|
823
|
+
Number.isFinite(cachedMediaInfo.width) &&
|
|
824
|
+
cachedMediaInfo.width > 0 &&
|
|
825
|
+
Number.isFinite(cachedMediaInfo.height) &&
|
|
826
|
+
cachedMediaInfo.height > 0;
|
|
827
|
+
const mediaInfo = cachedUsable
|
|
828
|
+
? cachedMediaInfo
|
|
829
|
+
: await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
|
|
830
|
+
const mediaInfoMs = Date.now() - mediaInfoStartMs;
|
|
831
|
+
const mediaInfoSource = cachedUsable ? "cached" : "probed";
|
|
832
|
+
const durationSeconds = mediaInfo.durationSeconds;
|
|
833
|
+
const sourceWidth = mediaInfo.width;
|
|
834
|
+
const sourceHeight = mediaInfo.height;
|
|
835
|
+
const sourceStartTime = Number.isFinite(mediaInfo.startTime) ? mediaInfo.startTime : 0;
|
|
836
|
+
// Tone-map an HDR source to SDR only when re-encoding video on the software
|
|
837
|
+
// path and this ffmpeg has the filters. Hardware encoders keep their own
|
|
838
|
+
// (untone-mapped) path for now; when unavailable, HDR falls back to a plain
|
|
839
|
+
// 8-bit convert (washed-out but playable).
|
|
840
|
+
const applyTonemap =
|
|
841
|
+
transcodeVideo === true &&
|
|
842
|
+
mediaInfo.isHdr === true &&
|
|
843
|
+
this.tonemapSupported &&
|
|
844
|
+
this.videoEncoder?.kind === "software";
|
|
845
|
+
// Output frame rate inherited from the source (integer, capped) so 25/30
|
|
846
|
+
// fps content is not resampled to 24. Fixed-GOP encoders keep the fps↔GOP
|
|
847
|
+
// relationship exact; time-based-keyframe encoders just use it as the rate.
|
|
848
|
+
const outputFps = chooseOutputFps(mediaInfo.fps);
|
|
849
|
+
const hasDuration = Number.isFinite(durationSeconds) && durationSeconds > 0;
|
|
850
|
+
const logName = normalizeLogFileName(fileName, fileIndex);
|
|
851
|
+
if (!hasDuration) {
|
|
852
|
+
logger.warn(
|
|
853
|
+
`transcode ${sessionId}: could not probe duration; falling back to ` +
|
|
854
|
+
`ffmpeg-managed (growing) playlist for "${logName}"`
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// For the video-copy path we cannot insert keyframes, so the playlist's
|
|
859
|
+
// segment boundaries must match the source's real keyframes (otherwise the
|
|
860
|
+
// player sees gaps on seek). Re-encoded video uses a uniform grid for
|
|
861
|
+
// segment boundaries instead (its fixed GOP makes the cuts land there —
|
|
862
|
+
// computeSegmentBoundaries ignores keyframeTimes when transcodeVideo).
|
|
863
|
+
//
|
|
864
|
+
// But the probe is ALSO used for something both branches need: choosing a
|
|
865
|
+
// SOURCE seek position ffmpeg can actually land on. `-ss` before `-i` trusts
|
|
866
|
+
// the container's own on-the-fly seek/index, which for some containers
|
|
867
|
+
// (observed: AVI with VBR MP3 audio) can point at a position with no valid
|
|
868
|
+
// frame boundary at all — ffmpeg then fails outright ("Seek failed" /
|
|
869
|
+
// "Header missing"), not just imprecisely. Snapping the seek to the nearest
|
|
870
|
+
// KNOWN real keyframe (see #startEncodeRun) avoids that. So probe for both
|
|
871
|
+
// branches; on failure both fall back to their current behaviour (uniform
|
|
872
|
+
// grid for boundaries, raw target for seeking) — no regression.
|
|
873
|
+
let keyframeTimes = null;
|
|
874
|
+
let keyframeMs = -1; // -1 = not run (skipped), -2 = running in the background
|
|
875
|
+
if (hasDuration && !transcodeVideo) {
|
|
876
|
+
// Video-COPY path: keyframeTimes are REQUIRED to build correct segment
|
|
877
|
+
// boundaries (the playlist itself), so this MUST block session creation —
|
|
878
|
+
// an incorrect playlist is worse than a slower start. Short timeout: mp4
|
|
879
|
+
// keyframes come from the moov index (fast); containers that force a full
|
|
880
|
+
// packet scan time out and fall back to a uniform grid, so this never adds
|
|
881
|
+
// more than ~6 s to session start.
|
|
882
|
+
const keyframeStartMs = Date.now();
|
|
883
|
+
keyframeTimes = await probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 6_000);
|
|
884
|
+
keyframeMs = Date.now() - keyframeStartMs;
|
|
885
|
+
if (!keyframeTimes) {
|
|
886
|
+
logger.warn(
|
|
887
|
+
`transcode ${sessionId}: keyframe probe unavailable; using uniform grid ` +
|
|
888
|
+
`for "${logName}" (seek precision may be reduced)`
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
} else if (hasDuration && transcodeVideo) {
|
|
892
|
+
// Re-encode path: keyframeTimes are ONLY used to snap a LATER seek (see
|
|
893
|
+
// #startEncodeRun) — segment boundaries stay on the uniform grid either
|
|
894
|
+
// way. So this does NOT need to block session creation / the first
|
|
895
|
+
// segment's start. Run it in the background with a FULL budget instead of
|
|
896
|
+
// the 6 s cap: AVI-class containers need a full packet scan, which 6 s can
|
|
897
|
+
// never afford without delaying playback start — that starved budget is
|
|
898
|
+
// exactly why the probe kept missing on the container where the seek bug
|
|
899
|
+
// was field-diagnosed. #startEncodeRun reads session.keyframeTimes fresh
|
|
900
|
+
// on every call, so a seek that happens AFTER this finishes picks it up
|
|
901
|
+
// automatically; one that happens before falls back to the existing
|
|
902
|
+
// circuit breaker as a safety net (no regression either way).
|
|
903
|
+
keyframeMs = -2;
|
|
904
|
+
const backgroundStartedAt = Date.now();
|
|
905
|
+
void probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 25_000).then((times) => {
|
|
906
|
+
const liveSession = this.sessionsById.get(sessionId);
|
|
907
|
+
if (!liveSession || liveSession.state === "disposed") {
|
|
908
|
+
return; // Session gone before the probe finished — nothing to update.
|
|
909
|
+
}
|
|
910
|
+
liveSession.keyframeTimes = times;
|
|
911
|
+
const elapsedMs = Date.now() - backgroundStartedAt;
|
|
912
|
+
logger.info(
|
|
913
|
+
times
|
|
914
|
+
? `transcode ${sessionId}: background keyframe probe found ${times.length} keyframes ` +
|
|
915
|
+
`(${elapsedMs}ms) for "${logName}" — later seeks will snap to them`
|
|
916
|
+
: `transcode ${sessionId}: background keyframe probe unavailable (${elapsedMs}ms) for "${logName}" ` +
|
|
917
|
+
`— seeks keep using the raw target (falls back to the circuit breaker on failure)`
|
|
918
|
+
);
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
logger.info(
|
|
922
|
+
`cold-start ${sessionId.slice(0, 8)}: media-info=${mediaInfoMs}ms (${mediaInfoSource}) ` +
|
|
923
|
+
`keyframes=${keyframeMs === -1 ? "skipped" : keyframeMs === -2 ? "background" : `${keyframeMs}ms`} ` +
|
|
924
|
+
`create-total=${Date.now() - createEntryMs}ms`
|
|
925
|
+
);
|
|
926
|
+
const segmentBoundaries = hasDuration
|
|
927
|
+
? computeSegmentBoundaries({
|
|
928
|
+
transcodeVideo,
|
|
929
|
+
durationSeconds,
|
|
930
|
+
segDur: this.segmentDurationSec,
|
|
931
|
+
keyframeTimes,
|
|
932
|
+
startTime: sourceStartTime
|
|
933
|
+
})
|
|
934
|
+
: [];
|
|
935
|
+
const usingKeyframeBoundaries = hasDuration && !transcodeVideo && Array.isArray(keyframeTimes);
|
|
936
|
+
const segmentCount = segmentBoundaries.length > 1 ? segmentBoundaries.length - 1 : 0;
|
|
937
|
+
|
|
938
|
+
// Realtime budget (software encoder): pick the output resolution + libx264
|
|
939
|
+
// preset this host can encode faster than realtime. On a weak host this
|
|
940
|
+
// downscales below the client target (the orientation-independent ceiling)
|
|
941
|
+
// instead of dropping into sub-realtime playback. Null for hardware
|
|
942
|
+
// encoders or when the source size / benchmark is unavailable — the encode
|
|
943
|
+
// then keeps the client target box and buildVideoArgs's default preset.
|
|
944
|
+
//
|
|
945
|
+
// Manual quality bypasses the budget entirely: the user forced a specific
|
|
946
|
+
// resolution, so encode exactly that box (capped to source by the scale
|
|
947
|
+
// filter) with the default preset, and the runtime downswitch is skipped
|
|
948
|
+
// for the session (budgetLadder stays null).
|
|
949
|
+
const encodeBudget = forceManualQuality
|
|
950
|
+
? null
|
|
951
|
+
: this.#chooseEncodeBudget({
|
|
952
|
+
transcodeVideo,
|
|
953
|
+
targetWidth: normalizedTargetWidth,
|
|
954
|
+
targetHeight: normalizedTargetHeight,
|
|
955
|
+
sourceWidth,
|
|
956
|
+
sourceHeight,
|
|
957
|
+
outputFps
|
|
958
|
+
});
|
|
959
|
+
const softwarePreset = encodeBudget?.preset ?? null;
|
|
960
|
+
// Effective encode box: the budget's downscaled resolution when applied,
|
|
961
|
+
// otherwise the client target (0 = keep source, handled by buildVideoArgs).
|
|
962
|
+
const encodeWidth = encodeBudget?.width ?? normalizedTargetWidth;
|
|
963
|
+
const encodeHeight = encodeBudget?.height ?? normalizedTargetHeight;
|
|
964
|
+
|
|
965
|
+
const session = {
|
|
966
|
+
id: sessionId,
|
|
967
|
+
sourceMapKey,
|
|
968
|
+
fileName: logName,
|
|
969
|
+
dirPath: sessionDir,
|
|
970
|
+
state: "starting",
|
|
971
|
+
startedAt: Date.now(),
|
|
972
|
+
lastAccessedAt: Date.now(),
|
|
973
|
+
ffmpeg: null,
|
|
974
|
+
encodeRunGeneration: 0,
|
|
975
|
+
lastError: "",
|
|
976
|
+
// Cold-start timing: entry timestamp + a once-guard so the first servable
|
|
977
|
+
// segment logs its latency exactly once.
|
|
978
|
+
createEntryMs,
|
|
979
|
+
firstSegmentLogged: false,
|
|
980
|
+
consumers: new Set(consumerId ? [consumerId] : []),
|
|
981
|
+
// Transcode parameters retained so the encode run can be restarted at an
|
|
982
|
+
// arbitrary segment when the player seeks (server-side seeking).
|
|
983
|
+
sourceKey,
|
|
984
|
+
fileIndex,
|
|
985
|
+
transcodeVideo,
|
|
986
|
+
transcodeAudio,
|
|
987
|
+
audioTrackIndex: normalizedAudioTrack,
|
|
988
|
+
outputFps,
|
|
989
|
+
// Client-requested target box (the orientation-independent ceiling). Kept
|
|
990
|
+
// for the session key and reference; the actual encode uses encodeWidth/
|
|
991
|
+
// encodeHeight, which the realtime budget may have downscaled below this.
|
|
992
|
+
targetWidth: normalizedTargetWidth,
|
|
993
|
+
targetHeight: normalizedTargetHeight,
|
|
994
|
+
// Effective encode resolution handed to ffmpeg (budget-selected on weak
|
|
995
|
+
// software hosts, else the client target). 0 = keep source.
|
|
996
|
+
encodeWidth,
|
|
997
|
+
encodeHeight,
|
|
998
|
+
// Whether to insert the HDR→SDR tone-map chain (software path only).
|
|
999
|
+
applyTonemap,
|
|
1000
|
+
// Realtime-budget runtime state (software encoder only). The ladder is the
|
|
1001
|
+
// resolution rungs from the ceiling down; rungIndex is the current rung.
|
|
1002
|
+
// The monitor steps rungIndex down when the encoder is sustainedly
|
|
1003
|
+
// CPU-bound and restarts ffmpeg at the current segment.
|
|
1004
|
+
budgetLadder: encodeBudget?.ladder ?? null,
|
|
1005
|
+
budgetRungIndex: Number.isInteger(encodeBudget?.rungIndex) ? encodeBudget.rungIndex : 0,
|
|
1006
|
+
budgetDownshifts: 0,
|
|
1007
|
+
budgetSlowSince: 0,
|
|
1008
|
+
budgetLastActionAt: 0,
|
|
1009
|
+
// Latest viewer link report ({ linkMbps, bufferedAheadSec, at }) and the
|
|
1010
|
+
// link-deficit slow window (mirrors budgetSlowSince for the CPU path).
|
|
1011
|
+
netReport: null,
|
|
1012
|
+
linkSlowSince: 0,
|
|
1013
|
+
sourceWidth,
|
|
1014
|
+
sourceHeight,
|
|
1015
|
+
// Container start time (seconds); subtracted on the copy path so the
|
|
1016
|
+
// output timeline is 0-based even when the source starts at e.g. 0.1 s.
|
|
1017
|
+
sourceStartTime,
|
|
1018
|
+
// Chosen libx264 preset for this stream (software only), or null.
|
|
1019
|
+
softwarePreset,
|
|
1020
|
+
inputUrl: inputUrl.toString(),
|
|
1021
|
+
// VOD playlist bookkeeping.
|
|
1022
|
+
useSyntheticPlaylist: hasDuration,
|
|
1023
|
+
totalDurationSeconds: hasDuration ? durationSeconds : null,
|
|
1024
|
+
// Segment start times (0-based). Uniform grid for re-encoded video; real
|
|
1025
|
+
// keyframe positions for copied video. Drives the playlist and seeking.
|
|
1026
|
+
segmentBoundaries,
|
|
1027
|
+
segmentCount,
|
|
1028
|
+
// Real source keyframe times (sorted seconds), or null when the probe
|
|
1029
|
+
// failed/timed out. Used by #startEncodeRun to snap a source seek onto a
|
|
1030
|
+
// KNOWN valid position instead of trusting the container's own on-the-fly
|
|
1031
|
+
// seek at an arbitrary target — see the probe call above for why.
|
|
1032
|
+
keyframeTimes,
|
|
1033
|
+
playlistText: hasDuration ? this.#buildVodPlaylist(segmentBoundaries) : "",
|
|
1034
|
+
// Segment index the current ffmpeg run started producing from.
|
|
1035
|
+
encodeStartIndex: 0,
|
|
1036
|
+
// Guards against repeatedly restarting to the same seek position.
|
|
1037
|
+
pendingRestartIndex: -1,
|
|
1038
|
+
// Timestamp of the last encode (re)start, for the restart cooldown.
|
|
1039
|
+
lastRestartAt: 0,
|
|
1040
|
+
// Seek debounce: pending settle timer, the far segment index to restart
|
|
1041
|
+
// at once the burst settles, and the timestamp of the burst's first far
|
|
1042
|
+
// request (for the SEEK_SETTLE_MAX_MS cap).
|
|
1043
|
+
seekSettleTimer: null,
|
|
1044
|
+
seekTarget: null,
|
|
1045
|
+
// Monotonic sequence of INCOMING segment requests (see #ensureEncodingFor
|
|
1046
|
+
// and nextRequestSeq): a request is issued one number when it arrives and
|
|
1047
|
+
// keeps it across all its long-poll iterations, so a burst of requests
|
|
1048
|
+
// from one scrub cannot take turns steering the encoder.
|
|
1049
|
+
requestSeqCounter: 0,
|
|
1050
|
+
latestRequestSeq: 0,
|
|
1051
|
+
seekFirstFarAt: 0,
|
|
1052
|
+
// Circuit breaker: consecutive FAST failures (see SEEK_FAST_FAIL_MS) at
|
|
1053
|
+
// seekFailureTarget. Reset whenever a run starts at a DIFFERENT target or
|
|
1054
|
+
// survives past the fast-fail window. See the exit handler in
|
|
1055
|
+
// #wireEncodeProcess and MAX_SEEK_FAILURES.
|
|
1056
|
+
seekFailureTarget: -1,
|
|
1057
|
+
seekFailureCount: 0,
|
|
1058
|
+
progress: {
|
|
1059
|
+
state: "starting",
|
|
1060
|
+
processedSeconds: 0,
|
|
1061
|
+
startPositionSeconds: 0,
|
|
1062
|
+
totalSeconds: hasDuration ? durationSeconds : null,
|
|
1063
|
+
percent: null,
|
|
1064
|
+
remainingSeconds: hasDuration ? durationSeconds : null,
|
|
1065
|
+
speed: "",
|
|
1066
|
+
updatedAt: Date.now(),
|
|
1067
|
+
lastLoggedAt: 0
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
this.sessionsById.set(sessionId, session);
|
|
1071
|
+
this.sessionIdBySource.set(sourceMapKey, sessionId);
|
|
1072
|
+
|
|
1073
|
+
logger.info(
|
|
1074
|
+
`transcode ${sessionId} start "${logName}" ` +
|
|
1075
|
+
`video=${transcodeVideo ? `${this.videoEncoder.name}${softwarePreset ? `/${softwarePreset}` : ""}` : "copy"} ` +
|
|
1076
|
+
`audio=${transcodeAudio ? "aac" : "copy"} ` +
|
|
1077
|
+
// Branch tag for log correlation: A = video re-encode (fixed GOP, grid
|
|
1078
|
+
// aligned, ts-offset); B = video copy (cut at source keyframes, copyts).
|
|
1079
|
+
`branch=${transcodeVideo ? "A(reencode,fixed-gop)" : "B(copy,copyts)"} ` +
|
|
1080
|
+
`seg=${usingKeyframeBoundaries ? "keyframe" : "uniform"} ` +
|
|
1081
|
+
`${sourceWidth && sourceHeight ? `src=${sourceWidth}x${sourceHeight} ` : ""}` +
|
|
1082
|
+
// Effective encode resolution: budget-on (auto downscale from the
|
|
1083
|
+
// ceiling), manual (user-forced, budget off), or unset (keep source).
|
|
1084
|
+
`${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
|
|
1085
|
+
`${transcodeVideo && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual ` : ""}` +
|
|
1086
|
+
// HDR source and whether the tone-map chain was applied (vs washed-out
|
|
1087
|
+
// fallback when the filters are missing or on a hardware encoder).
|
|
1088
|
+
`${transcodeVideo && mediaInfo.isHdr ? `hdr=1 tonemap=${applyTonemap ? "on" : "off"} ` : ""}` +
|
|
1089
|
+
`${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
|
|
1090
|
+
`duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
await this.#startEncodeRun(session, 0);
|
|
1094
|
+
|
|
1095
|
+
try {
|
|
1096
|
+
await this.waitUntilReady(session);
|
|
1097
|
+
return session;
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
if (session.state === "failed") {
|
|
1100
|
+
await this.disposeSession(session.id);
|
|
1101
|
+
throw error;
|
|
1102
|
+
}
|
|
1103
|
+
// Do not fail session creation on warmup timeout; the synthetic playlist
|
|
1104
|
+
// is already available and segments appear as ffmpeg produces them.
|
|
1105
|
+
return session;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Build a complete VOD HLS playlist for the full media duration.
|
|
1111
|
+
*
|
|
1112
|
+
* The playlist lists every segment up-front and is terminated with
|
|
1113
|
+
* `#EXT-X-ENDLIST`, so the player knows the total duration and can seek to
|
|
1114
|
+
* any position immediately — even before the corresponding segment has been
|
|
1115
|
+
* transcoded. Segments are produced on demand (see {@link getFileStream}).
|
|
1116
|
+
*
|
|
1117
|
+
* @param {number[]} boundaries - Segment start times (0-based); segment i
|
|
1118
|
+
* spans `[boundaries[i], boundaries[i+1])`.
|
|
1119
|
+
* @returns {string}
|
|
1120
|
+
*/
|
|
1121
|
+
#buildVodPlaylist(boundaries) {
|
|
1122
|
+
const count = Math.max(0, boundaries.length - 1);
|
|
1123
|
+
let maxDuration = 0;
|
|
1124
|
+
for (let index = 0; index < count; index += 1) {
|
|
1125
|
+
const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
|
|
1126
|
+
if (duration > maxDuration) {
|
|
1127
|
+
maxDuration = duration;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
const lines = [
|
|
1131
|
+
"#EXTM3U",
|
|
1132
|
+
// The container decides the minimum version (fMP4 + `#EXT-X-MAP` needs 7,
|
|
1133
|
+
// MPEG-TS is fine at 3).
|
|
1134
|
+
`#EXT-X-VERSION:${this.segmentFormat.playlistVersion}`,
|
|
1135
|
+
`#EXT-X-TARGETDURATION:${Math.ceil(maxDuration)}`,
|
|
1136
|
+
"#EXT-X-MEDIA-SEQUENCE:0",
|
|
1137
|
+
"#EXT-X-PLAYLIST-TYPE:VOD",
|
|
1138
|
+
"#EXT-X-INDEPENDENT-SEGMENTS",
|
|
1139
|
+
// Container-specific header lines (e.g. fMP4's `#EXT-X-MAP`).
|
|
1140
|
+
...this.segmentFormat.playlistHeaderLines()
|
|
1141
|
+
];
|
|
1142
|
+
for (let index = 0; index < count; index += 1) {
|
|
1143
|
+
const duration = Math.max(0.1, boundaries[index + 1] - boundaries[index]);
|
|
1144
|
+
lines.push(`#EXTINF:${duration.toFixed(6)},`);
|
|
1145
|
+
lines.push(this.segmentFormat.segmentFileName(index));
|
|
1146
|
+
}
|
|
1147
|
+
lines.push("#EXT-X-ENDLIST");
|
|
1148
|
+
return `${lines.join("\n")}\n`;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Start time (seconds, 0-based) of segment `index`, from the session's
|
|
1153
|
+
* boundary table. Clamped to valid range.
|
|
1154
|
+
*
|
|
1155
|
+
* @param {HlsSession} session
|
|
1156
|
+
* @param {number} index
|
|
1157
|
+
* @returns {number}
|
|
1158
|
+
*/
|
|
1159
|
+
#segmentStartTime(session, index) {
|
|
1160
|
+
const boundaries = Array.isArray(session.segmentBoundaries) ? session.segmentBoundaries : [];
|
|
1161
|
+
if (boundaries.length === 0) {
|
|
1162
|
+
return index * this.segmentDurationSec;
|
|
1163
|
+
}
|
|
1164
|
+
const clamped = Math.max(0, Math.min(index, boundaries.length - 1));
|
|
1165
|
+
return boundaries[clamped];
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Segment index whose span contains time `t` (0-based), via the boundary
|
|
1170
|
+
* table.
|
|
1171
|
+
*
|
|
1172
|
+
* @param {HlsSession} session
|
|
1173
|
+
* @param {number} t
|
|
1174
|
+
* @returns {number}
|
|
1175
|
+
*/
|
|
1176
|
+
#segmentIndexForTime(session, t) {
|
|
1177
|
+
const boundaries = Array.isArray(session.segmentBoundaries) ? session.segmentBoundaries : [];
|
|
1178
|
+
if (boundaries.length < 2) {
|
|
1179
|
+
return Math.max(0, Math.floor(t / this.segmentDurationSec));
|
|
1180
|
+
}
|
|
1181
|
+
// boundaries is sorted ascending; find the last boundary <= t.
|
|
1182
|
+
let lo = 0;
|
|
1183
|
+
let hi = boundaries.length - 1;
|
|
1184
|
+
let result = 0;
|
|
1185
|
+
while (lo <= hi) {
|
|
1186
|
+
const mid = (lo + hi) >> 1;
|
|
1187
|
+
if (boundaries[mid] <= t) {
|
|
1188
|
+
result = mid;
|
|
1189
|
+
lo = mid + 1;
|
|
1190
|
+
} else {
|
|
1191
|
+
hi = mid - 1;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return Math.min(result, boundaries.length - 2);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Realtime budget (software encoder only): choose the output resolution AND
|
|
1199
|
+
* libx264 preset this host can encode faster than realtime, from the startup
|
|
1200
|
+
* benchmark. The ceiling is the client-requested box capped to the source
|
|
1201
|
+
* (never upscaled); the budget picks the highest resolution rung at or below
|
|
1202
|
+
* that ceiling that clears realtime × margin, then the best preset at that
|
|
1203
|
+
* resolution. On a weak host this downscales below the client target instead
|
|
1204
|
+
* of dropping into sub-realtime playback. Returns null when not applicable
|
|
1205
|
+
* (no video transcode, hardware encoder, or missing benchmark/source size) —
|
|
1206
|
+
* the encode then keeps the ceiling resolution and the default preset.
|
|
1207
|
+
*
|
|
1208
|
+
* @param {{ transcodeVideo: boolean, targetWidth: number, targetHeight: number, sourceWidth: number | null, sourceHeight: number | null, outputFps: number }} params
|
|
1209
|
+
* @returns {{ width: number, height: number, preset: string } | null}
|
|
1210
|
+
*/
|
|
1211
|
+
#chooseEncodeBudget({ transcodeVideo, targetWidth, targetHeight, sourceWidth, sourceHeight, outputFps }) {
|
|
1212
|
+
if (!transcodeVideo || this.videoEncoder?.kind !== "software" || !this.softwarePresetBenchmark) {
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
const ceiling = computeOutputDimensions(targetWidth, targetHeight, sourceWidth, sourceHeight);
|
|
1216
|
+
if (!ceiling) {
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
return chooseSoftwareEncodeSettings(this.softwarePresetBenchmark, { width: ceiling.w, height: ceiling.h }, outputFps);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Parse ffmpeg's `speed` progress value (e.g. "0.903x", "1.6x", "N/A") into a
|
|
1224
|
+
* number. Returns null when it cannot be parsed (no data yet).
|
|
1225
|
+
*
|
|
1226
|
+
* @param {string} value
|
|
1227
|
+
* @returns {number | null}
|
|
1228
|
+
*/
|
|
1229
|
+
#parseSpeed(value) {
|
|
1230
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1231
|
+
return null;
|
|
1232
|
+
}
|
|
1233
|
+
const numeric = Number.parseFloat(value);
|
|
1234
|
+
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Realtime budget monitor (software encoder only). For each active
|
|
1239
|
+
* software-transcode session, watch the encoder's cumulative `speed`: when it
|
|
1240
|
+
* stays below realtime for a sustained window AND the input is not
|
|
1241
|
+
* download-starved (so the limit is the encoder, not the torrent), step the
|
|
1242
|
+
* resolution one rung down the ladder and restart the encode at the current
|
|
1243
|
+
* segment. Conservative: sustained window, post-action cooldown, a step cap,
|
|
1244
|
+
* and a resolution floor (the last ladder rung). No upswitch in v1.
|
|
1245
|
+
*
|
|
1246
|
+
* @returns {Promise<void>}
|
|
1247
|
+
*/
|
|
1248
|
+
/**
|
|
1249
|
+
* Record the latest viewer link report for a session (adaptive bitrate).
|
|
1250
|
+
* Returns false for an unknown/disposed session.
|
|
1251
|
+
*
|
|
1252
|
+
* @param {string} sessionId
|
|
1253
|
+
* @param {{ linkMbps: number, bufferedAheadSec: number }} report
|
|
1254
|
+
* @returns {boolean}
|
|
1255
|
+
*/
|
|
1256
|
+
recordNetReport(sessionId, { linkMbps, bufferedAheadSec }) {
|
|
1257
|
+
const session = this.sessionsById.get(sessionId);
|
|
1258
|
+
if (!session || session.state === "disposed") {
|
|
1259
|
+
return false;
|
|
1260
|
+
}
|
|
1261
|
+
session.netReport = { linkMbps, bufferedAheadSec, at: Date.now() };
|
|
1262
|
+
return true;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Observed produced bitrate (Mbit/s) averaged over the last few COMPLETED
|
|
1267
|
+
* segment files (the newest file may still be being written and is
|
|
1268
|
+
* excluded). Transcode sessions only — their segment grid is uniform, so
|
|
1269
|
+
* bytes / (count × segDur) is exact. Returns null when there is not enough
|
|
1270
|
+
* material to measure.
|
|
1271
|
+
*
|
|
1272
|
+
* @param {HlsSession} session
|
|
1273
|
+
* @returns {Promise<number | null>}
|
|
1274
|
+
*/
|
|
1275
|
+
async #observedStreamMbps(session) {
|
|
1276
|
+
let names;
|
|
1277
|
+
try {
|
|
1278
|
+
names = await readdir(session.dirPath);
|
|
1279
|
+
} catch {
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
const indices = [];
|
|
1283
|
+
for (const name of names) {
|
|
1284
|
+
const index = this.segmentFormat.segmentIndexFromName(name);
|
|
1285
|
+
if (index >= 0) {
|
|
1286
|
+
indices.push(index);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
if (indices.length < 3) {
|
|
1290
|
+
return null; // need ≥2 completed segments after dropping the newest
|
|
1291
|
+
}
|
|
1292
|
+
indices.sort((a, b) => a - b);
|
|
1293
|
+
const completed = indices.slice(0, -1).slice(-LINK_OBSERVED_SEGMENTS);
|
|
1294
|
+
let bytes = 0;
|
|
1295
|
+
try {
|
|
1296
|
+
for (const index of completed) {
|
|
1297
|
+
const st = await stat(path.join(session.dirPath, this.segmentFormat.segmentFileName(index)));
|
|
1298
|
+
bytes += st.size;
|
|
1299
|
+
}
|
|
1300
|
+
} catch {
|
|
1301
|
+
return null; // a segment vanished mid-measure (seek-restart cleanup)
|
|
1302
|
+
}
|
|
1303
|
+
return (bytes * 8) / (completed.length * this.segmentDurationSec) / 1e6;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Viewer-link deficit check for one session (adaptive bitrate, part b).
|
|
1308
|
+
* Mirrors the CPU slow-window pattern; shares the action cooldown and the
|
|
1309
|
+
* downshift machinery. Returns true when a downshift was applied this tick.
|
|
1310
|
+
*
|
|
1311
|
+
* @param {HlsSession} session
|
|
1312
|
+
* @param {number} now
|
|
1313
|
+
* @returns {Promise<boolean>}
|
|
1314
|
+
*/
|
|
1315
|
+
async #checkLinkBudget(session, now) {
|
|
1316
|
+
const report = session.netReport;
|
|
1317
|
+
if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
|
|
1318
|
+
session.linkSlowSince = 0; // no fresh data — old clients / stopped reporter
|
|
1319
|
+
return false;
|
|
1320
|
+
}
|
|
1321
|
+
if (report.bufferedAheadSec >= LINK_LOW_BUFFER_SEC) {
|
|
1322
|
+
session.linkSlowSince = 0; // viewer is comfortable — nothing to fix
|
|
1323
|
+
return false;
|
|
1324
|
+
}
|
|
1325
|
+
const observed = await this.#observedStreamMbps(session);
|
|
1326
|
+
if (observed === null) {
|
|
1327
|
+
return false; // not enough produced material to compare against
|
|
1328
|
+
}
|
|
1329
|
+
if (report.linkMbps * LINK_SAFETY >= observed) {
|
|
1330
|
+
session.linkSlowSince = 0; // link keeps up
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
if (session.linkSlowSince === 0) {
|
|
1334
|
+
session.linkSlowSince = now;
|
|
1335
|
+
return false;
|
|
1336
|
+
}
|
|
1337
|
+
if (now - session.linkSlowSince < LINK_SLOW_WINDOW_MS) {
|
|
1338
|
+
return false; // not sustained yet
|
|
1339
|
+
}
|
|
1340
|
+
if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
|
|
1341
|
+
return false; // let the previous action settle
|
|
1342
|
+
}
|
|
1343
|
+
await this.#applyBudgetDownshift(
|
|
1344
|
+
session,
|
|
1345
|
+
`link=${report.linkMbps.toFixed(2)}Mbps stream=${observed.toFixed(2)}Mbps buffer=${report.bufferedAheadSec.toFixed(1)}s`,
|
|
1346
|
+
"link"
|
|
1347
|
+
);
|
|
1348
|
+
session.linkSlowSince = 0;
|
|
1349
|
+
return true;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
async #enforceRealtimeBudget() {
|
|
1353
|
+
if (this.videoEncoder?.kind !== "software") {
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const now = Date.now();
|
|
1357
|
+
for (const session of this.sessionsById.values()) {
|
|
1358
|
+
if (
|
|
1359
|
+
!session ||
|
|
1360
|
+
session.state === "disposed" ||
|
|
1361
|
+
session.state === "failed" ||
|
|
1362
|
+
!session.transcodeVideo ||
|
|
1363
|
+
!Array.isArray(session.budgetLadder) ||
|
|
1364
|
+
session.budgetLadder.length < 2
|
|
1365
|
+
) {
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
// Already at the floor or out of steps — nothing more to give.
|
|
1369
|
+
if (
|
|
1370
|
+
session.budgetRungIndex >= session.budgetLadder.length - 1 ||
|
|
1371
|
+
session.budgetDownshifts >= BUDGET_MAX_DOWNSHIFTS
|
|
1372
|
+
) {
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
// Viewer-link deficit first (adaptive bitrate): independent of encoder
|
|
1376
|
+
// speed — a thin cellular link starves even a faster-than-realtime
|
|
1377
|
+
// encode. When it acts, skip the CPU check this tick (shared cooldown
|
|
1378
|
+
// guards double-firing anyway).
|
|
1379
|
+
if (await this.#checkLinkBudget(session, now)) {
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
const speed = this.#parseSpeed(session.progress?.speed);
|
|
1383
|
+
if (speed === null) {
|
|
1384
|
+
continue; // no measurement yet
|
|
1385
|
+
}
|
|
1386
|
+
if (speed >= BUDGET_SPEED_OK) {
|
|
1387
|
+
session.budgetSlowSince = 0; // recovered — reset the slow window
|
|
1388
|
+
continue;
|
|
1389
|
+
}
|
|
1390
|
+
if (speed >= BUDGET_SPEED_SLOW) {
|
|
1391
|
+
continue; // in the hysteresis band; neither slow nor ok
|
|
1392
|
+
}
|
|
1393
|
+
// speed < BUDGET_SPEED_SLOW — track how long it has been slow.
|
|
1394
|
+
if (session.budgetSlowSince === 0) {
|
|
1395
|
+
session.budgetSlowSince = now;
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
if (now - session.budgetSlowSince < BUDGET_SUSTAINED_MS) {
|
|
1399
|
+
continue; // not sustained yet
|
|
1400
|
+
}
|
|
1401
|
+
if (now - session.budgetLastActionAt < BUDGET_ACTION_COOLDOWN_MS) {
|
|
1402
|
+
continue; // let the previous action settle
|
|
1403
|
+
}
|
|
1404
|
+
// Sustained sub-realtime. Only downscale if the encoder — not a
|
|
1405
|
+
// download-starved input — is the limit.
|
|
1406
|
+
const bound = await this.#classifyTranscodeBound(session);
|
|
1407
|
+
if (bound === "download") {
|
|
1408
|
+
logger.info(
|
|
1409
|
+
`[budget] transcode ${session.id} speed=${speed.toFixed(2)}x but download-limited ` +
|
|
1410
|
+
`"${session.fileName}"; not downscaling (torrent is the bottleneck)`
|
|
1411
|
+
);
|
|
1412
|
+
session.budgetSlowSince = 0; // re-evaluate fresh; don't thrash on this
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
await this.#applyBudgetDownshift(session, `speed=${speed.toFixed(2)}x`, bound);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Decide whether a sustained sub-realtime transcode is limited by the encoder
|
|
1421
|
+
* (CPU) or by a download-starved input. Compares the torrent's download rate
|
|
1422
|
+
* with the source's average byte rate; a fully-downloaded file can never be
|
|
1423
|
+
* download-bound. Returns "cpu" | "download" | "unknown" ("unknown" is treated
|
|
1424
|
+
* as CPU by the caller — the common case, logged as such).
|
|
1425
|
+
*
|
|
1426
|
+
* @param {HlsSession} session
|
|
1427
|
+
* @returns {Promise<"cpu" | "download" | "unknown">}
|
|
1428
|
+
*/
|
|
1429
|
+
async #classifyTranscodeBound(session) {
|
|
1430
|
+
if (!this.getSourceStats) {
|
|
1431
|
+
return "unknown";
|
|
1432
|
+
}
|
|
1433
|
+
let stats;
|
|
1434
|
+
try {
|
|
1435
|
+
stats = await this.getSourceStats(session.sourceKey, session.fileIndex);
|
|
1436
|
+
} catch {
|
|
1437
|
+
return "unknown";
|
|
1438
|
+
}
|
|
1439
|
+
if (!stats) {
|
|
1440
|
+
return "unknown";
|
|
1441
|
+
}
|
|
1442
|
+
// A fully (or almost fully) downloaded file cannot be download-bound.
|
|
1443
|
+
if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
|
|
1444
|
+
return "cpu";
|
|
1445
|
+
}
|
|
1446
|
+
const duration = Number.isFinite(session.totalDurationSeconds) ? session.totalDurationSeconds : 0;
|
|
1447
|
+
const length = Number.isFinite(stats.fileLength) && stats.fileLength > 0 ? stats.fileLength : 0;
|
|
1448
|
+
const downloadSpeed = Number.isFinite(stats.downloadSpeed) ? stats.downloadSpeed : 0;
|
|
1449
|
+
if (duration <= 0 || length <= 0) {
|
|
1450
|
+
return "unknown"; // cannot compute the source byte rate
|
|
1451
|
+
}
|
|
1452
|
+
const sourceByteRate = length / duration;
|
|
1453
|
+
return downloadSpeed >= sourceByteRate * BUDGET_DOWNLOAD_OK_FACTOR ? "cpu" : "download";
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
/**
|
|
1457
|
+
* Step a session one resolution rung down the budget ladder and restart the
|
|
1458
|
+
* encode at the current segment with the lighter profile.
|
|
1459
|
+
*
|
|
1460
|
+
* @param {HlsSession} session
|
|
1461
|
+
* @param {string} reasonText - Measurement summary for the log line.
|
|
1462
|
+
* @param {"cpu" | "unknown" | "link"} bound
|
|
1463
|
+
* @returns {Promise<void>}
|
|
1464
|
+
*/
|
|
1465
|
+
async #applyBudgetDownshift(session, reasonText, bound) {
|
|
1466
|
+
const nextIndex = session.budgetRungIndex + 1;
|
|
1467
|
+
const rung = session.budgetLadder[nextIndex];
|
|
1468
|
+
if (!rung) {
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
const fps = Number.isInteger(session.outputFps) && session.outputFps > 0 ? session.outputFps : TRANSCODE_FPS;
|
|
1472
|
+
session.budgetRungIndex = nextIndex;
|
|
1473
|
+
session.budgetDownshifts += 1;
|
|
1474
|
+
session.budgetLastActionAt = Date.now();
|
|
1475
|
+
session.budgetSlowSince = 0;
|
|
1476
|
+
session.encodeWidth = rung.width;
|
|
1477
|
+
session.encodeHeight = rung.height;
|
|
1478
|
+
session.softwarePreset = pickSoftwarePreset(this.softwarePresetBenchmark, rung.width * rung.height * fps);
|
|
1479
|
+
// Restart at the current live-edge segment so the lighter profile takes over
|
|
1480
|
+
// from where the viewer is watching (hard-restart tier).
|
|
1481
|
+
const head = session.encodeStartIndex;
|
|
1482
|
+
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
1483
|
+
? session.progress.processedSeconds
|
|
1484
|
+
: this.#segmentStartTime(session, head);
|
|
1485
|
+
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
1486
|
+
const boundLabel =
|
|
1487
|
+
bound === "link" ? "viewer-link-bound" : bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
|
|
1488
|
+
logger.info(
|
|
1489
|
+
`[budget] transcode ${session.id} ${boundLabel} ` +
|
|
1490
|
+
`${reasonText} → downscale to ${rung.width}x${rung.height}/${session.softwarePreset} ` +
|
|
1491
|
+
`(rung ${nextIndex + 1}/${session.budgetLadder.length}, downshift ${session.budgetDownshifts}/${BUDGET_MAX_DOWNSHIFTS}), ` +
|
|
1492
|
+
`restart at segment #${currentSeg} "${session.fileName}"`
|
|
1493
|
+
);
|
|
1494
|
+
await this.#startEncodeRun(session, currentSeg);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* (Re)start the ffmpeg encode run beginning at segment `startIndex`.
|
|
1499
|
+
*
|
|
1500
|
+
* Any ffmpeg process currently running for this session is terminated FIRST
|
|
1501
|
+
* AND ITS EXIT IS AWAITED before the replacement is spawned into the same
|
|
1502
|
+
* directory. This closes a real incident: a fire-and-forget SIGTERM does not
|
|
1503
|
+
* mean the process is dead — `ChildProcess.killed` reflects only that a
|
|
1504
|
+
* signal was sent, not that the process exited (ffmpeg's own blocking read of
|
|
1505
|
+
* our torrent-backed `/stream` input can defer signal handling for a long
|
|
1506
|
+
* time while starved). On a rapid sequence of seeks this left multiple
|
|
1507
|
+
* ffmpeg processes alive concurrently, all writing into the SAME session
|
|
1508
|
+
* directory — observed as `failed to rename file segment-NNNNN.m4s.tmp`
|
|
1509
|
+
* (a dying process racing a fresh one) and a zombie process still writing a
|
|
1510
|
+
* `.tmp` file ~30s after being "killed" by two LATER restarts, even after the
|
|
1511
|
+
* session had already been released. Multiple ffmpeg processes fighting over
|
|
1512
|
+
* CPU and the same files on a weak host is what a seek could get "stuck" on.
|
|
1513
|
+
*
|
|
1514
|
+
* Because this now awaits, a NEWER restart request can arrive while an OLDER
|
|
1515
|
+
* one is still waiting for the previous process to die. `encodeRunGeneration`
|
|
1516
|
+
* resolves that: each call captures its own generation number, and after the
|
|
1517
|
+
* await, a call whose generation was superseded aborts without spawning —
|
|
1518
|
+
* only the LATEST requested target ever actually starts a process.
|
|
1519
|
+
*
|
|
1520
|
+
* Segment files are named with a global index (`-start_number`) so they
|
|
1521
|
+
* always line up with the synthetic VOD playlist regardless of where
|
|
1522
|
+
* encoding started — this is what makes server-side seeking work.
|
|
1523
|
+
*
|
|
1524
|
+
* @param {HlsSession} session
|
|
1525
|
+
* @param {number} startIndex
|
|
1526
|
+
* @returns {Promise<void>}
|
|
1527
|
+
*/
|
|
1528
|
+
async #startEncodeRun(session, startIndex) {
|
|
1529
|
+
const generation = ++session.encodeRunGeneration;
|
|
1530
|
+
const previousFfmpeg = session.ffmpeg;
|
|
1531
|
+
if (previousFfmpeg && !hasChildExited(previousFfmpeg)) {
|
|
1532
|
+
try {
|
|
1533
|
+
previousFfmpeg.kill("SIGTERM");
|
|
1534
|
+
} catch {
|
|
1535
|
+
// Best effort.
|
|
1536
|
+
}
|
|
1537
|
+
await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
|
|
1538
|
+
if (!hasChildExited(previousFfmpeg)) {
|
|
1539
|
+
try {
|
|
1540
|
+
previousFfmpeg.kill("SIGKILL");
|
|
1541
|
+
} catch {
|
|
1542
|
+
// Best effort.
|
|
1543
|
+
}
|
|
1544
|
+
await waitForChildExit(previousFfmpeg, ENCODE_RUN_TERMINATE_GRACE_MS);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
// A newer restart (or disposal) won the race while we were waiting for the
|
|
1548
|
+
// old process to die — it either already spawned its own replacement or
|
|
1549
|
+
// there is nothing left to start. Do not also spawn from this stale call.
|
|
1550
|
+
if (session.encodeRunGeneration !== generation || session.state === "disposed") {
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
1555
|
+
// 0-based output time of this segment, from the boundary table (uniform for
|
|
1556
|
+
// re-encode, real keyframe for copy).
|
|
1557
|
+
const startSeconds = this.#segmentStartTime(session, safeIndex);
|
|
1558
|
+
const sourceStartTime = Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0;
|
|
1559
|
+
|
|
1560
|
+
// Terminate any existing encode process before starting a new one. The
|
|
1561
|
+
// old process's exit handler no-ops because session.ffmpeg is reassigned
|
|
1562
|
+
// below (it checks identity).
|
|
1563
|
+
if (session.ffmpeg && !session.ffmpeg.killed) {
|
|
1564
|
+
try {
|
|
1565
|
+
session.ffmpeg.kill("SIGTERM");
|
|
1566
|
+
} catch (_error) {
|
|
1567
|
+
// Best effort.
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Video: re-encode only when required, using the detected encoder
|
|
1572
|
+
// (hardware-accelerated or software). The descriptor builds the filter +
|
|
1573
|
+
// codec args (including keyframe alignment on segment boundaries).
|
|
1574
|
+
const videoCodecArgs = session.transcodeVideo
|
|
1575
|
+
? this.videoEncoder.buildVideoArgs({
|
|
1576
|
+
// Budget-selected encode box (may be below the client target on weak
|
|
1577
|
+
// software hosts); falls back to the client target for hardware.
|
|
1578
|
+
targetWidth: session.encodeWidth,
|
|
1579
|
+
targetHeight: session.encodeHeight,
|
|
1580
|
+
segmentDurationSec: this.segmentDurationSec,
|
|
1581
|
+
// Source-inherited output rate (integer, capped); descriptors that
|
|
1582
|
+
// use time-based keyframes just apply it as the frame rate.
|
|
1583
|
+
fps: session.outputFps,
|
|
1584
|
+
// Software-only; hardware descriptors ignore it.
|
|
1585
|
+
preset: session.softwarePreset ?? undefined,
|
|
1586
|
+
// HDR→SDR tone map (software path only; gated on filter availability).
|
|
1587
|
+
tonemap: session.applyTonemap === true
|
|
1588
|
+
})
|
|
1589
|
+
: ["-c:v", "copy"];
|
|
1590
|
+
const audioCodecArgs = session.transcodeAudio
|
|
1591
|
+
? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
|
|
1592
|
+
: ["-c:a", "copy"];
|
|
1593
|
+
|
|
1594
|
+
const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
|
|
1595
|
+
// Hardware decode/encode setup (e.g. VAAPI device) must precede -i, and
|
|
1596
|
+
// only applies when we actually re-encode the video track.
|
|
1597
|
+
if (session.transcodeVideo && Array.isArray(this.videoEncoder.inputArgs)) {
|
|
1598
|
+
args.push(...this.videoEncoder.inputArgs);
|
|
1599
|
+
}
|
|
1600
|
+
// Seek position in SOURCE time. For copy we seek to the real keyframe
|
|
1601
|
+
// (startSeconds is already a real-keyframe offset from 0, so add back the
|
|
1602
|
+
// container start time); for re-encode startSeconds is a plain grid offset.
|
|
1603
|
+
const seekSeconds = session.transcodeVideo ? startSeconds : startSeconds + sourceStartTime;
|
|
1604
|
+
// Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
|
|
1605
|
+
// keyframe (coarse, before -i — safe because WE sourced it from ffprobe,
|
|
1606
|
+
// not the container's own on-the-fly seek/index) and trim the short
|
|
1607
|
+
// residual (bounded by the keyframe interval) precisely AFTER -i, which is
|
|
1608
|
+
// always frame-accurate regardless of -accurate_seek.
|
|
1609
|
+
//
|
|
1610
|
+
// Root cause this works around: `-accurate_seek -ss X` before -i trusts the
|
|
1611
|
+
// CONTAINER's own seek to land near X. For some containers (observed: AVI
|
|
1612
|
+
// with VBR MP3 audio) that on-the-fly seek can point at a position with no
|
|
1613
|
+
// valid frame boundary at all — ffmpeg fails outright ("Seek failed" /
|
|
1614
|
+
// "Header missing"), not just imprecisely, and repeatedly so since every
|
|
1615
|
+
// retry re-tries the SAME bad container-computed position. A keyframe we
|
|
1616
|
+
// read directly from the packet list is a position ffmpeg has already
|
|
1617
|
+
// proven it can decode.
|
|
1618
|
+
const snappedKeyframe = Array.isArray(session.keyframeTimes) && session.keyframeTimes.length > 0
|
|
1619
|
+
? nearestKeyframeAtOrBefore(session.keyframeTimes, seekSeconds)
|
|
1620
|
+
: null;
|
|
1621
|
+
if (snappedKeyframe !== null) {
|
|
1622
|
+
const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
|
|
1623
|
+
if (snappedKeyframe > 0) {
|
|
1624
|
+
args.push("-ss", String(snappedKeyframe));
|
|
1625
|
+
}
|
|
1626
|
+
args.push("-i", session.inputUrl);
|
|
1627
|
+
if (residualSeconds > 0) {
|
|
1628
|
+
args.push("-ss", String(residualSeconds));
|
|
1629
|
+
}
|
|
1630
|
+
} else {
|
|
1631
|
+
if (seekSeconds > 0) {
|
|
1632
|
+
// No keyframe map (probe failed/timed out) — fall back to the previous
|
|
1633
|
+
// behaviour: trust the container's own accurate seek.
|
|
1634
|
+
args.push("-accurate_seek", "-ss", String(seekSeconds));
|
|
1635
|
+
}
|
|
1636
|
+
args.push("-i", session.inputUrl);
|
|
1637
|
+
}
|
|
1638
|
+
if (session.transcodeVideo) {
|
|
1639
|
+
// Branch A (re-encode): fixed GOP makes keyframes land exactly on the
|
|
1640
|
+
// segment grid; relabel output onto the original timeline so segment N
|
|
1641
|
+
// carries PTS = N × segmentDuration.
|
|
1642
|
+
if (startSeconds > 0) {
|
|
1643
|
+
args.push("-output_ts_offset", String(startSeconds));
|
|
1644
|
+
}
|
|
1645
|
+
} else {
|
|
1646
|
+
// Branch B (video copied — only audio is transcoded): we cannot insert
|
|
1647
|
+
// keyframes, so segments are cut at the source's own keyframes (the
|
|
1648
|
+
// playlist boundaries were built from those keyframes). Keep the source's
|
|
1649
|
+
// real timestamps (`-copyts`) so copied frames stay continuous across
|
|
1650
|
+
// boundaries/seeks, and shift by -startTime so the output timeline is
|
|
1651
|
+
// 0-based (a non-zero container start otherwise puts a hole at the very
|
|
1652
|
+
// beginning and desyncs audio/video). Audio is transcoded on this timeline.
|
|
1653
|
+
args.push("-copyts");
|
|
1654
|
+
if (sourceStartTime !== 0) {
|
|
1655
|
+
args.push("-output_ts_offset", String(-sourceStartTime));
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
args.push(
|
|
1659
|
+
"-map",
|
|
1660
|
+
"0:v:0?",
|
|
1661
|
+
"-map",
|
|
1662
|
+
// Type-relative audio track chosen by the viewer (default 0).
|
|
1663
|
+
`0:a:${session.audioTrackIndex ?? 0}?`,
|
|
1664
|
+
...videoCodecArgs,
|
|
1665
|
+
...audioCodecArgs,
|
|
1666
|
+
"-f",
|
|
1667
|
+
"hls",
|
|
1668
|
+
"-hls_time",
|
|
1669
|
+
String(this.segmentDurationSec),
|
|
1670
|
+
"-hls_list_size",
|
|
1671
|
+
"0",
|
|
1672
|
+
"-hls_flags",
|
|
1673
|
+
"independent_segments+temp_file",
|
|
1674
|
+
// Container selection + segment naming, from the active format module.
|
|
1675
|
+
...this.segmentFormat.muxerArgs(),
|
|
1676
|
+
"-start_number",
|
|
1677
|
+
String(safeIndex),
|
|
1678
|
+
// ffmpeg writes its own playlist here; we ignore it and serve the
|
|
1679
|
+
// synthetic VOD playlist instead (see getFileStream).
|
|
1680
|
+
PLAYLIST_FILE_NAME
|
|
1681
|
+
);
|
|
1682
|
+
|
|
1683
|
+
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
1684
|
+
cwd: session.dirPath,
|
|
1685
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1686
|
+
});
|
|
1687
|
+
session.ffmpeg = ffmpeg;
|
|
1688
|
+
session.encodeStartIndex = safeIndex;
|
|
1689
|
+
session.pendingRestartIndex = -1;
|
|
1690
|
+
session.lastRestartAt = Date.now();
|
|
1691
|
+
session.state = session.state === "disposed" ? "disposed" : "starting";
|
|
1692
|
+
session.progress.state = "running";
|
|
1693
|
+
session.progress.processedSeconds = startSeconds;
|
|
1694
|
+
session.progress.startPositionSeconds = startSeconds;
|
|
1695
|
+
session.progress.updatedAt = Date.now();
|
|
1696
|
+
// Any (re)start resets the cumulative `speed` ffmpeg reports, so reset the
|
|
1697
|
+
// realtime-budget slow window too — otherwise warm-up right after a user
|
|
1698
|
+
// seek could be mis-counted as sustained sub-realtime and trigger a
|
|
1699
|
+
// premature downscale.
|
|
1700
|
+
session.budgetSlowSince = 0;
|
|
1701
|
+
|
|
1702
|
+
logger.info(
|
|
1703
|
+
`transcode ${session.id} encode-run from segment #${safeIndex} ` +
|
|
1704
|
+
`(${formatSeconds(startSeconds)}) "${session.fileName}"`
|
|
1705
|
+
);
|
|
1706
|
+
|
|
1707
|
+
this.#wireEncodeProcess(session, ffmpeg);
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Rebase ffmpeg's `-progress` `out_time`/`out_time_ms` onto the SOURCE
|
|
1712
|
+
* (absolute) timeline, so `session.progress.processedSeconds` is always
|
|
1713
|
+
* comparable to `session.progress.startPositionSeconds` — which
|
|
1714
|
+
* `computeProgressMetrics` and the client's own cushion-percent/ETA math
|
|
1715
|
+
* both assume.
|
|
1716
|
+
*
|
|
1717
|
+
* ffmpeg's `-progress` output counts from the START OF THIS RUN on BOTH
|
|
1718
|
+
* branches — neither `-output_ts_offset` (branch A, re-encode) nor
|
|
1719
|
+
* `-copyts` (branch B, video copy) changes it: both relabel the MUXED
|
|
1720
|
+
* output's timestamps, which is a different thing from what `-progress`
|
|
1721
|
+
* reports. Verified empirically on each branch separately against a real
|
|
1722
|
+
* file on the field host:
|
|
1723
|
+
* - branch A: a clip encoded with `-output_ts_offset 100` reports
|
|
1724
|
+
* `out_time` counting 0→5, not 100→105;
|
|
1725
|
+
* - branch B: `-ss 600 … -copyts -c:v copy` reports `out_time` =
|
|
1726
|
+
* 0, 40.7, 54.9, 90.9 — relative, NOT 600, 640.7, …
|
|
1727
|
+
* The branch-B half was originally ASSUMED to be absolute (because of
|
|
1728
|
+
* `-copyts`) and left unrebased in 2.9.53; that assumption was wrong and
|
|
1729
|
+
* cost a field session — hence both measurements above are recorded here,
|
|
1730
|
+
* and neither branch may be exempted again without a fresh measurement.
|
|
1731
|
+
*
|
|
1732
|
+
* Left unrebased, `processedSeconds` jumps from the post-restart
|
|
1733
|
+
* placeholder (`session.progress.startPositionSeconds`, absolute) down to a
|
|
1734
|
+
* near-zero RELATIVE value the moment real ffmpeg progress starts flowing —
|
|
1735
|
+
* `processedSeconds - startPositionSeconds` then goes deeply negative,
|
|
1736
|
+
* clamps to 0, and the client's cushion percent/ETA reads as permanently
|
|
1737
|
+
* stuck at 0% for the whole run even while the encode is actively
|
|
1738
|
+
* producing (field-diagnosed 2026-08-01: `processed=39.5 startPos=1824` at
|
|
1739
|
+
* a healthy 6x speed on branch A; `processed=12.638 startPos=3312` at 12.6x
|
|
1740
|
+
* on branch B).
|
|
1741
|
+
*
|
|
1742
|
+
* @param {HlsSession} session
|
|
1743
|
+
* @param {number} rawSeconds - As parsed from `out_time`/`out_time_ms`.
|
|
1744
|
+
* @returns {number}
|
|
1745
|
+
*/
|
|
1746
|
+
#toAbsoluteProcessedSeconds(session, rawSeconds) {
|
|
1747
|
+
const offset = Number.isFinite(session.progress?.startPositionSeconds)
|
|
1748
|
+
? session.progress.startPositionSeconds
|
|
1749
|
+
: 0;
|
|
1750
|
+
return rawSeconds + offset;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
/**
|
|
1754
|
+
* Wire stdout (progress), stderr (errors) and exit handlers for an ffmpeg
|
|
1755
|
+
* encode process. Handlers no-op when the process has been superseded by a
|
|
1756
|
+
* later encode run (identity check against `session.ffmpeg`).
|
|
1757
|
+
*
|
|
1758
|
+
* @param {HlsSession} session
|
|
1759
|
+
* @param {import("node:child_process").ChildProcess} ffmpeg
|
|
1760
|
+
* @returns {void}
|
|
1761
|
+
*/
|
|
1762
|
+
#wireEncodeProcess(session, ffmpeg) {
|
|
1763
|
+
ffmpeg.stdout.on("data", (chunk) => {
|
|
1764
|
+
const lines = String(chunk).split(/\r?\n/);
|
|
1765
|
+
for (const line of lines) {
|
|
1766
|
+
const normalized = line.trim();
|
|
1767
|
+
if (!normalized) {
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
const separator = normalized.indexOf("=");
|
|
1771
|
+
if (separator <= 0) {
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
const key = normalized.slice(0, separator);
|
|
1775
|
+
const value = normalized.slice(separator + 1);
|
|
1776
|
+
|
|
1777
|
+
if (key === "out_time_ms") {
|
|
1778
|
+
const numeric = Number(value);
|
|
1779
|
+
if (Number.isFinite(numeric) && numeric >= 0) {
|
|
1780
|
+
session.progress.processedSeconds = this.#toAbsoluteProcessedSeconds(session, numeric / MICROSECONDS_PER_SECOND);
|
|
1781
|
+
}
|
|
1782
|
+
} else if (key === "out_time") {
|
|
1783
|
+
const parsed = parseFfmpegTimestamp(value);
|
|
1784
|
+
if (parsed != null) {
|
|
1785
|
+
session.progress.processedSeconds = this.#toAbsoluteProcessedSeconds(session, parsed);
|
|
1786
|
+
}
|
|
1787
|
+
} else if (key === "speed") {
|
|
1788
|
+
session.progress.speed = value;
|
|
1789
|
+
} else if (key === "progress") {
|
|
1790
|
+
session.progress.state = value === "end" ? "ready" : "running";
|
|
1791
|
+
}
|
|
1792
|
+
const metrics = computeProgressMetrics(
|
|
1793
|
+
session.progress.processedSeconds,
|
|
1794
|
+
session.progress.totalSeconds,
|
|
1795
|
+
session.progress.startPositionSeconds
|
|
1796
|
+
);
|
|
1797
|
+
session.progress.percent = metrics.percent;
|
|
1798
|
+
session.progress.remainingSeconds = metrics.remainingSeconds;
|
|
1799
|
+
session.progress.updatedAt = Date.now();
|
|
1800
|
+
const shouldLog =
|
|
1801
|
+
session.progress.percent != null &&
|
|
1802
|
+
session.progress.updatedAt - session.progress.lastLoggedAt >= PROGRESS_LOG_INTERVAL_MS;
|
|
1803
|
+
if (shouldLog) {
|
|
1804
|
+
session.progress.lastLoggedAt = session.progress.updatedAt;
|
|
1805
|
+
logger.info(
|
|
1806
|
+
`transcode ${session.id} "${session.fileName}" ${session.progress.percent.toFixed(1)}% ` +
|
|
1807
|
+
`(${formatSeconds(session.progress.processedSeconds)} / ${formatSeconds(session.progress.totalSeconds)})` +
|
|
1808
|
+
` speed=${session.progress.speed || "n/a"}`
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
ffmpeg.stderr.on("data", (chunk) => {
|
|
1815
|
+
const line = String(chunk).trim();
|
|
1816
|
+
if (line.length > 0) {
|
|
1817
|
+
session.lastError = line;
|
|
1818
|
+
logger.warn(`ffmpeg ${session.id}: ${line}`);
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
ffmpeg.on("error", (error) => {
|
|
1823
|
+
if (session.ffmpeg !== ffmpeg) {
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
session.state = "failed";
|
|
1827
|
+
session.lastError = error instanceof Error ? error.message : String(error);
|
|
1828
|
+
session.progress.state = "failed";
|
|
1829
|
+
session.progress.updatedAt = Date.now();
|
|
1830
|
+
logger.error(`ffmpeg ${session.id} process error: ${session.lastError}`);
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
ffmpeg.on("exit", (code, signal) => {
|
|
1834
|
+
// Ignore the exit of a process that was superseded by a seek-restart.
|
|
1835
|
+
if (session.ffmpeg !== ffmpeg) {
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
if (session.state === "disposed") {
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
if (code === 0) {
|
|
1842
|
+
session.state = "ready";
|
|
1843
|
+
session.progress.state = "ready";
|
|
1844
|
+
session.progress.updatedAt = Date.now();
|
|
1845
|
+
logger.info(`transcode ${session.id} encode-run complete "${session.fileName}"`);
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
if (!session.lastError) {
|
|
1849
|
+
session.lastError = `ffmpeg exited with code ${code ?? -1}${signal ? ` (signal ${signal})` : ""}`;
|
|
1850
|
+
}
|
|
1851
|
+
// Runtime safety net: if a hardware encode fails, downgrade this proxy to
|
|
1852
|
+
// software encoding for all sessions and restart this one, so playback is
|
|
1853
|
+
// never permanently broken by a hardware/driver issue.
|
|
1854
|
+
if (session.transcodeVideo && this.videoEncoder.kind !== "software") {
|
|
1855
|
+
const failedEncoder = this.videoEncoder.name;
|
|
1856
|
+
this.videoEncoder = softwareDescriptor();
|
|
1857
|
+
logger.warn(
|
|
1858
|
+
`transcode ${session.id} hardware encoder ${failedEncoder} failed ` +
|
|
1859
|
+
`(${session.lastError}); falling back to software libx264 and restarting`
|
|
1860
|
+
);
|
|
1861
|
+
void this.#startEncodeRun(session, session.encodeStartIndex);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
// Circuit-breaker bookkeeping: a seek-restart run that exits THIS fast
|
|
1865
|
+
// never did real work — it failed at the seek/open step itself, not
|
|
1866
|
+
// mid-stream (see SEEK_FAST_FAIL_MS). Track consecutive fast failures at
|
|
1867
|
+
// the SAME target so #ensureEncodingFor/#fireSettledSeek (which check
|
|
1868
|
+
// this below) can stop retrying instead of looping forever on a position
|
|
1869
|
+
// that keeps failing even with the keyframe-snapped seek.
|
|
1870
|
+
const elapsedMs = Date.now() - session.lastRestartAt;
|
|
1871
|
+
if (elapsedMs < SEEK_FAST_FAIL_MS && session.encodeStartIndex > 0) {
|
|
1872
|
+
if (session.seekFailureTarget === session.encodeStartIndex) {
|
|
1873
|
+
session.seekFailureCount += 1;
|
|
1874
|
+
} else {
|
|
1875
|
+
session.seekFailureTarget = session.encodeStartIndex;
|
|
1876
|
+
session.seekFailureCount = 1;
|
|
1877
|
+
}
|
|
1878
|
+
logger.warn(
|
|
1879
|
+
`transcode ${session.id} fast failure at segment #${session.encodeStartIndex} ` +
|
|
1880
|
+
`(${elapsedMs}ms) — ${session.seekFailureCount}/${MAX_SEEK_FAILURES} consecutive`
|
|
1881
|
+
);
|
|
1882
|
+
} else {
|
|
1883
|
+
// Real progress was made (or this was the very first run) — not a
|
|
1884
|
+
// repeating seek failure. Reset the breaker.
|
|
1885
|
+
session.seekFailureTarget = -1;
|
|
1886
|
+
session.seekFailureCount = 0;
|
|
1887
|
+
}
|
|
1888
|
+
session.state = "failed";
|
|
1889
|
+
session.progress.state = "failed";
|
|
1890
|
+
session.progress.updatedAt = Date.now();
|
|
1891
|
+
logger.error(`transcode ${session.id} encode-run failed: ${session.lastError}`);
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* Ensure the encoder is producing (or will soon produce) the requested
|
|
1897
|
+
* segment. If the segment is far ahead of the current encode head, or
|
|
1898
|
+
* behind it, restart ffmpeg at that segment (server-side seek). Requests
|
|
1899
|
+
* within the look-ahead window are served by waiting for the running encode.
|
|
1900
|
+
*
|
|
1901
|
+
* @param {HlsSession} session
|
|
1902
|
+
* @param {number} index
|
|
1903
|
+
* @returns {void}
|
|
1904
|
+
*/
|
|
1905
|
+
#ensureEncodingFor(session, index, requestSeq = Number.MAX_SAFE_INTEGER) {
|
|
1906
|
+
if (!session || session.state === "disposed" || index < 0) {
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
// A stale in-flight request must not steer the encoder. One scrub of the
|
|
1910
|
+
// seek bar makes the player fire SEVERAL segment requests within a few
|
|
1911
|
+
// hundred ms (field-observed 2026-08-01: #534, #694, #817, #828 within
|
|
1912
|
+
// 361 ms), and each one long-polls this method every 300 ms until it is
|
|
1913
|
+
// served or times out. Without this guard they take turns overwriting
|
|
1914
|
+
// `seekTarget`, so the encoder ping-pongs between their positions
|
|
1915
|
+
// (534→828→694→828→817→828) and none of them ever completes — the buffer
|
|
1916
|
+
// stayed empty for over a minute while ffmpeg restarted six times. Only
|
|
1917
|
+
// the NEWEST request may set the target: older ones keep polling (their
|
|
1918
|
+
// segment may still be produced) but no longer move the encoder.
|
|
1919
|
+
if (requestSeq < session.latestRequestSeq) {
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
session.latestRequestSeq = requestSeq;
|
|
1923
|
+
const head = session.encodeStartIndex;
|
|
1924
|
+
// Anchor the look-ahead window on the CURRENT encode position (start index +
|
|
1925
|
+
// seconds already processed), not the run's start index. Otherwise a long
|
|
1926
|
+
// run that has encoded well past `head` would needlessly restart for a
|
|
1927
|
+
// request just ahead of the live edge.
|
|
1928
|
+
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
1929
|
+
? session.progress.processedSeconds
|
|
1930
|
+
: this.#segmentStartTime(session, head);
|
|
1931
|
+
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
1932
|
+
const withinWindow = index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS;
|
|
1933
|
+
if (withinWindow) {
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
// Circuit breaker: this exact target has already failed MAX_SEEK_FAILURES
|
|
1937
|
+
// times in a row (fast failures — see #wireEncodeProcess's exit handler).
|
|
1938
|
+
// Stop auto-retrying it; session.state stays "failed" so getFileStream
|
|
1939
|
+
// reports a clean, retryable error instead of looping forever. A DIFFERENT
|
|
1940
|
+
// target (the viewer seeking elsewhere) is unaffected — it gets its own
|
|
1941
|
+
// fresh attempt budget.
|
|
1942
|
+
if (index === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
// Far request = a server-side seek. Do NOT restart on the first one:
|
|
1946
|
+
// debounce a burst of scattered requests into a single restart at the
|
|
1947
|
+
// position the player ended on. Record the latest target and (re)arm the
|
|
1948
|
+
// settle timer; the caller long-polls / the client retries meanwhile.
|
|
1949
|
+
session.seekTarget = index;
|
|
1950
|
+
if (session.seekSettleTimer) {
|
|
1951
|
+
clearTimeout(session.seekSettleTimer);
|
|
1952
|
+
} else {
|
|
1953
|
+
session.seekFirstFarAt = Date.now();
|
|
1954
|
+
}
|
|
1955
|
+
const waited = Date.now() - session.seekFirstFarAt;
|
|
1956
|
+
const delay = waited >= SEEK_SETTLE_MAX_MS ? 0 : Math.min(SEEK_SETTLE_MS, SEEK_SETTLE_MAX_MS - waited);
|
|
1957
|
+
session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), delay);
|
|
1958
|
+
session.seekSettleTimer.unref?.();
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
/**
|
|
1962
|
+
* Fire a settled server-side seek: restart the encoder once at the target
|
|
1963
|
+
* recorded during the settle window. Enforces the restart cooldown as a
|
|
1964
|
+
* floor between actual restarts (re-arming for the remainder if still
|
|
1965
|
+
* cooling down). No-op for a disposed session or a cleared target.
|
|
1966
|
+
*
|
|
1967
|
+
* @param {HlsSession} session
|
|
1968
|
+
* @returns {void}
|
|
1969
|
+
*/
|
|
1970
|
+
#fireSettledSeek(session) {
|
|
1971
|
+
const target = session.seekTarget;
|
|
1972
|
+
session.seekSettleTimer = null;
|
|
1973
|
+
if (!session || session.state === "disposed" || target == null) {
|
|
1974
|
+
session.seekTarget = null;
|
|
1975
|
+
session.seekFirstFarAt = 0;
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
// Circuit breaker (defense in depth): a timer armed before the cap was hit
|
|
1979
|
+
// could still be pending when it was reached — do not fire the restart it
|
|
1980
|
+
// was going to make. See the matching check in #ensureEncodingFor.
|
|
1981
|
+
if (target === session.seekFailureTarget && session.seekFailureCount >= MAX_SEEK_FAILURES) {
|
|
1982
|
+
session.seekTarget = null;
|
|
1983
|
+
session.seekFirstFarAt = 0;
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
// Minimum gap between actual restarts (the settle already collapses bursts;
|
|
1987
|
+
// this only guards back-to-back seeks). If still cooling down, re-arm once
|
|
1988
|
+
// for the remaining cooldown instead of restarting now.
|
|
1989
|
+
const sinceLastRestart = Date.now() - (session.lastRestartAt ?? 0);
|
|
1990
|
+
if (sinceLastRestart < RESTART_COOLDOWN_MS) {
|
|
1991
|
+
session.seekSettleTimer = setTimeout(() => this.#fireSettledSeek(session), RESTART_COOLDOWN_MS - sinceLastRestart);
|
|
1992
|
+
session.seekSettleTimer.unref?.();
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
session.seekTarget = null;
|
|
1996
|
+
session.seekFirstFarAt = 0;
|
|
1997
|
+
logger.info(`transcode ${session.id} seek settle → restart at segment #${target}`);
|
|
1998
|
+
void this.#startEncodeRun(session, target);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
|
|
2003
|
+
* header, or until the session fails, or until the startup timeout elapses.
|
|
2004
|
+
* Throws with message `"HLS playlist is still warming up."` on timeout.
|
|
2005
|
+
*
|
|
2006
|
+
* @param {HlsSession} session
|
|
2007
|
+
* @returns {Promise<void>}
|
|
2008
|
+
*/
|
|
2009
|
+
async waitUntilReady(session) {
|
|
2010
|
+
// With a synthetic VOD playlist there is nothing to wait for: the playlist
|
|
2011
|
+
// is generated from the probed duration and is available immediately.
|
|
2012
|
+
// Individual segments are long-polled by the segment route as ffmpeg
|
|
2013
|
+
// produces them.
|
|
2014
|
+
if (session.useSyntheticPlaylist) {
|
|
2015
|
+
if (session.state === "failed") {
|
|
2016
|
+
throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
|
|
2017
|
+
}
|
|
2018
|
+
session.state = "ready";
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
const playlistPath = path.join(session.dirPath, PLAYLIST_FILE_NAME);
|
|
2023
|
+
const deadline = Date.now() + this.startupWaitMs;
|
|
2024
|
+
|
|
2025
|
+
while (Date.now() < deadline) {
|
|
2026
|
+
if (session.state === "failed") {
|
|
2027
|
+
throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
|
|
2028
|
+
}
|
|
2029
|
+
try {
|
|
2030
|
+
await access(playlistPath);
|
|
2031
|
+
const text = await readFile(playlistPath, "utf8");
|
|
2032
|
+
if (text.includes("#EXTM3U")) {
|
|
2033
|
+
session.state = "ready";
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
} catch (_error) {
|
|
2037
|
+
// Playlist is not ready yet.
|
|
2038
|
+
}
|
|
2039
|
+
await delay(250);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
throw new Error("HLS playlist is still warming up.");
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* Issue the sequence number an incoming segment request keeps for all of its
|
|
2047
|
+
* long-poll iterations. The caller (the route) takes ONE number when the
|
|
2048
|
+
* request arrives and passes it back on every poll, which is what lets
|
|
2049
|
+
* #ensureEncodingFor tell "a newer request arrived" apart from "the same
|
|
2050
|
+
* request polled again" — see the ping-pong it prevents there.
|
|
2051
|
+
*
|
|
2052
|
+
* @param {string} sessionId
|
|
2053
|
+
* @returns {number} 0 when the session is unknown (treated as newest).
|
|
2054
|
+
*/
|
|
2055
|
+
nextRequestSeq(sessionId) {
|
|
2056
|
+
const session = isSafeSessionId(sessionId) ? this.sessionsById.get(sessionId) : null;
|
|
2057
|
+
if (!session) {
|
|
2058
|
+
return 0;
|
|
2059
|
+
}
|
|
2060
|
+
session.requestSeqCounter += 1;
|
|
2061
|
+
return session.requestSeqCounter;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* Open a read stream for an HLS segment or playlist file from a session.
|
|
2066
|
+
*
|
|
2067
|
+
* @param {string} sessionId
|
|
2068
|
+
* @param {string} fileName - Must match the playlist or segment name pattern.
|
|
2069
|
+
* @param {{ requestSeq?: number }} [options] - `requestSeq` from
|
|
2070
|
+
* {@link nextRequestSeq}, constant across one request's long-poll loop.
|
|
2071
|
+
* @returns {Promise<
|
|
2072
|
+
* | { kind: "not-found" }
|
|
2073
|
+
* | { kind: "warming-up" }
|
|
2074
|
+
* | { kind: "failed"; message: string }
|
|
2075
|
+
* | { kind: "file"; stream: import("node:fs").ReadStream; contentType: string; isPlaylist: boolean }
|
|
2076
|
+
* >}
|
|
2077
|
+
*/
|
|
2078
|
+
async getFileStream(sessionId, fileName, options = {}) {
|
|
2079
|
+
if (!isSafeSessionId(sessionId) || !isSafeFileName(fileName, this.segmentFormat)) {
|
|
2080
|
+
return { kind: "not-found" };
|
|
2081
|
+
}
|
|
2082
|
+
const session = this.sessionsById.get(sessionId);
|
|
2083
|
+
if (!session) {
|
|
2084
|
+
return { kind: "not-found" };
|
|
2085
|
+
}
|
|
2086
|
+
if (session.state === "failed") {
|
|
2087
|
+
return {
|
|
2088
|
+
kind: "failed",
|
|
2089
|
+
message: session.lastError || "ffmpeg failed for this transcode session."
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
session.lastAccessedAt = Date.now();
|
|
2093
|
+
|
|
2094
|
+
// Serve the synthetic VOD playlist (full duration, terminated with
|
|
2095
|
+
// #EXT-X-ENDLIST) so the player gets the correct total length and a fully
|
|
2096
|
+
// seekable timeline up-front, independent of how far ffmpeg has encoded.
|
|
2097
|
+
if (fileName === PLAYLIST_FILE_NAME && session.useSyntheticPlaylist) {
|
|
2098
|
+
return {
|
|
2099
|
+
kind: "file",
|
|
2100
|
+
stream: Readable.from([session.playlistText]),
|
|
2101
|
+
contentType: "application/vnd.apple.mpegurl",
|
|
2102
|
+
isPlaylist: true
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// The init segment (fMP4 only; referenced by #EXT-X-MAP). Each seek-restart
|
|
2107
|
+
// run REWRITES it, so cache the FIRST one and always serve that — the
|
|
2108
|
+
// player fetches it once and never re-fetches, so it must stay stable for
|
|
2109
|
+
// the session's lifetime. (What that costs, and why segments must therefore
|
|
2110
|
+
// carry their own position, is documented in `segment-formats/mp4-boxes.js`
|
|
2111
|
+
// `stampSegmentStartTime`.)
|
|
2112
|
+
//
|
|
2113
|
+
// ffmpeg creates init.mp4 before it has finished writing the fMP4 header
|
|
2114
|
+
// boxes into it (unlike segments, its write is not gated behind an atomic
|
|
2115
|
+
// rename), so a read can race a moment where the file EXISTS but is still
|
|
2116
|
+
// EMPTY. Root cause of a real incident: that empty read used to be cached
|
|
2117
|
+
// as `session.initBytes` — a zero-length Buffer is still a truthy object,
|
|
2118
|
+
// so `if (session.initBytes)` treated it as "already resolved" and served
|
|
2119
|
+
// the empty file for the rest of the session's life, permanently breaking
|
|
2120
|
+
// playback (hls.js can never initialize its SourceBuffer from an empty
|
|
2121
|
+
// init segment) while the transcode itself kept encoding normally. Guard
|
|
2122
|
+
// on non-empty content on both the cache check and the fresh read, so an
|
|
2123
|
+
// empty read is treated as not-yet-ready and the caller's long-poll keeps
|
|
2124
|
+
// retrying until ffmpeg has actually written the header.
|
|
2125
|
+
const { initFileName } = this.segmentFormat;
|
|
2126
|
+
if (initFileName !== null && fileName === initFileName) {
|
|
2127
|
+
if (session.initBytes && session.initBytes.length > 0) {
|
|
2128
|
+
return {
|
|
2129
|
+
kind: "file",
|
|
2130
|
+
stream: Readable.from([session.initBytes]),
|
|
2131
|
+
contentType: this.segmentFormat.initContentType,
|
|
2132
|
+
isPlaylist: false
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
try {
|
|
2136
|
+
const bytes = await readFile(path.join(session.dirPath, initFileName));
|
|
2137
|
+
if (bytes.length === 0) {
|
|
2138
|
+
return { kind: "warming-up" };
|
|
2139
|
+
}
|
|
2140
|
+
session.initBytes = bytes;
|
|
2141
|
+
return {
|
|
2142
|
+
kind: "file",
|
|
2143
|
+
stream: Readable.from([bytes]),
|
|
2144
|
+
contentType: this.segmentFormat.initContentType,
|
|
2145
|
+
isPlaylist: false
|
|
2146
|
+
};
|
|
2147
|
+
} catch {
|
|
2148
|
+
// Not produced yet — the encode run started at session creation writes
|
|
2149
|
+
// it early; the caller long-polls until it appears.
|
|
2150
|
+
return { kind: "warming-up" };
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
const filePath = path.join(session.dirPath, fileName);
|
|
2155
|
+
const isPlaylist = fileName === PLAYLIST_FILE_NAME;
|
|
2156
|
+
try {
|
|
2157
|
+
await access(filePath);
|
|
2158
|
+
// Cold-start: log the first servable SEGMENT of this session exactly once
|
|
2159
|
+
// — the time from session-create entry to a playable first segment.
|
|
2160
|
+
if (!isPlaylist && !session.firstSegmentLogged) {
|
|
2161
|
+
session.firstSegmentLogged = true;
|
|
2162
|
+
logger.info(
|
|
2163
|
+
`cold-start ${sessionId.slice(0, 8)}: first-segment ready +${Date.now() - session.createEntryMs}ms`
|
|
2164
|
+
);
|
|
2165
|
+
}
|
|
2166
|
+
// Formats whose segments need correcting before they are valid against
|
|
2167
|
+
// the session's cached init are read whole and passed through the format
|
|
2168
|
+
// module; the rest stream straight off disk.
|
|
2169
|
+
if (!isPlaylist && this.segmentFormat.needsSegmentRewrite) {
|
|
2170
|
+
const index = this.segmentFormat.segmentIndexFromName(fileName);
|
|
2171
|
+
const bytes = await readFile(filePath);
|
|
2172
|
+
const prepared = this.segmentFormat.prepareSegmentBytes(bytes, {
|
|
2173
|
+
startSeconds: this.#segmentStartTime(session, index),
|
|
2174
|
+
initBytes: session.initBytes ?? null
|
|
2175
|
+
});
|
|
2176
|
+
return {
|
|
2177
|
+
kind: "file",
|
|
2178
|
+
stream: Readable.from([prepared]),
|
|
2179
|
+
contentType: this.segmentFormat.segmentContentType,
|
|
2180
|
+
isPlaylist: false
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
return {
|
|
2184
|
+
kind: "file",
|
|
2185
|
+
stream: isPlaylist
|
|
2186
|
+
? createReadStream(filePath)
|
|
2187
|
+
: createReadStream(filePath, { highWaterMark: SEGMENT_READ_HIGH_WATER_MARK }),
|
|
2188
|
+
contentType: isPlaylist
|
|
2189
|
+
? "application/vnd.apple.mpegurl"
|
|
2190
|
+
: this.segmentFormat.segmentContentType,
|
|
2191
|
+
isPlaylist
|
|
2192
|
+
};
|
|
2193
|
+
} catch (_error) {
|
|
2194
|
+
// File not produced yet.
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// A segment was requested that ffmpeg has not produced yet. Decide whether
|
|
2198
|
+
// to wait for the current encode run to reach it or to restart the encoder
|
|
2199
|
+
// at this position (server-side seeking). The caller long-polls.
|
|
2200
|
+
if (!isPlaylist) {
|
|
2201
|
+
this.#ensureEncodingFor(
|
|
2202
|
+
session,
|
|
2203
|
+
this.segmentFormat.segmentIndexFromName(fileName),
|
|
2204
|
+
Number.isFinite(options?.requestSeq) ? options.requestSeq : Number.MAX_SAFE_INTEGER
|
|
2205
|
+
);
|
|
2206
|
+
}
|
|
2207
|
+
return { kind: "warming-up" };
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* Dispose all sessions that have been idle longer than `sessionTtlMs`.
|
|
2212
|
+
* Called automatically on the cleanup interval.
|
|
2213
|
+
*
|
|
2214
|
+
* @returns {Promise<void>}
|
|
2215
|
+
*/
|
|
2216
|
+
async cleanupExpired() {
|
|
2217
|
+
const now = Date.now();
|
|
2218
|
+
const idsToDispose = [];
|
|
2219
|
+
for (const [sessionId, session] of this.sessionsById.entries()) {
|
|
2220
|
+
if (now - session.lastAccessedAt > this.sessionTtlMs) {
|
|
2221
|
+
idsToDispose.push(sessionId);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
for (const sessionId of idsToDispose) {
|
|
2225
|
+
await this.disposeSession(sessionId);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* Return a progress snapshot for the given session, or `null` if not found.
|
|
2231
|
+
* Also refreshes `lastAccessedAt` to prevent the session from expiring.
|
|
2232
|
+
*
|
|
2233
|
+
* @param {string} sessionId
|
|
2234
|
+
* @returns {Promise<object | null>}
|
|
2235
|
+
*/
|
|
2236
|
+
async getSessionProgress(sessionId) {
|
|
2237
|
+
if (!isSafeSessionId(sessionId)) {
|
|
2238
|
+
return null;
|
|
2239
|
+
}
|
|
2240
|
+
const session = this.sessionsById.get(sessionId);
|
|
2241
|
+
if (!session) {
|
|
2242
|
+
return null;
|
|
2243
|
+
}
|
|
2244
|
+
session.lastAccessedAt = Date.now();
|
|
2245
|
+
const warmupTotalSeconds = this.startupWaitMs / 1000;
|
|
2246
|
+
const warmupElapsedSeconds = Math.max(0, (Date.now() - session.startedAt) / 1000);
|
|
2247
|
+
const isWarmupPhase = session.state === "starting" || session.progress.state === "starting";
|
|
2248
|
+
const warmupPercent = isWarmupPhase
|
|
2249
|
+
? Math.max(0, Math.min(100, (warmupElapsedSeconds / warmupTotalSeconds) * 100))
|
|
2250
|
+
: null;
|
|
2251
|
+
const warmupRemainingSeconds = isWarmupPhase
|
|
2252
|
+
? Math.max(0, warmupTotalSeconds - warmupElapsedSeconds)
|
|
2253
|
+
: null;
|
|
2254
|
+
// Observed OUTPUT bitrate (Mbit/s) from recently completed segment sizes —
|
|
2255
|
+
// already computed for the viewer-link budget check (#checkLinkBudget); also
|
|
2256
|
+
// exposed here so the browser can turn its OWN measured link throughput into
|
|
2257
|
+
// a "content-seconds delivered per wall-clock second" rate for the unified
|
|
2258
|
+
// three-stage ETA (download / transcode / delivery), the same way the
|
|
2259
|
+
// transcode's own `speed` already is one. Null when not enough segments yet.
|
|
2260
|
+
const outputMbps = await this.#observedStreamMbps(session);
|
|
2261
|
+
return {
|
|
2262
|
+
sessionId: session.id,
|
|
2263
|
+
state: session.progress.state,
|
|
2264
|
+
processedSeconds: session.progress.processedSeconds,
|
|
2265
|
+
startPositionSeconds: session.progress.startPositionSeconds ?? 0,
|
|
2266
|
+
totalSeconds: session.progress.totalSeconds,
|
|
2267
|
+
percent: session.progress.percent,
|
|
2268
|
+
remainingSeconds: session.progress.remainingSeconds,
|
|
2269
|
+
warmupPercent,
|
|
2270
|
+
warmupRemainingSeconds,
|
|
2271
|
+
// Segment length, so the browser can show progress toward the FIRST
|
|
2272
|
+
// segment (the only thing it waits for before playback starts) instead
|
|
2273
|
+
// of a percentage of the whole-file transcode.
|
|
2274
|
+
segmentDurationSec: this.segmentDurationSec,
|
|
2275
|
+
speed: session.progress.speed,
|
|
2276
|
+
outputMbps,
|
|
2277
|
+
updatedAt: session.progress.updatedAt,
|
|
2278
|
+
error: session.state === "failed" ? session.lastError : ""
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/**
|
|
2283
|
+
* Remove a consumer from a session. Disposes the session when the last
|
|
2284
|
+
* consumer leaves.
|
|
2285
|
+
*
|
|
2286
|
+
* @param {string} sessionId
|
|
2287
|
+
* @param {string} [consumerId=""]
|
|
2288
|
+
* @param {string} [reason=""] - Human-readable reason shown in logs.
|
|
2289
|
+
* @returns {Promise<boolean>} `false` if the session was not found.
|
|
2290
|
+
*/
|
|
2291
|
+
async releaseSessionConsumer(sessionId, consumerId = "", reason = "") {
|
|
2292
|
+
if (!isSafeSessionId(sessionId) || typeof consumerId !== "string" || consumerId.length === 0) {
|
|
2293
|
+
return false;
|
|
2294
|
+
}
|
|
2295
|
+
const session = this.sessionsById.get(sessionId);
|
|
2296
|
+
if (!session) {
|
|
2297
|
+
return false;
|
|
2298
|
+
}
|
|
2299
|
+
if (!(session.consumers instanceof Set)) {
|
|
2300
|
+
session.consumers = new Set();
|
|
2301
|
+
}
|
|
2302
|
+
session.consumers.delete(consumerId);
|
|
2303
|
+
session.lastAccessedAt = Date.now();
|
|
2304
|
+
const logReason = typeof reason === "string" && reason.length > 0 ? reason : "unspecified";
|
|
2305
|
+
logger.info(
|
|
2306
|
+
`consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
|
|
2307
|
+
`remaining=${session.consumers.size}`
|
|
2308
|
+
);
|
|
2309
|
+
if (session.consumers.size > 0) {
|
|
2310
|
+
return true;
|
|
2311
|
+
}
|
|
2312
|
+
await this.disposeSession(sessionId);
|
|
2313
|
+
return true;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
/**
|
|
2317
|
+
* Kill the ffmpeg process, remove it from all maps, and delete the temp dir.
|
|
2318
|
+
*
|
|
2319
|
+
* @param {string} sessionId
|
|
2320
|
+
* @returns {Promise<void>}
|
|
2321
|
+
*/
|
|
2322
|
+
async disposeSession(sessionId) {
|
|
2323
|
+
const session = this.sessionsById.get(sessionId);
|
|
2324
|
+
if (!session) {
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
session.state = "disposed";
|
|
2328
|
+
this.sessionsById.delete(sessionId);
|
|
2329
|
+
this.sessionIdBySource.delete(session.sourceMapKey);
|
|
2330
|
+
|
|
2331
|
+
// Clear any pending seek-settle timer so it cannot fire and restart a
|
|
2332
|
+
// disposed session.
|
|
2333
|
+
if (session.seekSettleTimer) {
|
|
2334
|
+
clearTimeout(session.seekSettleTimer);
|
|
2335
|
+
session.seekSettleTimer = null;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
if (session.ffmpeg && !session.ffmpeg.killed) {
|
|
2339
|
+
session.ffmpeg.kill("SIGTERM");
|
|
2340
|
+
await waitForChildExit(session.ffmpeg);
|
|
2341
|
+
}
|
|
2342
|
+
try {
|
|
2343
|
+
await rm(session.dirPath, { recursive: true, force: true });
|
|
2344
|
+
} catch (error) {
|
|
2345
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2346
|
+
logger.warn(`failed to cleanup HLS temp dir: ${message}`);
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
/**
|
|
2351
|
+
* Stop the cleanup timer, dispose all active sessions, and attempt to
|
|
2352
|
+
* remove the shared temp root directory if it is empty.
|
|
2353
|
+
* Called by Fastify's `onClose` hook during graceful shutdown.
|
|
2354
|
+
*
|
|
2355
|
+
* @returns {Promise<void>}
|
|
2356
|
+
*/
|
|
2357
|
+
async disposeAll() {
|
|
2358
|
+
clearInterval(this.cleanupTimer);
|
|
2359
|
+
clearInterval(this.budgetTimer);
|
|
2360
|
+
const activeIds = Array.from(this.sessionsById.keys());
|
|
2361
|
+
for (const sessionId of activeIds) {
|
|
2362
|
+
await this.disposeSession(sessionId);
|
|
2363
|
+
}
|
|
2364
|
+
const rootDir = path.join(os.tmpdir(), "torrent-tv-hls");
|
|
2365
|
+
try {
|
|
2366
|
+
const dirs = await readdir(rootDir);
|
|
2367
|
+
if (dirs.length === 0) {
|
|
2368
|
+
await rm(rootDir, { recursive: true, force: true });
|
|
2369
|
+
}
|
|
2370
|
+
} catch (_error) {
|
|
2371
|
+
// Best effort cleanup.
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
}
|