@torrent-tv/proxy 2.74.0 → 2.75.0

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.
@@ -17,8 +17,7 @@
17
17
  * Reading the clusters costs nothing extra at all.
18
18
  */
19
19
 
20
- import { MatroskaContainer } from "../container/MatroskaContainer.js";
21
- import { Mp4Container } from "../container/Mp4Container.js";
20
+ import { ContainerFactory } from "../container/ContainerFactory.js";
22
21
  import { TextSubtitleTrack } from "../tracks/TextSubtitleTrack.js";
23
22
  import { detectLanguage } from "../language-detect.js";
24
23
  import { logger } from "../../utils/logger.js";
@@ -111,18 +110,25 @@ function readHeld(file, start, end) {
111
110
  /**
112
111
  * A container over one file of a torrent, told how to read it.
113
112
  *
114
- * The container is given two functions and never the torrent: whether a byte
115
- * range is already downloaded, and how to read one without asking the swarm for
116
- * anything. That is the whole of what this layer knows that the container does
117
- * not, and reducing it to two functions is what lets the reading itself live
118
- * where the format is specified.
113
+ * The container is given two readers and a predicate and never the torrent:
114
+ * whether a byte range is already downloaded, how to read one without asking
115
+ * the swarm, and how to read one that may need fetching. That is the whole of
116
+ * what this layer knows and the container does not.
119
117
  *
118
+ * WHICH container is decided from the bytes, by the factory's sniff — the
119
+ * header is what the muxer wrote, and a file name is what somebody typed. Only
120
+ * where the head is not downloaded, and so cannot be sniffed without asking the
121
+ * swarm, does the name answer instead. One choice either way: the caller never
122
+ * decides a second time from a track's shape, which is two decisions from
123
+ * different evidence that have to agree.
124
+ *
125
+ * @param {object} state - This file's state; the container is kept on it.
120
126
  * @param {object} torrent
121
127
  * @param {object} file
122
- * @returns {MatroskaContainer | Mp4Container}
128
+ * @returns {Promise<import("../container/Container.js").Container | null>}
123
129
  */
124
- function containerOver(state, torrent, file) {
125
- if (state.container) {
130
+ async function containerOver(state, torrent, file) {
131
+ if (state.container !== null) {
126
132
  return state.container;
127
133
  }
128
134
  const held = async (start, end) => readHeld(file, start, Math.min(end, file.length - 1));
@@ -133,8 +139,13 @@ function containerOver(state, torrent, file) {
133
139
  fileSize: file.length,
134
140
  label: String(file.name ?? "")
135
141
  };
136
- const name = String(file.name ?? "");
137
- state.container = /\.(mp4|m4v)$/i.test(name) ? new Mp4Container(params) : new MatroskaContainer(params);
142
+ const sniffed = await ContainerFactory.create(params);
143
+ if (sniffed) {
144
+ state.container = sniffed;
145
+ return state.container;
146
+ }
147
+ const ByName = ContainerFactory.byName(file.name);
148
+ state.container = ByName ? new ByName(params) : null;
138
149
  return state.container;
139
150
  }
140
151
 
@@ -251,33 +262,8 @@ async function readPlan(torrent, fileIndex, state) {
251
262
  state.plan = empty;
252
263
  return state.plan;
253
264
  }
254
- const container = containerOver(state, torrent, file);
255
- const name = String(file.name);
256
- if (/\.mp4$/i.test(name) || /\.m4v$/i.test(name)) {
257
- // An MP4 states every sample's byte range in its own table, so a cue costs
258
- // its own few dozen bytes rather than the cluster around it. The samples
259
- // are carried as `clusterPositions` of one byte range each, so the harvest
260
- // treats both containers the same way.
261
- const mp4 = await container.readSubtitlePlan();
262
- state.plan = mp4
263
- ? {
264
- ...empty,
265
- tracks: mp4.tracks.map((track, order) => ({
266
- trackNumber: track.trackId,
267
- declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
268
- codecId: track.format,
269
- language: track.language,
270
- name: "",
271
- isDefault: order === 0,
272
- codecPrivate: "",
273
- clusterPositions: [],
274
- samples: track.samples
275
- }))
276
- }
277
- : empty;
278
- return state.plan;
279
- }
280
- if (!/\.mkv$/i.test(name) && !/\.webm$/i.test(name)) {
265
+ const container = await containerOver(state, torrent, file);
266
+ if (!container) {
281
267
  state.plan = empty;
282
268
  return state.plan;
283
269
  }
@@ -372,52 +358,33 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
372
358
  */
373
359
  async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
374
360
  const file = torrent.files[fileIndex];
375
- const container = containerOver(state, torrent, file);
376
- const cuesOf = (number) => {
377
- let held = state.cues.get(number);
378
- if (!held) {
379
- held = [];
380
- state.cues.set(number, held);
381
- }
382
- return held;
383
- };
384
-
385
- if (Array.isArray(track.samples)) {
386
- // An MP4 states every cue's byte range, so the container reads per sample.
387
- let harvested = state.harvested.get(trackNumber);
388
- if (!harvested) {
389
- harvested = new Set();
390
- state.harvested.set(trackNumber, harvested);
391
- }
392
- const cues = cuesOf(trackNumber);
393
- for (const cue of await container.readHeldSamples(track, harvested)) {
394
- cues.push({ ...cue, seq: nextSeq(state, trackNumber) });
395
- }
396
- cues.sort((left, right) => left.startSeconds - right.startSeconds);
397
- return {
398
- cues,
399
- coveredClusters: harvested.size,
400
- indexedClusters: track.samples.length,
401
- track
402
- };
361
+ const container = await containerOver(state, torrent, file);
362
+ if (!container) {
363
+ return { cues: [], coveredClusters: 0, indexedClusters: 0, track };
403
364
  }
404
365
 
405
- // Matroska: one walk of the file fills every track, because a cluster carries
406
- // the blocks of every track that has anything to say over its span.
407
- const found = await container.walkHeldClusters(plan, state.walked);
366
+ // One question, whichever container this is. Matroska walks the clusters its
367
+ // Cues table names and fills every track from one walk; an MP4 reads the
368
+ // samples its own table states for this track. Nothing here chooses between
369
+ // them, which is the point: the container was chosen once, from the bytes.
370
+ const { found, covered, indexed } = await container.readHeldCues(plan, track, state);
371
+
408
372
  for (const [number, cues] of found) {
409
- const into = cuesOf(number);
373
+ let into = state.cues.get(number);
374
+ if (!into) {
375
+ into = [];
376
+ state.cues.set(number, into);
377
+ }
410
378
  for (const cue of cues) {
411
379
  into.push({ ...cue, seq: nextSeq(state, number) });
412
380
  }
413
381
  into.sort((left, right) => left.startSeconds - right.startSeconds);
414
382
  }
383
+
415
384
  return {
416
385
  cues: state.cues.get(trackNumber) ?? [],
417
- // Every track is filled by the same walk, so this is a fact about the FILE
418
- // and reads the same whichever track asked.
419
- coveredClusters: state.walked.size,
420
- indexedClusters: track.clusterPositions.length,
386
+ coveredClusters: covered,
387
+ indexedClusters: indexed,
421
388
  track
422
389
  };
423
390
  }
@@ -1,40 +1,131 @@
1
- /**
2
- * @file Audio track — extends ContainerTrack with audio-specific flags.
3
- *
4
- * Matroska RFC 9559 §5.1.4.1:
5
- * - FlagOriginal 0x55AE (default 0) — track is original language.
6
- * - FlagCommentary 0x55AF — commentary track.
7
- * - FlagVisualImpaired 0x55AC — audio description for visually impaired.
8
- * - FlagTextDescriptions 0x55AD, FlagHearingImpaired etc. may also appear
9
- * but the three above drive the audio menu (research/container-spec-conformance).
10
- * MP4 ISO/IEC 14496-12: tkhd alternate_group groups alternate audio tracks.
11
- */
12
-
13
- import { ContainerTrack } from "./ContainerTrack.js";
14
-
15
- export class AudioTrack extends ContainerTrack {
16
- /**
17
- * @param {object} params - See ContainerTrack plus:
18
- * @param {boolean} params.isOriginal - FlagOriginal
19
- * @param {boolean} params.isCommentary - FlagCommentary
20
- * @param {boolean} params.isVisualImpaired - FlagVisualImpaired / descriptive audio
21
- * @param {number | null} params.channels
22
- * @param {number | null} params.samplingFrequency
23
- */
24
- constructor(params) {
25
- super({ ...params, type: "audio" });
26
- this.isOriginal = params.isOriginal === true;
27
- this.isCommentary = params.isCommentary === true;
28
- this.isVisualImpaired = params.isVisualImpaired === true;
29
- this.channels = Number.isFinite(params.channels) ? params.channels : null;
30
- this.samplingFrequency = Number.isFinite(params.samplingFrequency) ? params.samplingFrequency : null;
31
- }
32
-
33
- /** Human label helper — commentary and descriptive audio must not look like main track. */
34
- audioRoleLabel() {
35
- if (this.isCommentary) return "commentary";
36
- if (this.isVisualImpaired) return "descriptive";
37
- if (this.isOriginal) return "original";
38
- return "";
39
- }
40
- }
1
+ /**
2
+ * @file Audio track — extends ContainerTrack with audio-specific flags.
3
+ *
4
+ * Matroska RFC 9559 §5.1.4.1:
5
+ * - FlagOriginal 0x55AE (default 0) — track is original language.
6
+ * - FlagCommentary 0x55AF — commentary track.
7
+ * - FlagVisualImpaired 0x55AC — audio description for visually impaired.
8
+ * - FlagTextDescriptions 0x55AD, FlagHearingImpaired etc. may also appear
9
+ * but the three above drive the audio menu (research/container-spec-conformance).
10
+ * MP4 ISO/IEC 14496-12: tkhd alternate_group groups alternate audio tracks.
11
+ */
12
+
13
+ import { ContainerTrack } from "./ContainerTrack.js";
14
+
15
+ /**
16
+ * Codec identifiers as containers write them, against the name ffmpeg prints.
17
+ *
18
+ * Needed because the browser decides whether it can play a soundtrack from that
19
+ * name, and for a sidecar file there is no ffmpeg banner to read it from — the
20
+ * track came from the container's own table, where Matroska writes `A_AC3` and
21
+ * MP4 writes `ac-3` for the thing ffmpeg calls `ac3`. Only what a soundtrack can
22
+ * actually be is listed; an identifier not here is reported as it was written,
23
+ * which the browser treats as one it does not know and therefore transcodes.
24
+ */
25
+ const CODEC_NAMES = new Map([
26
+ ["A_AAC", "aac"],
27
+ ["A_AC3", "ac3"],
28
+ ["A_EAC3", "eac3"],
29
+ ["A_DTS", "dts"],
30
+ ["A_FLAC", "flac"],
31
+ ["A_OPUS", "opus"],
32
+ ["A_VORBIS", "vorbis"],
33
+ ["A_TRUEHD", "truehd"],
34
+ ["A_MPEG/L3", "mp3"],
35
+ ["A_MPEG/L2", "mp2"],
36
+ ["A_ALAC", "alac"],
37
+ ["mp4a", "aac"],
38
+ ["ac-3", "ac3"],
39
+ ["ec-3", "eac3"],
40
+ ["alac", "alac"],
41
+ ["opus", "opus"],
42
+ ["Opus", "opus"],
43
+ ["fLaC", "flac"],
44
+ ["flac", "flac"]
45
+ ]);
46
+
47
+ /**
48
+ * Extensions of raw elementary streams, against the codec they carry.
49
+ *
50
+ * A bare `.ac3` has no track table to read, and its extension is the only thing
51
+ * that states its codec — which for an elementary stream is exactly what the
52
+ * extension means.
53
+ */
54
+ const CODEC_BY_EXTENSION = new Map([
55
+ [".aac", "aac"],
56
+ [".ac3", "ac3"],
57
+ [".eac3", "eac3"],
58
+ [".dts", "dts"],
59
+ [".dtshd", "dts"],
60
+ [".flac", "flac"],
61
+ [".mp3", "mp3"],
62
+ [".mp2", "mp2"],
63
+ [".opus", "opus"],
64
+ [".ogg", "vorbis"],
65
+ [".oga", "vorbis"],
66
+ [".wav", "pcm"],
67
+ [".thd", "truehd"],
68
+ [".mlp", "truehd"],
69
+ [".m4a", "aac"]
70
+ ]);
71
+
72
+ export class AudioTrack extends ContainerTrack {
73
+ /**
74
+ * @param {object} params - See ContainerTrack plus:
75
+ * @param {boolean} params.isOriginal - FlagOriginal
76
+ * @param {boolean} params.isCommentary - FlagCommentary
77
+ * @param {boolean} params.isVisualImpaired - FlagVisualImpaired / descriptive audio
78
+ * @param {number | null} params.channels
79
+ * @param {number | null} params.samplingFrequency
80
+ */
81
+ constructor(params) {
82
+ super({ ...params, type: "audio" });
83
+ this.isOriginal = params.isOriginal === true;
84
+ this.isCommentary = params.isCommentary === true;
85
+ this.isVisualImpaired = params.isVisualImpaired === true;
86
+ this.channels = Number.isFinite(params.channels) ? params.channels : null;
87
+ this.samplingFrequency = Number.isFinite(params.samplingFrequency) ? params.samplingFrequency : null;
88
+ }
89
+
90
+ /**
91
+ * The ffmpeg-side codec name for one track, from whatever the reading gave.
92
+ *
93
+ * @param {{ codec?: string, codecId?: string }} track
94
+ * @param {string} [extension] - The sidecar file's extension, when the track
95
+ * came from a file with no readable table.
96
+ * @returns {string}
97
+ */
98
+ static codecNameOf(track, extension = "") {
99
+ const fromBanner = typeof track?.codec === "string" ? track.codec.trim() : "";
100
+ if (fromBanner.length > 0) {
101
+ return fromBanner.toLowerCase();
102
+ }
103
+ const codecId = typeof track?.codecId === "string" ? track.codecId.trim() : "";
104
+ if (codecId.length > 0) {
105
+ const known = CODEC_NAMES.get(codecId);
106
+ if (known) {
107
+ return known;
108
+ }
109
+ // Matroska allows a suffix — `A_AAC/MPEG4/LC`, `A_PCM/INT/LIT` — so the
110
+ // family is what the first two segments say.
111
+ const family = codecId.split("/").slice(0, 2).join("/");
112
+ const byFamily = CODEC_NAMES.get(family) ?? CODEC_NAMES.get(codecId.split("/")[0]);
113
+ if (byFamily) {
114
+ return byFamily;
115
+ }
116
+ if (codecId.startsWith("A_PCM")) {
117
+ return "pcm";
118
+ }
119
+ return codecId.toLowerCase();
120
+ }
121
+ return CODEC_BY_EXTENSION.get(extension) ?? "";
122
+ }
123
+
124
+ /** Human label helper — commentary and descriptive audio must not look like main track. */
125
+ audioRoleLabel() {
126
+ if (this.isCommentary) return "commentary";
127
+ if (this.isVisualImpaired) return "descriptive";
128
+ if (this.isOriginal) return "original";
129
+ return "";
130
+ }
131
+ }