@torrent-tv/proxy 2.95.2 → 2.95.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.95.2",
3
+ "version": "2.95.3",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -60,6 +60,7 @@ import { DownloadMaps } from "./services/viewer/DownloadMaps.js";
60
60
  import { nativeSourceMap } from "./services/viewer/NativeSourceMap.js";
61
61
  import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
62
62
  import { SegmentInputs } from "./services/media/SegmentInputs.js";
63
+ import { ContainerTrack } from "./services/media/tracks/ContainerTrack.js";
63
64
  import { pauseCoefficient, viewerStartsOn } from "./services/viewer/PriorityMap.js";
64
65
  import { coalescing } from "./utils/coalesce.js";
65
66
  import {
@@ -581,7 +582,7 @@ export async function startProxyServer({
581
582
  const spec = output.spec[type];
582
583
  if (!spec) continue;
583
584
  const list = selected.get(spec.fileIndex) ?? [];
584
- list.push({ type, index: type === "video" ? 0 : spec.trackIndex,
585
+ list.push({ type, index: type === "video" ? null : spec.trackIndex,
585
586
  mode: type === "video" ? spec.encode ? "transcode" : "copy" : spec.transcode ? "transcode" : "copy" });
586
587
  selected.set(spec.fileIndex, list);
587
588
  }
@@ -595,7 +596,10 @@ export async function startProxyServer({
595
596
  if (typeof container?.readPacketIndex !== "function") return { kind: "needs-index", reason: "packet-index-not-yet-available" };
596
597
  const modes = new Map(), wanted = [];
597
598
  for (const choice of choices) {
598
- const track = tracks.value.filter(track => track.type === choice.type)[choice.index];
599
+ // The picture is the first usable video track (torrent-tv/meta#49);
600
+ // a soundtrack keeps the number every list of them uses.
601
+ const track = choice.type === "video" ? ContainerTrack.firstUsable(tracks.value, "video")
602
+ : tracks.value.filter(track => track.type === choice.type)[choice.index];
599
603
  if (!track) return { kind: "terminal", reason: "selected-track-is-absent" };
600
604
  wanted.push(track); modes.set(track, choice.mode);
601
605
  }
@@ -604,7 +604,7 @@ export class MatroskaContainer extends Container {
604
604
  // picture cues alone cannot prove that an earlier subtitle has ended.
605
605
  if (requested.every(track => track.type === "subtitle")) return first;
606
606
  const cueTrack = requested.find(track => track.type === "video") ??
607
- tracks.find(track => track.type === "video") ?? requested[0];
607
+ ContainerTrack.firstUsable(tracks, "video") ?? requested[0];
608
608
  if (!cueTrack) return first;
609
609
  const points = (cues?.points ?? []).flatMap(point => point.positions
610
610
  .filter(position => position.track === cueTrack.trackNumber && position.clusterAt >= first && position.clusterAt < layout.segmentEnd)
@@ -823,7 +823,7 @@ export class MatroskaContainer extends Container {
823
823
  }
824
824
 
825
825
  /**
826
- * The keyframe times of the first video track, from the one Cues reading.
826
+ * The keyframe times of the first usable video track, from the one Cues reading.
827
827
  *
828
828
  * A CuePoint belongs to the track named inside its CueTrackPositions, and a
829
829
  * muxer indexes whatever tracks it likes — field files index their subtitle
@@ -845,7 +845,9 @@ export class MatroskaContainer extends Container {
845
845
  return null;
846
846
  }
847
847
  const tracks = await this.readTracks();
848
- const video = tracks.find((track) => track.type === "video")?.trackNumber ?? null;
848
+ // The picture's own entries: a video track the file marks unusable is not
849
+ // played, so its keyframes are not where the picture can be cut.
850
+ const video = ContainerTrack.firstUsable(tracks, "video")?.trackNumber ?? null;
849
851
  const timesFor = (wanted) =>
850
852
  cues.points
851
853
  .filter((point) => wanted === null || point.positions.some((position) => position.track === wanted))
@@ -21,6 +21,7 @@ import { VideoTrack } from "../tracks/VideoTrack.js";
21
21
  import { AudioTrack } from "../tracks/AudioTrack.js";
22
22
  import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
23
23
  import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
24
+ import { ContainerTrack } from "../tracks/ContainerTrack.js";
24
25
  import { isUnavailable } from "./unavailable.js";
25
26
  import { PacketIndex } from "./PacketIndex.js";
26
27
  import { h264Configuration } from "./h264-configuration.js";
@@ -565,7 +566,7 @@ export class Mp4Container extends Container {
565
566
  this.fragmentState ??= {};
566
567
  index = await readMp4Fragments({ readRange: this.readRange, fileSize: this.fileSize,
567
568
  tracks: fragmentTracks(held, tracks), memory: this.packetMemory, state: this.fragmentState });
568
- const timeline = tracks.find(track => track.type === "video") ?? tracks.find(track => track.type === "audio");
569
+ const timeline = ContainerTrack.firstUsable(tracks, "video") ?? ContainerTrack.firstUsable(tracks, "audio");
569
570
  const bounds = timeline && index.boundsOf(timeline.trackNumber);
570
571
  if (bounds) this.mediaInfo = { ...(await this.readMediaInfo()), startTimeSeconds: bounds.start,
571
572
  durationSeconds: bounds.end - bounds.start };
@@ -842,7 +843,7 @@ export class Mp4Container extends Container {
842
843
  async parseKeyframeIndex() {
843
844
  const held = await this.#moovBuffer();
844
845
  if (held && childOf(held.moov, held.header, held.moov.length, "mvex")) {
845
- const video = (await this.readTracks()).find(track => track.type === "video");
846
+ const video = ContainerTrack.firstUsable(await this.readTracks(), "video");
846
847
  return video ? { times: (await this.readPacketIndex()).keyframesOf(video.trackNumber), tolerance: 0 } : null;
847
848
  }
848
849
  const r = held ? keyframeTimesFromMoov(held.moov, held.header) : null;
@@ -1664,6 +1665,13 @@ function keyframeTimesFromMoov(moov, headerBytes) {
1664
1665
  if (!mdia || !isVideoTrack(moov, mdia)) {
1665
1666
  continue;
1666
1667
  }
1668
+ // "A disabled track (the low bit is zero) is treated as if it were not
1669
+ // present" (ISO/IEC 14496-12 §8.3.2), so it is not the picture whose
1670
+ // keyframes decide the cuts (torrent-tv/meta#49).
1671
+ const tkhd = findBox(moov, trak.dataOffset, trak.end, "tkhd");
1672
+ if (tkhd && tkhd.dataOffset + 4 <= tkhd.end && (moov.readUIntBE(tkhd.dataOffset + 1, 3) & 0x000001) === 0) {
1673
+ continue;
1674
+ }
1667
1675
  const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
1668
1676
  if (!mdhd) {
1669
1677
  continue;
@@ -1,5 +1,6 @@
1
1
  import { AudioTrack } from "./tracks/AudioTrack.js";
2
2
  import { TextSubtitleTrack } from "./tracks/TextSubtitleTrack.js";
3
+ import { ContainerTrack } from "./tracks/ContainerTrack.js";
3
4
 
4
5
  const VIDEO_CODECS = new Map([
5
6
  ["V_MPEG4/ISO/AVC", "h264"], ["avc1", "h264"], ["avc3", "h264"],
@@ -33,7 +34,9 @@ function videoCodecOf(track) {
33
34
  export function playbackDeclarations({ tracks, media, fileBytes = null }) {
34
35
  const ordered = type => tracks.filter(track => track.type === type)
35
36
  .sort((a, b) => a.declaredIndex - b.declaredIndex);
36
- const video = ordered("video")[0];
37
+ // The picture is the first video track the container marks usable; a
38
+ // disabled one is not played (torrent-tv/meta#49).
39
+ const video = ContainerTrack.firstUsable(tracks, "video") ?? undefined;
37
40
  const inventory = type => ordered(type).map((track, index) => ({
38
41
  ...track, index, streamIndex: track.declaredIndex,
39
42
  codec: type === "audio" ? AudioTrack.codecNameOf(track)
@@ -46,7 +49,8 @@ export function playbackDeclarations({ tracks, media, fileBytes = null }) {
46
49
  }));
47
50
  const audioTracks = inventory("audio"), subtitleTracks = inventory("subtitle");
48
51
  return { tracks, video, audioTracks, subtitleTracks,
49
- audioCodec: audioTracks[0]?.codec ?? "",
52
+ // The soundtrack a file opens with is its first usable one.
53
+ audioCodec: ContainerTrack.firstUsable(audioTracks, "audio")?.codec ?? "",
50
54
  videoCodec: video ? videoCodecOf(video) : "",
51
55
  container: media.format,
52
56
  durationSeconds: media.durationSeconds,
@@ -72,6 +72,29 @@ export class ContainerTrack {
72
72
  this.alternateGroup = Number.isFinite(alternateGroup) ? alternateGroup : 0;
73
73
  }
74
74
 
75
+ /**
76
+ * The first track of `type` the container marks usable, or null.
77
+ *
78
+ * Matroska `FlagEnabled`: "Set to 1 if the track is usable" (RFC 9559
79
+ * §5.1.4.1.3). MP4 `track_enabled`: "A disabled track … is treated as if it
80
+ * were not present" (ISO/IEC 14496-12 §8.3.2). So the picture a file is
81
+ * played with, and the soundtrack it opens with when nobody chose one, are the
82
+ * first USABLE ones — not the first declared. A disabled track keeps its
83
+ * position in the list, because ffmpeg numbers it either way.
84
+ *
85
+ * Takes plain objects as well as tracks: tracks cross the worker boundary as
86
+ * plain objects.
87
+ *
88
+ * @param {Array<{ type: string, isEnabled?: boolean, declaredIndex?: number }>} tracks
89
+ * @param {"video" | "audio" | "subtitle"} type
90
+ * @returns {object | null}
91
+ */
92
+ static firstUsable(tracks, type) {
93
+ return (Array.isArray(tracks) ? tracks : [])
94
+ .filter((track) => track?.type === type && track.isEnabled !== false)
95
+ .sort((a, b) => (a.declaredIndex ?? 0) - (b.declaredIndex ?? 0))[0] ?? null;
96
+ }
97
+
75
98
  /** Whether this track should be offered in UI menus. Base rule: disabled tracks hidden but still counted for declaredIndex. */
76
99
  isOfferable() {
77
100
  return this.isEnabled;