@torrent-tv/proxy 2.6.2 → 2.6.6
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
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2.6.3
|
|
2
|
+
|
|
3
|
+
- **Fix**: Data channel handler now logs **all** requests regardless of body presence — `GET /transcode/…`, `GET /api/…/progress`, `GET /api/…/stats` etc. were previously invisible in logs. Non-2xx response statuses and fetch errors are also logged, enabling diagnosis of HLS manifest load failures.
|
|
4
|
+
|
|
1
5
|
## 2.6.1
|
|
2
6
|
|
|
3
7
|
- **Fix**: `TorrentPool.getTorrent()` — eliminated a race condition where two concurrent requests for the same torrent both found the cache empty and both called `client.add()`, causing WebTorrent to throw "Cannot add duplicate torrent". In-flight promises are now cached in a private `#pending` map; subsequent requests for the same key join the existing promise instead of triggering a second `client.add()`.
|
package/package.json
CHANGED
|
@@ -14,14 +14,17 @@
|
|
|
14
14
|
export async function handleTranscodeSessionFileGet(req, reply, { hlsSessionManager }) {
|
|
15
15
|
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
16
16
|
const fileName = typeof req.params.fileName === "string" ? req.params.fileName : "";
|
|
17
|
-
const result = await waitForSessionFile(hlsSessionManager, sessionId, fileName,
|
|
17
|
+
const result = await waitForSessionFile(hlsSessionManager, sessionId, fileName, 30_000);
|
|
18
18
|
|
|
19
19
|
if (result.kind === "not-found") {
|
|
20
20
|
return reply.code(404).send({ error: "Transcode session file was not found." });
|
|
21
21
|
}
|
|
22
22
|
if (result.kind === "warming-up") {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// The segment is still being produced (e.g. just after a seek-restart).
|
|
24
|
+
// Return a retryable 503 — never 202, which hls.js cannot consume as a
|
|
25
|
+
// media segment — so the player retries the fetch shortly.
|
|
26
|
+
reply.header("Retry-After", "1");
|
|
27
|
+
return reply.code(503).send({ error: "Transcode segment is still being produced." });
|
|
25
28
|
}
|
|
26
29
|
if (result.kind === "failed") {
|
|
27
30
|
return reply.code(500).send({ error: result.message });
|
|
@@ -143,9 +143,9 @@ export function createDataChannelHandler({ proxyPort, onLog }) {
|
|
|
143
143
|
return;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
146
|
+
const queryInfo = query ? `?${query}` : "";
|
|
147
|
+
const bodyInfo = body != null && typeof body === "string" && body.length > 0 ? ` body=${body.length} bytes` : "";
|
|
148
|
+
log(`[dc] ${method} ${path}${queryInfo}${bodyInfo}`);
|
|
149
149
|
|
|
150
150
|
const targetUrl = `http://127.0.0.1:${proxyPort}${path}${query ? `?${query}` : ""}`;
|
|
151
151
|
const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
|
|
@@ -159,10 +159,15 @@ export function createDataChannelHandler({ proxyPort, onLog }) {
|
|
|
159
159
|
redirect: "manual"
|
|
160
160
|
});
|
|
161
161
|
} catch (fetchError) {
|
|
162
|
+
log(`[dc] ${method} ${path}${queryInfo} → error: ${fetchError?.message ?? String(fetchError)}`);
|
|
162
163
|
send(channel, { type: "response-error", requestId, error: fetchError?.message ?? String(fetchError) });
|
|
163
164
|
return;
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
if (response.status !== 200 && response.status !== 206) {
|
|
168
|
+
log(`[dc] ${method} ${path}${queryInfo} → ${response.status}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
166
171
|
/** @type {Record<string, string>} */
|
|
167
172
|
const responseHeaders = {};
|
|
168
173
|
for (const [name, value] of response.headers.entries()) {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { createReadStream } from "node:fs";
|
|
11
11
|
import { access, mkdir, readdir, readFile, rm } from "node:fs/promises";
|
|
12
|
+
import { Readable } from "node:stream";
|
|
12
13
|
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import { randomUUID } from "node:crypto";
|
|
@@ -19,6 +20,10 @@ const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
|
19
20
|
const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
|
|
20
21
|
const CLEANUP_INTERVAL_MS = 60_000;
|
|
21
22
|
const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
23
|
+
// How many segments ahead of the current encode head a missing-segment request
|
|
24
|
+
// is allowed to be before we restart ffmpeg at that position (server-side seek).
|
|
25
|
+
// Requests within the window are served by waiting for the running encode.
|
|
26
|
+
const MAX_LOOKAHEAD_SEGMENTS = 8;
|
|
22
27
|
const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
|
|
23
28
|
const DEFAULT_STARTUP_WAIT_MS = 5_000;
|
|
24
29
|
const MICROSECONDS_PER_SECOND = 1_000_000;
|
|
@@ -120,6 +125,21 @@ function isSafeFileName(fileName) {
|
|
|
120
125
|
return fileName === PLAYLIST_FILE_NAME || SEGMENT_FILE_NAME_PATTERN.test(fileName);
|
|
121
126
|
}
|
|
122
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Extract the zero-based segment index from a segment file name.
|
|
130
|
+
* Returns -1 when the name is not a valid segment file.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} fileName - e.g. "segment-00012.ts"
|
|
133
|
+
* @returns {number}
|
|
134
|
+
*/
|
|
135
|
+
function segmentIndexFromName(fileName) {
|
|
136
|
+
const match = /^segment-(\d{5})\.ts$/.exec(fileName);
|
|
137
|
+
if (!match) {
|
|
138
|
+
return -1;
|
|
139
|
+
}
|
|
140
|
+
return Number(match[1]);
|
|
141
|
+
}
|
|
142
|
+
|
|
123
143
|
/**
|
|
124
144
|
* Parse an ffmpeg `HH:MM:SS.mmm` timestamp string into total seconds.
|
|
125
145
|
* Returns `null` if the value is absent or malformed.
|
|
@@ -421,12 +441,153 @@ export class HlsSessionManager {
|
|
|
421
441
|
const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
|
|
422
442
|
inputUrl.searchParams.set("sourceKey", sourceKey);
|
|
423
443
|
inputUrl.searchParams.set("fileIndex", String(fileIndex));
|
|
444
|
+
|
|
445
|
+
// Probe the full media duration up-front so we can serve a complete VOD
|
|
446
|
+
// playlist (terminated with #EXT-X-ENDLIST) immediately. This gives the
|
|
447
|
+
// player the correct total duration and a fully seekable timeline before a
|
|
448
|
+
// single segment has been transcoded.
|
|
424
449
|
const durationSeconds = await probeInputDurationSeconds(this.ffmpegBin, inputUrl.toString());
|
|
450
|
+
const hasDuration = Number.isFinite(durationSeconds) && durationSeconds > 0;
|
|
451
|
+
const logName = normalizeLogFileName(fileName, fileIndex);
|
|
452
|
+
if (!hasDuration) {
|
|
453
|
+
logger.warn(
|
|
454
|
+
`transcode ${sessionId}: could not probe duration; falling back to ` +
|
|
455
|
+
`ffmpeg-managed (growing) playlist for "${logName}"`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
const segmentCount = hasDuration
|
|
459
|
+
? Math.max(1, Math.ceil(durationSeconds / this.segmentDurationSec))
|
|
460
|
+
: 0;
|
|
425
461
|
|
|
426
|
-
const
|
|
462
|
+
const session = {
|
|
463
|
+
id: sessionId,
|
|
464
|
+
sourceMapKey,
|
|
465
|
+
fileName: logName,
|
|
466
|
+
dirPath: sessionDir,
|
|
467
|
+
state: "starting",
|
|
468
|
+
startedAt: Date.now(),
|
|
469
|
+
lastAccessedAt: Date.now(),
|
|
470
|
+
ffmpeg: null,
|
|
471
|
+
lastError: "",
|
|
472
|
+
consumers: new Set(consumerId ? [consumerId] : []),
|
|
473
|
+
// Transcode parameters retained so the encode run can be restarted at an
|
|
474
|
+
// arbitrary segment when the player seeks (server-side seeking).
|
|
475
|
+
sourceKey,
|
|
476
|
+
fileIndex,
|
|
477
|
+
transcodeVideo,
|
|
478
|
+
transcodeAudio,
|
|
479
|
+
targetWidth: normalizedTargetWidth,
|
|
480
|
+
targetHeight: normalizedTargetHeight,
|
|
481
|
+
inputUrl: inputUrl.toString(),
|
|
482
|
+
// VOD playlist bookkeeping.
|
|
483
|
+
useSyntheticPlaylist: hasDuration,
|
|
484
|
+
totalDurationSeconds: hasDuration ? durationSeconds : null,
|
|
485
|
+
segmentCount,
|
|
486
|
+
playlistText: hasDuration ? this.#buildVodPlaylist(durationSeconds, this.segmentDurationSec) : "",
|
|
487
|
+
// Segment index the current ffmpeg run started producing from.
|
|
488
|
+
encodeStartIndex: 0,
|
|
489
|
+
// Guards against repeatedly restarting to the same seek position.
|
|
490
|
+
pendingRestartIndex: -1,
|
|
491
|
+
progress: {
|
|
492
|
+
state: "starting",
|
|
493
|
+
processedSeconds: 0,
|
|
494
|
+
startPositionSeconds: 0,
|
|
495
|
+
totalSeconds: hasDuration ? durationSeconds : null,
|
|
496
|
+
percent: null,
|
|
497
|
+
remainingSeconds: hasDuration ? durationSeconds : null,
|
|
498
|
+
speed: "",
|
|
499
|
+
updatedAt: Date.now(),
|
|
500
|
+
lastLoggedAt: 0
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
this.sessionsById.set(sessionId, session);
|
|
504
|
+
this.sessionIdBySource.set(sourceMapKey, sessionId);
|
|
505
|
+
|
|
506
|
+
logger.info(
|
|
507
|
+
`transcode ${sessionId} start "${logName}" ` +
|
|
508
|
+
`video=${transcodeVideo ? "x264" : "copy"} audio=${transcodeAudio ? "aac" : "copy"} ` +
|
|
509
|
+
`duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
this.#startEncodeRun(session, 0);
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
await this.waitUntilReady(session);
|
|
516
|
+
return session;
|
|
517
|
+
} catch (error) {
|
|
518
|
+
if (session.state === "failed") {
|
|
519
|
+
await this.disposeSession(session.id);
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
// Do not fail session creation on warmup timeout; the synthetic playlist
|
|
523
|
+
// is already available and segments appear as ffmpeg produces them.
|
|
524
|
+
return session;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Build a complete VOD HLS playlist for the full media duration.
|
|
530
|
+
*
|
|
531
|
+
* The playlist lists every segment up-front and is terminated with
|
|
532
|
+
* `#EXT-X-ENDLIST`, so the player knows the total duration and can seek to
|
|
533
|
+
* any position immediately — even before the corresponding segment has been
|
|
534
|
+
* transcoded. Segments are produced on demand (see {@link getFileStream}).
|
|
535
|
+
*
|
|
536
|
+
* @param {number} totalSeconds
|
|
537
|
+
* @param {number} segSec
|
|
538
|
+
* @returns {string}
|
|
539
|
+
*/
|
|
540
|
+
#buildVodPlaylist(totalSeconds, segSec) {
|
|
541
|
+
const count = Math.max(1, Math.ceil(totalSeconds / segSec));
|
|
542
|
+
const lines = [
|
|
543
|
+
"#EXTM3U",
|
|
544
|
+
"#EXT-X-VERSION:3",
|
|
545
|
+
`#EXT-X-TARGETDURATION:${Math.ceil(segSec)}`,
|
|
546
|
+
"#EXT-X-MEDIA-SEQUENCE:0",
|
|
547
|
+
"#EXT-X-PLAYLIST-TYPE:VOD",
|
|
548
|
+
"#EXT-X-INDEPENDENT-SEGMENTS"
|
|
549
|
+
];
|
|
550
|
+
for (let index = 0; index < count; index += 1) {
|
|
551
|
+
const remaining = totalSeconds - index * segSec;
|
|
552
|
+
const duration = index < count - 1 ? segSec : Math.max(0.1, remaining);
|
|
553
|
+
lines.push(`#EXTINF:${duration.toFixed(6)},`);
|
|
554
|
+
lines.push(`segment-${String(index).padStart(5, "0")}.ts`);
|
|
555
|
+
}
|
|
556
|
+
lines.push("#EXT-X-ENDLIST");
|
|
557
|
+
return `${lines.join("\n")}\n`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* (Re)start the ffmpeg encode run beginning at segment `startIndex`.
|
|
562
|
+
*
|
|
563
|
+
* Any ffmpeg process currently running for this session is terminated first.
|
|
564
|
+
* Segment files are named with a global index (`-start_number`) so they
|
|
565
|
+
* always line up with the synthetic VOD playlist regardless of where
|
|
566
|
+
* encoding started — this is what makes server-side seeking work.
|
|
567
|
+
*
|
|
568
|
+
* @param {HlsSession} session
|
|
569
|
+
* @param {number} startIndex
|
|
570
|
+
* @returns {void}
|
|
571
|
+
*/
|
|
572
|
+
#startEncodeRun(session, startIndex) {
|
|
573
|
+
const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
574
|
+
const startSeconds = safeIndex * this.segmentDurationSec;
|
|
575
|
+
|
|
576
|
+
// Terminate any existing encode process before starting a new one. The
|
|
577
|
+
// old process's exit handler no-ops because session.ffmpeg is reassigned
|
|
578
|
+
// below (it checks identity).
|
|
579
|
+
if (session.ffmpeg && !session.ffmpeg.killed) {
|
|
580
|
+
try {
|
|
581
|
+
session.ffmpeg.kill("SIGTERM");
|
|
582
|
+
} catch (_error) {
|
|
583
|
+
// Best effort.
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const videoCodecArgs = session.transcodeVideo
|
|
427
588
|
? [
|
|
428
589
|
"-vf",
|
|
429
|
-
this.#buildVideoFilter(
|
|
590
|
+
this.#buildVideoFilter(session.targetWidth, session.targetHeight),
|
|
430
591
|
"-c:v",
|
|
431
592
|
"libx264",
|
|
432
593
|
"-preset",
|
|
@@ -434,29 +595,29 @@ export class HlsSessionManager {
|
|
|
434
595
|
"-crf",
|
|
435
596
|
VIDEO_TRANSCODE_CRF,
|
|
436
597
|
"-pix_fmt",
|
|
437
|
-
"yuv420p"
|
|
598
|
+
"yuv420p",
|
|
599
|
+
// Force keyframes on segment boundaries so each segment is
|
|
600
|
+
// independently decodable and exactly segmentDuration long — this
|
|
601
|
+
// keeps the synthetic playlist's timing accurate.
|
|
602
|
+
"-force_key_frames",
|
|
603
|
+
`expr:gte(t,n_forced*${this.segmentDurationSec})`
|
|
438
604
|
]
|
|
439
605
|
: ["-c:v", "copy"];
|
|
440
|
-
const audioCodecArgs = transcodeAudio
|
|
606
|
+
const audioCodecArgs = session.transcodeAudio
|
|
441
607
|
? ["-c:a", "aac", "-ac", "2", "-b:a", "128k"]
|
|
442
608
|
: ["-c:a", "copy"];
|
|
443
609
|
|
|
444
610
|
const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (normalizedStartPosition > 0) {
|
|
449
|
-
args.push("-ss", String(normalizedStartPosition));
|
|
611
|
+
if (startSeconds > 0) {
|
|
612
|
+
// Fast keyframe-level seek before -i (skips decoding earlier frames).
|
|
613
|
+
args.push("-ss", String(startSeconds));
|
|
450
614
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (normalizedStartPosition > 0) {
|
|
457
|
-
args.push("-output_ts_offset", String(normalizedStartPosition));
|
|
615
|
+
args.push("-i", session.inputUrl);
|
|
616
|
+
if (startSeconds > 0) {
|
|
617
|
+
// Keep output timestamps on the original timeline so video.currentTime
|
|
618
|
+
// matches the requested position.
|
|
619
|
+
args.push("-output_ts_offset", String(startSeconds));
|
|
458
620
|
}
|
|
459
|
-
|
|
460
621
|
args.push(
|
|
461
622
|
"-map",
|
|
462
623
|
"0:v:0?",
|
|
@@ -470,48 +631,48 @@ export class HlsSessionManager {
|
|
|
470
631
|
String(this.segmentDurationSec),
|
|
471
632
|
"-hls_list_size",
|
|
472
633
|
"0",
|
|
473
|
-
"-hls_playlist_type",
|
|
474
|
-
"event",
|
|
475
634
|
"-hls_flags",
|
|
476
635
|
"independent_segments+temp_file",
|
|
636
|
+
"-start_number",
|
|
637
|
+
String(safeIndex),
|
|
477
638
|
"-hls_segment_filename",
|
|
478
639
|
"segment-%05d.ts",
|
|
640
|
+
// ffmpeg writes its own playlist here; we ignore it and serve the
|
|
641
|
+
// synthetic VOD playlist instead (see getFileStream).
|
|
479
642
|
PLAYLIST_FILE_NAME
|
|
480
643
|
);
|
|
481
644
|
|
|
482
645
|
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
483
|
-
cwd:
|
|
646
|
+
cwd: session.dirPath,
|
|
484
647
|
stdio: ["ignore", "pipe", "pipe"]
|
|
485
648
|
});
|
|
649
|
+
session.ffmpeg = ffmpeg;
|
|
650
|
+
session.encodeStartIndex = safeIndex;
|
|
651
|
+
session.pendingRestartIndex = -1;
|
|
652
|
+
session.state = session.state === "disposed" ? "disposed" : "starting";
|
|
653
|
+
session.progress.state = "running";
|
|
654
|
+
session.progress.processedSeconds = startSeconds;
|
|
655
|
+
session.progress.startPositionSeconds = startSeconds;
|
|
656
|
+
session.progress.updatedAt = Date.now();
|
|
486
657
|
|
|
487
|
-
|
|
488
|
-
id
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
lastAccessedAt: Date.now(),
|
|
495
|
-
ffmpeg,
|
|
496
|
-
lastError: "",
|
|
497
|
-
consumers: new Set(consumerId ? [consumerId] : []),
|
|
498
|
-
progress: {
|
|
499
|
-
state: "starting",
|
|
500
|
-
// With -output_ts_offset the first out_time will be ≈ normalizedStartPosition,
|
|
501
|
-
// so initialise processedSeconds to that value for consistent percent math.
|
|
502
|
-
processedSeconds: normalizedStartPosition,
|
|
503
|
-
startPositionSeconds: normalizedStartPosition,
|
|
504
|
-
totalSeconds: Number.isFinite(durationSeconds) ? durationSeconds : null,
|
|
505
|
-
percent: null,
|
|
506
|
-
remainingSeconds: Number.isFinite(durationSeconds) ? durationSeconds - normalizedStartPosition : null,
|
|
507
|
-
speed: "",
|
|
508
|
-
updatedAt: Date.now(),
|
|
509
|
-
lastLoggedAt: 0
|
|
510
|
-
}
|
|
511
|
-
};
|
|
512
|
-
this.sessionsById.set(sessionId, session);
|
|
513
|
-
this.sessionIdBySource.set(sourceMapKey, sessionId);
|
|
658
|
+
logger.info(
|
|
659
|
+
`transcode ${session.id} encode-run from segment #${safeIndex} ` +
|
|
660
|
+
`(${formatSeconds(startSeconds)}) "${session.fileName}"`
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
this.#wireEncodeProcess(session, ffmpeg);
|
|
664
|
+
}
|
|
514
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Wire stdout (progress), stderr (errors) and exit handlers for an ffmpeg
|
|
668
|
+
* encode process. Handlers no-op when the process has been superseded by a
|
|
669
|
+
* later encode run (identity check against `session.ffmpeg`).
|
|
670
|
+
*
|
|
671
|
+
* @param {HlsSession} session
|
|
672
|
+
* @param {import("node:child_process").ChildProcess} ffmpeg
|
|
673
|
+
* @returns {void}
|
|
674
|
+
*/
|
|
675
|
+
#wireEncodeProcess(session, ffmpeg) {
|
|
515
676
|
ffmpeg.stdout.on("data", (chunk) => {
|
|
516
677
|
const lines = String(chunk).split(/\r?\n/);
|
|
517
678
|
for (const line of lines) {
|
|
@@ -567,19 +728,26 @@ export class HlsSessionManager {
|
|
|
567
728
|
const line = String(chunk).trim();
|
|
568
729
|
if (line.length > 0) {
|
|
569
730
|
session.lastError = line;
|
|
570
|
-
logger.warn(`ffmpeg: ${line}`);
|
|
731
|
+
logger.warn(`ffmpeg ${session.id}: ${line}`);
|
|
571
732
|
}
|
|
572
733
|
});
|
|
573
734
|
|
|
574
735
|
ffmpeg.on("error", (error) => {
|
|
736
|
+
if (session.ffmpeg !== ffmpeg) {
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
575
739
|
session.state = "failed";
|
|
576
740
|
session.lastError = error instanceof Error ? error.message : String(error);
|
|
577
741
|
session.progress.state = "failed";
|
|
578
742
|
session.progress.updatedAt = Date.now();
|
|
579
|
-
logger.error(`ffmpeg process error: ${session.lastError}`);
|
|
743
|
+
logger.error(`ffmpeg ${session.id} process error: ${session.lastError}`);
|
|
580
744
|
});
|
|
581
745
|
|
|
582
|
-
ffmpeg.on("exit", (code) => {
|
|
746
|
+
ffmpeg.on("exit", (code, signal) => {
|
|
747
|
+
// Ignore the exit of a process that was superseded by a seek-restart.
|
|
748
|
+
if (session.ffmpeg !== ffmpeg) {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
583
751
|
if (session.state === "disposed") {
|
|
584
752
|
return;
|
|
585
753
|
}
|
|
@@ -587,27 +755,45 @@ export class HlsSessionManager {
|
|
|
587
755
|
session.state = "ready";
|
|
588
756
|
session.progress.state = "ready";
|
|
589
757
|
session.progress.updatedAt = Date.now();
|
|
758
|
+
logger.info(`transcode ${session.id} encode-run complete "${session.fileName}"`);
|
|
590
759
|
return;
|
|
591
760
|
}
|
|
592
761
|
session.state = "failed";
|
|
593
762
|
session.progress.state = "failed";
|
|
594
763
|
session.progress.updatedAt = Date.now();
|
|
595
764
|
if (!session.lastError) {
|
|
596
|
-
session.lastError = `ffmpeg exited with code ${code ?? -1}`;
|
|
765
|
+
session.lastError = `ffmpeg exited with code ${code ?? -1}${signal ? ` (signal ${signal})` : ""}`;
|
|
597
766
|
}
|
|
767
|
+
logger.error(`transcode ${session.id} encode-run failed: ${session.lastError}`);
|
|
598
768
|
});
|
|
769
|
+
}
|
|
599
770
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
771
|
+
/**
|
|
772
|
+
* Ensure the encoder is producing (or will soon produce) the requested
|
|
773
|
+
* segment. If the segment is far ahead of the current encode head, or
|
|
774
|
+
* behind it, restart ffmpeg at that segment (server-side seek). Requests
|
|
775
|
+
* within the look-ahead window are served by waiting for the running encode.
|
|
776
|
+
*
|
|
777
|
+
* @param {HlsSession} session
|
|
778
|
+
* @param {number} index
|
|
779
|
+
* @returns {void}
|
|
780
|
+
*/
|
|
781
|
+
#ensureEncodingFor(session, index) {
|
|
782
|
+
if (!session || session.state === "disposed" || index < 0) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const head = session.encodeStartIndex;
|
|
786
|
+
const withinWindow = index >= head && index <= head + MAX_LOOKAHEAD_SEGMENTS;
|
|
787
|
+
if (withinWindow) {
|
|
788
|
+
return;
|
|
610
789
|
}
|
|
790
|
+
if (session.pendingRestartIndex === index) {
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
logger.info(
|
|
794
|
+
`transcode ${session.id} seek → restart at segment #${index} (encode head #${head})`
|
|
795
|
+
);
|
|
796
|
+
this.#startEncodeRun(session, index);
|
|
611
797
|
}
|
|
612
798
|
|
|
613
799
|
#buildVideoFilter(targetWidth, targetHeight) {
|
|
@@ -625,6 +811,18 @@ export class HlsSessionManager {
|
|
|
625
811
|
* @returns {Promise<void>}
|
|
626
812
|
*/
|
|
627
813
|
async waitUntilReady(session) {
|
|
814
|
+
// With a synthetic VOD playlist there is nothing to wait for: the playlist
|
|
815
|
+
// is generated from the probed duration and is available immediately.
|
|
816
|
+
// Individual segments are long-polled by the segment route as ffmpeg
|
|
817
|
+
// produces them.
|
|
818
|
+
if (session.useSyntheticPlaylist) {
|
|
819
|
+
if (session.state === "failed") {
|
|
820
|
+
throw new Error(session.lastError || "ffmpeg failed to start HLS session.");
|
|
821
|
+
}
|
|
822
|
+
session.state = "ready";
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
628
826
|
const playlistPath = path.join(session.dirPath, PLAYLIST_FILE_NAME);
|
|
629
827
|
const deadline = Date.now() + this.startupWaitMs;
|
|
630
828
|
|
|
@@ -675,21 +873,42 @@ export class HlsSessionManager {
|
|
|
675
873
|
};
|
|
676
874
|
}
|
|
677
875
|
session.lastAccessedAt = Date.now();
|
|
876
|
+
|
|
877
|
+
// Serve the synthetic VOD playlist (full duration, terminated with
|
|
878
|
+
// #EXT-X-ENDLIST) so the player gets the correct total length and a fully
|
|
879
|
+
// seekable timeline up-front, independent of how far ffmpeg has encoded.
|
|
880
|
+
if (fileName === PLAYLIST_FILE_NAME && session.useSyntheticPlaylist) {
|
|
881
|
+
return {
|
|
882
|
+
kind: "file",
|
|
883
|
+
stream: Readable.from([session.playlistText]),
|
|
884
|
+
contentType: "application/vnd.apple.mpegurl",
|
|
885
|
+
isPlaylist: true
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
678
889
|
const filePath = path.join(session.dirPath, fileName);
|
|
679
890
|
try {
|
|
680
891
|
await access(filePath);
|
|
892
|
+
return {
|
|
893
|
+
kind: "file",
|
|
894
|
+
stream: createReadStream(filePath),
|
|
895
|
+
contentType:
|
|
896
|
+
fileName === PLAYLIST_FILE_NAME
|
|
897
|
+
? "application/vnd.apple.mpegurl"
|
|
898
|
+
: "video/mp2t",
|
|
899
|
+
isPlaylist: fileName === PLAYLIST_FILE_NAME
|
|
900
|
+
};
|
|
681
901
|
} catch (_error) {
|
|
682
|
-
|
|
902
|
+
// File not produced yet.
|
|
683
903
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
};
|
|
904
|
+
|
|
905
|
+
// A segment was requested that ffmpeg has not produced yet. Decide whether
|
|
906
|
+
// to wait for the current encode run to reach it or to restart the encoder
|
|
907
|
+
// at this position (server-side seeking). The caller long-polls.
|
|
908
|
+
if (fileName !== PLAYLIST_FILE_NAME) {
|
|
909
|
+
this.#ensureEncodingFor(session, segmentIndexFromName(fileName));
|
|
910
|
+
}
|
|
911
|
+
return { kind: "warming-up" };
|
|
693
912
|
}
|
|
694
913
|
|
|
695
914
|
/**
|
|
@@ -20,9 +20,19 @@ const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
|
|
|
20
20
|
function parseStreamCodecs(ffmpegOutput) {
|
|
21
21
|
const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
|
|
22
22
|
const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
|
|
23
|
+
const containerMatch = ffmpegOutput.match(/Input #0,\s*([^,]+(?:,[^,]+)*?),\s*from/i);
|
|
24
|
+
const durationMatch = ffmpegOutput.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
|
|
25
|
+
let durationSeconds = 0;
|
|
26
|
+
if (durationMatch) {
|
|
27
|
+
const value =
|
|
28
|
+
Number(durationMatch[1]) * 3600 + Number(durationMatch[2]) * 60 + Number(durationMatch[3]);
|
|
29
|
+
durationSeconds = Number.isFinite(value) ? value : 0;
|
|
30
|
+
}
|
|
23
31
|
return {
|
|
24
32
|
audioCodec: audioMatch ? String(audioMatch[1]).toLowerCase() : "",
|
|
25
|
-
videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : ""
|
|
33
|
+
videoCodec: videoMatch ? String(videoMatch[1]).toLowerCase() : "",
|
|
34
|
+
container: containerMatch ? String(containerMatch[1]).trim().toLowerCase() : "",
|
|
35
|
+
durationSeconds
|
|
26
36
|
};
|
|
27
37
|
}
|
|
28
38
|
|
|
@@ -43,7 +53,11 @@ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_
|
|
|
43
53
|
if (typeof userAgent === "string" && userAgent.trim().length > 0) {
|
|
44
54
|
args.push("-user_agent", userAgent.trim());
|
|
45
55
|
}
|
|
46
|
-
|
|
56
|
+
// Decode a tiny slice of all streams (no per-stream -map, so video-only
|
|
57
|
+
// files probe correctly too). The ffmpeg banner that precedes decoding
|
|
58
|
+
// gives us audio/video codecs, the container format and the duration in a
|
|
59
|
+
// single pass.
|
|
60
|
+
args.push("-i", inputUrl, "-t", "0.1", "-f", "null", "-");
|
|
47
61
|
|
|
48
62
|
const ffmpeg = spawn(ffmpegBin, args, {
|
|
49
63
|
stdio: ["ignore", "ignore", "pipe"],
|
|
@@ -105,6 +119,8 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
|
|
|
105
119
|
* @property {string} reason - Human-readable explanation of the chosen mode.
|
|
106
120
|
* @property {string} audioCodec
|
|
107
121
|
* @property {string} videoCodec
|
|
122
|
+
* @property {string} container - Demuxer/container name(s) reported by ffmpeg.
|
|
123
|
+
* @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
|
|
108
124
|
*/
|
|
109
125
|
|
|
110
126
|
/**
|
|
@@ -174,7 +190,9 @@ export function createPlaybackPlanner({
|
|
|
174
190
|
directUrl,
|
|
175
191
|
reason: "transcode-disabled",
|
|
176
192
|
audioCodec: "",
|
|
177
|
-
videoCodec: ""
|
|
193
|
+
videoCodec: "",
|
|
194
|
+
container: "",
|
|
195
|
+
durationSeconds: 0
|
|
178
196
|
};
|
|
179
197
|
cache.set(cacheKey, plan);
|
|
180
198
|
return plan;
|
|
@@ -186,7 +204,7 @@ export function createPlaybackPlanner({
|
|
|
186
204
|
// the end of the file and hasn't been downloaded yet.
|
|
187
205
|
await torrentPool.prefetchFileEdges(torrent, fileIndex);
|
|
188
206
|
|
|
189
|
-
const { audioCodec, videoCodec } = await probeStreamCodecs({
|
|
207
|
+
const { audioCodec, videoCodec, container, durationSeconds } = await probeStreamCodecs({
|
|
190
208
|
ffmpegBin,
|
|
191
209
|
inputUrl: directUrl,
|
|
192
210
|
userAgent
|
|
@@ -199,11 +217,16 @@ export function createPlaybackPlanner({
|
|
|
199
217
|
// browser-side loading pipeline already has its own transcode fallback.
|
|
200
218
|
const requiresTranscode = audioCodec.length > 0 && !DIRECT_AUDIO_CODECS.has(audioCodec);
|
|
201
219
|
const plan = {
|
|
220
|
+
// `mode` is advisory only (audio-codec based). The browser makes the
|
|
221
|
+
// authoritative decision independently per stream via canPlayType /
|
|
222
|
+
// mediaCapabilities, transcoding only what it cannot play.
|
|
202
223
|
mode: requiresTranscode ? "hls" : "direct",
|
|
203
224
|
directUrl,
|
|
204
225
|
reason: requiresTranscode ? "audio-codec-transcode-required" : "audio-codec-supported",
|
|
205
226
|
audioCodec,
|
|
206
|
-
videoCodec
|
|
227
|
+
videoCodec,
|
|
228
|
+
container,
|
|
229
|
+
durationSeconds
|
|
207
230
|
};
|
|
208
231
|
cache.set(cacheKey, plan);
|
|
209
232
|
return plan;
|