@torrent-tv/proxy 2.73.1 → 2.74.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 +1447 -1437
- package/CLAUDE.md +165 -160
- package/docs/container-architecture.md +192 -184
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +205 -205
- package/services/container/Container.js +354 -135
- package/services/container/MatroskaContainer.js +1155 -516
- package/services/container/Mp4Container.js +858 -392
- package/services/container/SubtitleFileContainer.js +323 -261
- package/services/controllers/SubtitleController.js +128 -127
- package/services/delivery-probe.js +64 -6
- package/services/hls-session-manager.js +32 -35
- package/services/language-detect.js +174 -228
- package/services/playback-planner.js +747 -747
- package/services/produced-index.js +300 -0
- package/services/torrent-worker/subtitle-cues.js +582 -633
- package/services/tracks/TextSubtitleTrack.js +287 -47
- package/services/tracks/index.js +14 -14
- package/test/delivery-probe.test.js +67 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -127
- package/test/produced-index.test.js +188 -0
- package/test/subtitle-cue-framing.test.js +200 -202
- package/test/subtitle-cue-walk.test.js +369 -0
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +370 -370
- package/services/container-index/matroska-blocks.js +0 -202
- package/services/container-index/matroska-subtitles.js +0 -372
- package/services/container-index/mp4-subtitles.js +0 -404
- package/services/subtitle-convert.js +0 -144
- package/services/subtitle-defaults.js +0 -157
- package/services/tracks/subtitle-markup.js +0 -104
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Which subtitle track the FILE says to show, read from the file rather than
|
|
3
|
-
* from ffmpeg's description of it.
|
|
4
|
-
*
|
|
5
|
-
* Why this exists. The browser decides which subtitle track to turn on from
|
|
6
|
-
* `isDefault`, and until now that came from ffmpeg's `-i` banner, which prints
|
|
7
|
-
* `(default)`. In Matroska `FlagDefault` DEFAULTS TO 1 and ffmpeg has already
|
|
8
|
-
* applied that default by the time it prints — so a file whose muxer wrote the
|
|
9
|
-
* flag on no track arrives looking exactly like one that wrote it on every
|
|
10
|
-
* track: everything marked. The banner cannot tell the two apart, and the
|
|
11
|
-
* difference is the whole question, because one of them means "show this one"
|
|
12
|
-
* and the other means "the file has no opinion".
|
|
13
|
-
*
|
|
14
|
-
* The container itself can be asked, and the EBML reader already walks the
|
|
15
|
-
* Tracks element for subtitle extraction. What it now also records is whether
|
|
16
|
-
* the element was WRITTEN, which is the fact the banner destroys.
|
|
17
|
-
*
|
|
18
|
-
* The awkward part is lining the two readings up. ffmpeg numbers its subtitle
|
|
19
|
-
* streams `0:s:0`, `0:s:1`, … over EVERY subtitle stream, picture-based ones
|
|
20
|
-
* included, in the order the container declares them; the container reading is
|
|
21
|
-
* a list in that same order. So position is the correspondence — but a position
|
|
22
|
-
* match that is merely assumed is worth nothing, so it is CHECKED: each pair
|
|
23
|
-
* has to agree on language or on title. One pair that agrees on neither, or a
|
|
24
|
-
* length that differs, means the two readings are not describing the same
|
|
25
|
-
* thing in the same order, and then the container reading is not used at all.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Language codes that carry no information, and so cannot confirm a pairing.
|
|
30
|
-
*
|
|
31
|
-
* ffmpeg prints `und` for a stream with no language; Matroska's own default
|
|
32
|
-
* for `Language` is `eng`, which is why an absent element cannot be read as a
|
|
33
|
-
* statement either — but `eng` is also a real answer, so it is not listed here
|
|
34
|
-
* and is compared like any other.
|
|
35
|
-
*/
|
|
36
|
-
const EMPTY_LANGUAGES = new Set(["", "und", "unknown"]);
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @param {unknown} value
|
|
40
|
-
* @returns {string}
|
|
41
|
-
*/
|
|
42
|
-
function normalise(value) {
|
|
43
|
-
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Whether one banner stream and one container track can be the same track.
|
|
48
|
-
*
|
|
49
|
-
* Agreement on either the language or the name is enough; both being empty is
|
|
50
|
-
* not agreement, because two tracks that say nothing about themselves say
|
|
51
|
-
* nothing about their pairing either.
|
|
52
|
-
*
|
|
53
|
-
* @param {{ language?: string, title?: string }} banner
|
|
54
|
-
* @param {{ language?: string, name?: string }} container
|
|
55
|
-
* @returns {boolean}
|
|
56
|
-
*/
|
|
57
|
-
export function pairingHolds(banner, container) {
|
|
58
|
-
const bannerLanguage = normalise(banner?.language);
|
|
59
|
-
const containerLanguage = normalise(container?.language);
|
|
60
|
-
if (
|
|
61
|
-
!EMPTY_LANGUAGES.has(bannerLanguage) &&
|
|
62
|
-
!EMPTY_LANGUAGES.has(containerLanguage) &&
|
|
63
|
-
bannerLanguage === containerLanguage
|
|
64
|
-
) {
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
const bannerTitle = normalise(banner?.title);
|
|
68
|
-
const containerName = normalise(container?.name);
|
|
69
|
-
if (bannerTitle.length > 0 && bannerTitle === containerName) {
|
|
70
|
-
return true;
|
|
71
|
-
}
|
|
72
|
-
// Nothing to compare on either side. Not a disagreement — a file may name
|
|
73
|
-
// neither — so it does not break the alignment; it simply adds no support.
|
|
74
|
-
return (
|
|
75
|
-
(EMPTY_LANGUAGES.has(bannerLanguage) || EMPTY_LANGUAGES.has(containerLanguage)) &&
|
|
76
|
-
(bannerTitle.length === 0 || containerName.length === 0)
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* The banner's subtitle tracks, with what the container says about each.
|
|
82
|
-
*
|
|
83
|
-
* Every returned track gains `declaresDefault`: whether the FILE wrote the flag
|
|
84
|
-
* for it. When the container reading cannot be trusted — no declarations, a
|
|
85
|
-
* different number of them, or a pair that agrees on neither language nor name
|
|
86
|
-
* — every track gets `declaresDefault: false` and its `isDefault` is left as
|
|
87
|
-
* the banner had it. That is the honest answer for a file we cannot read this
|
|
88
|
-
* way: the container has not been heard from, so nothing is shown unasked.
|
|
89
|
-
*
|
|
90
|
-
* @param {Array<{ index?: number, language?: string, title?: string, isDefault?: boolean }>} bannerTracks
|
|
91
|
-
* @param {Array<{ language?: string, name?: string, isDefault?: boolean, declaresDefault?: boolean }>} declared
|
|
92
|
-
* @returns {{ tracks: object[], aligned: boolean, reason: string }}
|
|
93
|
-
*/
|
|
94
|
-
export function mergeContainerSubtitleFlags(bannerTracks, declared) {
|
|
95
|
-
const banner = Array.isArray(bannerTracks) ? bannerTracks : [];
|
|
96
|
-
const container = Array.isArray(declared) ? declared : [];
|
|
97
|
-
const undecided = () => ({
|
|
98
|
-
// The container reading could not be lined up, so nothing of it is used —
|
|
99
|
-
// including the flags, which would otherwise be attributed to the wrong
|
|
100
|
-
// track.
|
|
101
|
-
tracks: banner.map((track) => ({
|
|
102
|
-
...track,
|
|
103
|
-
declaresDefault: false,
|
|
104
|
-
isForced: false,
|
|
105
|
-
isHearingImpaired: false,
|
|
106
|
-
// Not "the container says this track is unusable" — nothing of the
|
|
107
|
-
// container is being used here. A track is offered unless it was read to
|
|
108
|
-
// say otherwise.
|
|
109
|
-
isEnabled: true,
|
|
110
|
-
languageBcp47: ""
|
|
111
|
-
}))
|
|
112
|
-
});
|
|
113
|
-
if (container.length === 0) {
|
|
114
|
-
return { ...undecided(), aligned: false, reason: "the container declares no subtitle track" };
|
|
115
|
-
}
|
|
116
|
-
if (container.length !== banner.length) {
|
|
117
|
-
return {
|
|
118
|
-
...undecided(),
|
|
119
|
-
aligned: false,
|
|
120
|
-
reason: `the container declares ${container.length} subtitle tracks and the probe found ${banner.length}`
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
for (const [order, track] of banner.entries()) {
|
|
124
|
-
if (!pairingHolds(track, container[order])) {
|
|
125
|
-
return {
|
|
126
|
-
...undecided(),
|
|
127
|
-
aligned: false,
|
|
128
|
-
reason:
|
|
129
|
-
`subtitle ${order} is "${normalise(track?.title) || "-"}"/${normalise(track?.language) || "-"} ` +
|
|
130
|
-
`in the probe and "${normalise(container[order]?.name) || "-"}"/` +
|
|
131
|
-
`${normalise(container[order]?.language) || "-"} in the container`
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return {
|
|
136
|
-
tracks: banner.map((track, order) => ({
|
|
137
|
-
...track,
|
|
138
|
-
isDefault: container[order].isDefault === true,
|
|
139
|
-
declaresDefault: container[order].declaresDefault === true,
|
|
140
|
-
// Read from the file rather than guessed from the track's name. Both are
|
|
141
|
-
// stated by the container itself (RFC 9559 §5.1.4.1) and neither reaches
|
|
142
|
-
// ffmpeg's `-i` banner, which is where every other field here comes from.
|
|
143
|
-
isForced: container[order].isForced === true,
|
|
144
|
-
isHearingImpaired: container[order].isHearingImpaired === true,
|
|
145
|
-
// FlagEnabled, so the browser can leave an unusable track out of the
|
|
146
|
-
// menu. It stays in this list and keeps its number: ffmpeg creates a
|
|
147
|
-
// stream for it either way.
|
|
148
|
-
isEnabled: container[order].isEnabled !== false,
|
|
149
|
-
// The RFC 5646 tag, where the file writes one. Kept beside the code
|
|
150
|
-
// rather than replacing it: what this list is aligned against is ffmpeg's
|
|
151
|
-
// banner, which prints the three-letter form.
|
|
152
|
-
languageBcp47: typeof container[order].languageBcp47 === "string" ? container[order].languageBcp47 : ""
|
|
153
|
-
})),
|
|
154
|
-
aligned: true,
|
|
155
|
-
reason: ""
|
|
156
|
-
};
|
|
157
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The markup a subtitle codec puts INSIDE the text of one cue, and how to
|
|
3
|
-
* take it off. One axis of variation, and only one.
|
|
4
|
-
*
|
|
5
|
-
* Nothing here knows which container the text came out of. That is the other
|
|
6
|
-
* axis and it belongs to `container/`: how a cue's bytes are framed is stated by
|
|
7
|
-
* the CONTAINER's specification, not by the subtitle format's. Matroska
|
|
8
|
-
* reorders an ASS dialogue row and drops its two timing fields
|
|
9
|
-
* (`matroska.org/technical/subtitles.html`); a `.ass` file states its own field
|
|
10
|
-
* order in the `Format:` line of `[Events]`; an MP4 carries each cue as a sample
|
|
11
|
-
* with a length prefix. Every one of those is a fact about the container.
|
|
12
|
-
*
|
|
13
|
-
* What IS a fact about ASS, wherever it is stored: override groups in braces,
|
|
14
|
-
* `\N` and `\n` for a line break, `\h` for a hard space. That is this file.
|
|
15
|
-
*
|
|
16
|
-
* The two were mixed in one function until 2.72.1, which is what broke English
|
|
17
|
-
* subtitles on an embedded ASS track: the function counted commas to guess
|
|
18
|
-
* which framing it had been handed, expected ten fields — the shape of a row in
|
|
19
|
-
* a FILE — and a Matroska block carries nine. Every field of the row was then
|
|
20
|
-
* shown to the viewer as if it were dialogue. A function that has to guess the
|
|
21
|
-
* shape of its input is being called by someone who knew and did not say.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/** How a cue's text is marked up, once the container's framing is off. */
|
|
25
|
-
export const MarkupKind = {
|
|
26
|
-
/** Sub Station Alpha and its advanced form: `{\pos(…)}`, `\N`, `\h`. */
|
|
27
|
-
ASS: "ass",
|
|
28
|
-
/** Nothing to strip: the text is what is shown. */
|
|
29
|
-
NONE: "none"
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Which markup a codec's cue text carries.
|
|
34
|
-
*
|
|
35
|
-
* The keys are every name this proxy has for a text subtitle codec: Matroska
|
|
36
|
-
* CodecIDs (RFC 9559 §5.1.4.1.28 and the codec mappings beside it), MP4 sample
|
|
37
|
-
* entry types (ISO/IEC 14496-12 §12.6, plus Apple's `tx3g`), and the file
|
|
38
|
-
* extensions a subtitle shipped beside the film uses. They are listed together
|
|
39
|
-
* because the ANSWER is the same for all of them — ASS is ASS whether it sits
|
|
40
|
-
* in a Matroska block, in a file, or nowhere yet — and keeping three tables
|
|
41
|
-
* would mean three places to forget.
|
|
42
|
-
*
|
|
43
|
-
* @type {Map<string, string>}
|
|
44
|
-
*/
|
|
45
|
-
const MARKUP_BY_CODEC = new Map([
|
|
46
|
-
["S_TEXT/ASS", MarkupKind.ASS],
|
|
47
|
-
["S_TEXT/SSA", MarkupKind.ASS],
|
|
48
|
-
[".ass", MarkupKind.ASS],
|
|
49
|
-
[".ssa", MarkupKind.ASS],
|
|
50
|
-
["S_TEXT/UTF8", MarkupKind.NONE],
|
|
51
|
-
["S_TEXT/WEBVTT", MarkupKind.NONE],
|
|
52
|
-
[".srt", MarkupKind.NONE],
|
|
53
|
-
[".vtt", MarkupKind.NONE],
|
|
54
|
-
[".webvtt", MarkupKind.NONE],
|
|
55
|
-
["tx3g", MarkupKind.NONE],
|
|
56
|
-
["text", MarkupKind.NONE],
|
|
57
|
-
["wvtt", MarkupKind.NONE]
|
|
58
|
-
]);
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The markup kind of a codec, by any of its names.
|
|
62
|
-
*
|
|
63
|
-
* An unknown codec is answered `NONE` rather than refused: the text is then
|
|
64
|
-
* shown as it is, which is wrong only in so far as some markup stays visible,
|
|
65
|
-
* where a refusal would show nothing at all.
|
|
66
|
-
*
|
|
67
|
-
* @param {string} codecId - Matroska CodecID, MP4 sample entry type, or a file
|
|
68
|
-
* extension including the dot. Case is ignored for extensions, which arrive
|
|
69
|
-
* from file names, and kept for the others, which are spelled by a spec.
|
|
70
|
-
* @returns {string} One of {@link MarkupKind}.
|
|
71
|
-
*/
|
|
72
|
-
export function markupKindOf(codecId) {
|
|
73
|
-
const name = String(codecId ?? "");
|
|
74
|
-
return MARKUP_BY_CODEC.get(name) ?? MARKUP_BY_CODEC.get(name.toLowerCase()) ?? MarkupKind.NONE;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* The visible text of one cue: its markup taken off, nothing else touched.
|
|
79
|
-
*
|
|
80
|
-
* @param {string} text - The cue's text FIELD, already out of the container's
|
|
81
|
-
* framing. Handing a whole dialogue row to this is the mistake described at
|
|
82
|
-
* the top of this file.
|
|
83
|
-
* @param {string} codecId
|
|
84
|
-
* @returns {string} Possibly empty — a cue whose text is only a drawing command
|
|
85
|
-
* or a positioning group has nothing to show, and the caller drops it.
|
|
86
|
-
*/
|
|
87
|
-
export function plainCueText(text, codecId) {
|
|
88
|
-
const raw = String(text ?? "");
|
|
89
|
-
if (markupKindOf(codecId) !== MarkupKind.ASS) {
|
|
90
|
-
return raw.trim();
|
|
91
|
-
}
|
|
92
|
-
return raw
|
|
93
|
-
// An override group. Any brace content is a directive, never dialogue:
|
|
94
|
-
// drawing commands, karaoke timing, positioning, font changes.
|
|
95
|
-
.replace(/\{[^}]*\}/g, "")
|
|
96
|
-
// Both breaks reach a player as a break. ASS distinguishes them — `\N` is
|
|
97
|
-
// always a break, `\n` only where the style does not wrap — and a WebVTT
|
|
98
|
-
// cue has no way to express the difference, so it takes the break.
|
|
99
|
-
.replace(/\\N/g, "\n")
|
|
100
|
-
.replace(/\\n/g, "\n")
|
|
101
|
-
// A space the renderer may not collapse.
|
|
102
|
-
.replace(/\\h/g, " ")
|
|
103
|
-
.trim();
|
|
104
|
-
}
|