@torrent-tv/proxy 2.50.0 → 2.52.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 +20 -0
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +40 -4
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -0
- package/server.js +4 -0
- package/services/hls-session-manager.js +298 -22
- package/services/torrent-pool.js +440 -22
- package/services/torrent-worker/worker.js +9 -2
- package/test/dht-bootstrap.test.js +72 -0
- package/test/run-position-follows-published-grid.test.js +87 -0
- package/test/swarm-reach.test.js +125 -0
- package/test/viewer-position-on-open.test.js +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## 2.52.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: A file opened at a position puts the SOUND there too. Where the audio rendition starts is computed from where the viewer is, and that reading consulted only two things — a position seeked to, and the last segment the session had served — both written by events that have not happened yet at the moment a file is opened partway through. The answer was therefore zero. Field 2026-08-21, `Minions.and.Monsters.1080p.mkv` reopened from the address bar at 52:07: the picture session was created at `start=3130s` and ran from segment #781, and half a second later the audio rendition was created at `start=0s` with no `-ss` at all and set about re-encoding the film from the beginning. The player asked both for #782; the picture had it, the sound reached 57.5 s of 3130 in the 45 s the request lasted and then answered 404 — which the viewer was shown as "the proxy accepted the request but sent no video". The position a session was OPENED at is now the third reading, and `resolveViewerPosition` is pure and tested. The same calculation prepares a track for a language change, so that case is covered by the same fix.
|
|
4
|
+
- **Fix**: The line describing a swarm answers the question it is asked. It printed `peers=N` beside `wires=?`, which reads as two quantities of which one is unknown — while WebTorrent's `numPeers` IS `wires.length` (`lib/torrent.js`, identically in 2.8.5 and 3.0.21), so the first was the connection count and the second was a field that has never printed anything in any line it has ever written: the torrent lives on a worker thread and that property does not exist on the side doing the printing. What was missing was the other half of the question, and it is now there — how many peer addresses the client HOLDS, how many are queued to be tried, and what the tracker said the swarm has. Five offered and none connected is a connectivity fault; nobody offered is a supply fault; they need opposite investigations and one line now tells them apart. What the trackers said is kept PER TRACKER and reported as the best answer any of them gave: they answer separately, and a dead one replying `0` after a live one replied `500` would otherwise turn "several offered" into "nobody offered", inverting the very distinction being drawn.
|
|
5
|
+
- **New**: The wait for the first connected peer is measured, said once when it ends, and carried in the stats while it is still going. Measured 2026-08-21 on `JUFD665.mp4`: the tracker answered `seeders=5` at 13:40:30 and the first wire arrived at 13:44:47 — 4 min 17 s of a viewer watching an unexplained wait, after which the file's 12 MiB of edges arrived at 6.8 MB/s and the plan finished in three seconds. The whole cold start was that one number, and it was neither counted nor shown. The watching is attached to what `add` returns rather than inside its ready callback, because for a magnet everything it watches happens before `ready`: peer discovery starts before the metadata arrives, so the trackers' answers land before any listener exists, and the peer that DELIVERED the metadata connected before `ready` fired — `wire` is emitted on connection and never replayed. It is also attached once per torrent: WebTorrent answers a duplicate add by handing back the torrent it already has, and re-attaching reset the timing of a live swarm, after which the next connection would print "first peer connected after 0.3s" about a torrent that had been connected for minutes.
|
|
6
|
+
- **Fix**: One name for a torrent, and it is the infohash. The pool's `added`, `announce` and `warning` lines were labelled with the first eight characters of a sha1 of the SOURCE BYTES, the upload lines used the infohash, and the stats line used the registry's own key — three different hashes of one film, printed in the same second, none matching. The infohash is now on all of them, and on every stats line rather than only on the ones that look empty.
|
|
7
|
+
- **Chore**: `askedFor=0` is called `fileIndex=0`. It is the index of the file being asked about, and it was printed under a name that reads as "nothing was asked for" — in a line whose subject is a download that is not happening.
|
|
8
|
+
|
|
9
|
+
## 2.51.0
|
|
10
|
+
|
|
11
|
+
- **Fix**: A run is POSITIONED where the player was told the segment begins, on the same table its cuts are stated on. There are two boundary tables — the one the playlist text was written from, which never changes, and the live one, corrected as produced segments reveal where the file's cuts truly are. 2.45.0 moved the CUT LIST onto the published table and left the position on the live one, and that is one fault rather than two: `-segment_times` are measured from wherever the run really began, so any distance between the two carries into EVERY cut the run makes. The corrections run backwards, so each restart began a little earlier than the grid its cuts were stated on, and because the corrections accumulate, so did the distance. Measured 2026-08-21 on `JUFD665.mp4` — an MP4 whose index was read cleanly, 1765 keyframes, served by copy: after one seek restart a produced segment held the boundary **two** places before its own number (16.684 s, exactly 2.0000 segments), after the next restart **four** (33.5 s). The player's buffer then stops extending at all, because every fragment's content lands before the time its playlist entry names: `bufferEnd` stood still at 4571.1 s through four `frag-far` warnings until hls.js gave up and jumped the viewer 16.8 s forward. Four of those jumps in one window is what the viewer reported as sticking on every seek.
|
|
12
|
+
- **Fix**: The line about a segment that began away from its grid follows the numbers instead of the branch it is printed from. On the copy path it always read "the container's keyframe index disagrees with the file", including in the case this code's own neighbouring comment defines as a fault in this code — a segment holding another boundary **of the same list**. It now says which of the two it is, because they need opposite fixes and that sentence is what sent the reading of the session above after the file instead of after the arithmetic. The soundtrack's variant of the sentence stops claiming anything about a keyframe index: a soundtrack has no keyframes and is cut exactly where it was asked to be.
|
|
13
|
+
- **New**: A run says where it was positioned — the time, the boundary it came from, that boundary's time in the PUBLISHED table and in the LIVE one, the distance between them, and the number it starts counting from. Four numbers whose disagreement was invisible everywhere else.
|
|
14
|
+
- **New**: A correction to the live boundary table states the total drift it is part of. Corrections are applied one boundary at a time and each is small enough to look harmless; nobody was watching the sum, which is the quantity that actually moves the cuts.
|
|
15
|
+
- **Fix**: The realignment that starts the sound where a copied picture truly begins is given that instant EXPLICITLY. It used to be smuggled through the live boundary table — the correction wrote the measured time there and the restarted run read its position from the same place — which stops working the moment a run positions itself on the table the player holds, as it now must. Smuggled, the restart would land exactly where it already was: picture and sound stay apart AND a healthy soundtrack run is discarded for nothing, which is the shape the field already showed on 2026-08-21 — eleven audio restarts in under four minutes, eight of them dying with `run had produced 0.0s`.
|
|
16
|
+
- **Fix**: What a run was asked for is taken from the run rather than looked up again in a table. Two lines did the second — the one reporting where a run landed, and the one explaining a held segment — and once the position moved to the published grid they could disagree with the run by the distance between the two tables: a perfect landing reported as a drift, a real drift of the same size cancelled to zero, and a held segment printing a negative "produced" that sends the reader after the torrent when the encoder is the subject.
|
|
17
|
+
- **New**: The player's own "this fragment is nowhere near my buffer" reading is answered instead of being left in its console. `POST /api/transcode-sessions/:id/fragment-far` takes the report and the proxy replies in the log with the one fact only this side holds: which boundary the segment of that number really begins at, and whether that is the one its number claims. The report says which stream it is about, and a report about the soundtrack is answered from the soundtrack's own session: picture and sound are produced by two sessions positioned by two runs, which is how they come apart, so answering one from the other's records would state something confident about the wrong stream. Diagnostic only — nothing is repositioned on the strength of a browser's reading, because a wrong answer there would restart an encoder a viewer is waiting on.
|
|
18
|
+
- **Fix**: The DHT is given entry points that answer. Measured 2026-08-21 from the addon host: of the three bootstrap nodes the library ships, `router.bittorrent.com` and `router.utorrent.com` replied to nothing while a control datagram to a DNS server came back in 20 ms, and the third, `dht.transmissionbt.com`, is alive — it answered `find_node` with eight nodes — but on a host with global IPv6 its name resolves to an IPv6 address first while the DHT's socket is IPv4, so by name it was never reached. The list now carries a live node, and the names are resolved to IPv4 here rather than left to whichever family the host prefers. Measured on that host: 0 nodes after 21 s by name, 22 nodes in 5 s by address. The resolution is capped at two seconds per name, because it is awaited before the torrent client exists and a host whose resolver black-holes would otherwise hold that thread for c-ares' own four tries with nothing said. What it cost: on `JUFD665.mp4` the tracker answered `seeders=5` at 13:40:30 and the first peer connected at 13:44:47 — 4 min 17 s of a viewer watching an unexplained wait with an empty routing table beside them. A private torrent still ignores the DHT, by specification.
|
|
19
|
+
- **New**: The size of the DHT's routing table is said a minute after start, and an empty one is said as a warning. A bootstrap list rots — that is exactly what had happened here — and nothing reported it for as long as it was wrong, so the next list to die would be found the same way this one was: by hand, after a viewer waited four minutes.
|
|
20
|
+
|
|
1
21
|
## 2.50.0
|
|
2
22
|
|
|
3
23
|
- **Fix**: An AVI seek is asked for late enough to survive the container's own arithmetic. AVI names a keyframe by its frame NUMBER, and `services/container-index/avi.js` turned that into a time by multiplying by the frame duration the header declares — which lands 10-44 ms from the presentation time the demuxer computes, always under one frame (measured 2026-08-21 against the files themselves: 1196 index entries against 1196 real keyframes and 901 against 901, the frames exactly right and only their names off). A name sitting just BELOW its real keyframe seeks to before it and lands on the one before that, which is the same fault the landing offset already exists for. The reader now declares how far its times may be, and the request carries that on top. Matroska and MP4 declare nothing, because they state instants outright — nine files and 11 665 keyframes with not one disagreement.
|
package/package.json
CHANGED
|
@@ -61,6 +61,37 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
61
61
|
stats.headerBytes != null
|
|
62
62
|
? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
|
|
63
63
|
: "n/a";
|
|
64
|
+
// The swarm, said so that the question this line is asked can be answered
|
|
65
|
+
// from it. "Connected" and "known" are different numbers and their
|
|
66
|
+
// difference is the diagnosis: a tracker offering five while we are
|
|
67
|
+
// connected to none is a connectivity fault, and nobody offering anything is
|
|
68
|
+
// a supply fault. Until 2026-08-21 the line printed `peers=` beside
|
|
69
|
+
// `wires=?` — two quantities of which one looked unknown, while in truth the
|
|
70
|
+
// first WAS the connection count and the second was a field that never
|
|
71
|
+
// printed anything, because the torrent lives on another thread and the
|
|
72
|
+
// property does not exist on this side of it.
|
|
73
|
+
const known = stats.knownPeers === null || stats.knownPeers === undefined ? "?" : stats.knownPeers;
|
|
74
|
+
const queued = stats.queuedPeers === null || stats.queuedPeers === undefined ? "?" : stats.queuedPeers;
|
|
75
|
+
// The BEST answer any tracker gave, and how many answered — not the most
|
|
76
|
+
// recent one. Trackers answer separately, and a dead one replying `0` after a
|
|
77
|
+
// live one replied `500` would otherwise turn "several offered" into "nobody
|
|
78
|
+
// offered", which is the distinction this whole line exists to make.
|
|
79
|
+
const offered = stats.trackerSeeders === null || stats.trackerSeeders === undefined
|
|
80
|
+
? (stats.trackersAnswered
|
|
81
|
+
// Answered, and none of them knew of anybody — a different state from
|
|
82
|
+
// "no tracker has replied at all", and the line must not read as the
|
|
83
|
+
// second when it means the first.
|
|
84
|
+
? `${stats.trackersAnswered} tracker(s) answered, none reported a count`
|
|
85
|
+
: "no tracker answer yet")
|
|
86
|
+
: `${stats.trackersAnswered ?? "?"} tracker(s) offered up to ${stats.trackerSeeders} seeders ` +
|
|
87
|
+
`${stats.trackerLeechers ?? "?"} leechers`;
|
|
88
|
+
// The wait for a first peer can be the whole of a cold start — 257 s of it,
|
|
89
|
+
// measured — and it was neither counted nor shown.
|
|
90
|
+
const firstPeer = Number.isFinite(stats.secondsToFirstPeer)
|
|
91
|
+
? ` firstPeerAfter=${stats.secondsToFirstPeer.toFixed(1)}s`
|
|
92
|
+
: Number.isFinite(stats.secondsWaitingForFirstPeer)
|
|
93
|
+
? ` noPeerFor=${stats.secondsWaitingForFirstPeer.toFixed(1)}s`
|
|
94
|
+
: "";
|
|
64
95
|
// When the answer is empty, say WHICH thing is missing. Field 2026-08-05: a
|
|
65
96
|
// source reported `peers=0 file=n/a header=n/a` for minutes while that very
|
|
66
97
|
// torrent was announcing to trackers with hundreds of seeders — and the line
|
|
@@ -70,12 +101,17 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
70
101
|
// answerable from the log rather than by reasoning about it afterwards.
|
|
71
102
|
const emptyAnswer = stats.fileProgress == null || (stats.numPeers === 0 && torrent.done !== true);
|
|
72
103
|
const detail = emptyAnswer
|
|
73
|
-
? ` |
|
|
74
|
-
`
|
|
75
|
-
`
|
|
104
|
+
? ` | files=${torrent.files?.length ?? "?"}` +
|
|
105
|
+
` fileIndex=${fileIndex ?? "none"} resolved=${torrent.files?.[fileIndex ?? -1] ? "yes" : "no"}` +
|
|
106
|
+
` done=${torrent.done === true}`
|
|
76
107
|
: "";
|
|
108
|
+
// The infohash is on EVERY line, not only on the ones that look empty: it is
|
|
109
|
+
// the one identifier the pool's own lines, the worker's and this one share,
|
|
110
|
+
// and a line that carries it can be lined up with them without a guess.
|
|
77
111
|
logger.info(
|
|
78
|
-
`[stats] ${sourceKey.slice(0, 8)}
|
|
112
|
+
`[stats] ${sourceKey.slice(0, 8)} ${String(torrent.infoHash).slice(0, 8)} ` +
|
|
113
|
+
`peers=${stats.connectedPeers ?? stats.numPeers} connected of ${known} known (${queued} queued, ${offered})` +
|
|
114
|
+
`${firstPeer} down=${downKbps}KB/s file=${filePct} header=${header}${detail}`
|
|
79
115
|
);
|
|
80
116
|
|
|
81
117
|
return reply.send(stats);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accept the player's report that a delivered fragment sits far from the edge
|
|
3
|
+
* of what it has buffered.
|
|
4
|
+
*
|
|
5
|
+
* This is the earliest statement that exists of a stream coming apart, and
|
|
6
|
+
* until now it lived only in the browser's console. Measured 2026-08-21 on
|
|
7
|
+
* `JUFD665.mp4`: four of these across half a minute, each naming a gap of
|
|
8
|
+
* ~33.5 s, while the buffer stood still at 4571.1 s — and then hls.js gave up
|
|
9
|
+
* and jumped the viewer 16.8 s forward. The proxy is the only side that can say
|
|
10
|
+
* what the gap MEANS, because only it knows which boundary the segment it
|
|
11
|
+
* produced actually holds, and whether that is the one its number claims.
|
|
12
|
+
*
|
|
13
|
+
* POST /api/transcode-sessions/:sessionId/fragment-far
|
|
14
|
+
* Body: { sn: number, track?: string, fragStartSec: number, bufferEndSec: number, currentTimeSec: number }
|
|
15
|
+
*
|
|
16
|
+
* `track` is hls.js's own name for the stream the fragment belongs to ("main"
|
|
17
|
+
* or "audio"). It matters because picture and sound are produced by two
|
|
18
|
+
* different sessions positioned by two different runs — which is how they come
|
|
19
|
+
* apart in the first place — so a report answered against the wrong one is a
|
|
20
|
+
* confident statement about a stream nobody asked about.
|
|
21
|
+
*
|
|
22
|
+
* Diagnostic only — it changes nothing about the encode. Invalid body → 400,
|
|
23
|
+
* unknown session → 404, ok → 204.
|
|
24
|
+
*
|
|
25
|
+
* @param {import("fastify").FastifyRequest} req
|
|
26
|
+
* @param {import("fastify").FastifyReply} reply
|
|
27
|
+
* @param {{ hlsSessionManager: import("../../../../services/hls-session-manager.js").HlsSessionManager }} deps
|
|
28
|
+
* @returns {Promise<void>}
|
|
29
|
+
*/
|
|
30
|
+
export async function handleApiTranscodeSessionFragmentFarPost(req, reply, { hlsSessionManager }) {
|
|
31
|
+
const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
|
|
32
|
+
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
33
|
+
const sn = Number(body.sn);
|
|
34
|
+
const track = typeof body.track === "string" ? body.track : "";
|
|
35
|
+
const fragStartSec = Number(body.fragStartSec);
|
|
36
|
+
const bufferEndSec = Number(body.bufferEndSec);
|
|
37
|
+
const currentTimeSec = Number(body.currentTimeSec);
|
|
38
|
+
if (
|
|
39
|
+
!sessionId ||
|
|
40
|
+
!Number.isInteger(sn) ||
|
|
41
|
+
sn < 0 ||
|
|
42
|
+
!Number.isFinite(fragStartSec) ||
|
|
43
|
+
!Number.isFinite(bufferEndSec) ||
|
|
44
|
+
!Number.isFinite(currentTimeSec)
|
|
45
|
+
) {
|
|
46
|
+
return reply.code(400).send({ error: "sn (integer >=0), fragStartSec, bufferEndSec and currentTimeSec are required." });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const recorded = hlsSessionManager.recordFragmentFar(sessionId, {
|
|
50
|
+
sn,
|
|
51
|
+
track,
|
|
52
|
+
fragStartSec,
|
|
53
|
+
bufferEndSec,
|
|
54
|
+
currentTimeSec
|
|
55
|
+
});
|
|
56
|
+
if (!recorded) {
|
|
57
|
+
return reply.code(404).send({ error: "Transcode session was not found." });
|
|
58
|
+
}
|
|
59
|
+
return reply.code(204).send();
|
|
60
|
+
}
|
package/server.js
CHANGED
|
@@ -26,6 +26,7 @@ import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/
|
|
|
26
26
|
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
27
27
|
import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
|
|
28
28
|
import { handleApiTranscodeSessionNetReportPost } from "./routes/api/transcode-sessions/net-report/post.js";
|
|
29
|
+
import { handleApiTranscodeSessionFragmentFarPost } from "./routes/api/transcode-sessions/fragment-far/post.js";
|
|
29
30
|
import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessions/seek/post.js";
|
|
30
31
|
import { handleStreamGet } from "./routes/stream/get.js";
|
|
31
32
|
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
@@ -244,6 +245,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
244
245
|
app.post("/api/transcode-sessions/:sessionId/net-report", async (req, reply) =>
|
|
245
246
|
handleApiTranscodeSessionNetReportPost(req, reply, { hlsSessionManager })
|
|
246
247
|
);
|
|
248
|
+
app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
|
|
249
|
+
handleApiTranscodeSessionFragmentFarPost(req, reply, { hlsSessionManager })
|
|
250
|
+
);
|
|
247
251
|
app.post("/api/transcode-sessions/:sessionId/seek", async (req, reply) =>
|
|
248
252
|
handleApiTranscodeSessionSeekPost(req, reply, { hlsSessionManager })
|
|
249
253
|
);
|
|
@@ -422,6 +422,12 @@ const LOOKAHEAD_RESUME_SECONDS = 60;
|
|
|
422
422
|
// encoding 125 s of content before reaching the viewer's position. Field
|
|
423
423
|
// 2026-08-02: a seek took 56 s, of which ~50 s was this backoff.
|
|
424
424
|
const SEEK_BACKOFF_SEGMENTS = 1;
|
|
425
|
+
|
|
426
|
+
// How many produced segments' true start times to remember, so a player's
|
|
427
|
+
// report about one of them can be answered. Two hundred is about twenty
|
|
428
|
+
// minutes of playback at these segment lengths — far more than the recent past
|
|
429
|
+
// a stall report can be about, and small enough to be free.
|
|
430
|
+
const TRUE_START_MEMORY = 200;
|
|
425
431
|
// How long to wait for a scrub to stop moving before acting on it. Small,
|
|
426
432
|
// because the browser already collapses a drag into ONE report
|
|
427
433
|
// (`SEEK_REPORT_DEBOUNCE_MS`, 300 ms) and only reports where it settled — this
|
|
@@ -1176,6 +1182,97 @@ export function segmentCutTimesFrom(boundaries, startIndex) {
|
|
|
1176
1182
|
return times;
|
|
1177
1183
|
}
|
|
1178
1184
|
|
|
1185
|
+
/**
|
|
1186
|
+
* Where the viewer is, from the three things that can say so.
|
|
1187
|
+
*
|
|
1188
|
+
* In order, because each is a better answer than the next and each may be
|
|
1189
|
+
* absent:
|
|
1190
|
+
*
|
|
1191
|
+
* 1. a position they seeked to — they said it themselves;
|
|
1192
|
+
* 2. the start of the last segment this session actually served — where the
|
|
1193
|
+
* reading is;
|
|
1194
|
+
* 3. **the position the file was OPENED at.**
|
|
1195
|
+
*
|
|
1196
|
+
* The third used to be missing, and its absence made the answer zero at exactly
|
|
1197
|
+
* the moment it is asked. Both of the others are written by things that have
|
|
1198
|
+
* not happened yet when a file is opened at a position — the first by a seek,
|
|
1199
|
+
* the second by a segment served — so a session created to begin at 3130 s
|
|
1200
|
+
* answered "the viewer is at the beginning". That is not a cautious default; it
|
|
1201
|
+
* is a wrong answer, and the soundtrack acts on it.
|
|
1202
|
+
*
|
|
1203
|
+
* Field 2026-08-21, `Minions.and.Monsters.1080p.mkv` reopened from the address
|
|
1204
|
+
* bar at 52:07: the picture session was created at `start=3130s` and ran from
|
|
1205
|
+
* segment #781; half a second later the audio rendition was created from this
|
|
1206
|
+
* reading — `start=0s`, no `-ss` at all — and set about re-encoding the film
|
|
1207
|
+
* from the beginning. The player asked both for #782. The picture had it. The
|
|
1208
|
+
* sound reached 57.5 s of 3130 in the 45 s the request lasted, then answered
|
|
1209
|
+
* 404, which the viewer was shown as "the proxy accepted the request but sent
|
|
1210
|
+
* no video".
|
|
1211
|
+
*
|
|
1212
|
+
* @param {{ seeked?: number, lastRequestedStart?: number | null, openedAt?: number }} readings
|
|
1213
|
+
* @returns {number} Seconds, never negative.
|
|
1214
|
+
*/
|
|
1215
|
+
export function resolveViewerPosition({ seeked, lastRequestedStart, openedAt }) {
|
|
1216
|
+
if (Number.isFinite(seeked) && seeked > 0) {
|
|
1217
|
+
return seeked;
|
|
1218
|
+
}
|
|
1219
|
+
if (Number.isFinite(lastRequestedStart) && lastRequestedStart > 0) {
|
|
1220
|
+
return lastRequestedStart;
|
|
1221
|
+
}
|
|
1222
|
+
if (Number.isFinite(openedAt) && openedAt > 0) {
|
|
1223
|
+
return openedAt;
|
|
1224
|
+
}
|
|
1225
|
+
return 0;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* How far the live boundary table has moved from the one the player holds, said
|
|
1230
|
+
* in words.
|
|
1231
|
+
*
|
|
1232
|
+
* The corrections are applied one boundary at a time and each is small enough
|
|
1233
|
+
* to look harmless; what nobody was watching is the total. It matters because a
|
|
1234
|
+
* run positioned on one table and cut on the other carries their difference into
|
|
1235
|
+
* every cut it makes — the fault of 2026-08-21, where the distance reached two
|
|
1236
|
+
* whole segments after one seek and four after the next. Printed beside each
|
|
1237
|
+
* correction so the total is visible while it is still small.
|
|
1238
|
+
*
|
|
1239
|
+
* @param {number[]} published - The table the playlist text was written from.
|
|
1240
|
+
* @param {number[]} live - The table corrected from produced segments.
|
|
1241
|
+
* @returns {string} A phrase, always readable, never throwing on odd input.
|
|
1242
|
+
*/
|
|
1243
|
+
export function describeGridDrift(published, live) {
|
|
1244
|
+
if (!Array.isArray(published) || !Array.isArray(live) || published.length === 0) {
|
|
1245
|
+
return "not comparable";
|
|
1246
|
+
}
|
|
1247
|
+
if (published.length !== live.length) {
|
|
1248
|
+
return `a different length (${published.length} against ${live.length})`;
|
|
1249
|
+
}
|
|
1250
|
+
let apart = 0;
|
|
1251
|
+
let worst = 0;
|
|
1252
|
+
let worstAt = -1;
|
|
1253
|
+
for (let index = 0; index < published.length; index += 1) {
|
|
1254
|
+
// Rounded to the millisecond BEFORE comparing, not only before printing.
|
|
1255
|
+
// Two boundaries moved by the same amount differ in the last bits of a
|
|
1256
|
+
// double, so an unrounded comparison picks between them by an accident
|
|
1257
|
+
// invisible in the printed figure — and the line would name a boundary the
|
|
1258
|
+
// reader cannot tell apart from the one before it. Rounded, ties keep the
|
|
1259
|
+
// earliest, which is also the one worth looking at first.
|
|
1260
|
+
const distance = Math.round(Math.abs(live[index] - published[index]) * 1000) / 1000;
|
|
1261
|
+
if (distance <= 0.001) {
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
apart += 1;
|
|
1265
|
+
if (distance > worst) {
|
|
1266
|
+
worst = distance;
|
|
1267
|
+
worstAt = index;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
if (apart === 0) {
|
|
1271
|
+
return "identical";
|
|
1272
|
+
}
|
|
1273
|
+
return `${apart} of ${published.length} boundaries apart, worst ${worst.toFixed(3)}s at #${worstAt}`;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1179
1276
|
/**
|
|
1180
1277
|
* How much later than a keyframe to ASK, so that ffmpeg lands on that keyframe.
|
|
1181
1278
|
*
|
|
@@ -2550,6 +2647,22 @@ export class HlsSessionManager {
|
|
|
2550
2647
|
: (session.segmentBoundaries ?? []);
|
|
2551
2648
|
}
|
|
2552
2649
|
|
|
2650
|
+
/**
|
|
2651
|
+
* Where a run beginning at `index` must be positioned: the time the PLAYER
|
|
2652
|
+
* was told that segment starts at.
|
|
2653
|
+
*
|
|
2654
|
+
* Public because it is the invariant this class has broken twice, and a
|
|
2655
|
+
* private one cannot be pinned by a test. It must always be the table the cut
|
|
2656
|
+
* list is taken from — see the comment where a run is started.
|
|
2657
|
+
*
|
|
2658
|
+
* @param {HlsSession} session
|
|
2659
|
+
* @param {number} index
|
|
2660
|
+
* @returns {number}
|
|
2661
|
+
*/
|
|
2662
|
+
runStartTimeFor(session, index) {
|
|
2663
|
+
return this.#publishedStartTime(session, index);
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2553
2666
|
#publishedStartTime(session, index) {
|
|
2554
2667
|
const boundaries = Array.isArray(session.publishedBoundaries) && session.publishedBoundaries.length > 0
|
|
2555
2668
|
? session.publishedBoundaries
|
|
@@ -2709,6 +2822,69 @@ export class HlsSessionManager {
|
|
|
2709
2822
|
return true;
|
|
2710
2823
|
}
|
|
2711
2824
|
|
|
2825
|
+
/**
|
|
2826
|
+
* Answer the player's report that a delivered fragment sits far from the edge
|
|
2827
|
+
* of its buffer, with the one fact only this side holds: which boundary the
|
|
2828
|
+
* segment of that number really begins at.
|
|
2829
|
+
*
|
|
2830
|
+
* The player can say the gap; it cannot say whether the cause is its own
|
|
2831
|
+
* loading or a run whose output no longer matches its numbering. Here both
|
|
2832
|
+
* are in hand — the time the playlist gave that segment, and, when the
|
|
2833
|
+
* segment has been served, the time it truly began at — so the line either
|
|
2834
|
+
* names a shifted run or clears this side of it.
|
|
2835
|
+
*
|
|
2836
|
+
* Diagnostic only: nothing is repositioned on the strength of a browser's
|
|
2837
|
+
* reading, deliberately, because a wrong answer here would restart an encoder
|
|
2838
|
+
* the viewer is waiting on.
|
|
2839
|
+
*
|
|
2840
|
+
* @param {string} sessionId
|
|
2841
|
+
* @param {{ sn: number, track?: string, fragStartSec: number, bufferEndSec: number, currentTimeSec: number }} report
|
|
2842
|
+
* @returns {boolean} False when no such session exists.
|
|
2843
|
+
*/
|
|
2844
|
+
recordFragmentFar(sessionId, { sn, track, fragStartSec, bufferEndSec, currentTimeSec }) {
|
|
2845
|
+
const named = this.sessionsById.get(sessionId);
|
|
2846
|
+
if (!named || named.state === "disposed") {
|
|
2847
|
+
return false;
|
|
2848
|
+
}
|
|
2849
|
+
// Which of the two streams the report is about. The browser addresses
|
|
2850
|
+
// everything to the video session's id — the soundtrack is served under
|
|
2851
|
+
// `/a/<n>/` on that same id — but it is a session of its own, with its own
|
|
2852
|
+
// run and its own position, and that is exactly the pair this report exists
|
|
2853
|
+
// to tell apart. Answering an audio report from the picture's records would
|
|
2854
|
+
// state, confidently, something about the wrong stream.
|
|
2855
|
+
const onScreen = this.#activeVariant(named);
|
|
2856
|
+
const session = track === "audio"
|
|
2857
|
+
? ([...this.#familyOf(onScreen)].find((member) => member.audioOnly === true) ?? onScreen)
|
|
2858
|
+
: onScreen;
|
|
2859
|
+
const gap = fragStartSec - bufferEndSec;
|
|
2860
|
+
const declared = this.#publishedStartTime(session, sn);
|
|
2861
|
+
const trueStart = session.trueStartByIndex instanceof Map ? session.trueStartByIndex.get(sn) : undefined;
|
|
2862
|
+
const verdict = trueStart === undefined
|
|
2863
|
+
// Where a segment truly began is only ever read off one that was cut on
|
|
2864
|
+
// an explicit list — a uniform grid has nothing to read back — so this is
|
|
2865
|
+
// "not recorded", which is not the same as "not produced", and the line
|
|
2866
|
+
// must not claim the second.
|
|
2867
|
+
? "where that segment began is not recorded on this side, so the gap cannot be attributed here"
|
|
2868
|
+
: (() => {
|
|
2869
|
+
const at = this.#boundaryIndexAt(session, trueStart, this.publishedGridFor(session));
|
|
2870
|
+
if (at === null) {
|
|
2871
|
+
return `it really began at ${trueStart.toFixed(3)}s, which is no boundary of this grid`;
|
|
2872
|
+
}
|
|
2873
|
+
if (at === sn) {
|
|
2874
|
+
return `it really began at boundary #${sn}, where it should — the gap is not this run's`;
|
|
2875
|
+
}
|
|
2876
|
+
return `it really began at boundary #${at}, ${sn - at} place(s) before its own number — ` +
|
|
2877
|
+
"this run's output does not match its numbering";
|
|
2878
|
+
})();
|
|
2879
|
+
logger.warn(
|
|
2880
|
+
`transcode ${session.id} the player is stuck: ${session.audioOnly === true ? "sound" : "picture"} ` +
|
|
2881
|
+
`fragment #${sn} starts ${gap.toFixed(1)}s past ` +
|
|
2882
|
+
`the end of its buffer (${bufferEndSec.toFixed(1)}s, viewer at ${currentTimeSec.toFixed(1)}s, ` +
|
|
2883
|
+
`the playlist puts it at ${declared.toFixed(3)}s) — ${verdict}`
|
|
2884
|
+
);
|
|
2885
|
+
return true;
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2712
2888
|
/**
|
|
2713
2889
|
* Observed produced bitrate (Mbit/s) averaged over the last few COMPLETED
|
|
2714
2890
|
* segment files (the newest file may still be being written and is
|
|
@@ -3756,7 +3932,7 @@ export class HlsSessionManager {
|
|
|
3756
3932
|
const head = session.encodeStartIndex;
|
|
3757
3933
|
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
3758
3934
|
? session.progress.processedSeconds
|
|
3759
|
-
: this
|
|
3935
|
+
: this.runStartTimeFor(session, head);
|
|
3760
3936
|
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
3761
3937
|
const boundLabel =
|
|
3762
3938
|
bound === "link" ? "viewer-link-bound" : bound === "unknown" ? "assuming CPU-bound" : "CPU-bound";
|
|
@@ -3798,9 +3974,18 @@ export class HlsSessionManager {
|
|
|
3798
3974
|
*
|
|
3799
3975
|
* @param {HlsSession} session
|
|
3800
3976
|
* @param {number} startIndex
|
|
3977
|
+
* @param {number} [positionSecondsOverride] - Begin the run at this instant
|
|
3978
|
+
* instead of at the time the playlist gives `startIndex`, keeping the
|
|
3979
|
+
* numbering and the cut list on the published grid. The one caller is the
|
|
3980
|
+
* realignment below: a COPIED picture cannot cut anywhere but at the
|
|
3981
|
+
* source's own keyframes, so its segment #N begins where the file says and
|
|
3982
|
+
* not where the grid does — and the sound that plays with it has to begin
|
|
3983
|
+
* at that same instant, or the two are apart by the difference. The output
|
|
3984
|
+
* is still labelled from the source clock (`-copyts`) and stamped on serve,
|
|
3985
|
+
* so the player sees both at the time the playlist names.
|
|
3801
3986
|
* @returns {Promise<void>}
|
|
3802
3987
|
*/
|
|
3803
|
-
async #startEncodeRun(session, startIndex) {
|
|
3988
|
+
async #startEncodeRun(session, startIndex, positionSecondsOverride) {
|
|
3804
3989
|
// A new run starts its own reckoning: a pair spanning the restart would
|
|
3805
3990
|
// count the gap between two runs as slow encoding.
|
|
3806
3991
|
session.learnSample = null;
|
|
@@ -3899,8 +4084,30 @@ export class HlsSessionManager {
|
|
|
3899
4084
|
}
|
|
3900
4085
|
|
|
3901
4086
|
const safeIndex = Number.isInteger(startIndex) && startIndex > 0 ? startIndex : 0;
|
|
3902
|
-
// 0-based output time of this segment, from the
|
|
3903
|
-
|
|
4087
|
+
// 0-based output time of this segment, from the table the PLAYER holds —
|
|
4088
|
+
// the same one the cut list below is taken from.
|
|
4089
|
+
//
|
|
4090
|
+
// These two were read from different tables until 2026-08-21, and that is
|
|
4091
|
+
// one fault, not two: `-segment_times` are measured from wherever the run
|
|
4092
|
+
// really began, so any distance between the position and the cut list moves
|
|
4093
|
+
// EVERY cut of that run by it. The live table keeps being corrected as
|
|
4094
|
+
// produced segments reveal where the file's cuts truly are, and those
|
|
4095
|
+
// corrections run backwards, so each restart began a little earlier than
|
|
4096
|
+
// the grid the cuts were stated on — and since the corrections accumulate,
|
|
4097
|
+
// so did the distance. Measured on `JUFD665.mp4`: after one seek restart a
|
|
4098
|
+
// produced segment held the boundary two places before its own number
|
|
4099
|
+
// (16.684 s, exactly 2.0000 segments), after the next it held the one four
|
|
4100
|
+
// places before (33.5 s). The player's buffer then stops extending at all,
|
|
4101
|
+
// because the content of every fragment lands before the time its playlist
|
|
4102
|
+
// entry names: `bufferEnd` stood still at 4571.1 s through four `frag-far`
|
|
4103
|
+
// warnings until hls.js gave up and jumped the viewer 16.8 s forward.
|
|
4104
|
+
//
|
|
4105
|
+
// 2.45.0 moved the CUT LIST onto the published table for this same reason
|
|
4106
|
+
// and left the position on the live one. Both belong on the published
|
|
4107
|
+
// table: a run must begin where the player was told the segment begins.
|
|
4108
|
+
const startSeconds = Number.isFinite(positionSecondsOverride)
|
|
4109
|
+
? positionSecondsOverride
|
|
4110
|
+
: this.runStartTimeFor(session, safeIndex);
|
|
3904
4111
|
const sourceStartTime = Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0;
|
|
3905
4112
|
// Cut where this session's grid says, whoever is producing the frames. The
|
|
3906
4113
|
// times are measured from the start of THIS run; the same list serves as
|
|
@@ -4192,6 +4399,17 @@ export class HlsSessionManager {
|
|
|
4192
4399
|
`(+${Date.now() - restartEnteredAt}ms since the restart was asked for) ` +
|
|
4193
4400
|
`(${formatSeconds(startSeconds)}) "${session.fileName}"`
|
|
4194
4401
|
);
|
|
4402
|
+
// The four numbers a run is positioned by, said once, because their
|
|
4403
|
+
// disagreement is invisible everywhere else. The two tables are printed
|
|
4404
|
+
// side by side: while they differ, every cut of this run is off by the
|
|
4405
|
+
// difference, and nothing downstream can tell that from a bad index.
|
|
4406
|
+
const liveStart = this.#segmentStartTime(session, safeIndex);
|
|
4407
|
+
logger.info(
|
|
4408
|
+
`transcode ${session.id} ${session.runLabel} positioned at ${startSeconds.toFixed(3)}s ` +
|
|
4409
|
+
`for boundary #${safeIndex} (published ${startSeconds.toFixed(3)}s, ` +
|
|
4410
|
+
`live ${liveStart.toFixed(3)}s, apart ${(liveStart - startSeconds).toFixed(3)}s), ` +
|
|
4411
|
+
`numbering from #${safeIndex}`
|
|
4412
|
+
);
|
|
4195
4413
|
|
|
4196
4414
|
this.#wireEncodeProcess(session, ffmpeg);
|
|
4197
4415
|
}
|
|
@@ -4511,7 +4729,7 @@ export class HlsSessionManager {
|
|
|
4511
4729
|
// request just ahead of the live edge.
|
|
4512
4730
|
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
4513
4731
|
? session.progress.processedSeconds
|
|
4514
|
-
: this
|
|
4732
|
+
: this.runStartTimeFor(session, head);
|
|
4515
4733
|
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
4516
4734
|
const withinWindow = index >= head && index <= currentSeg + MAX_LOOKAHEAD_SEGMENTS;
|
|
4517
4735
|
if (withinWindow) {
|
|
@@ -4804,7 +5022,7 @@ export class HlsSessionManager {
|
|
|
4804
5022
|
const head = session.encodeStartIndex;
|
|
4805
5023
|
const processed = Number.isFinite(session.progress?.processedSeconds)
|
|
4806
5024
|
? session.progress.processedSeconds
|
|
4807
|
-
: this
|
|
5025
|
+
: this.runStartTimeFor(session, head);
|
|
4808
5026
|
const currentSeg = Math.max(head, this.#segmentIndexForTime(session, processed));
|
|
4809
5027
|
// Already covered by the running encode — the data is on its way, so
|
|
4810
5028
|
// restarting would only destroy work the viewer is waiting for. The run has
|
|
@@ -5240,7 +5458,17 @@ export class HlsSessionManager {
|
|
|
5240
5458
|
return;
|
|
5241
5459
|
}
|
|
5242
5460
|
session.landingReportedForRun = index;
|
|
5243
|
-
|
|
5461
|
+
// What the run was ASKED for, taken from the run itself rather than looked
|
|
5462
|
+
// up again in a table. The two used to be the same lookup; they stopped
|
|
5463
|
+
// being so when a run began positioning on the published grid while this
|
|
5464
|
+
// read the live one, which made a perfect landing report a drift equal to
|
|
5465
|
+
// the distance between the tables — and cancelled a real landing error of
|
|
5466
|
+
// the same size to zero. A run also has one legitimate position that is in
|
|
5467
|
+
// no table at all: the realignment that starts the sound where the copied
|
|
5468
|
+
// picture truly begins.
|
|
5469
|
+
const asked = Number.isFinite(session.progress?.startPositionSeconds)
|
|
5470
|
+
? session.progress.startPositionSeconds
|
|
5471
|
+
: this.runStartTimeFor(session, index);
|
|
5244
5472
|
const drift = trueStart - asked;
|
|
5245
5473
|
if (!Number.isFinite(drift) || Math.abs(drift) <= PLAYER_BUFFER_HOLE_SEC) {
|
|
5246
5474
|
return;
|
|
@@ -5255,6 +5483,18 @@ export class HlsSessionManager {
|
|
|
5255
5483
|
#noteIndexAccuracy(session, index, trueStart, declaredStart) {
|
|
5256
5484
|
const deviation = Math.abs(trueStart - declaredStart);
|
|
5257
5485
|
session.indexCheck ??= newIndexCheck();
|
|
5486
|
+
// Where each produced segment truly began, kept so that a player reporting
|
|
5487
|
+
// a stall can be ANSWERED rather than merely believed. Bounded: only the
|
|
5488
|
+
// recent past can be the subject of such a report, and an unbounded map on
|
|
5489
|
+
// a two-hour film is a leak.
|
|
5490
|
+
session.trueStartByIndex ??= new Map();
|
|
5491
|
+
session.trueStartByIndex.set(index, trueStart);
|
|
5492
|
+
if (session.trueStartByIndex.size > TRUE_START_MEMORY) {
|
|
5493
|
+
const oldest = session.trueStartByIndex.keys().next();
|
|
5494
|
+
if (!oldest.done) {
|
|
5495
|
+
session.trueStartByIndex.delete(oldest.value);
|
|
5496
|
+
}
|
|
5497
|
+
}
|
|
5258
5498
|
// Did this segment begin at ANOTHER keyframe from the same list? Half an
|
|
5259
5499
|
// audio frame is the tolerance — anything the list names is exact, so a
|
|
5260
5500
|
// match is a match. `keyframeTimes` is the list the grid was built from, so
|
|
@@ -5299,7 +5539,18 @@ export class HlsSessionManager {
|
|
|
5299
5539
|
// rung's segments no longer stand where the stream it accompanies
|
|
5300
5540
|
// would have put them. That is a broken splice, not a wrong index.
|
|
5301
5541
|
? "this rung did not cut where its grid says; a switch to it will not join cleanly"
|
|
5302
|
-
|
|
5542
|
+
// A copy. The two possible faults need opposite fixes and the
|
|
5543
|
+
// numbers already tell them apart, so the sentence follows THEM
|
|
5544
|
+
// rather than the branch it is printed from. Until 2026-08-21 it
|
|
5545
|
+
// blamed the index either way — including through a session whose
|
|
5546
|
+
// segments each held the boundary two, then four places before
|
|
5547
|
+
// their own number, which is this code's own definition of a fault
|
|
5548
|
+
// in this code. That sentence is what sent the reading of that
|
|
5549
|
+
// session after the file instead of after the arithmetic.
|
|
5550
|
+
: at === null
|
|
5551
|
+
? "the container's keyframe index disagrees with the file; using the file"
|
|
5552
|
+
: `this began at another boundary of the same list, ${index - at} place(s) before ` +
|
|
5553
|
+
"its own number — the numbering of this run is shifted, not the index")
|
|
5303
5554
|
);
|
|
5304
5555
|
}
|
|
5305
5556
|
}
|
|
@@ -5382,7 +5633,9 @@ export class HlsSessionManager {
|
|
|
5382
5633
|
}
|
|
5383
5634
|
logger.info(
|
|
5384
5635
|
`transcode ${session.id} boundary #${index} corrected ${wasAt.toFixed(3)}s → ` +
|
|
5385
|
-
`${trueStart.toFixed(3)}s from the file itself`
|
|
5636
|
+
`${trueStart.toFixed(3)}s from the file itself` +
|
|
5637
|
+
`, and the live table is now ${describeGridDrift(this.publishedGridFor(session), boundaries)}` +
|
|
5638
|
+
" from the one the player holds"
|
|
5386
5639
|
);
|
|
5387
5640
|
// And every OTHER member whose run begins at this very boundary is moved
|
|
5388
5641
|
// to the same instant.
|
|
@@ -5418,9 +5671,18 @@ export class HlsSessionManager {
|
|
|
5418
5671
|
// time: a seek decides by index, finds this run already begins at #index,
|
|
5419
5672
|
// and answers "already within the running encode" — which is true about
|
|
5420
5673
|
// the index and false about the instant, and it is why the first version
|
|
5421
|
-
// of this fix moved nothing at all.
|
|
5422
|
-
//
|
|
5423
|
-
|
|
5674
|
+
// of this fix moved nothing at all.
|
|
5675
|
+
//
|
|
5676
|
+
// The instant is passed EXPLICITLY. It used to be smuggled through the
|
|
5677
|
+
// live boundary table — this function had just written `trueStart` into
|
|
5678
|
+
// it, and the run read its position from there — which stopped working
|
|
5679
|
+
// the moment a run began positioning itself on the table the player
|
|
5680
|
+
// holds, as it now must. Smuggled, the restart would land exactly where
|
|
5681
|
+
// it already was: picture and sound would stay apart and a healthy audio
|
|
5682
|
+
// run would be discarded for nothing, which is the shape the field
|
|
5683
|
+
// already showed (eleven restarts in four minutes, eight of them dying
|
|
5684
|
+
// with `run had produced 0.0s`).
|
|
5685
|
+
void this.#startEncodeRun(member, index, trueStart).catch(() => {});
|
|
5424
5686
|
}
|
|
5425
5687
|
}
|
|
5426
5688
|
|
|
@@ -5503,8 +5765,14 @@ export class HlsSessionManager {
|
|
|
5503
5765
|
* @param {number} seconds
|
|
5504
5766
|
* @returns {number | null}
|
|
5505
5767
|
*/
|
|
5506
|
-
#boundaryIndexAt(session, seconds) {
|
|
5507
|
-
|
|
5768
|
+
#boundaryIndexAt(session, seconds, table) {
|
|
5769
|
+
// The table is nameable because the two answer different questions. The
|
|
5770
|
+
// LIVE one says "is this a cut this file actually has", which is what a
|
|
5771
|
+
// reading taken off a produced segment is about. The PUBLISHED one says "is
|
|
5772
|
+
// this a cut the player believes in", which is what a report from the player
|
|
5773
|
+
// is about. Answering one with the other prints an index from one grid
|
|
5774
|
+
// beside a time from the other.
|
|
5775
|
+
const boundaries = Array.isArray(table) ? table : session.segmentBoundaries;
|
|
5508
5776
|
if (!Array.isArray(boundaries)) {
|
|
5509
5777
|
return null;
|
|
5510
5778
|
}
|
|
@@ -6702,13 +6970,14 @@ export class HlsSessionManager {
|
|
|
6702
6970
|
}
|
|
6703
6971
|
|
|
6704
6972
|
#viewerPositionOf(session) {
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6973
|
+
const lastRequestedStart = Number.isInteger(session.lastRequestedSegment) && session.lastRequestedSegment > 0
|
|
6974
|
+
? this.#segmentStartTime(session, session.lastRequestedSegment)
|
|
6975
|
+
: null;
|
|
6976
|
+
return resolveViewerPosition({
|
|
6977
|
+
seeked: session.viewerPositionSeconds,
|
|
6978
|
+
lastRequestedStart,
|
|
6979
|
+
openedAt: session.progress?.startPositionSeconds
|
|
6980
|
+
});
|
|
6712
6981
|
}
|
|
6713
6982
|
|
|
6714
6983
|
/**
|
|
@@ -7985,7 +8254,14 @@ export class HlsSessionManager {
|
|
|
7985
8254
|
// causes indistinguishable: an encoder waiting for torrent pieces looks
|
|
7986
8255
|
// exactly like one that is encoding and simply has not finished. The
|
|
7987
8256
|
// difference is whether its position has moved at all.
|
|
7988
|
-
|
|
8257
|
+
// Where this run began, from the run — the same reckoning
|
|
8258
|
+
// `processedSeconds` is counted in. A table lookup here can disagree with
|
|
8259
|
+
// it by the distance between the two grids, which is enough to print a
|
|
8260
|
+
// negative "produced" and send the reader after the torrent when the
|
|
8261
|
+
// encoder is the subject.
|
|
8262
|
+
const runStartSeconds = Number.isFinite(session.progress?.startPositionSeconds)
|
|
8263
|
+
? session.progress.startPositionSeconds
|
|
8264
|
+
: this.runStartTimeFor(session, session.encodeStartIndex ?? 0);
|
|
7989
8265
|
const position = Number(session.progress?.processedSeconds);
|
|
7990
8266
|
const produced = Number.isFinite(position) ? position - runStartSeconds : null;
|
|
7991
8267
|
const speed = session.progress?.speed ?? "n/a";
|