@torrent-tv/proxy 2.73.1 → 2.74.1

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +1453 -1437
  2. package/CLAUDE.md +165 -160
  3. package/docs/container-architecture.md +192 -184
  4. package/package.json +1 -1
  5. package/routes/api/subtitles/get.js +205 -205
  6. package/services/container/Container.js +400 -135
  7. package/services/container/ContainerFactory.js +55 -31
  8. package/services/container/MatroskaContainer.js +1166 -516
  9. package/services/container/Mp4Container.js +898 -392
  10. package/services/container/SubtitleFileContainer.js +323 -261
  11. package/services/controllers/SubtitleController.js +128 -127
  12. package/services/delivery-probe.js +64 -6
  13. package/services/hls-session-manager.js +32 -35
  14. package/services/language-detect.js +174 -228
  15. package/services/playback-planner.js +747 -747
  16. package/services/produced-index.js +300 -0
  17. package/services/torrent-worker/subtitle-cues.js +549 -633
  18. package/services/tracks/TextSubtitleTrack.js +287 -47
  19. package/services/tracks/index.js +14 -14
  20. package/test/delivery-probe.test.js +67 -0
  21. package/test/matroska-blocks.test.js +0 -0
  22. package/test/mp4-subtitles.test.js +173 -127
  23. package/test/produced-index.test.js +188 -0
  24. package/test/subtitle-cue-framing.test.js +200 -202
  25. package/test/subtitle-cue-walk.test.js +369 -0
  26. package/test/subtitle-defaults.test.js +97 -97
  27. package/test/subtitle-language.test.js +252 -252
  28. package/test/subtitle-track-numbering.test.js +370 -370
  29. package/services/container-index/matroska-blocks.js +0 -202
  30. package/services/container-index/matroska-subtitles.js +0 -372
  31. package/services/container-index/mp4-subtitles.js +0 -404
  32. package/services/subtitle-convert.js +0 -144
  33. package/services/subtitle-defaults.js +0 -157
  34. 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
- }