@torrent-tv/proxy 2.75.0 → 2.76.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 (113) hide show
  1. package/CHANGELOG.md +1509 -1461
  2. package/CLAUDE.md +11 -1
  3. package/biome.json +182 -1
  4. package/docs/container-architecture.md +2 -1
  5. package/docs/encode-run-state.md +1 -1
  6. package/knip.json +14 -0
  7. package/package.json +1 -1
  8. package/routes/api/sources/warm/post.js +1 -1
  9. package/routes/api/transcode-sessions/post.js +185 -185
  10. package/routes/api/transcode-sessions/progress/get.js +5 -1
  11. package/routes/transcode/audio-file/get.js +11 -1
  12. package/routes/transcode/audio-warm/get.js +11 -1
  13. package/routes/transcode/session-file/get.js +1 -1
  14. package/routes/transcode/variant-file/get.js +10 -1
  15. package/scripts/render-run-graph.js +2 -2
  16. package/server.js +25 -0
  17. package/services/audio-inventory.js +206 -201
  18. package/services/container/AviContainer.js +1 -1
  19. package/services/container/Container.js +33 -1
  20. package/services/container/MatroskaContainer.js +1 -1
  21. package/services/container/Mp4Container.js +1 -1
  22. package/services/container/SubtitleFileContainer.js +0 -1
  23. package/services/controllers/SubtitleController.js +128 -128
  24. package/services/demand/index.js +7 -10
  25. package/services/download/registry.js +0 -14
  26. package/services/encode/CoverageMap.js +281 -0
  27. package/services/encode/EncodePlan.js +255 -0
  28. package/services/encode/EncodeRun.js +587 -0
  29. package/services/encode/Encoder.js +84 -0
  30. package/services/encode/NvencEncoder.js +45 -0
  31. package/services/encode/QsvEncoder.js +47 -0
  32. package/services/encode/SegmentDemand.js +0 -0
  33. package/services/encode/SegmentStore.js +529 -0
  34. package/services/encode/SoftwareEncoder.js +111 -0
  35. package/services/encode/V4l2m2mEncoder.js +53 -0
  36. package/services/encode/VaapiEncoder.js +53 -0
  37. package/services/encode/args.js +200 -0
  38. package/services/{encode-exit.js → encode/encode-exit.js} +17 -0
  39. package/services/encode/index.js +9 -0
  40. package/services/encode/run-command.js +647 -0
  41. package/services/hls-session-manager.js +11100 -10711
  42. package/services/hwaccel.js +1688 -1992
  43. package/services/orchestrators/EncodeOrchestrator.js +359 -0
  44. package/services/output/LiveOutputs.js +213 -0
  45. package/services/output/Output.js +94 -0
  46. package/services/output/OutputSpec.js +195 -0
  47. package/services/output/Timeline.js +220 -0
  48. package/services/output/index.js +1 -0
  49. package/services/output/ladder.js +26 -0
  50. package/services/playback-planner.js +806 -775
  51. package/services/produced-index.js +222 -300
  52. package/services/source/SourceFile.js +346 -0
  53. package/services/{sidecar-files.js → torrent/files.js} +107 -11
  54. package/services/torrent/naming.js +619 -0
  55. package/services/torrent-worker/client.js +10 -0
  56. package/services/torrent-worker/container-tracks.js +71 -43
  57. package/services/torrent-worker/pool-adapter.js +18 -0
  58. package/services/torrent-worker/protocol.js +7 -0
  59. package/services/torrent-worker/subtitle-cues.js +549 -549
  60. package/services/torrent-worker/worker.js +18 -0
  61. package/services/tracks/TextSubtitleTrack.js +287 -287
  62. package/services/tracks/index.js +15 -14
  63. package/services/viewer/Viewer.js +145 -0
  64. package/services/viewer/Viewers.js +124 -0
  65. package/test/auto-quality-step.test.js +508 -506
  66. package/test/behind-head-repair.test.js +17 -7
  67. package/test/coverage-map.test.js +153 -0
  68. package/test/cut-times-timeline.test.js +6 -5
  69. package/test/cuts-follow-published-grid.test.js +4 -4
  70. package/test/decode-cost.test.js +31 -12
  71. package/test/encode-exit.test.js +1 -1
  72. package/test/encode-orchestrator.test.js +196 -0
  73. package/test/encode-plan.test.js +245 -0
  74. package/test/encode-run-state.test.js +2 -2
  75. package/test/encode-run.test.js +168 -0
  76. package/test/encoder-kinds.test.js +122 -0
  77. package/test/held-request-width.test.js +9 -3
  78. package/test/helpers/encode-run.js +128 -0
  79. package/test/keyframe-index-accuracy.test.js +19 -12
  80. package/test/keyframes-belong-to-the-file.test.js +132 -0
  81. package/test/orchestrator-wired.test.js +164 -0
  82. package/test/output-shape.test.js +68 -0
  83. package/test/output-spec.test.js +157 -0
  84. package/test/produced-copy-choice.test.js +58 -92
  85. package/test/produced-index.test.js +142 -188
  86. package/test/quality-variants.test.js +1079 -1075
  87. package/test/run-graph-drift.test.js +1 -1
  88. package/test/run-intervals.test.js +329 -0
  89. package/test/run-position-follows-published-grid.test.js +4 -4
  90. package/test/seek-landing.test.js +8 -8
  91. package/test/seek-target-not-superseded.test.js +21 -9
  92. package/test/segment-demand.test.js +82 -0
  93. package/test/segment-serve-wiring.test.js +47 -52
  94. package/test/segment-store.test.js +187 -0
  95. package/test/segments-are-shared.test.js +175 -0
  96. package/test/sidecar-naming.test.js +142 -0
  97. package/test/source-file.test.js +133 -0
  98. package/test/stale-request-after-seek.test.js +18 -12
  99. package/test/subtitle-language.test.js +252 -252
  100. package/test/timeline.test.js +95 -0
  101. package/test/{sidecar-files.test.js → torrent-files.test.js} +44 -1
  102. package/test/torrent-naming.test.js +255 -0
  103. package/test/tracks-begin-together.test.js +44 -32
  104. package/test/two-viewers-one-picture.test.js +347 -0
  105. package/test/viewer-outputs.test.js +273 -0
  106. package/test/viewer.test.js +91 -0
  107. package/utils/perf.js +1 -63
  108. package/services/container/index.js +0 -6
  109. package/services/controllers/index.js +0 -2
  110. package/services/download/index.js +0 -8
  111. package/services/orchestrators/index.js +0 -2
  112. /package/services/{encode-run-state.js → encode/encode-run-state.js} +0 -0
  113. /package/services/{language-detect.js → tracks/language-detect.js} +0 -0
@@ -22,7 +22,7 @@ import {
22
22
  matchSidecarFiles,
23
23
  namesPair,
24
24
  splitTorrentPath
25
- } from "../services/sidecar-files.js";
25
+ } from "../services/torrent/files.js";
26
26
 
27
27
  /**
28
28
  * The Drifters torrent, as WebTorrent reports it: every path prefixed with the
@@ -160,6 +160,49 @@ test("files of no interest are ignored", () => {
160
160
  const matched = matchSidecarFiles({ files, videoIndex: 0, torrentName: "X", videoCount: 1 });
161
161
  assert.deepEqual(matched.audio, []);
162
162
  assert.deepEqual(matched.subtitles, []);
163
+ assert.deepEqual(
164
+ matched.images,
165
+ [],
166
+ "a stray cover is not THIS film's cover: the one-video relaxation does not reach images, " +
167
+ "because showing the wrong poster is a visible mistake where showing none is nothing"
168
+ );
169
+ });
170
+
171
+ test("a contact sheet named by the video's whole name is paired with it", () => {
172
+ // The shape a pack of a hundred videos actually ships: one sheet per video,
173
+ // named `<video file name>.jpg`. Its BASE name is `c0930.com_chijyo0073.wmv`,
174
+ // which no base-name rule would match against the video's `c0930…`.
175
+ const files = [
176
+ { path: "P/c0930.com_chijyo0073.wmv", name: "c0930.com_chijyo0073.wmv", length: 900 },
177
+ {
178
+ path: "P/Скринлисты/c0930.com_chijyo0073.wmv.jpg",
179
+ name: "c0930.com_chijyo0073.wmv.jpg",
180
+ length: 90
181
+ },
182
+ { path: "P/other.wmv", name: "other.wmv", length: 900 },
183
+ { path: "P/Скринлисты/other.wmv.jpg", name: "other.wmv.jpg", length: 90 }
184
+ ];
185
+
186
+ const matched = matchSidecarFiles({ files, videoIndex: 0, torrentName: "P", videoCount: 2 });
187
+
188
+ assert.equal(matched.images.length, 1, "its own sheet, and not the other video's");
189
+ assert.equal(matched.images[0].fileIndex, 1);
190
+ assert.deepEqual(matched.images[0].folders, ["Скринлисты"]);
191
+ });
192
+
193
+ test("a cover named like the film is paired with it, whatever its extension", () => {
194
+ const files = [
195
+ { path: "X/Film.2019.mkv", name: "Film.2019.mkv", length: 100 },
196
+ { path: "X/Film.2019.png", name: "Film.2019.png", length: 10 },
197
+ { path: "X/Film.2019.webp", name: "Film.2019.webp", length: 10 }
198
+ ];
199
+
200
+ const matched = matchSidecarFiles({ files, videoIndex: 0, torrentName: "X", videoCount: 1 });
201
+
202
+ assert.deepEqual(
203
+ matched.images.map((image) => image.name).sort(),
204
+ ["Film.2019.png", "Film.2019.webp"]
205
+ );
163
206
  });
164
207
 
165
208
  test("a raw elementary stream is recognised but declares no tracks of its own", () => {
@@ -0,0 +1,255 @@
1
+ /**
2
+ * @file The name grammar, checked against the forms its sources document.
3
+ *
4
+ * Every case here is an example one of the five surveyed players either prints
5
+ * in its own documentation or handles in its own source. The survey and the
6
+ * quotes are `research/sidecar-naming-conventions-2026-09-01.md`; the roadmap
7
+ * item is 67.
8
+ *
9
+ * The rule these checks exist to hold: what the person who made the file wrote
10
+ * is read, and nothing is guessed at. A token becomes a language only by being
11
+ * in the table.
12
+ */
13
+
14
+ import test from "node:test";
15
+ import assert from "node:assert/strict";
16
+
17
+ import {
18
+ isNeutralWord,
19
+ languageFromFolderNames,
20
+ lookupLanguage,
21
+ nameFollows,
22
+ parseLanguageTag,
23
+ readSidecarName,
24
+ sidecarNaming,
25
+ tokenizeName
26
+ } from "../services/torrent/naming.js";
27
+
28
+ /**
29
+ * Read a whole path against a known picture, the way the pairing does.
30
+ *
31
+ * `code` is `"und"` where nothing was read, which is the shape the browser is
32
+ * given: a track that states no language is not a track with no language.
33
+ *
34
+ * @param {string} name - The sidecar's file name.
35
+ * @param {string} [video] - The picture's file name.
36
+ * @param {string} [relativePath] - The path, for the folders above the file.
37
+ * @returns {{ code: string, group: string | null, isForced: boolean, isHearingImpaired: boolean, isDefault: boolean }}
38
+ */
39
+ function readPath(name, video = "Film.mkv", relativePath = name) {
40
+ const folders = String(relativePath).split("/").slice(0, -1);
41
+ const naming = sidecarNaming({ folders, fileName: name, videoName: video });
42
+ return { ...naming, code: naming.code ?? "und", group: naming.releaser };
43
+ }
44
+
45
+ /** Read a name against a known picture, the way the loader does. */
46
+ function read(name, video = "Film.mkv") {
47
+ return readPath(name, video);
48
+ }
49
+
50
+ test("the dot-separated suffix, which is what every library writes", () => {
51
+ // Plex: "Movie_Name (Release Date).[Language_Code].ext", the code being
52
+ // "ISO-639-1 (2-letter) or ISO-639-2/B (3-letter)".
53
+ assert.equal(read("Film.en.srt").code, "en");
54
+ assert.equal(read("Film.eng.srt").code, "en");
55
+ assert.equal(read("Film.ru.srt").code, "ru");
56
+ assert.equal(read("Film.rus.srt").code, "ru");
57
+ // Kodi's own example: "The Matrix.English.srt".
58
+ assert.equal(read("Film.English.srt").code, "en");
59
+ assert.equal(read("Film.Russian.srt").code, "ru");
60
+ });
61
+
62
+ test("both ISO 639-2 sets, because the two disagree for about twenty languages", () => {
63
+ // Plex names the bibliographic set; ffmpeg and Matroska write either.
64
+ for (const [bibliographic, terminological, code] of [
65
+ ["ger", "deu", "de"], ["fre", "fra", "fr"], ["cze", "ces", "cs"],
66
+ ["chi", "zho", "zh"], ["dut", "nld", "nl"], ["gre", "ell", "el"],
67
+ ["per", "fas", "fa"], ["ice", "isl", "is"], ["may", "msa", "ms"],
68
+ ["rum", "ron", "ro"], ["slo", "slk", "sk"], ["alb", "sqi", "sq"],
69
+ ["arm", "hye", "hy"], ["baq", "eus", "eu"], ["bur", "mya", "my"],
70
+ ["geo", "kat", "ka"], ["mac", "mkd", "mk"], ["mao", "mri", "mi"],
71
+ ["tib", "bod", "bo"], ["wel", "cym", "cy"]
72
+ ]) {
73
+ assert.equal(lookupLanguage(bibliographic)?.code, code, bibliographic);
74
+ assert.equal(lookupLanguage(terminological)?.code, code, terminological);
75
+ }
76
+ });
77
+
78
+ test("a BCP-47 tag keeps its region, and Kodi's underscore form is accepted", () => {
79
+ // Kodi: "a BCP 47 tag (with - characters replaced with _)"; its own example
80
+ // is "The Matrix.zh_yue.srt".
81
+ assert.deepEqual(parseLanguageTag("pt-BR"), { code: "pt-BR", name: "Portuguese" });
82
+ assert.deepEqual(parseLanguageTag("pt_BR"), { code: "pt-BR", name: "Portuguese" });
83
+ assert.deepEqual(parseLanguageTag("zh_yue"), { code: "zh-yue", name: "Chinese" });
84
+ assert.deepEqual(parseLanguageTag("en-US"), { code: "en-US", name: "English" });
85
+ assert.equal(read("Film.pt-BR.srt").code, "pt-BR");
86
+ // A region is kept, so pt-BR and pt-PT stay apart on screen.
87
+ assert.notEqual(read("Film.pt-BR.srt").code, read("Film.pt-PT.srt").code);
88
+ });
89
+
90
+ test("a tag whose primary subtag is not a language is not a language", () => {
91
+ assert.equal(parseLanguageTag("web-dl"), null);
92
+ assert.equal(parseLanguageTag("x-264"), null);
93
+ assert.equal(parseLanguageTag("Upscale"), null);
94
+ });
95
+
96
+ test("the flags, with the spellings Jellyfin and mpv accept", () => {
97
+ assert.equal(read("Film.en.forced.srt").isForced, true);
98
+ assert.equal(read("Film.en.foreign.srt").isForced, true);
99
+ assert.equal(read("Film.en.sdh.srt").isHearingImpaired, true);
100
+ assert.equal(read("Film.en.cc.srt").isHearingImpaired, true);
101
+ assert.equal(read("Film.default.srt").isDefault, true);
102
+ // Jellyfin's own extended example: several flags on one file.
103
+ const both = read("Film.default.en.forced.ass");
104
+ assert.equal(both.code, "en");
105
+ assert.equal(both.isDefault, true);
106
+ assert.equal(both.isForced, true);
107
+ });
108
+
109
+ test("Kodi separates the forced flag with a dot, a space or a dash", () => {
110
+ // "The forced flag can be either upper, lower or mixed case in the filename
111
+ // and can use a '.' (dot), ' ' (space) or '-' (dash) to separate them".
112
+ assert.equal(read("Film.English.Forced.srt").isForced, true);
113
+ assert.equal(read("Film.English Forced.srt").isForced, true);
114
+ assert.equal(read("Film.English-Forced.srt").isForced, true);
115
+ assert.equal(read("Film.English Forced.srt").code, "en");
116
+ });
117
+
118
+ test("hi is Hindi alone and hearing-impaired beside another language", () => {
119
+ // Jellyfin: "`hi` by itself will resolve as a Hindi language track, while
120
+ // `hi` in conjunction with another language identifier … will use the other
121
+ // language and tag it as hearing impaired."
122
+ const hindi = read("Film.hi.srt");
123
+ assert.equal(hindi.code, "hi");
124
+ assert.equal(hindi.isHearingImpaired, false);
125
+
126
+ const impaired = read("Film.en.hi.srt");
127
+ assert.equal(impaired.code, "en");
128
+ assert.equal(impaired.isHearingImpaired, true);
129
+ });
130
+
131
+ test("the last language in the name wins", () => {
132
+ // Jellyfin: "If multiple languages are defined within the filename the last
133
+ // one will be used and the others ignored."
134
+ assert.equal(read("Film.en.ru.srt").code, "ru");
135
+ assert.equal(read("Film.ru.en.srt").code, "en");
136
+ });
137
+
138
+ test("a bracketed tail is read, and only through the table", () => {
139
+ // mpv takes `(` as the delimiter when the name ends `)`, `[` when it ends `]`.
140
+ assert.equal(read("Film (Russian).srt").code, "ru");
141
+ assert.equal(read("Film [rus].srt").code, "ru");
142
+ // And the case from the test torrents that a shape-based rule would ruin.
143
+ assert.equal(read("Gibo no Toiki - 01 (Upscale).ass").code, "und");
144
+ assert.equal(read("[HorribleSubs] Drifters - 03 [1080p].ass").code, "und");
145
+ // A bracketed tail does not stop the rest of the name being read.
146
+ assert.equal(read("Film.rus [1080p].srt").code, "ru");
147
+ assert.equal(read("Film.en.forced [x264].srt").code, "en");
148
+ assert.equal(read("Film.en.forced [x264].srt").isForced, true);
149
+ });
150
+
151
+ test("leftover text is the track's title", () => {
152
+ // Jellyfin: "Any arbitrary text not parsable to a language or flag will be
153
+ // combined and used as the title of the stream."
154
+ const info = read("Film.English Commentary.en.srt");
155
+ assert.equal(info.code, "en");
156
+ assert.equal(info.group, "English Commentary");
157
+ // The underscore form this project already read, unchanged.
158
+ assert.equal(read("Film_rus_AT_Team.srt").code, "ru");
159
+ assert.equal(read("Film_rus_AT_Team.srt").group, "AT Team");
160
+ });
161
+
162
+ test("a leftover that only describes the encode is not a title", () => {
163
+ // Jellyfin keeps every leftover word, and on a tidy library that is right.
164
+ // A torrent is not tidy: this would read as "Russian (1080p)".
165
+ assert.equal(read("Film.1080p.rus.srt").code, "ru");
166
+ assert.equal(read("Film.1080p.rus.srt").group, null);
167
+ assert.equal(read("Film.WEB-DL.x264.eng.srt").group, null);
168
+ });
169
+
170
+ test("the film's own name is never mined for a language", () => {
171
+ // Jellyfin parses only what follows the video's name, and this is why: `It`
172
+ // is Italian, and `Follows` is not a flag, so a reader that walked the whole
173
+ // name would call this Italian.
174
+ assert.equal(readPath("It.Follows.2014.srt", "It.Follows.2014.mkv").code, "und");
175
+ // With no picture to measure against, reading stops at the first token that
176
+ // is neither a flag nor a language — mpv's rule.
177
+ assert.equal(readPath("It.Follows.2014.srt", "").code, "und");
178
+ assert.equal(readPath("It.Follows.2014.rus.srt", "").code, "ru");
179
+ });
180
+
181
+ test("a folder states a language, or a kind of track, or a team", () => {
182
+ assert.equal(languageFromFolderNames(["RUS"])?.code, "ru");
183
+ assert.equal(languageFromFolderNames(["Rus Sub"])?.code, "ru");
184
+ assert.equal(languageFromFolderNames(["subs", "eng"])?.code, "en");
185
+ // VLC's own default search path — a place, not a language.
186
+ for (const folder of ["Subs", "subs", "Subtitles", "subtitles"]) {
187
+ assert.equal(languageFromFolderNames([folder]), null, folder);
188
+ assert.equal(isNeutralWord(folder), true, folder);
189
+ }
190
+ // A team's bracket is not a language either.
191
+ assert.equal(languageFromFolderNames(["Sub", "[Stan WarHammer & Nesitach]"]), null);
192
+ });
193
+
194
+ test("a Russian release names its folders in Russian", () => {
195
+ assert.equal(languageFromFolderNames(["Субтитры"]), null);
196
+ assert.equal(languageFromFolderNames(["Рус"])?.code, "ru");
197
+ assert.equal(languageFromFolderNames(["Русские субтитры"])?.code, "ru");
198
+ assert.equal(languageFromFolderNames(["Английские субтитры"])?.code, "en");
199
+ assert.equal(languageFromFolderNames(["Рус Звук"])?.code, "ru");
200
+ assert.equal(languageFromFolderNames(["Японские"])?.code, "ja");
201
+ });
202
+
203
+ test("the shapes the real test torrents carry still read as they did", () => {
204
+ // Measured 2026-09-01 over the 112 torrents in Dropbox/trn.
205
+ assert.equal(readPath("Enjo Kouhai_01.rus.ass", "Enjo Kouhai_01.mp4", "Enjo Kouhai/rus/Enjo Kouhai_01.rus.ass").code, "ru");
206
+ assert.equal(readPath("Enjo Kouhai_01.chi.ass", "Enjo Kouhai_01.mp4", "Enjo Kouhai/chi/Enjo Kouhai_01.chi.ass").code, "zh");
207
+ const drifters = readPath(
208
+ "[HorribleSubs] Drifters - 03 [1080p].ass",
209
+ "[HorribleSubs] Drifters - 03 [1080p].mkv",
210
+ "Sub/[Stan WarHammer & Nesitach]/[HorribleSubs] Drifters - 03 [1080p].ass"
211
+ );
212
+ assert.equal(drifters.code, "und");
213
+ assert.equal(drifters.group, "Stan WarHammer & Nesitach");
214
+ });
215
+
216
+ test("a soundtrack is read by the same grammar", () => {
217
+ // Jellyfin's own example set: Film.en.ac3 beside Film.german.ac3.
218
+ assert.equal(sidecarNaming({ folders: [], fileName: "Film.en.ac3", videoName: "Film.mkv" }).code, "en");
219
+ assert.equal(sidecarNaming({ folders: [], fileName: "Film.german.ac3", videoName: "Film.mkv" }).code, "de");
220
+ // The field case: the folder says Russian and the file carries only the
221
+ // picture's own brackets.
222
+ const dub = sidecarNaming({
223
+ folders: ["Rus Sound"],
224
+ fileName: "[HorribleSubs] Drifters - 02 [1080p].mka",
225
+ videoName: "[HorribleSubs] Drifters - 02 [1080p].mkv"
226
+ });
227
+ assert.equal(dub.code, "ru");
228
+ assert.equal(dub.releaser, null);
229
+ });
230
+
231
+ test("tokenizing is stable on names with nothing to say", () => {
232
+ assert.deepEqual(tokenizeName(""), []);
233
+ assert.deepEqual(readSidecarName("").language, null);
234
+ assert.deepEqual(readSidecarName("Film", "Film").language, null);
235
+ });
236
+
237
+ test("a name continues the video's only at a token boundary", () => {
238
+ // The rule that makes "begins with the video's name" safe. Without it
239
+ // `Film.20.rus.srt` reads as belonging to `Film.2.mkv` — measured 2026-09-04
240
+ // against the browser's own rule, which had no boundary — and the grammar
241
+ // then reports the leftover `0` as the track's title, which is the tell that
242
+ // the remainder is a fragment of another film's name.
243
+ assert.equal(nameFollows("Film.2", "Film.2"), true, "the same name continues itself");
244
+ assert.equal(nameFollows("Film.2.rus", "Film.2"), true);
245
+ assert.equal(nameFollows("Film.20.rus", "Film.2"), false, "another film, one digit longer");
246
+ assert.equal(nameFollows("Film_rus", "Film"), true);
247
+ assert.equal(nameFollows("Film-rus", "Film"), true);
248
+ assert.equal(nameFollows("Film rus", "Film"), true);
249
+ // mpv's bracket forms.
250
+ assert.equal(nameFollows("Film (Russian)", "Film"), true);
251
+ assert.equal(nameFollows("Film [rus]", "Film"), true);
252
+ // Nothing to measure against, and nothing to continue.
253
+ assert.equal(nameFollows("Film.rus", ""), false);
254
+ assert.equal(nameFollows("Fil", "Film"), false);
255
+ });
@@ -13,10 +13,13 @@
13
13
  */
14
14
 
15
15
  import assert from "node:assert/strict";
16
+ import { SourceFile } from "../services/source/SourceFile.js";
17
+ import { startRunOn } from "./helpers/encode-run.js";
18
+ import { Timeline } from "../services/output/Timeline.js";
16
19
  import test from "node:test";
17
20
 
18
21
  import { HlsSessionManager } from "../services/hls-session-manager.js";
19
- import { ENCODE_RUN_STATE, INITIAL_RUN_STATE } from "../services/encode-run-state.js";
22
+ import { ENCODE_RUN_STATE, INITIAL_RUN_STATE } from "../services/encode/encode-run-state.js";
20
23
 
21
24
  const BOUNDARIES = [0, 4, 8, 12, 16, 20];
22
25
 
@@ -27,6 +30,9 @@ const BOUNDARIES = [0, 4, 8, 12, 16, 20];
27
30
  * @returns {{ manager: HlsSessionManager, picture: object, sound: object }}
28
31
  */
29
32
  function familyAtBoundaryTwo() {
33
+ // ONE table for the film. Where it is cut is a fact about the FILE, so the
34
+ // picture and its soundtrack hold the same array rather than a copy each.
35
+ const boundaries = [...BOUNDARIES];
30
36
  const manager = new HlsSessionManager({
31
37
  enabled: true,
32
38
  ffmpegBin: "ffmpeg",
@@ -36,24 +42,27 @@ function familyAtBoundaryTwo() {
36
42
  const picture = {
37
43
  id: "picture",
38
44
  state: "ready",
39
- runState: ENCODE_RUN_STATE.PRODUCING,
40
- segmentBoundaries: [...BOUNDARIES],
41
- encodeStartIndex: 2,
42
- runSerial: 0,
43
- audioRenditionSessions: new Map([[1, "sound"]]),
45
+ timeline: new Timeline({ boundaries: boundaries, cutGrid: "uniform" }),
46
+ file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "film.mkv" }),
47
+ runs: new Set(),
48
+ pendingRun: null,
44
49
  indexCheck: null
45
50
  };
51
+ // The soundtrack of that picture: the same file, published on its own. Found
52
+ // by what it is, since no list of ids names it any more.
46
53
  const sound = {
47
54
  id: "sound",
48
55
  state: "ready",
49
- runState: ENCODE_RUN_STATE.PRODUCING,
50
56
  audioOnly: true,
51
57
  baseSessionId: "picture",
52
- segmentBoundaries: [...BOUNDARIES],
53
- encodeStartIndex: 2,
54
- runSerial: 0,
58
+ timeline: new Timeline({ boundaries: boundaries, cutGrid: "uniform" }),
59
+ file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "film.mkv" }),
60
+ runs: new Set(),
61
+ pendingRun: null,
55
62
  indexCheck: null
56
63
  };
64
+ startRunOn(picture, { from: 2 });
65
+ startRunOn(sound, { from: 2 });
57
66
  manager.sessionsById.set("picture", picture);
58
67
  manager.sessionsById.set("sound", sound);
59
68
 
@@ -62,62 +71,65 @@ function familyAtBoundaryTwo() {
62
71
 
63
72
  test("a soundtrack follows the picture to the instant the picture really began", () => {
64
73
  const { manager, picture, sound } = familyAtBoundaryTwo();
65
- const runsBefore = sound.runSerial;
74
+ const runBefore = [...sound.runs][0];
66
75
 
67
76
  manager.correctBoundaryFromSegment(picture, 2, 10.5);
68
77
 
69
78
  assert.deepEqual(
70
- picture.segmentBoundaries,
79
+ picture.timeline.boundaries,
71
80
  [0, 4, 10.5, 12, 16, 20],
72
81
  "the family's table must hold what the file itself said"
73
82
  );
74
83
  assert.equal(
75
- sound.segmentBoundaries[2],
84
+ sound.timeline.boundaries[2],
76
85
  10.5,
77
- "and every member's table with it — one film, one timeline"
86
+ "and every member's table with it — one film, one table, nothing to keep in step"
78
87
  );
79
88
  // A NEW run, not a seek. A seek decides by index, finds the soundtrack
80
89
  // already begins at #2 and answers "already within the running encode" —
81
90
  // true about the index, false about the instant. The first version of this
82
91
  // fix did exactly that and moved nothing.
83
92
  //
84
- // `runSerial` is the evidence because it is the first thing a run start
85
- // writes, before it awaits anything: the assertion then holds without the
86
- // test needing a filesystem, a process, or a guess about how many ticks to
87
- // wait for one.
88
- assert.equal(
89
- sound.runSerial,
90
- runsBefore + 1,
93
+ // The attempt is the evidence because it is what a run start claims before
94
+ // it awaits anything: the assertion then holds without the test needing a
95
+ // filesystem, a process, or a guess about how many ticks to wait for one.
96
+ assert.ok(
97
+ sound.pendingRun,
91
98
  "the soundtrack's run must be started again, at the corrected time"
92
99
  );
100
+ assert.equal(sound.pendingRun.startIndex, 2);
101
+ assert.equal([...sound.runs][0], runBefore, "and the run in force is only replaced once one is built");
93
102
  });
94
103
 
95
104
  test("a correction the table already holds moves nobody", () => {
96
105
  const { manager, picture, sound } = familyAtBoundaryTwo();
97
- const before = [...sound.segmentBoundaries];
106
+ const before = [...sound.timeline.boundaries];
98
107
  // Within the tolerance: the reading agrees with the table, so there is
99
108
  // nothing to correct and nothing to move. This is what makes the repositioning
100
109
  // converge instead of repeating on every produced segment.
101
110
  manager.correctBoundaryFromSegment(picture, 2, 8.1);
102
- assert.deepEqual(sound.segmentBoundaries, before);
111
+ assert.deepEqual(sound.timeline.boundaries, before);
103
112
  });
104
113
 
105
114
  test("a member that is not running is left alone", () => {
106
115
  const { manager, picture, sound } = familyAtBoundaryTwo();
107
116
  // A rung the viewer switched away from has no process. Moving it would start
108
117
  // an encoder for nobody — the failure that put three ffmpeg runs on one file.
109
- sound.runState = ENCODE_RUN_STATE.STOPPED;
118
+ const soundRun = [...sound.runs][0];
119
+ soundRun.stop("the viewer switched away");
120
+ // A stopped run is no longer live, so nothing of this session begins at #2
121
+ // any more — which is what "left alone" means here.
110
122
  manager.correctBoundaryFromSegment(picture, 2, 10.5);
111
- assert.equal(sound.runSerial, 0, "a stopped member is not started again for nobody");
112
- assert.equal(sound.encodeStartIndex, 2, "a stopped member keeps its place and its silence");
113
- assert.equal(sound.runState, ENCODE_RUN_STATE.STOPPED);
123
+ assert.equal(sound.pendingRun, null, "a stopped member is not started again for nobody");
124
+ assert.equal(soundRun.from, 2, "a stopped member keeps its place and its silence");
125
+ assert.equal(soundRun.state, ENCODE_RUN_STATE.STOPPED);
114
126
  assert.notEqual(INITIAL_RUN_STATE, ENCODE_RUN_STATE.STOPPED);
115
127
  });
116
128
 
117
129
  test("a soundtrack does not move the grid the picture is cut on", () => {
118
130
  const { manager, picture, sound } = familyAtBoundaryTwo();
119
- const pictureBefore = [...picture.segmentBoundaries];
120
- const soundBefore = [...sound.segmentBoundaries];
131
+ const pictureBefore = [...picture.timeline.boundaries];
132
+ const soundBefore = [...sound.timeline.boundaries];
121
133
 
122
134
  // The sound reports where IT began, which is where it was asked to begin, to
123
135
  // within one audio frame. The picture's own boundary is a keyframe of the
@@ -132,10 +144,10 @@ test("a soundtrack does not move the grid the picture is cut on", () => {
132
144
  manager.correctBoundaryFromSegment(sound, 2, 10.5);
133
145
 
134
146
  assert.deepEqual(
135
- picture.segmentBoundaries,
147
+ picture.timeline.boundaries,
136
148
  pictureBefore,
137
149
  "the grid is the picture's cut list and a soundtrack may not move it"
138
150
  );
139
- assert.deepEqual(sound.segmentBoundaries, soundBefore);
140
- assert.equal(picture.runSerial, 0, "and nothing is restarted on a soundtrack's say-so");
151
+ assert.deepEqual(sound.timeline.boundaries, soundBefore);
152
+ assert.equal(picture.pendingRun, null, "and nothing is restarted on a soundtrack's say-so");
141
153
  });