@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.
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +8 -5
- package/docs/container-architecture.md +25 -5
- package/package.json +1 -1
- package/services/audio-inventory.js +201 -411
- package/services/container/AviContainer.js +266 -81
- package/services/container/Container.js +294 -79
- package/services/container/ContainerFactory.js +122 -31
- package/services/container/MatroskaContainer.js +337 -7
- package/services/container/Mp4Container.js +414 -28
- package/services/hls-session-manager.js +2 -2
- package/services/playback-planner.js +43 -15
- package/services/torrent-worker/pool-adapter.js +352 -333
- package/services/torrent-worker/subtitle-cues.js +42 -75
- package/services/tracks/AudioTrack.js +131 -40
- package/test/audio-inventory.test.js +176 -177
- package/test/matroska-cues-track.test.js +192 -192
- package/test/mp4-composition-times.test.js +0 -0
- package/test/video-facts.test.js +102 -0
- package/services/container-index/avi.js +0 -167
- package/services/container-index/index.js +0 -118
- package/services/container-index/matroska.js +0 -336
- package/services/container-index/mp4.js +0 -358
- /package/services/{container-index → container}/ebml-reader.js +0 -0
|
@@ -17,8 +17,7 @@
|
|
|
17
17
|
* Reading the clusters costs nothing extra at all.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import {
|
|
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
|
|
115
|
-
* range is already downloaded,
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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 {
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
}
|