@torrent-tv/proxy 2.9.141 → 2.11.0
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 +28 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +17 -0
- package/routes/transcode/session-file/get.js +18 -0
- package/routes/transcode/variant-file/get.js +47 -0
- package/server.js +7 -0
- package/services/container-index/index.js +10 -6
- package/services/hls-session-manager.js +4962 -4183
- package/services/hwaccel.js +55 -16
- package/test/behind-head-repair.test.js +187 -0
- package/test/keyframe-index-accuracy.test.js +68 -0
- package/test/quality-variants.test.js +390 -0
- package/test/segment-serve-wiring.test.js +50 -34
package/services/hwaccel.js
CHANGED
|
@@ -148,15 +148,37 @@ function safeDimensions(targetWidth, targetHeight) {
|
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
150
|
* Force a keyframe on every segment boundary so each HLS segment is
|
|
151
|
-
* independently decodable
|
|
151
|
+
* independently decodable.
|
|
152
|
+
*
|
|
153
|
+
* Two grids exist. The usual one is even — a keyframe every
|
|
154
|
+
* `segmentDurationSec` — and the encoder is free to place them because it is
|
|
155
|
+
* producing every frame anyway. The other is the SOURCE's own keyframe times,
|
|
156
|
+
* used when this encode has to be interchangeable with a stream that is
|
|
157
|
+
* COPIED: a copy can only be cut where the source already has a keyframe, so a
|
|
158
|
+
* rung meant to splice into it must be cut at exactly those times and nowhere
|
|
159
|
+
* else. Then the times are given outright.
|
|
152
160
|
*
|
|
153
161
|
* @param {number} segmentDurationSec
|
|
162
|
+
* @param {number[] | null} [forcedTimes] - Run-relative seconds, ascending.
|
|
154
163
|
* @returns {string[]}
|
|
155
164
|
*/
|
|
156
|
-
function keyFrameArgs(segmentDurationSec) {
|
|
165
|
+
function keyFrameArgs(segmentDurationSec, forcedTimes = null) {
|
|
166
|
+
if (Array.isArray(forcedTimes) && forcedTimes.length > 0) {
|
|
167
|
+
return ["-force_key_frames", forcedTimes.join(",")];
|
|
168
|
+
}
|
|
157
169
|
return ["-force_key_frames", `expr:gte(t,n_forced*${segmentDurationSec})`];
|
|
158
170
|
}
|
|
159
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Whether an explicit cut list was supplied.
|
|
174
|
+
*
|
|
175
|
+
* @param {number[] | null | undefined} forcedTimes
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
function hasForcedTimes(forcedTimes) {
|
|
179
|
+
return Array.isArray(forcedTimes) && forcedTimes.length > 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
160
182
|
/** @returns {import("./hwaccel.js").VideoEncoderDescriptor} */
|
|
161
183
|
export function softwareDescriptor() {
|
|
162
184
|
return {
|
|
@@ -164,7 +186,7 @@ export function softwareDescriptor() {
|
|
|
164
186
|
kind: "software",
|
|
165
187
|
device: null,
|
|
166
188
|
inputArgs: [],
|
|
167
|
-
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap }) {
|
|
189
|
+
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap, forcedKeyframeTimes }) {
|
|
168
190
|
const { w, h } = safeDimensions(targetWidth, targetHeight);
|
|
169
191
|
const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
|
|
170
192
|
// Output frame rate: inherited from the source (rounded/capped) by the
|
|
@@ -200,11 +222,24 @@ export function softwareDescriptor() {
|
|
|
200
222
|
// independent of the PTS offset used on seek-restart — every HLS segment
|
|
201
223
|
// is exactly segmentDurationSec long and starts on a keyframe, so segment
|
|
202
224
|
// boundaries line up with the synthetic playlist with no gaps. (The old
|
|
203
|
-
//
|
|
204
|
-
// `t` is
|
|
205
|
-
//
|
|
225
|
+
// the OLD `expr:` form of -force_key_frames broke after a seek, because
|
|
226
|
+
// the `t` it reads is shifted by `-output_ts_offset`.)
|
|
227
|
+
//
|
|
228
|
+
// An explicit cut LIST is a different thing and does work: verified by
|
|
229
|
+
// running it, its times are on the run's own timeline — the same one
|
|
230
|
+
// `-segment_times` is measured on — so both are given one list and
|
|
231
|
+
// cannot drift apart. It replaces the frame-count GOP, which cannot
|
|
232
|
+
// describe the source's keyframes because they are not evenly spaced.
|
|
233
|
+
// `-g` stays as an upper bound on the interval: an extra keyframe
|
|
234
|
+
// inside a segment costs a little bitrate and cuts nothing, while
|
|
235
|
+
// leaving the interval unbounded means a driver that ignores the list
|
|
236
|
+
// produces one enormous segment instead of a wrong but cut one.
|
|
237
|
+
// `-keyint_min` goes, since a MINIMUM interval is the one thing that
|
|
238
|
+
// could argue with a forced keyframe.
|
|
206
239
|
"-g", String(segmentDurationSec * outFps),
|
|
207
|
-
|
|
240
|
+
...(hasForcedTimes(forcedKeyframeTimes)
|
|
241
|
+
? keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
|
|
242
|
+
: ["-keyint_min", String(segmentDurationSec * outFps)]),
|
|
208
243
|
"-sc_threshold", "0"
|
|
209
244
|
];
|
|
210
245
|
}
|
|
@@ -224,14 +259,14 @@ function vaapiDescriptor(device) {
|
|
|
224
259
|
inputArgs: ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", device],
|
|
225
260
|
// No fps filter: VAAPI inherits the source rate and keeps keyframes on the
|
|
226
261
|
// grid via time-based -force_key_frames, so it already honours source fps.
|
|
227
|
-
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
|
|
262
|
+
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
|
|
228
263
|
const { w, h } = safeDimensions(targetWidth, targetHeight);
|
|
229
264
|
return [
|
|
230
265
|
"-vf",
|
|
231
266
|
`scale_vaapi=w=${w}:h=${h}:force_original_aspect_ratio=decrease`,
|
|
232
267
|
"-c:v", "h264_vaapi",
|
|
233
268
|
"-qp", "24",
|
|
234
|
-
...keyFrameArgs(segmentDurationSec)
|
|
269
|
+
...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
|
|
235
270
|
];
|
|
236
271
|
}
|
|
237
272
|
};
|
|
@@ -247,13 +282,13 @@ function qsvDescriptor(device) {
|
|
|
247
282
|
kind: "qsv",
|
|
248
283
|
device,
|
|
249
284
|
inputArgs: ["-hwaccel", "qsv", "-qsv_device", device],
|
|
250
|
-
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
|
|
285
|
+
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
|
|
251
286
|
const { w, h } = safeDimensions(targetWidth, targetHeight);
|
|
252
287
|
return [
|
|
253
288
|
"-vf", `scale_qsv=w=${w}:h=${h}`,
|
|
254
289
|
"-c:v", "h264_qsv",
|
|
255
290
|
"-global_quality", "24",
|
|
256
|
-
...keyFrameArgs(segmentDurationSec)
|
|
291
|
+
...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
|
|
257
292
|
];
|
|
258
293
|
}
|
|
259
294
|
};
|
|
@@ -269,7 +304,7 @@ function nvencDescriptor() {
|
|
|
269
304
|
// No fps filter: NVENC is fast and places keyframes by time-based
|
|
270
305
|
// -force_key_frames, so it inherits the exact source rate (fractional
|
|
271
306
|
// included) with no need to round or cap. Same rationale as VAAPI/QSV.
|
|
272
|
-
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec }) {
|
|
307
|
+
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, forcedKeyframeTimes }) {
|
|
273
308
|
const { w, h } = safeDimensions(targetWidth, targetHeight);
|
|
274
309
|
return [
|
|
275
310
|
"-vf",
|
|
@@ -278,7 +313,7 @@ function nvencDescriptor() {
|
|
|
278
313
|
"-preset", "p4",
|
|
279
314
|
"-cq", "24",
|
|
280
315
|
"-pix_fmt", "yuv420p",
|
|
281
|
-
...keyFrameArgs(segmentDurationSec)
|
|
316
|
+
...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
|
|
282
317
|
];
|
|
283
318
|
}
|
|
284
319
|
};
|
|
@@ -296,7 +331,7 @@ function v4l2m2mDescriptor() {
|
|
|
296
331
|
kind: "v4l2m2m",
|
|
297
332
|
device: null,
|
|
298
333
|
inputArgs: [],
|
|
299
|
-
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps }) {
|
|
334
|
+
buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, fps, forcedKeyframeTimes }) {
|
|
300
335
|
const { w, h } = safeDimensions(targetWidth, targetHeight);
|
|
301
336
|
const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
|
|
302
337
|
return [
|
|
@@ -308,8 +343,12 @@ function v4l2m2mDescriptor() {
|
|
|
308
343
|
// userspace").
|
|
309
344
|
"-num_capture_buffers", "32",
|
|
310
345
|
"-b:v", "3M",
|
|
346
|
+
// Kept even with an explicit cut list, as an upper bound on the
|
|
347
|
+
// interval: this encoder is the one known not always to honour keyframe
|
|
348
|
+
// hints, and without any bound a list it ignores yields one segment for
|
|
349
|
+
// the whole file rather than a wrongly-cut one.
|
|
311
350
|
"-g", String(outFps * segmentDurationSec),
|
|
312
|
-
...keyFrameArgs(segmentDurationSec)
|
|
351
|
+
...keyFrameArgs(segmentDurationSec, forcedKeyframeTimes)
|
|
313
352
|
];
|
|
314
353
|
}
|
|
315
354
|
};
|
|
@@ -322,7 +361,7 @@ function v4l2m2mDescriptor() {
|
|
|
322
361
|
* @property {"software"|"vaapi"|"qsv"|"nvenc"|"v4l2m2m"} kind
|
|
323
362
|
* @property {string|null} device
|
|
324
363
|
* @property {string[]} inputArgs
|
|
325
|
-
* @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number }) => string[]} buildVideoArgs
|
|
364
|
+
* @property {(opts: { targetWidth: number, targetHeight: number, segmentDurationSec: number, preset?: string, fps?: number, tonemap?: boolean, forcedKeyframeTimes?: number[] | null }) => string[]} buildVideoArgs
|
|
326
365
|
*/
|
|
327
366
|
|
|
328
367
|
/**
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A segment request below the running encode must not be held for ever.
|
|
3
|
+
*
|
|
4
|
+
* The encoder only moves forward from where its run began, so a request BELOW
|
|
5
|
+
* that point cannot be answered by anything the run does. Every other far
|
|
6
|
+
* request is a claim the running encode may yet reach; this one is a hole.
|
|
7
|
+
*
|
|
8
|
+
* Measured 2026-08-11: a quality switch placed a run at segment #770 while the
|
|
9
|
+
* player needed #757, and the request was held for two minutes forty-one while
|
|
10
|
+
* the encoder produced 409 s of video nobody had asked for at 2.48x. The
|
|
11
|
+
* placement that caused it is fixed; this is the guard that stops the SHAPE
|
|
12
|
+
* from hanging a session again, whatever puts it there.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
21
|
+
import { fmp4Format } from "../services/segment-formats/fmp4.js";
|
|
22
|
+
|
|
23
|
+
const SESSION_ID = "bbbbbbbb-cccc-dddd-eeee-ffffffffffff";
|
|
24
|
+
const SEGMENT_SECONDS = 4;
|
|
25
|
+
const RUN_STARTS_AT = 770;
|
|
26
|
+
const WANTED = 757;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A session whose run began well past the segment being asked for.
|
|
30
|
+
*
|
|
31
|
+
* @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string, restarts: number[] }>}
|
|
32
|
+
*/
|
|
33
|
+
async function managerWithRunAhead() {
|
|
34
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "behind-head-"));
|
|
35
|
+
const manager = new HlsSessionManager({
|
|
36
|
+
enabled: true,
|
|
37
|
+
ffmpegBin: "ffmpeg",
|
|
38
|
+
localBindHost: "127.0.0.1",
|
|
39
|
+
localPort: 9090
|
|
40
|
+
});
|
|
41
|
+
const session = {
|
|
42
|
+
id: SESSION_ID,
|
|
43
|
+
dirPath,
|
|
44
|
+
state: "ready",
|
|
45
|
+
fileName: "film.avi",
|
|
46
|
+
startedAt: Date.now(),
|
|
47
|
+
createEntryMs: Date.now(),
|
|
48
|
+
lastAccessedAt: Date.now(),
|
|
49
|
+
lastError: "",
|
|
50
|
+
consumers: new Set(),
|
|
51
|
+
segmentFormat: fmp4Format,
|
|
52
|
+
transcodeVideo: true,
|
|
53
|
+
useSyntheticPlaylist: true,
|
|
54
|
+
playlistText: "#EXTM3U\n",
|
|
55
|
+
segmentBoundaries: Array.from({ length: 1937 }, (_, index) => index * SEGMENT_SECONDS),
|
|
56
|
+
segmentCount: 1936,
|
|
57
|
+
encodeStartIndex: RUN_STARTS_AT,
|
|
58
|
+
encodeRunGeneration: 0,
|
|
59
|
+
lastRestartAt: 0,
|
|
60
|
+
seekFailureTarget: -1,
|
|
61
|
+
seekFailureCount: 0,
|
|
62
|
+
seekSettleTimer: null,
|
|
63
|
+
seekTarget: null,
|
|
64
|
+
waitEpoch: 0,
|
|
65
|
+
firstWantedAt: new Map(),
|
|
66
|
+
ffmpeg: { pid: 4321, exitCode: null, signalCode: null, kill() {}, once(event, handler) { if (event === "exit") handler(); } },
|
|
67
|
+
progress: { state: "running", processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS + 400, startPositionSeconds: RUN_STARTS_AT * SEGMENT_SECONDS }
|
|
68
|
+
};
|
|
69
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
70
|
+
const restarts = [];
|
|
71
|
+
return { manager, session, dirPath, restarts };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("a request behind the run is repaired once it has waited", async (t) => {
|
|
75
|
+
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
76
|
+
t.after(async () => {
|
|
77
|
+
await manager.disposeAll();
|
|
78
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
// Asked for four seconds ago and still unanswerable.
|
|
81
|
+
session.firstWantedAt.set(WANTED, Date.now() - 4000);
|
|
82
|
+
|
|
83
|
+
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
|
|
84
|
+
|
|
85
|
+
assert.equal(
|
|
86
|
+
session.seekTarget,
|
|
87
|
+
WANTED - 1,
|
|
88
|
+
"the encoder must be moved back to it — one segment early, for the preceding keyframe"
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("a request behind the run is left alone at first", async (t) => {
|
|
93
|
+
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
94
|
+
t.after(async () => {
|
|
95
|
+
await manager.disposeAll();
|
|
96
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
|
|
100
|
+
|
|
101
|
+
assert.equal(
|
|
102
|
+
session.seekTarget,
|
|
103
|
+
null,
|
|
104
|
+
"a burst around a reported seek settles by itself, and the seek is what should move the encoder"
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("a seek already settling is not overridden", async (t) => {
|
|
109
|
+
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
110
|
+
t.after(async () => {
|
|
111
|
+
await manager.disposeAll();
|
|
112
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
113
|
+
});
|
|
114
|
+
session.firstWantedAt.set(WANTED, Date.now() - 4000);
|
|
115
|
+
// The viewer has stated where they are and it is about to be acted on.
|
|
116
|
+
session.seekTarget = 1200;
|
|
117
|
+
session.seekSettleTimer = setTimeout(() => {}, 60_000);
|
|
118
|
+
t.after(() => clearTimeout(session.seekSettleTimer));
|
|
119
|
+
|
|
120
|
+
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
|
|
121
|
+
|
|
122
|
+
assert.equal(
|
|
123
|
+
session.seekTarget,
|
|
124
|
+
1200,
|
|
125
|
+
"a statement from the viewer outranks anything inferred from what the player is fetching"
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a playlist scan far below the run is left where it belongs", async (t) => {
|
|
130
|
+
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
131
|
+
t.after(async () => {
|
|
132
|
+
await manager.disposeAll();
|
|
133
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
134
|
+
});
|
|
135
|
+
// A player that cannot get what it wants scans the playlist: field log
|
|
136
|
+
// 2026-08-02, probes at #178, #681, #725, #807, #74, #245, #387 within half a
|
|
137
|
+
// second. Steering on the lowest of those put the encoder at the start of the
|
|
138
|
+
// film and left the viewer's own requests unreachable ahead of it — the exact
|
|
139
|
+
// reason request-steering was removed from this proxy.
|
|
140
|
+
const probe = 74;
|
|
141
|
+
session.firstWantedAt.set(probe, Date.now() - 30_000);
|
|
142
|
+
|
|
143
|
+
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(probe), { requestSeq: 1 });
|
|
144
|
+
|
|
145
|
+
assert.equal(
|
|
146
|
+
session.seekTarget,
|
|
147
|
+
null,
|
|
148
|
+
"a misplaced run is out by a buffer; a scan probe is out by anything, and the two must not be confused"
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a rung whose encoder was stopped is not brought back by a held request", async (t) => {
|
|
153
|
+
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
154
|
+
t.after(async () => {
|
|
155
|
+
await manager.disposeAll();
|
|
156
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
157
|
+
});
|
|
158
|
+
// What a quality switch leaves behind: the rung nobody is watching, parked.
|
|
159
|
+
session.ffmpeg = null;
|
|
160
|
+
session.firstWantedAt.set(WANTED, Date.now() - 4000);
|
|
161
|
+
|
|
162
|
+
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(WANTED), { requestSeq: 1 });
|
|
163
|
+
|
|
164
|
+
assert.equal(
|
|
165
|
+
session.seekTarget,
|
|
166
|
+
null,
|
|
167
|
+
"restarting it would put a second encoder on a host sized for one, for a rung nobody is watching"
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a request ahead of the run is not touched", async (t) => {
|
|
172
|
+
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
173
|
+
t.after(async () => {
|
|
174
|
+
await manager.disposeAll();
|
|
175
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
176
|
+
});
|
|
177
|
+
const ahead = RUN_STARTS_AT + 400;
|
|
178
|
+
session.firstWantedAt.set(ahead, Date.now() - 30_000);
|
|
179
|
+
|
|
180
|
+
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(ahead), { requestSeq: 1 });
|
|
181
|
+
|
|
182
|
+
assert.equal(
|
|
183
|
+
session.seekTarget,
|
|
184
|
+
null,
|
|
185
|
+
"the running encode may yet reach it; restarting on a far request is what produced nine restarts in a minute"
|
|
186
|
+
);
|
|
187
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How well a container's keyframe index describes its own file.
|
|
3
|
+
*
|
|
4
|
+
* The cut times of a copied video ARE its index — ffmpeg can only cut where a
|
|
5
|
+
* keyframe already is — and an index can be wrong: measured 2026-08-06, one
|
|
6
|
+
* claimed a keyframe at 157.99 s where the real ones were 153.82 and 164.247.
|
|
7
|
+
* Whether a re-encoded rung can be cut on that same grid and spliced into the
|
|
8
|
+
* copy depends entirely on how often that happens, so it is counted.
|
|
9
|
+
*
|
|
10
|
+
* No scan is involved and no undownloaded byte is touched: each produced piece
|
|
11
|
+
* states where it truly begins, and it is already read whole in order to be
|
|
12
|
+
* stamped. Only boundaries that were actually produced are counted — the parts
|
|
13
|
+
* somebody watched.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { newIndexCheck, noteIndexDeviation } from "../services/hls-session-manager.js";
|
|
19
|
+
|
|
20
|
+
test("an index that describes its file exactly is reported as such", () => {
|
|
21
|
+
const check = newIndexCheck();
|
|
22
|
+
|
|
23
|
+
for (let index = 0; index < 4; index += 1) {
|
|
24
|
+
noteIndexDeviation(check, index, 0);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
assert.equal(check.checked, 4);
|
|
28
|
+
assert.equal(check.disagreed, 0, "nothing disagreed — which is a finding, not silence");
|
|
29
|
+
assert.equal(check.maxDeviationSec, 0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("a boundary the index placed wrongly is counted, with how far out it was", () => {
|
|
33
|
+
const check = newIndexCheck();
|
|
34
|
+
|
|
35
|
+
noteIndexDeviation(check, 0, 0);
|
|
36
|
+
// The measured shape: the playlist said 157.99 s, the file cut at 153.82 s.
|
|
37
|
+
noteIndexDeviation(check, 2, 4.17);
|
|
38
|
+
noteIndexDeviation(check, 3, 0.01);
|
|
39
|
+
|
|
40
|
+
assert.equal(check.checked, 3);
|
|
41
|
+
assert.equal(check.disagreed, 1);
|
|
42
|
+
assert.equal(check.firstDisagreementIndex, 2);
|
|
43
|
+
assert.equal(
|
|
44
|
+
check.maxDeviationSec,
|
|
45
|
+
4.17,
|
|
46
|
+
"the size of the error is what decides whether a rung can be cut on this grid"
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("a deviation within tolerance is not a disagreement, but still shows in the worst case", () => {
|
|
51
|
+
const check = newIndexCheck();
|
|
52
|
+
|
|
53
|
+
noteIndexDeviation(check, 0, 0.2);
|
|
54
|
+
|
|
55
|
+
assert.equal(check.disagreed, 0, "rounding in a container's timestamps is not the index being wrong");
|
|
56
|
+
assert.equal(check.maxDeviationSec, 0.2, "and it is still worth knowing how close to the line it ran");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a segment requested again is not new evidence", () => {
|
|
60
|
+
const check = newIndexCheck();
|
|
61
|
+
|
|
62
|
+
noteIndexDeviation(check, 1, 0.9);
|
|
63
|
+
noteIndexDeviation(check, 1, 0.9);
|
|
64
|
+
noteIndexDeviation(check, 1, 0.9);
|
|
65
|
+
|
|
66
|
+
assert.equal(check.checked, 1, "a repeat request is the same boundary, counted once");
|
|
67
|
+
assert.equal(check.disagreed, 1);
|
|
68
|
+
});
|