@torrent-tv/proxy 2.72.0 → 2.72.2

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.
@@ -0,0 +1,361 @@
1
+ /**
2
+ * @file When several runs have written the same segment number, which copy
3
+ * answers — and what happens to the one that cannot.
4
+ *
5
+ * Field 2026-09-03. A run was suspended 548 ms after it started with
6
+ * `segment-00025.mp4` newly opened, so the file stayed at zero bytes. From then
7
+ * on the two halves of the proxy disagreed about it and neither could see the
8
+ * other's reason: the look-ahead counted the NAME, found the numbering unbroken
9
+ * through #83, called it `420s ahead of the viewer` and kept the encoder
10
+ * stopped; the serving path read the FILE, found it short of a track, and
11
+ * waited for a run that had produced nothing to finish it. A complete copy of
12
+ * #25 lay in the previous run's directory the whole time and was never reached,
13
+ * because the search returned the first name it found and stopped.
14
+ *
15
+ * The viewer's picture stood still for ten minutes with the bytes they needed
16
+ * already on the disk, on a transport measuring 3-9 ms round trip.
17
+ */
18
+
19
+ import test from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import {
25
+ HlsSessionManager,
26
+ discardOpenPiece,
27
+ usableSegmentIndices
28
+ } from "../services/hls-session-manager.js";
29
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
30
+
31
+ const MOVIE_TIMESCALE = 1000;
32
+ const VIDEO_TIMESCALE = 90_000;
33
+ const AUDIO_TIMESCALE = 48_000;
34
+ const SEGMENT_SECONDS = 12.5;
35
+ const SESSION_ID = "aef21c88-a8d6-4a9a-8e7a-d0a9536351cf";
36
+
37
+ /**
38
+ * @param {string} type
39
+ * @param {Buffer} body
40
+ * @returns {Buffer}
41
+ */
42
+ function box(type, body) {
43
+ const head = Buffer.alloc(8);
44
+ head.writeUInt32BE(8 + body.length, 0);
45
+ head.write(type, 4, "latin1");
46
+ return Buffer.concat([head, body]);
47
+ }
48
+
49
+ /**
50
+ * @param {number} offsetSeconds
51
+ * @returns {Buffer}
52
+ */
53
+ function emptyEdit(offsetSeconds) {
54
+ const body = Buffer.alloc(16);
55
+ body.writeUInt32BE(1, 4);
56
+ body.writeUInt32BE(Math.round(offsetSeconds * MOVIE_TIMESCALE), 8);
57
+ body.writeInt32BE(-1, 12);
58
+ return box("elst", body);
59
+ }
60
+
61
+ /**
62
+ * @param {number} trackId
63
+ * @param {number} timescale
64
+ * @param {number} offsetSeconds
65
+ * @returns {Buffer}
66
+ */
67
+ function trak(trackId, timescale, offsetSeconds) {
68
+ const tkhdBody = Buffer.alloc(84);
69
+ tkhdBody.writeUInt32BE(trackId, 12);
70
+ const mdhdBody = Buffer.alloc(20);
71
+ mdhdBody.writeUInt32BE(timescale, 12);
72
+ return box("trak", Buffer.concat([
73
+ box("tkhd", tkhdBody),
74
+ box("edts", emptyEdit(offsetSeconds)),
75
+ box("mdia", box("mdhd", mdhdBody))
76
+ ]));
77
+ }
78
+
79
+ /**
80
+ * @param {number} trackId
81
+ * @returns {Buffer}
82
+ */
83
+ function traf(trackId) {
84
+ const tfhdBody = Buffer.alloc(8);
85
+ tfhdBody.writeUInt32BE(trackId, 4);
86
+ const tfdtBody = Buffer.alloc(12);
87
+ tfdtBody.writeUInt8(1, 0);
88
+ tfdtBody.writeBigUInt64BE(0n, 4);
89
+ return box("traf", Buffer.concat([box("tfhd", tfhdBody), box("tfdt", tfdtBody)]));
90
+ }
91
+
92
+ /**
93
+ * A piece shaped like the `segment` muxer's output, carrying both tracks.
94
+ *
95
+ * @param {number} offsetSeconds
96
+ * @returns {Buffer}
97
+ */
98
+ function wholePiece(offsetSeconds) {
99
+ const mvhdBody = Buffer.alloc(100);
100
+ mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
101
+ const moov = box("moov", Buffer.concat([
102
+ box("mvhd", mvhdBody),
103
+ trak(1, VIDEO_TIMESCALE, offsetSeconds),
104
+ trak(2, AUDIO_TIMESCALE, offsetSeconds)
105
+ ]));
106
+ const moof = box("moof", Buffer.concat([traf(1), traf(2)]));
107
+ return Buffer.concat([
108
+ box("ftyp", Buffer.alloc(16, 0)),
109
+ moov,
110
+ moof,
111
+ box("mdat", Buffer.alloc(64, 0x5a)),
112
+ box("mfra", Buffer.alloc(24, 0))
113
+ ]);
114
+ }
115
+
116
+ /**
117
+ * The same shape with ONE track in the fragment — what a run killed mid-write
118
+ * leaves when it had muxed the picture and not yet the sound.
119
+ *
120
+ * @param {number} offsetSeconds
121
+ * @returns {Buffer}
122
+ */
123
+ function halfPiece(offsetSeconds) {
124
+ const mvhdBody = Buffer.alloc(100);
125
+ mvhdBody.writeUInt32BE(MOVIE_TIMESCALE, 12);
126
+ const moov = box("moov", Buffer.concat([
127
+ box("mvhd", mvhdBody),
128
+ trak(1, VIDEO_TIMESCALE, offsetSeconds),
129
+ trak(2, AUDIO_TIMESCALE, offsetSeconds)
130
+ ]));
131
+ return Buffer.concat([
132
+ box("ftyp", Buffer.alloc(16, 0)),
133
+ moov,
134
+ box("moof", traf(1)),
135
+ box("mdat", Buffer.alloc(64, 0x5a))
136
+ ]);
137
+ }
138
+
139
+ /**
140
+ * A session whose runs are laid out on disk exactly as the field case was.
141
+ *
142
+ * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
143
+ */
144
+ async function sessionWithTwoRuns() {
145
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "produced-copy-"));
146
+ await mkdir(path.join(dirPath, "run-1"), { recursive: true });
147
+ await mkdir(path.join(dirPath, "run-2"), { recursive: true });
148
+ const manager = new HlsSessionManager({
149
+ enabled: true,
150
+ ffmpegBin: "ffmpeg",
151
+ localBindHost: "127.0.0.1",
152
+ localPort: 9090
153
+ });
154
+ const session = {
155
+ id: SESSION_ID,
156
+ dirPath,
157
+ runDirPath: path.join(dirPath, "run-2"),
158
+ runSerial: 2,
159
+ state: "ready",
160
+ fileName: "Drifters - 04.mkv",
161
+ startedAt: Date.now(),
162
+ createEntryMs: Date.now(),
163
+ lastAccessedAt: Date.now(),
164
+ // A run exists and is alive — the state the field case was in, and the one
165
+ // in which the old test called every leftover "still being written".
166
+ ffmpeg: { pid: 0, killed: false, kill() {} },
167
+ lastError: "",
168
+ consumers: new Set(),
169
+ netReports: new Map(),
170
+ segmentFormat: fmp4Format,
171
+ usesExplicitCuts: true,
172
+ useSyntheticPlaylist: true,
173
+ playlistText: "#EXTM3U\n",
174
+ segmentBoundaries: [0, SEGMENT_SECONDS, 25, 37.5, 50],
175
+ initBytes: fmp4Format.extractInit(wholePiece(0)),
176
+ encodeStartIndex: 0,
177
+ firstSegmentLogged: false,
178
+ waitEpoch: 0
179
+ };
180
+ manager.sessionsById.set(SESSION_ID, session);
181
+ return { manager, session, dirPath };
182
+ }
183
+
184
+ test("the complete copy answers when the newest run's is empty", async (t) => {
185
+ const { manager, dirPath } = await sessionWithTwoRuns();
186
+ t.after(async () => {
187
+ await manager.disposeAll();
188
+ await rm(dirPath, { recursive: true, force: true });
189
+ });
190
+ // run-1 made this segment and finished it; run-2 opened it and was stopped.
191
+ await writeFile(path.join(dirPath, "run-1", "segment-00001.mp4"), wholePiece(SEGMENT_SECONDS));
192
+ await writeFile(path.join(dirPath, "run-1", "segment-00002.mp4"), wholePiece(25));
193
+ await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), Buffer.alloc(0));
194
+
195
+ const result = await manager.getFileStream(SESSION_ID, "segment-00001.mp4", { requestSeq: 1 });
196
+
197
+ assert.equal(
198
+ result.kind,
199
+ "file",
200
+ "a complete copy exists one run away; holding here is what froze the picture for ten minutes"
201
+ );
202
+ const chunks = [];
203
+ for await (const chunk of result.stream) {
204
+ chunks.push(chunk);
205
+ }
206
+ assert.ok(Buffer.concat(chunks).length > 0, "and it must be the copy with bytes in it");
207
+ });
208
+
209
+ test("a copy short of a track is passed over for an older one that is whole", async (t) => {
210
+ const { manager, dirPath } = await sessionWithTwoRuns();
211
+ t.after(async () => {
212
+ await manager.disposeAll();
213
+ await rm(dirPath, { recursive: true, force: true });
214
+ });
215
+ // Not empty — a terminated run closes its file properly, with only what it
216
+ // had muxed by then, which after a seek-restart is routinely one track of two.
217
+ await writeFile(path.join(dirPath, "run-1", "segment-00001.mp4"), wholePiece(SEGMENT_SECONDS));
218
+ await writeFile(path.join(dirPath, "run-1", "segment-00002.mp4"), wholePiece(25));
219
+ await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), halfPiece(SEGMENT_SECONDS));
220
+
221
+ const result = await manager.getFileStream(SESSION_ID, "segment-00001.mp4", { requestSeq: 1 });
222
+
223
+ assert.equal(result.kind, "file", "a piece carrying one track of two is not servable; the whole one is");
224
+ });
225
+
226
+ test("a leftover of a run that has ended is removed, not waited on", async (t) => {
227
+ const { manager, dirPath } = await sessionWithTwoRuns();
228
+ t.after(async () => {
229
+ await manager.disposeAll();
230
+ await rm(dirPath, { recursive: true, force: true });
231
+ });
232
+ // The only copy, and it belongs to a run that is gone. The current run —
233
+ // run-2 — has written nothing, so calling this "still being written" waits on
234
+ // a process that will never touch it.
235
+ await writeFile(path.join(dirPath, "run-1", "segment-00001.mp4"), Buffer.alloc(0));
236
+
237
+ const result = await manager.getFileStream(SESSION_ID, "segment-00001.mp4", { requestSeq: 1 });
238
+
239
+ assert.equal(result.kind, "warming-up", "nothing servable exists yet, so the viewer waits");
240
+ const left = await readdir(path.join(dirPath, "run-1"));
241
+ assert.deepEqual(
242
+ left,
243
+ [],
244
+ "and the unusable file must go, or the current run's own output is never looked for"
245
+ );
246
+ });
247
+
248
+ test("the current run's own unfinished piece is waited for, never deleted", async (t) => {
249
+ const { manager, session, dirPath } = await sessionWithTwoRuns();
250
+ t.after(async () => {
251
+ await manager.disposeAll();
252
+ await rm(dirPath, { recursive: true, force: true });
253
+ });
254
+ // The same file, in the directory of the run that is alive. It is being
255
+ // written right now. Deleting it is the 2026-08-06 incident: #225 was removed
256
+ // 14 s into the run producing it, which then wrote on into a file nobody
257
+ // could open, and the segment never appeared.
258
+ session.encodeStartIndex = 1;
259
+ await writeFile(path.join(dirPath, "run-2", "segment-00001.mp4"), Buffer.alloc(0));
260
+
261
+ const result = await manager.getFileStream(SESSION_ID, "segment-00001.mp4", { requestSeq: 1 });
262
+
263
+ assert.equal(result.kind, "warming-up");
264
+ const left = await readdir(path.join(dirPath, "run-2"));
265
+ assert.deepEqual(left, ["segment-00001.mp4"], "the live run's own output must be left alone");
266
+ });
267
+
268
+ test("the look-ahead does not count a file with nothing in it", async (t) => {
269
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "usable-indices-"));
270
+ t.after(async () => {
271
+ await rm(dirPath, { recursive: true, force: true });
272
+ });
273
+ const runTwo = path.join(dirPath, "run-2");
274
+ const runOne = path.join(dirPath, "run-1");
275
+ await mkdir(runTwo, { recursive: true });
276
+ await mkdir(runOne, { recursive: true });
277
+ await writeFile(path.join(runTwo, "segment-00000.mp4"), wholePiece(0));
278
+ await writeFile(path.join(runTwo, "segment-00001.mp4"), Buffer.alloc(0));
279
+
280
+ assert.deepEqual(
281
+ [...usableSegmentIndices([runTwo, runOne], fmp4Format, new Set())].sort((a, b) => a - b),
282
+ [0],
283
+ "an empty file bridged the hole and bought the encoder a suspension it had not earned"
284
+ );
285
+
286
+ // The same number, made properly by an earlier run: now it genuinely is ready.
287
+ await writeFile(path.join(runOne, "segment-00001.mp4"), wholePiece(SEGMENT_SECONDS));
288
+ assert.deepEqual(
289
+ [...usableSegmentIndices([runTwo, runOne], fmp4Format, new Set())].sort((a, b) => a - b),
290
+ [0, 1],
291
+ "some run holds a copy with bytes in it, which is what the serving path will find"
292
+ );
293
+ });
294
+
295
+ test("what a run holds is asked of the filesystem once per file", async (t) => {
296
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "usable-memo-"));
297
+ t.after(async () => {
298
+ await rm(dirPath, { recursive: true, force: true });
299
+ });
300
+ const runOne = path.join(dirPath, "run-1");
301
+ await mkdir(runOne, { recursive: true });
302
+ await writeFile(path.join(runOne, "segment-00000.mp4"), wholePiece(0));
303
+ const known = new Set();
304
+
305
+ usableSegmentIndices([runOne], fmp4Format, known);
306
+
307
+ assert.deepEqual(
308
+ [...known],
309
+ [path.join(runOne, "segment-00000.mp4")],
310
+ "a piece that has bytes never loses them, and this runs on the thread carrying the data channel"
311
+ );
312
+ });
313
+
314
+ test("a run killed with a piece open leaves nothing behind", async (t) => {
315
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "open-piece-"));
316
+ t.after(async () => {
317
+ await rm(dirPath, { recursive: true, force: true });
318
+ });
319
+ await writeFile(path.join(dirPath, "segment-00000.mp4"), wholePiece(0));
320
+ await writeFile(path.join(dirPath, "segment-00001.mp4"), Buffer.alloc(0));
321
+
322
+ assert.equal(await discardOpenPiece(dirPath, fmp4Format, null), 1);
323
+ assert.deepEqual(
324
+ await readdir(dirPath),
325
+ ["segment-00000.mp4"],
326
+ "only the piece that was open goes; everything the run finished stays"
327
+ );
328
+ });
329
+
330
+ test("a run that finished its last piece keeps it", async (t) => {
331
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "open-piece-good-"));
332
+ t.after(async () => {
333
+ await rm(dirPath, { recursive: true, force: true });
334
+ });
335
+ await writeFile(path.join(dirPath, "segment-00000.mp4"), wholePiece(0));
336
+ await writeFile(path.join(dirPath, "segment-00001.mp4"), wholePiece(SEGMENT_SECONDS));
337
+
338
+ assert.equal(
339
+ await discardOpenPiece(dirPath, fmp4Format, () => true),
340
+ null,
341
+ "a stop between two cuts leaves good output; deleting it means encoding it twice"
342
+ );
343
+ assert.equal((await readdir(dirPath)).length, 2);
344
+ });
345
+
346
+ test("a last piece short of a track goes, even though it has bytes", async (t) => {
347
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "open-piece-half-"));
348
+ t.after(async () => {
349
+ await rm(dirPath, { recursive: true, force: true });
350
+ });
351
+ await writeFile(path.join(dirPath, "segment-00000.mp4"), wholePiece(0));
352
+ await writeFile(path.join(dirPath, "segment-00001.mp4"), halfPiece(SEGMENT_SECONDS));
353
+ const init = fmp4Format.extractInit(wholePiece(0));
354
+
355
+ assert.equal(
356
+ await discardOpenPiece(dirPath, fmp4Format, (raw) =>
357
+ fmp4Format.hasEveryTrack(fmp4Format.stripInit(raw), init)),
358
+ 1,
359
+ "a size above zero is not the same as a piece that can be played"
360
+ );
361
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * @file Two axes, kept apart: the framing a CONTAINER puts round a cue, and the
3
+ * markup the CODEC puts inside it.
4
+ *
5
+ * Field 2026-09-03, an English track of an embedded `.mkv`: the viewer was
6
+ * shown `21,0,Default,,0000,0000,0000,,I am the powerful Demon King of the
7
+ * Sixth Heaven.` — the whole dialogue row, fields and all. The cause was one
8
+ * function doing both jobs and deciding which framing it held by counting
9
+ * commas: it expected the ten fields a row has in a FILE, a Matroska block
10
+ * carries nine, so it returned the row untouched.
11
+ *
12
+ * What each check here pins is therefore not "ASS is stripped" but "the right
13
+ * side answers": the container takes off what it put on, and the codec takes
14
+ * off what it put in. The case that matters most is the last one — the same
15
+ * line of dialogue, stored two ways, coming out identical.
16
+ */
17
+
18
+ import test from "node:test";
19
+ import assert from "node:assert/strict";
20
+
21
+ import { AviContainer } from "../services/container/AviContainer.js";
22
+ import { MatroskaContainer } from "../services/container/MatroskaContainer.js";
23
+ import { Mp4Container } from "../services/container/Mp4Container.js";
24
+ import { SubtitleFileContainer } from "../services/container/SubtitleFileContainer.js";
25
+ import { convertSubtitleToVtt, cuesToVtt, finalizeCues } from "../services/subtitle-convert.js";
26
+ import { MarkupKind, markupKindOf, plainCueText } from "../services/tracks/subtitle-markup.js";
27
+ import { TextSubtitleTrack } from "../services/tracks/TextSubtitleTrack.js";
28
+
29
+ /** The line from the field report, as Matroska stores it. */
30
+ const FIELD_BLOCK =
31
+ "21,0,Default,,0000,0000,0000,,I am the powerful Demon King of the Sixth Heaven. Good... Evil...";
32
+
33
+ function block(text) {
34
+ return Buffer.from(text, "utf8");
35
+ }
36
+
37
+ test("a Matroska ASS block gives up its text and nothing else", () => {
38
+ // Nine fields exactly, and no comma in the dialogue: the shape that defeated
39
+ // the old field count and put the whole row on the viewer's screen.
40
+ assert.equal(
41
+ MatroskaContainer.cueTextOf(block(FIELD_BLOCK), "S_TEXT/ASS"),
42
+ "I am the powerful Demon King of the Sixth Heaven. Good... Evil..."
43
+ );
44
+ });
45
+
46
+ test("commas in the dialogue are dialogue, not fields", () => {
47
+ assert.equal(
48
+ MatroskaContainer.cueTextOf(block("7,0,Main,Nobunaga,0000,0000,0000,,Yes, of course, my lord"), "S_TEXT/ASS"),
49
+ "Yes, of course, my lord"
50
+ );
51
+ });
52
+
53
+ test("an SSA block is framed the same way as an ASS one", () => {
54
+ // SSA writes `Marked` where ASS writes `Layer`; the count is the same and so
55
+ // is the answer.
56
+ assert.equal(
57
+ MatroskaContainer.cueTextOf(block("3,,Default,,0000,0000,0000,,Toujours rien."), "S_TEXT/SSA"),
58
+ "Toujours rien."
59
+ );
60
+ });
61
+
62
+ test("a row too short to hold a text field yields nothing to show", () => {
63
+ assert.equal(MatroskaContainer.cueTextOf(block("21,0,Default,,0000"), "S_TEXT/ASS"), "");
64
+ assert.equal(MatroskaContainer.cueTextOf(block("21,0,Default,,0000,0000,0000,,"), "S_TEXT/ASS"), "");
65
+ });
66
+
67
+ test("a plain-text Matroska block is not touched at all", () => {
68
+ // `S_TEXT/UTF8` has no framing: the block IS the text, commas included.
69
+ const line = "Yes, of course, my lord";
70
+ assert.equal(MatroskaContainer.cueTextOf(block(line), "S_TEXT/UTF8"), line);
71
+ assert.equal(MatroskaContainer.cueTextOf(block(line), "S_TEXT/WEBVTT"), line);
72
+ });
73
+
74
+ test("an MP4 sample is unframed by the MP4, length prefix and all", () => {
75
+ const text = Buffer.from("Yes, of course, my lord", "utf8");
76
+ const sample = Buffer.concat([Buffer.from([0x00, text.length]), text]);
77
+ assert.equal(Mp4Container.cueTextOf(sample, "tx3g"), "Yes, of course, my lord");
78
+ });
79
+
80
+ test("a container with no framing of its own refuses to guess", () => {
81
+ // AVI declares no subtitle tracks, so no cue can reach it. If one ever did,
82
+ // the answer must be an error and not a guess at somebody else's framing.
83
+ assert.throws(() => AviContainer.cueTextOf(block("x"), "S_TEXT/ASS"), /cueTextOf not implemented/);
84
+ });
85
+
86
+ test("ASS markup is taken off wherever the text came from", () => {
87
+ assert.equal(plainCueText("{\\pos(640,620)}Hello{\\i1} there{\\i0}", "S_TEXT/ASS"), "Hello there");
88
+ assert.equal(plainCueText("First\\NSecond\\nThird", "S_TEXT/ASS"), "First\nSecond\nThird");
89
+ assert.equal(plainCueText("Wide\\hspace", "S_TEXT/ASS"), "Wide space");
90
+ // The same text under a codec that has no such markup keeps its characters.
91
+ assert.equal(plainCueText("{\\pos(1,2)}Hello", "S_TEXT/UTF8"), "{\\pos(1,2)}Hello");
92
+ });
93
+
94
+ test("a codec is known by any of its names", () => {
95
+ assert.equal(markupKindOf("S_TEXT/ASS"), MarkupKind.ASS);
96
+ assert.equal(markupKindOf(".ASS"), MarkupKind.ASS);
97
+ assert.equal(markupKindOf("S_TEXT/UTF8"), MarkupKind.NONE);
98
+ assert.equal(markupKindOf("wvtt"), MarkupKind.NONE);
99
+ // Unknown: shown as it is, rather than refused.
100
+ assert.equal(markupKindOf("S_TEXT/SOMETHING"), MarkupKind.NONE);
101
+ });
102
+
103
+ test("a text track answers for its own markup", () => {
104
+ const track = new TextSubtitleTrack({ trackNumber: 3, declaredIndex: 0, codecId: "S_TEXT/ASS" });
105
+ assert.equal(track.markupKind, MarkupKind.ASS);
106
+ assert.equal(track.plainText("{\\be1}Готовьте лучников"), "Готовьте лучников");
107
+ });
108
+
109
+ test("a file states its own column order and is obeyed", () => {
110
+ // This `Format:` has no `Name` column, so the text is the ninth field and not
111
+ // the tenth. A reader that assumed a position would take the effect field as
112
+ // the line — which is the file-side form of the defect this suite is about.
113
+ const file = [
114
+ "[Script Info]",
115
+ "ScriptType: v4.00+",
116
+ "[Events]",
117
+ "Format: Layer, Start, End, Style, MarginL, MarginR, MarginV, Effect, Text",
118
+ "Dialogue: 0,0:00:01.00,0:00:03.50,Default,0000,0000,0000,,Ты видишь их?",
119
+ ""
120
+ ].join("\n");
121
+
122
+ const cues = new SubtitleFileContainer({ extension: ".ass" }).readCues(file);
123
+ assert.deepEqual(cues, [{ startSeconds: 1, endSeconds: 3.5, text: "Ты видишь их?" }]);
124
+ });
125
+
126
+ test("a dialogue row before any Format line is not guessed at", () => {
127
+ const file = ["[Events]", "Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Ignored", ""].join("\n");
128
+ assert.deepEqual(new SubtitleFileContainer({ extension: ".ass" }).readCues(file), []);
129
+ });
130
+
131
+ test("SubRip cues keep their words and lose their numbering", () => {
132
+ const file = [
133
+ "1",
134
+ "00:00:01,000 --> 00:00:03,500",
135
+ "Ты видишь их?",
136
+ "",
137
+ "2",
138
+ "00:01:02,250 --> 00:01:04,000",
139
+ "<i>Да.</i>",
140
+ "Но не столько вижу.",
141
+ ""
142
+ ].join("\n");
143
+
144
+ const cues = new SubtitleFileContainer({ extension: ".srt" }).readCues(file);
145
+ assert.deepEqual(cues, [
146
+ { startSeconds: 1, endSeconds: 3.5, text: "Ты видишь их?" },
147
+ { startSeconds: 62.25, endSeconds: 64, text: "<i>Да.</i>\nНо не столько вижу." }
148
+ ]);
149
+ });
150
+
151
+ test("a SubRip file with no blank line between cues does not swallow the ordinal", () => {
152
+ const file = [
153
+ "1",
154
+ "00:00:01,000 --> 00:00:02,000",
155
+ "First",
156
+ "2",
157
+ "00:00:03,000 --> 00:00:04,000",
158
+ "Second"
159
+ ].join("\n");
160
+
161
+ const cues = new SubtitleFileContainer({ extension: ".srt" }).readCues(file);
162
+ assert.deepEqual(cues.map((cue) => cue.text), ["First", "Second"]);
163
+ });
164
+
165
+ test("a WebVTT file is passed through, not taken apart", () => {
166
+ const file = "WEBVTT\n\nSTYLE\n::cue { color: yellow }\n\nintro\n00:00:01.000 --> 00:00:02.000\nHello\n";
167
+ assert.equal(convertSubtitleToVtt(file, ".vtt"), file);
168
+ assert.equal(new SubtitleFileContainer({ extension: ".vtt" }).readCues(file), null);
169
+ });
170
+
171
+ test("a format nothing here reads is refused rather than mangled", () => {
172
+ assert.equal(convertSubtitleToVtt("whatever", ".sup"), null);
173
+ assert.equal(convertSubtitleToVtt("whatever", ".ttml"), null);
174
+ });
175
+
176
+ test("one line of dialogue, two framings, one result", () => {
177
+ // The whole point of the split. The same ASS line stored in a Matroska block
178
+ // and in a file must reach the viewer identically, and neither path may know
179
+ // anything about the other.
180
+ const spoken = "Yes, of course, my lord";
181
+ const markup = `{\\pos(640,620)}${spoken}`;
182
+
183
+ const fromBlock = finalizeCues(
184
+ [{
185
+ startSeconds: 1,
186
+ endSeconds: 3,
187
+ text: MatroskaContainer.cueTextOf(block(`21,0,Default,,0000,0000,0000,,${markup}`), "S_TEXT/ASS")
188
+ }],
189
+ "S_TEXT/ASS"
190
+ );
191
+
192
+ const fromFile = new SubtitleFileContainer({ extension: ".ass" }).readCues([
193
+ "[Events]",
194
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
195
+ `Dialogue: 0,0:00:01.00,0:00:03.00,Default,,0000,0000,0000,,${markup}`,
196
+ ""
197
+ ].join("\n"));
198
+
199
+ assert.equal(fromBlock[0].text, spoken);
200
+ assert.equal(finalizeCues(fromFile, ".ass")[0].text, spoken);
201
+ assert.equal(cuesToVtt(fromBlock, "S_TEXT/ASS"), cuesToVtt(fromFile, ".ass"));
202
+ });
@@ -26,7 +26,8 @@ import assert from "node:assert/strict";
26
26
 
27
27
  import { cueTextOfVtt, detectLanguage, detectLanguageFromVtt } from "../services/language-detect.js";
28
28
  import { convertSubtitleToVtt } from "../services/subtitle-convert.js";
29
- import { finalizeCues } from "../services/torrent-worker/subtitle-cues.js";
29
+ import { finalizeCues } from "../services/subtitle-convert.js";
30
+ import { MatroskaContainer } from "../services/container/MatroskaContainer.js";
30
31
 
31
32
  /** Varied Russian dialogue, the length a few minutes of an episode carries. */
32
33
  const RUSSIAN_DIALOGUE = [
@@ -176,20 +177,30 @@ test("a document with no cues yields no language rather than a guess", () => {
176
177
  assert.equal(detectLanguageFromVtt(null), null);
177
178
  });
178
179
 
179
- test("an embedded ASS cue is read through finalizeCues, not raw", () => {
180
- // What the cluster walk holds: the dialogue row without its `Dialogue:`
181
- // header nine comma-separated fields, then the text with override groups.
182
- const cues = RUSSIAN_DIALOGUE.map((line, index) => ({
180
+ test("an embedded ASS cue is unframed by the container, then read", () => {
181
+ // What a Matroska block actually holds, per
182
+ // `matroska.org/technical/subtitles.html`: "Events are stored in the Block in
183
+ // this order: ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV,
184
+ // Effect, Text", with Start and End taken out into the block's own timing.
185
+ // Eight fields, and the earlier fixture here wrongly carried ten — it kept
186
+ // the two timestamps a FILE has, which is why it passed while the field
187
+ // subtitles of a real `.mkv` were shown to the viewer whole.
188
+ const blocks = RUSSIAN_DIALOGUE.map((line, index) => Buffer.from(
189
+ `${index + 1},0,Default,,0000,0000,0000,,` +
190
+ `${index % 5 === 4 ? "{\\pos(640,620)}" : ""}${line}`,
191
+ "utf8"
192
+ ));
193
+
194
+ const cues = blocks.map((payload, index) => ({
183
195
  startSeconds: index * 4,
184
196
  endSeconds: index * 4 + 3,
185
- text: `0,${assTime(index * 4)},${assTime(index * 4 + 3)},Default,,0,0,0,,` +
186
- `${index % 5 === 4 ? "{\\pos(640,620)}" : ""}${line}`
197
+ text: MatroskaContainer.cueTextOf(payload, "S_TEXT/ASS")
187
198
  }));
188
199
 
189
200
  const spoken = finalizeCues(cues, "S_TEXT/ASS").map((cue) => cue.text).join("\n");
190
201
  assert.ok(!spoken.includes("Default"), "the style field survived into the text");
202
+ assert.ok(!spoken.includes("0000"), "a margin field survived into the text");
191
203
  assert.ok(!spoken.includes("pos("), "an override group survived into the text");
192
- assert.ok(!spoken.includes("0:00:12"), "a timestamp field survived into the text");
193
204
  assert.deepEqual(detectLanguage(spoken), { code: "ru", name: "Russian" });
194
205
  });
195
206