@torrent-tv/proxy 2.83.7 → 2.84.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 +16 -0
- package/CLAUDE.md +5 -3
- package/bin/cli.js +1 -0
- package/docs/encode-architecture.md +26 -1
- package/docs/logs.md +97 -51
- package/package.json +1 -1
- package/routes/api/client-logs/post.js +86 -0
- package/routes/api/transcode-sessions/post.js +139 -137
- package/server.js +13 -1
- package/services/data-channel-handler.js +7 -0
- package/services/hls-session-manager.js +9777 -10006
- package/services/output/OutputSpec.js +202 -195
- package/services/priority/PriorityMap.js +390 -362
- package/services/priority/PriorityOrchestrator.js +316 -318
- package/services/viewer/Viewer.js +60 -33
- package/services/viewer/positions.js +120 -48
- package/test/client-log-file.test.js +110 -0
- package/test/held-request-width.test.js +8 -5
- package/test/orchestrator-wired.test.js +14 -8
- package/test/output-spec.test.js +157 -157
- package/test/plan-is-steady.test.js +223 -0
- package/test/priority-map-emptied.test.js +207 -207
- package/test/priority-map-per-output.test.js +209 -211
- package/test/quality-variants.test.js +1167 -1222
- package/test/seek-target-not-superseded.test.js +14 -12
- package/test/stale-request-after-seek.test.js +2 -3
- package/test/viewer.test.js +115 -108
- package/utils/client-log-file.js +151 -0
- package/test/viewer-position-on-open.test.js +0 -92
|
@@ -42,7 +42,7 @@ async function managerAfterSeek() {
|
|
|
42
42
|
localBindHost: "127.0.0.1",
|
|
43
43
|
localPort: 9090
|
|
44
44
|
});
|
|
45
|
-
|
|
45
|
+
const session = {
|
|
46
46
|
id: SESSION_ID,
|
|
47
47
|
dirPath,
|
|
48
48
|
// Where this file is cut, held by the file. A fixture that stated it
|
|
@@ -66,9 +66,11 @@ async function managerAfterSeek() {
|
|
|
66
66
|
segmentCount: 399,
|
|
67
67
|
encodeStartIndex: SEGMENT_AT_SEEK,
|
|
68
68
|
waitEpoch: 1,
|
|
69
|
-
furthestViewerSeconds: SEEK_TO_SECONDS,
|
|
70
69
|
runState: "PRODUCING"
|
|
71
|
-
}
|
|
70
|
+
};
|
|
71
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
72
|
+
viewerOf(session, "").moveTo(SEEK_TO_SECONDS);
|
|
73
|
+
viewerOf(session, "").playing = false;
|
|
72
74
|
return { manager, dirPath };
|
|
73
75
|
}
|
|
74
76
|
|
|
@@ -141,15 +143,15 @@ test("the viewer who made the request is the one it is judged against", async ()
|
|
|
141
143
|
const { manager, dirPath } = await managerAfterSeek();
|
|
142
144
|
const session = manager.sessionsById.get(SESSION_ID);
|
|
143
145
|
session.viewers = new Map();
|
|
144
|
-
// Two people watching one copied picture
|
|
145
|
-
//
|
|
146
|
-
//
|
|
146
|
+
// Two people watching one copied picture: one at the seek target, one a
|
|
147
|
+
// hundred segments back and waiting for a segment there. Asked about nobody
|
|
148
|
+
// in particular the answer is the FURTHEST of them, because what lies behind
|
|
149
|
+
// that one has already been made.
|
|
147
150
|
const behind = SEGMENT_AT_SEEK - 100;
|
|
148
|
-
viewerOf(session, "behind").
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
};
|
|
151
|
+
viewerOf(session, "behind").moveTo(behind * SEGMENT_SECONDS);
|
|
152
|
+
viewerOf(session, "behind").playing = false;
|
|
153
|
+
viewerOf(session, "ahead").moveTo(SEEK_TO_SECONDS);
|
|
154
|
+
viewerOf(session, "ahead").playing = false;
|
|
153
155
|
|
|
154
156
|
assert.equal(
|
|
155
157
|
manager.requestStillWanted(SESSION_ID, `segment-${String(behind).padStart(5, "0")}.mp4`, "behind"),
|
|
@@ -159,7 +161,7 @@ test("the viewer who made the request is the one it is judged against", async ()
|
|
|
159
161
|
assert.equal(
|
|
160
162
|
manager.requestStillWanted(SESSION_ID, `segment-${String(behind).padStart(5, "0")}.mp4`),
|
|
161
163
|
false,
|
|
162
|
-
"unnamed, it
|
|
164
|
+
"unnamed, it is judged against the furthest viewer, which is the leader"
|
|
163
165
|
);
|
|
164
166
|
|
|
165
167
|
await rm(dirPath, { recursive: true, force: true });
|
|
@@ -155,9 +155,8 @@ test("a request cannot move the viewer's position backwards", async (t) => {
|
|
|
155
155
|
manager.requestSeek(SESSION_ID, 2083.4, "viewer-1");
|
|
156
156
|
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(BEHIND_INDEX), { requestSeq: 1 });
|
|
157
157
|
|
|
158
|
-
assert.
|
|
159
|
-
manager.viewers.get("viewer-1")?.positionSeconds(),
|
|
160
|
-
2083.4,
|
|
158
|
+
assert.ok(
|
|
159
|
+
Math.abs((manager.viewers.get("viewer-1")?.positionSeconds() ?? -1) - 2083.4) < 1,
|
|
161
160
|
"a stale request must not rewrite what the viewer said about themselves"
|
|
162
161
|
);
|
|
163
162
|
});
|
package/test/viewer.test.js
CHANGED
|
@@ -1,108 +1,115 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file One object per person watching, and one deletion to forget them.
|
|
3
|
-
*
|
|
4
|
-
* It was six maps hung on the session, each keyed by consumer id, and the
|
|
5
|
-
* forgetting was already wrong: releasing a consumer emptied none of them, so a
|
|
6
|
-
* viewer who had left went on counting as wanting their soundtrack until their
|
|
7
|
-
* head expired, and their entries stayed for the life of the session.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import test from "node:test";
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
import { SourceFile } from "../services/source/SourceFile.js";
|
|
13
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
14
|
-
import os from "node:os";
|
|
15
|
-
import path from "node:path";
|
|
16
|
-
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
17
|
-
import { Viewer, viewerOf, viewersOf } from "../services/viewer/Viewer.js";
|
|
18
|
-
|
|
19
|
-
const SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
20
|
-
|
|
21
|
-
test("presence and position are two facts, and presence does not wait for a request", () => {
|
|
22
|
-
const now = 1_000_000;
|
|
23
|
-
const viewer = new Viewer("someone", now);
|
|
24
|
-
|
|
25
|
-
// The whole of the 2026-09-05 failure in one assertion: a viewer who has
|
|
26
|
-
// arrived and asked for nothing yet IS watching. Answered from the position
|
|
27
|
-
// alone — which is written only when a segment is requested — this read
|
|
28
|
-
// false, every encoder on their output was stopped for having nobody, and
|
|
29
|
-
// the `init.mp4` they needed in order to request their first segment was
|
|
30
|
-
// therefore never made.
|
|
31
|
-
assert.equal(viewer.position, null, "nothing has placed them yet");
|
|
32
|
-
assert.equal(viewer.isPresent(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
viewer.seen(now -
|
|
40
|
-
assert.equal(viewer.isPresent(
|
|
41
|
-
|
|
42
|
-
// Something SAID they are gone
|
|
43
|
-
//
|
|
44
|
-
viewer.
|
|
45
|
-
viewer.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
viewer.
|
|
54
|
-
assert.equal(viewer.positionSeconds(), 40
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file One object per person watching, and one deletion to forget them.
|
|
3
|
+
*
|
|
4
|
+
* It was six maps hung on the session, each keyed by consumer id, and the
|
|
5
|
+
* forgetting was already wrong: releasing a consumer emptied none of them, so a
|
|
6
|
+
* viewer who had left went on counting as wanting their soundtrack until their
|
|
7
|
+
* head expired, and their entries stayed for the life of the session.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
13
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
17
|
+
import { Viewer, viewerOf, viewersOf } from "../services/viewer/Viewer.js";
|
|
18
|
+
|
|
19
|
+
const SESSION_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
20
|
+
|
|
21
|
+
test("presence and position are two facts, and presence does not wait for a request", () => {
|
|
22
|
+
const now = 1_000_000;
|
|
23
|
+
const viewer = new Viewer("someone", now);
|
|
24
|
+
|
|
25
|
+
// The whole of the 2026-09-05 failure in one assertion: a viewer who has
|
|
26
|
+
// arrived and asked for nothing yet IS watching. Answered from the position
|
|
27
|
+
// alone — which is written only when a segment is requested — this read
|
|
28
|
+
// false, every encoder on their output was stopped for having nobody, and
|
|
29
|
+
// the `init.mp4` they needed in order to request their first segment was
|
|
30
|
+
// therefore never made.
|
|
31
|
+
assert.equal(viewer.position, null, "nothing has placed them yet");
|
|
32
|
+
assert.equal(viewer.isPresent(), true, "and they are still here");
|
|
33
|
+
|
|
34
|
+
// SILENCE IS NOT ABSENCE, however long it lasts. A paused viewer, a hidden
|
|
35
|
+
// tab whose timers the browser has throttled, and one holding two minutes of
|
|
36
|
+
// cushion all say nothing and are all still watching — so there is no
|
|
37
|
+
// interval that separates them from someone who has left, and the threshold
|
|
38
|
+
// that used to try is gone.
|
|
39
|
+
viewer.seen(now - 600_000);
|
|
40
|
+
assert.equal(viewer.isPresent(), true, "ten minutes of silence is not leaving");
|
|
41
|
+
|
|
42
|
+
// Something SAID they are gone: the browser released the session, or their
|
|
43
|
+
// connection closed. That is the only way out.
|
|
44
|
+
viewer.gone = true;
|
|
45
|
+
assert.equal(viewer.isPresent(), false, "a statement is what ends it");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("position is one fact with one writer, and a function of time", () => {
|
|
49
|
+
const viewer = new Viewer("someone");
|
|
50
|
+
assert.equal(viewer.positionSeconds(), null);
|
|
51
|
+
|
|
52
|
+
viewer.moveTo(40, 1_000_000);
|
|
53
|
+
viewer.playing = false;
|
|
54
|
+
assert.equal(viewer.positionSeconds(1_000_000), 40);
|
|
55
|
+
assert.equal(viewer.positionSeconds(1_060_000), 40, "a stopped picture covers no film");
|
|
56
|
+
|
|
57
|
+
viewer.playing = true;
|
|
58
|
+
assert.equal(viewer.positionSeconds(1_010_000), 50, "a playing one covers a second a second");
|
|
59
|
+
|
|
60
|
+
// A seek is the same statement, and there is no second field for it to win
|
|
61
|
+
// against: two writers taking turns on one reading is what made the priority
|
|
62
|
+
// map jump backwards several times a second in the field on 2026-09-13.
|
|
63
|
+
viewer.moveTo(900, 1_010_000);
|
|
64
|
+
assert.equal(viewer.positionSeconds(1_010_000), 900);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("asking for a viewer twice is the same viewer", () => {
|
|
68
|
+
const session = {};
|
|
69
|
+
const first = viewerOf(session, "one");
|
|
70
|
+
first.audio = { trackIndex: 3, transcode: false };
|
|
71
|
+
|
|
72
|
+
assert.equal(viewerOf(session, "one"), first);
|
|
73
|
+
assert.equal(viewerOf(session, "one").audio.trackIndex, 3);
|
|
74
|
+
assert.notEqual(viewerOf(session, "two"), first);
|
|
75
|
+
assert.equal(viewersOf(session).size, 2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("releasing a consumer forgets everything that was true of them alone", async (t) => {
|
|
79
|
+
const dirPath = await mkdtemp(path.join(os.tmpdir(), "viewer-"));
|
|
80
|
+
t.after(async () => {
|
|
81
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
const manager = new HlsSessionManager({
|
|
84
|
+
enabled: true,
|
|
85
|
+
ffmpegBin: "ffmpeg",
|
|
86
|
+
localBindHost: "127.0.0.1",
|
|
87
|
+
localPort: 9090
|
|
88
|
+
});
|
|
89
|
+
const session = {
|
|
90
|
+
id: SESSION_ID,
|
|
91
|
+
dirPath,
|
|
92
|
+
state: "live",
|
|
93
|
+
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
|
|
94
|
+
// An ordinary session reads its own file, and its sound is inside it. The
|
|
95
|
+
// three differ only for a soundtrack shipped as a file of its own.
|
|
96
|
+
get inputFile() { return this.file; },
|
|
97
|
+
get audioFile() { return this.file; },
|
|
98
|
+
consumers: new Set(["staying", "leaving"]),
|
|
99
|
+
viewers: new Map(),
|
|
100
|
+
lastAccessedAt: Date.now()
|
|
101
|
+
};
|
|
102
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
103
|
+
|
|
104
|
+
const leaving = viewerOf(session, "leaving");
|
|
105
|
+
leaving.audio = { trackIndex: 2, transcode: true };
|
|
106
|
+
leaving.activeVariantId = "some-variant";
|
|
107
|
+
leaving.position = { segment: 40, seconds: 160, at: Date.now() };
|
|
108
|
+
viewerOf(session, "staying").audio = { trackIndex: 0, transcode: false };
|
|
109
|
+
|
|
110
|
+
await manager.releaseSessionConsumer(SESSION_ID, "leaving", "the tab was closed");
|
|
111
|
+
|
|
112
|
+
assert.equal(session.viewers.has("leaving"), false, "one deletion, not six");
|
|
113
|
+
assert.equal(session.viewers.has("staying"), true, "and it takes nobody else with it");
|
|
114
|
+
assert.equal(session.consumers.size, 1);
|
|
115
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Where a browser's own log lands: a file beside the proxy's, per session.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. Both halves of every failure this project investigates live
|
|
5
|
+
* in two places at once — what the proxy did, and what the page saw — and until
|
|
6
|
+
* now the second half went to the SERVER's standard output on the droplet.
|
|
7
|
+
* Every release recreates that container and destroys it. On 2026-09-13 a
|
|
8
|
+
* viewer reported a desync and a frozen picture, and the analysis ran on half
|
|
9
|
+
* the evidence for exactly this reason.
|
|
10
|
+
*
|
|
11
|
+
* Written here instead, next to the proxy's own log, on the same durable
|
|
12
|
+
* directory (`/data` on the addon host). One file per viewing session, named so
|
|
13
|
+
* the two halves join without guessing: the session's start, then the torrent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** One file may reach this before it is rotated. */
|
|
20
|
+
const MAX_BYTES = 16 * 1024 * 1024;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Make a name safe for a file and short enough to read in a directory listing.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately strict: a torrent name is arbitrary bytes chosen by a stranger,
|
|
26
|
+
* and it is about to become a path.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} value
|
|
29
|
+
* @param {number} max
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function safeName(value, max) {
|
|
33
|
+
const cleaned = String(value ?? "")
|
|
34
|
+
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
35
|
+
.replace(/-+/g, "-")
|
|
36
|
+
.replace(/^[-.]+|[-.]+$/g, "");
|
|
37
|
+
return cleaned.slice(0, max);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `2026-09-13T16:05:24.123Z` → `20260913-160524`, which sorts and reads.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} iso
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function stamp(iso) {
|
|
47
|
+
const when = new Date(iso);
|
|
48
|
+
const at = Number.isNaN(when.getTime()) ? new Date() : when;
|
|
49
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
50
|
+
return `${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1)}${pad(at.getUTCDate())}` +
|
|
51
|
+
`-${pad(at.getUTCHours())}${pad(at.getUTCMinutes())}${pad(at.getUTCSeconds())}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A sink that writes one session's browser lines to its own file.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} proxyLogPath - The proxy's own log file; its directory is
|
|
58
|
+
* used, so the two halves are always side by side.
|
|
59
|
+
* @returns {{ write: (session: object, lines: string[]) => void, close: () => void }}
|
|
60
|
+
*/
|
|
61
|
+
export function createClientLogFiles(proxyLogPath) {
|
|
62
|
+
const directory = typeof proxyLogPath === "string" && proxyLogPath.length > 0
|
|
63
|
+
? dirname(proxyLogPath)
|
|
64
|
+
: "";
|
|
65
|
+
/** @type {Map<string, { stream: import("node:fs").WriteStream, path: string, bytes: number }>} */
|
|
66
|
+
const open = new Map();
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The file for this session, opened on first use.
|
|
70
|
+
*
|
|
71
|
+
* Keyed by the session's own id rather than by the name, because the name
|
|
72
|
+
* gains the torrent the moment one is chosen and the earlier lines — the
|
|
73
|
+
* page opening, the proxy being chosen, a connection failing — belong to the
|
|
74
|
+
* same session and must not start a second file.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ sessionId: string, startedAt: string, torrentName: string, infoHash: string }} session
|
|
77
|
+
* @returns {{ stream: import("node:fs").WriteStream, path: string, bytes: number } | null}
|
|
78
|
+
*/
|
|
79
|
+
function fileFor(session) {
|
|
80
|
+
if (!directory) return null;
|
|
81
|
+
const key = session.sessionId;
|
|
82
|
+
const existing = open.get(key);
|
|
83
|
+
if (existing) return existing;
|
|
84
|
+
const film = session.torrentName
|
|
85
|
+
? `${safeName(session.torrentName, 60)}-${safeName(session.infoHash, 8)}`
|
|
86
|
+
: "no-torrent-yet";
|
|
87
|
+
const path = join(directory, `client-${stamp(session.startedAt)}-${safeName(session.sessionId, 8)}-${film}.log`);
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(directory, { recursive: true });
|
|
90
|
+
const record = {
|
|
91
|
+
stream: createWriteStream(path, { flags: "a" }),
|
|
92
|
+
path,
|
|
93
|
+
bytes: statSync(path, { throwIfNoEntry: false })?.size ?? 0
|
|
94
|
+
};
|
|
95
|
+
// A file that stops being writable must not take the proxy down with it,
|
|
96
|
+
// and must not be retried on every line.
|
|
97
|
+
record.stream.on("error", () => open.delete(key));
|
|
98
|
+
open.set(key, record);
|
|
99
|
+
return record;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
write(session, lines) {
|
|
107
|
+
const record = fileFor(session);
|
|
108
|
+
if (!record) return;
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
const text = `${line}\n`;
|
|
111
|
+
record.bytes += Buffer.byteLength(text);
|
|
112
|
+
record.stream.write(text);
|
|
113
|
+
}
|
|
114
|
+
if (record.bytes >= MAX_BYTES) {
|
|
115
|
+
try {
|
|
116
|
+
record.stream.end(() => {
|
|
117
|
+
try {
|
|
118
|
+
renameSync(record.path, `${record.path}.1`);
|
|
119
|
+
} catch {
|
|
120
|
+
// Renaming a file that has gone leaves nothing to do.
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
// A rotation that cannot happen leaves the file growing, which is
|
|
125
|
+
// better than losing the session's log to an exception.
|
|
126
|
+
}
|
|
127
|
+
open.delete(session.sessionId);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
/**
|
|
131
|
+
* Finish every open file.
|
|
132
|
+
*
|
|
133
|
+
* Awaitable, and that is not a convenience: a write stream opens and
|
|
134
|
+
* flushes asynchronously, so a process that ends without waiting loses the
|
|
135
|
+
* last lines of every session — which are the ones a crash is explained by.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<void>}
|
|
138
|
+
*/
|
|
139
|
+
async close() {
|
|
140
|
+
const ending = [...open.values()].map((record) => new Promise((resolve) => {
|
|
141
|
+
try {
|
|
142
|
+
record.stream.end(resolve);
|
|
143
|
+
} catch {
|
|
144
|
+
resolve(undefined);
|
|
145
|
+
}
|
|
146
|
+
}));
|
|
147
|
+
open.clear();
|
|
148
|
+
await Promise.all(ending);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Opening a file at a position puts the SOUND there too.
|
|
3
|
-
*
|
|
4
|
-
* The audio rendition is a session of its own, and where it starts is computed
|
|
5
|
-
* from where the viewer is. That reading had three sources and only two were
|
|
6
|
-
* consulted — a position seeked to, and the last segment this session served —
|
|
7
|
-
* both of which are written by things that have not happened yet at the moment
|
|
8
|
-
* a file is opened at a position. So the answer was zero.
|
|
9
|
-
*
|
|
10
|
-
* Field 2026-08-21, `Minions.and.Monsters.1080p.mkv` reopened from the address
|
|
11
|
-
* bar at 52:07:
|
|
12
|
-
*
|
|
13
|
-
* 18:02:55.124 8ed85605 start=3130s -ss 3125.25 -an -map 0:v:0 -c:v copy
|
|
14
|
-
* 18:02:55.632 33b0b046 start=0s no -ss -vn -map 0:a:0 -c:a aac
|
|
15
|
-
*
|
|
16
|
-
* The picture went to segment #781, the sound to #0. The player asked both for
|
|
17
|
-
* #782; the picture had it, the sound re-encoded 57.5 s of a 3130 s film in the
|
|
18
|
-
* 45 s the request lasted and then answered 404 — shown to the viewer as "the
|
|
19
|
-
* proxy accepted the request but sent no video".
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import assert from "node:assert/strict";
|
|
23
|
-
import test from "node:test";
|
|
24
|
-
|
|
25
|
-
import { resolveViewerPosition, viewerPositionSource } from "../services/hls-session-manager.js";
|
|
26
|
-
|
|
27
|
-
test("a file opened at a position has its viewer at that position", () => {
|
|
28
|
-
// Nothing has been seeked and nothing served yet — the state at the instant
|
|
29
|
-
// the audio rendition is created.
|
|
30
|
-
assert.equal(resolveViewerPosition({ openedAt: 3130 }), 3130);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("a seek beats everything else", () => {
|
|
34
|
-
assert.equal(
|
|
35
|
-
resolveViewerPosition({ seeked: 900, lastRequestedStart: 400, openedAt: 3130 }),
|
|
36
|
-
900
|
|
37
|
-
);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("what has been served beats where the file was opened", () => {
|
|
41
|
-
// The opening position is the oldest of the three readings: once a segment
|
|
42
|
-
// has been served, that is where the reading is.
|
|
43
|
-
assert.equal(resolveViewerPosition({ lastRequestedStart: 400, openedAt: 3130 }), 400);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("with nothing to go on the answer is the beginning", () => {
|
|
47
|
-
assert.equal(resolveViewerPosition({}), 0);
|
|
48
|
-
assert.equal(resolveViewerPosition({ seeked: 0, lastRequestedStart: 0, openedAt: 0 }), 0);
|
|
49
|
-
// Values that are not readings must not become one.
|
|
50
|
-
assert.equal(resolveViewerPosition({ seeked: Number.NaN, openedAt: Number.NaN }), 0);
|
|
51
|
-
assert.equal(resolveViewerPosition({ seeked: -5, openedAt: -5 }), 0);
|
|
52
|
-
assert.equal(resolveViewerPosition({ lastRequestedStart: null, openedAt: undefined }), 0);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Which of the three answered is a separate question, and the audio start needs
|
|
57
|
-
* it. A seek and a served segment are request edges — the picture is behind
|
|
58
|
-
* them by however deep the viewer's buffer is, which is what the subtraction in
|
|
59
|
-
* `#audioStartSecondsFor` converts. The opening position is not an edge: it is
|
|
60
|
-
* where the session was made, nothing has been asked for since, and a browser
|
|
61
|
-
* that has just opened holds nothing.
|
|
62
|
-
*
|
|
63
|
-
* Field 2026-08-31: a page opened at 588s, no report yet, and the whole 120 s
|
|
64
|
-
* look-ahead was subtracted — the sound started at 460s and its first segment
|
|
65
|
-
* took 38.8 s to appear against the picture's 8.4 s.
|
|
66
|
-
*/
|
|
67
|
-
test("the reading says which of the three it came from", () => {
|
|
68
|
-
assert.equal(viewerPositionSource({ seeked: 900, lastRequestedStart: 400, openedAt: 3130 }), "seeked");
|
|
69
|
-
assert.equal(viewerPositionSource({ lastRequestedStart: 400, openedAt: 3130 }), "requested");
|
|
70
|
-
assert.equal(viewerPositionSource({ openedAt: 3130 }), "opened");
|
|
71
|
-
assert.equal(viewerPositionSource({}), "none");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("the source agrees with the position, reading for reading", () => {
|
|
75
|
-
const readings = [
|
|
76
|
-
{ seeked: 900, lastRequestedStart: 400, openedAt: 3130 },
|
|
77
|
-
{ lastRequestedStart: 400, openedAt: 3130 },
|
|
78
|
-
{ openedAt: 3130 },
|
|
79
|
-
{ seeked: Number.NaN, openedAt: Number.NaN },
|
|
80
|
-
{ seeked: -5, openedAt: -5 },
|
|
81
|
-
{}
|
|
82
|
-
];
|
|
83
|
-
for (const reading of readings) {
|
|
84
|
-
const position = resolveViewerPosition(reading);
|
|
85
|
-
const source = viewerPositionSource(reading);
|
|
86
|
-
assert.equal(
|
|
87
|
-
source === "none",
|
|
88
|
-
position === 0,
|
|
89
|
-
`no source must mean no position, and the other way round: ${JSON.stringify(reading)}`
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
});
|