@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.6.2",
3
+ "version": "2.6.6",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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, 15_000);
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
- reply.header("Retry-After", "2");
24
- return reply.code(202).send({ status: "warming-up" });
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
- if (body != null && typeof body === "string" && body.length > 0) {
147
- log(`[dc] ${method} ${path} body=${body.length} bytes`);
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 videoCodecArgs = transcodeVideo
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(normalizedTargetWidth, normalizedTargetHeight),
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
- // Fast keyframe-level seek before -i. This is efficient because ffmpeg
447
- // skips decoding all frames before the target position.
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
- args.push("-i", inputUrl.toString());
453
-
454
- // Shift output timestamps to match the original timeline so that
455
- // video.currentTime reflects the seeked position, not a reset-to-zero.
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: sessionDir,
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
- const session = {
488
- id: sessionId,
489
- sourceMapKey,
490
- fileName: normalizeLogFileName(fileName, fileIndex),
491
- dirPath: sessionDir,
492
- state: "starting",
493
- startedAt: Date.now(),
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
- try {
601
- await this.waitUntilReady(session);
602
- return session;
603
- } catch (error) {
604
- if (session.state === "failed") {
605
- await this.disposeSession(session.id);
606
- throw error;
607
- }
608
- // Do not fail session creation on warmup timeout; playlist can appear later.
609
- return session;
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
- return { kind: "warming-up" };
902
+ // File not produced yet.
683
903
  }
684
- return {
685
- kind: "file",
686
- stream: createReadStream(filePath),
687
- contentType:
688
- fileName === PLAYLIST_FILE_NAME
689
- ? "application/vnd.apple.mpegurl"
690
- : "video/mp2t",
691
- isPlaylist: fileName === PLAYLIST_FILE_NAME
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
- args.push("-i", inputUrl, "-map", "0:a:0", "-t", "0.1", "-f", "null", "-");
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;