@torrent-tv/proxy 2.72.0 → 2.72.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.
@@ -5,8 +5,17 @@
5
5
  * ASS/SSA (.ass/.ssa) to WebVTT so the browser can attach them to a `<track>`
6
6
  * without any client-side conversion. The proxy owns subtitle conversion so it
7
7
  * can also run language detection where the full text is available.
8
+ *
9
+ * Reading a format's own framing is NOT here — it is `SubtitleFileContainer`
10
+ * for a file beside the film, and `MatroskaContainer` / `Mp4Container` for a
11
+ * track inside it. What is here is everything after that: a cue's missing end
12
+ * time, its codec's markup, and writing the WebVTT document. One writer, so a
13
+ * pushed cue and a pulled one cannot read differently.
8
14
  */
9
15
 
16
+ import { SubtitleFileContainer } from "./container/SubtitleFileContainer.js";
17
+ import { plainCueText } from "./tracks/subtitle-markup.js";
18
+
10
19
  /**
11
20
  * Decode subtitle bytes to text. Prefers UTF-8 (honouring a BOM); if the UTF-8
12
21
  * decode yields many replacement characters the bytes are re-decoded as
@@ -41,110 +50,95 @@ function stripBom(text) {
41
50
  return typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
42
51
  }
43
52
 
44
- function srtTsToVtt(ts) {
45
- return ts.replace(",", ".");
46
- }
47
-
48
53
  /**
49
- * Convert SubRip (.srt) text to WebVTT.
54
+ * One cue's start or end as WebVTT writes it: `hh:mm:ss.mmm`.
50
55
  *
51
- * @param {string} text
56
+ * @param {number} seconds
52
57
  * @returns {string}
53
58
  */
54
- function srtToVtt(text) {
55
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
56
- const out = ["WEBVTT", ""];
57
- for (const line of lines) {
58
- const m = line.match(/^(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})(.*)?$/);
59
- out.push(m ? `${srtTsToVtt(m[1])} --> ${srtTsToVtt(m[2])}${m[3] ?? ""}` : line);
60
- }
61
- return out.join("\n");
59
+ export function vttTime(seconds) {
60
+ const safe = Math.max(0, Number(seconds) || 0);
61
+ const hours = Math.floor(safe / 3600);
62
+ const minutes = Math.floor((safe % 3600) / 60);
63
+ const rest = safe % 60;
64
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${rest.toFixed(3).padStart(6, "0")}`;
62
65
  }
63
66
 
64
- function assTsToVtt(ts) {
65
- const m = ts.match(/^(\d+):(\d{2}):(\d{2})\.(\d{2})$/);
66
- if (!m) {
67
- return "00:00:00.000";
68
- }
69
- const ms = (parseInt(m[4], 10) * 10).toString().padStart(3, "0");
70
- return `${m[1].padStart(2, "0")}:${m[2]}:${m[3]}.${ms}`;
71
- }
72
-
73
- function stripAssTags(text) {
74
- return text
75
- .replace(/\{[^}]*\}/g, "")
76
- .replace(/\\N/g, "\n")
77
- .replace(/\\n/g, "\n")
78
- .replace(/\\h/g, " ")
79
- .trim();
67
+ /**
68
+ * Resolve what a cue is missing and take its codec's markup off, so what is
69
+ * left is what a player shows.
70
+ *
71
+ * The container's framing is NOT undone here — it is undone where the cue is
72
+ * read, by the container that framed it, which is the only place the framing is
73
+ * known. Until 2.72.1 this function tried to do both by counting commas, and
74
+ * on an embedded ASS track it showed every field of the dialogue row to the
75
+ * viewer.
76
+ *
77
+ * A cue with no duration — a Matroska SimpleBlock, which subtitles rarely use —
78
+ * is given the time until the next one IN THIS LIST, and the last such cue a
79
+ * few seconds. Not an invention about the film: it is what a player does with
80
+ * an open-ended cue, made explicit so every consumer agrees on it.
81
+ *
82
+ * @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
83
+ * @param {string} codecId
84
+ * @returns {{ startSeconds: number, endSeconds: number, text: string }[]}
85
+ */
86
+ export function finalizeCues(cues, codecId) {
87
+ const result = [];
88
+ (Array.isArray(cues) ? cues : []).forEach((cue, index) => {
89
+ const next = cues[index + 1];
90
+ const endSeconds = cue.endSeconds ?? (next ? next.startSeconds : cue.startSeconds + 4);
91
+ const text = plainCueText(cue.text, codecId);
92
+ if (!text) {
93
+ return;
94
+ }
95
+ result.push({ startSeconds: cue.startSeconds, endSeconds, text });
96
+ });
97
+ return result;
80
98
  }
81
99
 
82
100
  /**
83
- * Convert ASS/SSA text to WebVTT (only the [Events] section; styling dropped).
101
+ * A WebVTT document from a list of cues — the one writer, used by every path
102
+ * that produces subtitles: a file beside the film, a track inside it, a pull
103
+ * and a push.
84
104
  *
85
- * @param {string} text
105
+ * @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
106
+ * @param {string} codecId
86
107
  * @returns {string}
87
108
  */
88
- function assToVtt(text) {
89
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
90
- let inEvents = false;
91
- let formatCols = null;
92
- const cues = [];
93
- for (const line of lines) {
94
- const trimmed = line.trim();
95
- if (trimmed === "[Events]") {
96
- inEvents = true;
97
- continue;
98
- }
99
- if (trimmed.startsWith("[") && trimmed.endsWith("]") && inEvents) {
100
- inEvents = false;
101
- continue;
102
- }
103
- if (!inEvents) {
104
- continue;
105
- }
106
- if (trimmed.startsWith("Format:")) {
107
- formatCols = trimmed.slice("Format:".length).split(",").map((c) => c.trim().toLowerCase());
108
- continue;
109
- }
110
- if (trimmed.startsWith("Dialogue:") && formatCols) {
111
- const parts = trimmed.slice("Dialogue:".length).split(",");
112
- const startIdx = formatCols.indexOf("start");
113
- const endIdx = formatCols.indexOf("end");
114
- const textIdx = formatCols.indexOf("text");
115
- if (startIdx < 0 || endIdx < 0 || textIdx < 0) {
116
- continue;
117
- }
118
- const cueText = stripAssTags(parts.slice(textIdx).join(","));
119
- if (!cueText) {
120
- continue;
121
- }
122
- cues.push(`${assTsToVtt((parts[startIdx] ?? "").trim())} --> ${assTsToVtt((parts[endIdx] ?? "").trim())}\n${cueText}`);
123
- }
109
+ export function cuesToVtt(cues, codecId) {
110
+ const lines = ["WEBVTT", ""];
111
+ for (const cue of finalizeCues(cues, codecId)) {
112
+ lines.push(`${vttTime(cue.startSeconds)} --> ${vttTime(cue.endSeconds)}`);
113
+ lines.push(cue.text);
114
+ lines.push("");
124
115
  }
125
- return cues.length === 0 ? "WEBVTT\n" : `WEBVTT\n\n${cues.join("\n\n")}`;
116
+ return lines.join("\n");
126
117
  }
127
118
 
128
119
  /**
129
120
  * Convert subtitle text to WebVTT by file extension. Returns null for formats
130
121
  * that cannot be converted in-place (image-based .sup, ambiguous .sub, .ttml).
131
122
  *
123
+ * The reading is `SubtitleFileContainer`'s: the file states how its own cues
124
+ * are framed — SubRip by position, ASS by the `Format:` line of `[Events]` —
125
+ * and that is a fact about the file, not about this conversion.
126
+ *
132
127
  * @param {string} text
133
128
  * @param {string} ext - Lowercase extension including the dot, e.g. ".srt".
134
129
  * @returns {string | null}
135
130
  */
136
131
  export function convertSubtitleToVtt(text, ext) {
137
132
  const clean = stripBom(text);
138
- switch (ext) {
139
- case ".vtt":
140
- case ".webvtt":
141
- return clean.trimStart().startsWith("WEBVTT") ? clean : `WEBVTT\n\n${clean}`;
142
- case ".srt":
143
- return srtToVtt(clean);
144
- case ".ass":
145
- case ".ssa":
146
- return assToVtt(clean);
147
- default:
148
- return null;
133
+ const extension = String(ext ?? "").toLowerCase();
134
+ if (extension === ".vtt" || extension === ".webvtt") {
135
+ // Already what a browser reads. Parsing it to write it back would drop its
136
+ // styles, its regions and its cue identifiers for nothing.
137
+ return clean.trimStart().startsWith("WEBVTT") ? clean : `WEBVTT\n\n${clean}`;
138
+ }
139
+ if (!SubtitleFileContainer.detect(extension)) {
140
+ return null;
149
141
  }
142
+ const cues = new SubtitleFileContainer({ extension }).readCues(clean);
143
+ return cues === null ? null : cuesToVtt(cues, extension);
150
144
  }
@@ -18,8 +18,11 @@
18
18
  */
19
19
 
20
20
  import { readSubtitlePlan, harvestCluster } from "../container-index/matroska-subtitles.js";
21
- import { decodeSubtitleSample, readMp4SubtitlePlan } from "../container-index/mp4-subtitles.js";
21
+ import { readMp4SubtitlePlan } from "../container-index/mp4-subtitles.js";
22
22
  import { iterateElements } from "../container-index/ebml-reader.js";
23
+ import { MatroskaContainer } from "../container/MatroskaContainer.js";
24
+ import { Mp4Container } from "../container/Mp4Container.js";
25
+ import { finalizeCues } from "../subtitle-convert.js";
23
26
  import { detectLanguage } from "../language-detect.js";
24
27
  import { logger } from "../../utils/logger.js";
25
28
 
@@ -358,7 +361,8 @@ async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
358
361
  continue;
359
362
  }
360
363
  harvested.add(sample.offset);
361
- const text = decodeSubtitleSample(bytes, track.codecId);
364
+ // The MP4 has framed this cue and is the one that unframes it.
365
+ const text = Mp4Container.cueTextOf(bytes, track.codecId);
362
366
  if (text) {
363
367
  cues.push({
364
368
  startSeconds: sample.startSeconds,
@@ -424,9 +428,18 @@ async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
424
428
  state.cues.set(candidate.trackNumber, into);
425
429
  }
426
430
  let found = false;
427
- for (const cue of harvestCluster(bytes, candidate.trackNumber, plan.secondsPerTick)) {
428
- cue.seq = nextSeq(state, candidate.trackNumber);
429
- into.push(cue);
431
+ for (const block of harvestCluster(bytes, candidate.trackNumber, plan.secondsPerTick)) {
432
+ // The block's bytes become this track's text HERE, where the container
433
+ // that framed them is known. A cue kept in its framed form and unframed
434
+ // later cannot be unframed at all: nothing downstream knows which
435
+ // container it came out of, and guessing from the field count is what
436
+ // showed the dialogue row's own fields to the viewer.
437
+ into.push({
438
+ startSeconds: block.startSeconds,
439
+ endSeconds: block.endSeconds,
440
+ text: MatroskaContainer.cueTextOf(block.payload, candidate.codecId),
441
+ seq: nextSeq(state, candidate.trackNumber)
442
+ });
430
443
  found = true;
431
444
  }
432
445
  if (found) {
@@ -515,54 +528,6 @@ export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
515
528
  return fresh;
516
529
  }
517
530
 
518
- /**
519
- * Resolve a cue's end time and strip codec-specific formatting, turning a raw
520
- * block-derived cue into text a player can show directly. The same step
521
- * `routes/api/subtitles/get.js` applies when building WebVTT for a pull —
522
- * factored out here so a pushed cue and a pulled one read identically.
523
- *
524
- * A cue with no duration — a SimpleBlock, which subtitles rarely use — is
525
- * given the time until the next one IN THIS LIST, and the last such cue a few
526
- * seconds. Not an invention about the film: it is what a player does with an
527
- * open-ended cue, made explicit so every consumer agrees on it.
528
- *
529
- * @param {{ startSeconds: number, endSeconds: number | null, text: string }[]} cues
530
- * @param {string} codecId
531
- * @returns {{ startSeconds: number, endSeconds: number, text: string }[]}
532
- */
533
- export function finalizeCues(cues, codecId) {
534
- const isAss = codecId === "S_TEXT/ASS" || codecId === "S_TEXT/SSA";
535
- const result = [];
536
- cues.forEach((cue, index) => {
537
- const next = cues[index + 1];
538
- const endSeconds = cue.endSeconds ?? (next ? next.startSeconds : cue.startSeconds + 4);
539
- const text = isAss ? assDialogueToText(cue.text) : cue.text.trim();
540
- if (!text) {
541
- return;
542
- }
543
- result.push({ startSeconds: cue.startSeconds, endSeconds, text });
544
- });
545
- return result;
546
- }
547
-
548
- /**
549
- * The visible text of an ASS dialogue row.
550
- *
551
- * A block carries the fields after `Dialogue:` without their header — nine of
552
- * them, then the text, which itself holds override groups in braces.
553
- *
554
- * @param {string} raw
555
- * @returns {string}
556
- */
557
- function assDialogueToText(raw) {
558
- const fields = raw.split(",");
559
- const text = fields.length > 9 ? fields.slice(9).join(",") : raw;
560
- return text
561
- .replace(/\{[^}]*\}/g, "")
562
- .replace(/\\N/gi, "\n")
563
- .trim();
564
- }
565
-
566
531
  /**
567
532
  * The text subtitle tracks of a file, for the menu the viewer sees.
568
533
  *
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { SubtitleTrack } from "./SubtitleTrack.js";
10
+ import { markupKindOf, plainCueText } from "./subtitle-markup.js";
10
11
 
11
12
  const TEXT_CODECS_MATROSKA = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT"]);
12
13
  const TEXT_FORMATS_MP4 = new Set(["tx3g", "text", "wvtt"]);
@@ -21,6 +22,23 @@ export class TextSubtitleTrack extends SubtitleTrack {
21
22
  return true;
22
23
  }
23
24
 
25
+ /** Which markup this track's cue text carries — see `subtitle-markup.js`. */
26
+ get markupKind() {
27
+ return markupKindOf(this.textCodec);
28
+ }
29
+
30
+ /**
31
+ * The visible text of one of this track's cues.
32
+ *
33
+ * @param {string} textField - The cue's text field, already out of its
34
+ * container's framing. This method knows the codec and not the container,
35
+ * which is why it cannot be handed a whole dialogue row.
36
+ * @returns {string}
37
+ */
38
+ plainText(textField) {
39
+ return plainCueText(textField, this.textCodec);
40
+ }
41
+
24
42
  static isTextCodec(codecId) {
25
43
  return TEXT_CODECS_MATROSKA.has(codecId) || TEXT_FORMATS_MP4.has(codecId);
26
44
  }
@@ -4,6 +4,7 @@ export { AudioTrack } from "./AudioTrack.js";
4
4
  export { SubtitleTrack } from "./SubtitleTrack.js";
5
5
  export { TextSubtitleTrack, TEXT_CODECS_MATROSKA, TEXT_FORMATS_MP4 } from "./TextSubtitleTrack.js";
6
6
  export { ImageSubtitleTrack } from "./ImageSubtitleTrack.js";
7
+ export { MarkupKind, markupKindOf, plainCueText } from "./subtitle-markup.js";
7
8
  // There is deliberately no class for a track that lives in a file of its own.
8
9
  // `<name>.mka` is a Matroska container holding an `AudioTrack`, and
9
10
  // `MatroskaContainer` reads it exactly as it reads the picture's — so "external"
@@ -0,0 +1,104 @@
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
+ }