@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.
@@ -42,7 +42,7 @@ async function managerAfterSeek() {
42
42
  localBindHost: "127.0.0.1",
43
43
  localPort: 9090
44
44
  });
45
- manager.sessionsById.set(SESSION_ID, {
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. The shared position belongs to the
145
- // one in front — it is the furthest segment anybody asked for — and the one
146
- // behind is a hundred segments back, waiting for a segment there.
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").position = {
149
- segment: behind,
150
- seconds: behind * SEGMENT_SECONDS,
151
- at: Date.now()
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 can only be judged against the shared position — which is the leader's"
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.equal(
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
  });
@@ -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(now, 60_000), true, "and they are still here");
33
-
34
- viewer.seen(now - 10_000);
35
- assert.equal(viewer.isPresent(now, 60_000), true);
36
-
37
- // Silence longer than any a watching viewer can produce. This is a backstop
38
- // for a connection that failed to say so, not the ordinary way of leaving.
39
- viewer.seen(now - 120_000);
40
- assert.equal(viewer.isPresent(now, 60_000), false);
41
-
42
- // Something SAID they are gone. Then it does not matter how recently they
43
- // were heard from.
44
- viewer.seen(now);
45
- viewer.gone = true;
46
- assert.equal(viewer.isPresent(now, 60_000), false, "a statement outranks silence");
47
- });
48
-
49
- test("a stated position outranks the segment they last asked for", () => {
50
- const viewer = new Viewer("someone");
51
- assert.equal(viewer.positionSeconds(), null);
52
-
53
- viewer.position = { segment: 10, seconds: 40, at: 1 };
54
- assert.equal(viewer.positionSeconds(), 40, "where their player is reading");
55
-
56
- viewer.position = { segment: 10, seconds: 40, at: 1, seeked: 900 };
57
- assert.equal(viewer.positionSeconds(), 900, "a seek is the viewer saying where they are");
58
- });
59
-
60
- test("asking for a viewer twice is the same viewer", () => {
61
- const session = {};
62
- const first = viewerOf(session, "one");
63
- first.audio = { trackIndex: 3, transcode: false };
64
-
65
- assert.equal(viewerOf(session, "one"), first);
66
- assert.equal(viewerOf(session, "one").audio.trackIndex, 3);
67
- assert.notEqual(viewerOf(session, "two"), first);
68
- assert.equal(viewersOf(session).size, 2);
69
- });
70
-
71
- test("releasing a consumer forgets everything that was true of them alone", async (t) => {
72
- const dirPath = await mkdtemp(path.join(os.tmpdir(), "viewer-"));
73
- t.after(async () => {
74
- await rm(dirPath, { recursive: true, force: true });
75
- });
76
- const manager = new HlsSessionManager({
77
- enabled: true,
78
- ffmpegBin: "ffmpeg",
79
- localBindHost: "127.0.0.1",
80
- localPort: 9090
81
- });
82
- const session = {
83
- id: SESSION_ID,
84
- dirPath,
85
- state: "live",
86
- file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
87
- // An ordinary session reads its own file, and its sound is inside it. The
88
- // three differ only for a soundtrack shipped as a file of its own.
89
- get inputFile() { return this.file; },
90
- get audioFile() { return this.file; },
91
- consumers: new Set(["staying", "leaving"]),
92
- viewers: new Map(),
93
- lastAccessedAt: Date.now()
94
- };
95
- manager.sessionsById.set(SESSION_ID, session);
96
-
97
- const leaving = viewerOf(session, "leaving");
98
- leaving.audio = { trackIndex: 2, transcode: true };
99
- leaving.activeVariantId = "some-variant";
100
- leaving.position = { segment: 40, seconds: 160, at: Date.now() };
101
- viewerOf(session, "staying").audio = { trackIndex: 0, transcode: false };
102
-
103
- await manager.releaseSessionConsumer(SESSION_ID, "leaving", "the tab was closed");
104
-
105
- assert.equal(session.viewers.has("leaving"), false, "one deletion, not six");
106
- assert.equal(session.viewers.has("staying"), true, "and it takes nobody else with it");
107
- assert.equal(session.consumers.size, 1);
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
- });