@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.
- package/CHANGELOG.md +22 -0
- package/package.json +1 -1
- package/services/container/Container.js +35 -0
- package/services/container/MatroskaContainer.js +42 -0
- package/services/container/Mp4Container.js +22 -1
- package/services/container/SubtitleFileContainer.js +261 -0
- package/services/container/index.js +1 -0
- package/services/container-index/matroska-subtitles.js +6 -1
- package/services/controllers/SubtitleController.js +6 -25
- package/services/hls-session-manager.js +10584 -10243
- package/services/piece-store/shared-piece-store.js +1512 -1502
- package/services/subtitle-convert.js +74 -80
- package/services/torrent-worker/subtitle-cues.js +18 -53
- package/services/tracks/TextSubtitleTrack.js +18 -0
- package/services/tracks/index.js +1 -0
- package/services/tracks/subtitle-markup.js +104 -0
- package/test/piece-store-reservations.test.js +31 -8
- package/test/produced-copy-choice.test.js +361 -0
- package/test/subtitle-cue-framing.test.js +202 -0
- package/test/subtitle-language.test.js +19 -8
|
@@ -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
|
-
*
|
|
54
|
+
* One cue's start or end as WebVTT writes it: `hh:mm:ss.mmm`.
|
|
50
55
|
*
|
|
51
|
-
* @param {
|
|
56
|
+
* @param {number} seconds
|
|
52
57
|
* @returns {string}
|
|
53
58
|
*/
|
|
54
|
-
function
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
*
|
|
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}
|
|
105
|
+
* @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
|
|
106
|
+
* @param {string} codecId
|
|
86
107
|
* @returns {string}
|
|
87
108
|
*/
|
|
88
|
-
function
|
|
89
|
-
const lines =
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
428
|
-
|
|
429
|
-
|
|
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
|
}
|
package/services/tracks/index.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -156,7 +156,12 @@ test("a spill that fails gives back the slot the eviction claimed", async () =>
|
|
|
156
156
|
}
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
// A bound, because the failure this catches is a claim that never ends: without
|
|
160
|
+
// it the check does not fail, it HANGS, and `node --test` then cannot finish at
|
|
161
|
+
// all — which is what happened between 2026-08-31 and 2026-09-03 (roadmap item
|
|
162
|
+
// 54). The store gives up after PINNED_WAIT_MS, so 30 s is ample for the answer
|
|
163
|
+
// and short enough to be a failure rather than a stoppage.
|
|
164
|
+
test("a claim that cannot be met ends in an error, not in waiting for ever", { timeout: 30_000 }, async () => {
|
|
160
165
|
const disk = makeDisk({ holdWrites: true });
|
|
161
166
|
const { store } = makeStore({ pieces: 2, disk });
|
|
162
167
|
try {
|
|
@@ -183,7 +188,7 @@ test("a claim that cannot be met ends in an error, not in waiting for ever", asy
|
|
|
183
188
|
}
|
|
184
189
|
});
|
|
185
190
|
|
|
186
|
-
test("closing the store fails whoever is waiting for room", async () => {
|
|
191
|
+
test("closing the store fails whoever is waiting for room", { timeout: 30_000 }, async () => {
|
|
187
192
|
const disk = makeDisk({ holdWrites: true });
|
|
188
193
|
const { store } = makeStore({ pieces: 2, disk });
|
|
189
194
|
try {
|
|
@@ -194,7 +199,15 @@ test("closing the store fails whoever is waiting for room", async () => {
|
|
|
194
199
|
store.pin(1);
|
|
195
200
|
|
|
196
201
|
const waiting = put(store, 3);
|
|
197
|
-
|
|
202
|
+
// Either counter: the claim waits for the disk first — a block is in flight
|
|
203
|
+
// and the store is full, so evicting another piece would only raise the
|
|
204
|
+
// memory in use — and reaches the pinned wait five seconds later. Asking for
|
|
205
|
+
// `waitedForPins` alone named one of the two ways of waiting and timed out
|
|
206
|
+
// while the store was demonstrably doing the other.
|
|
207
|
+
await until(
|
|
208
|
+
() => store.stats().waitedForPins + store.stats().waitedForDisk > 0,
|
|
209
|
+
"the claim is waiting"
|
|
210
|
+
);
|
|
198
211
|
|
|
199
212
|
await new Promise((resolve) => store.close(resolve));
|
|
200
213
|
await assert.rejects(() => waiting, /closed/);
|
|
@@ -207,20 +220,30 @@ test("closing the store fails whoever is waiting for room", async () => {
|
|
|
207
220
|
}
|
|
208
221
|
});
|
|
209
222
|
|
|
210
|
-
test("a piece written back to memory is not resurrected on disk by its own spill", async () => {
|
|
223
|
+
test("a piece written back to memory is not resurrected on disk by its own spill", { timeout: 30_000 }, async () => {
|
|
211
224
|
const disk = makeDisk({ holdWrites: true });
|
|
212
|
-
const { store } = makeStore({ pieces:
|
|
225
|
+
const { store } = makeStore({ pieces: 4, disk });
|
|
213
226
|
try {
|
|
214
227
|
await put(store, 0);
|
|
215
228
|
await put(store, 1);
|
|
216
229
|
await put(store, 2);
|
|
217
230
|
|
|
218
231
|
// Piece 0 leaves memory because the machine's allowance fell; its write is
|
|
219
|
-
// still in flight. The allowance then recovers
|
|
220
|
-
//
|
|
232
|
+
// still in flight. The allowance then recovers to MORE than it was, so the
|
|
233
|
+
// piece coming back needs no eviction and no wait for that write.
|
|
234
|
+
//
|
|
235
|
+
// Four blocks, not three, and the reason is what this check is about. A
|
|
236
|
+
// block being written out is still memory in use, so with a ceiling of three
|
|
237
|
+
// and one block in flight the store is full, and the claim correctly waits
|
|
238
|
+
// for the disk — which the earlier setup did not allow for, so the check
|
|
239
|
+
// timed out on an admission policy it never meant to measure. Whether a
|
|
240
|
+
// piece arriving while its OWN spill is in flight should be admitted without
|
|
241
|
+
// taking a second block is a real question about the accounting and is
|
|
242
|
+
// roadmap item 9, not this check's subject: the subject is that the spill,
|
|
243
|
+
// when it completes, must not put the stale copy back on disk.
|
|
221
244
|
store.reviseGrowthCeiling(CHUNK * 2);
|
|
222
245
|
await until(() => disk.heldCount > 0, "the spill of piece 0 is in flight");
|
|
223
|
-
store.reviseGrowthCeiling(CHUNK *
|
|
246
|
+
store.reviseGrowthCeiling(CHUNK * 4);
|
|
224
247
|
|
|
225
248
|
// The swarm hands piece 0 back while that write is still going. The store
|
|
226
249
|
// must drop the disk copy AFTER the write has recorded it, not before —
|