@torrent-tv/proxy 2.9.104 → 2.9.106
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 +9 -0
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +82 -69
- package/services/piece-store/piece-lru.js +12 -0
- package/services/piece-store/shared-piece-store.js +672 -663
- package/services/playback-planner.js +29 -6
- package/services/torrent-pool.js +69 -1
- package/test/plan-host-timings.test.js +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.9.106
|
|
2
|
+
|
|
3
|
+
- **Fix**: The two figures the browser needs to say how long until playback now reach it for the file that needs them most. Both are medians of sessions already finished on this host, and the plan read them at the moment it was BUILT and then cached the result — so the very first file opened after a restart got `null` for both and kept answering `null` for the life of the process, however many sessions ran afterwards. Measured 2026-08-05: a fresh proxy answered `null`, then created the session in 6 ms and produced the first segment in 21 479 ms. They are now read when the plan is ANSWERED, so a cached plan reports what the host currently knows. Covered by tests.
|
|
4
|
+
- **New**: When the stats route has nothing to report it says which thing is missing — the torrent handle, the file index, or neither. A source answered `peers=0 file=n/a header=n/a` for minutes on 2026-08-05 while that very torrent was announcing to trackers with hundreds of seeders, and the line could not tell those cases apart. That line is what the viewer's loading screen shows, so it has to be answerable from the log.
|
|
5
|
+
|
|
6
|
+
## 2.9.105
|
|
7
|
+
|
|
8
|
+
- **Fix**: A reader's claim on pieces is put back when WebTorrent drops it, which is what stopped a download dead for eleven minutes. A reader declares the window it needs as a selection and withdraws it when it ends; that claim turns out not to be durable — the library deletes a selection the moment every piece in it is present (`remove fully downloaded selection`). While the reader keeps moving this is invisible, because the next window is claimed at once. It is fatal when the reader STOPS: the encoder gets held back by the look-ahead cap, ffmpeg stops reading, the reader parks on a window that is fully downloaded, the selection disappears, and no code of ours can notice because the reader is parked inside a write. Measured 2026-08-05: the encoder was suspended at 22:44:51, the download hit zero at 22:45:05 and stayed there for eleven minutes with 150 peer connections open and the new diagnostic reading `0 selection(s) covering 0 piece(s), 0 being asked, 0 blocks in flight`; when the encoder was let go there was nothing ahead of it. Live reader windows are now re-asserted from the pool's own timer, using the set the piece store already keeps, and only where something is actually missing — re-claiming a satisfied window would only be deleted again on the next pass.
|
|
9
|
+
|
|
1
10
|
## 2.9.104
|
|
2
11
|
|
|
3
12
|
- **Fix**: An encoder run that stops because its input ran dry is no longer reported as a finished file. ffmpeg exits 0 both when it reaches the end of the source and when the source simply stops delivering, and over HTTP it cannot tell the two apart — so when a torrent's download died mid-session (field 2026-08-05), a run that had produced 188 segments of 624 logged `encode-run complete`, the player consumed what was already on disk and then froze for 60 s on the first segment nobody was making. The claim is now checked against the playlist that was published: a run that stopped short is a failure, which the session can restart, rather than a completed file.
|
package/package.json
CHANGED
|
@@ -1,69 +1,82 @@
|
|
|
1
|
-
import { logger } from "../../../../utils/logger.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Return download statistics for a registered torrent source.
|
|
5
|
-
*
|
|
6
|
-
* Provides peer count, transfer speeds, and per-file download progress so
|
|
7
|
-
* that the browser client can display meaningful feedback while the proxy is
|
|
8
|
-
* pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
|
|
9
|
-
*
|
|
10
|
-
* GET /api/sources/:sourceKey/stats?fileIndex=N
|
|
11
|
-
*
|
|
12
|
-
* @param {import("fastify").FastifyRequest} req
|
|
13
|
-
* @param {import("fastify").FastifyReply} reply
|
|
14
|
-
* @param {{
|
|
15
|
-
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
16
|
-
* torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
|
|
17
|
-
* }} deps
|
|
18
|
-
* @returns {Promise<void>}
|
|
19
|
-
*/
|
|
20
|
-
export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
21
|
-
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
22
|
-
if (!sourceKey) {
|
|
23
|
-
return reply.code(400).send({ error: "sourceKey is required." });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
27
|
-
if (!sourceRecord) {
|
|
28
|
-
return reply.code(404).send({ error: "Source key was not found." });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let torrent;
|
|
32
|
-
try {
|
|
33
|
-
// getTorrent resolves immediately when the torrent is already loaded.
|
|
34
|
-
torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
35
|
-
} catch (error) {
|
|
36
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
-
return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
41
|
-
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
42
|
-
|
|
43
|
-
// Optional: pin the resume window to a FIXED byte offset for the duration of
|
|
44
|
-
// one buffering episode (see getFileStats JSDoc) instead of the live, moving
|
|
45
|
-
// read position — otherwise "bytes needed" can jump up mid-poll as the window
|
|
46
|
-
// slides forward with playback/encoding progress.
|
|
47
|
-
const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
|
|
48
|
-
const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
|
|
49
|
-
|
|
50
|
-
// Awaited: with the torrent on its own thread this is a round trip, not a
|
|
51
|
-
// local lookup. Without the await the reply was the pending promise itself,
|
|
52
|
-
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
53
|
-
const stats = await torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
|
|
54
|
-
|
|
55
|
-
// Diagnostic: surface the real swarm state per poll so a cold-start download
|
|
56
|
-
// stall (0 peers / header not advancing → playback-plan blocks on the codec
|
|
57
|
-
// probe → browser timeout) is visible in the proxy log.
|
|
58
|
-
const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
|
|
59
|
-
const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
|
|
60
|
-
const header =
|
|
61
|
-
stats.headerBytes != null
|
|
62
|
-
? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
|
|
63
|
-
: "n/a";
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
1
|
+
import { logger } from "../../../../utils/logger.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return download statistics for a registered torrent source.
|
|
5
|
+
*
|
|
6
|
+
* Provides peer count, transfer speeds, and per-file download progress so
|
|
7
|
+
* that the browser client can display meaningful feedback while the proxy is
|
|
8
|
+
* pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
|
|
9
|
+
*
|
|
10
|
+
* GET /api/sources/:sourceKey/stats?fileIndex=N
|
|
11
|
+
*
|
|
12
|
+
* @param {import("fastify").FastifyRequest} req
|
|
13
|
+
* @param {import("fastify").FastifyReply} reply
|
|
14
|
+
* @param {{
|
|
15
|
+
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
16
|
+
* torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
|
|
17
|
+
* }} deps
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
21
|
+
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
22
|
+
if (!sourceKey) {
|
|
23
|
+
return reply.code(400).send({ error: "sourceKey is required." });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
27
|
+
if (!sourceRecord) {
|
|
28
|
+
return reply.code(404).send({ error: "Source key was not found." });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let torrent;
|
|
32
|
+
try {
|
|
33
|
+
// getTorrent resolves immediately when the torrent is already loaded.
|
|
34
|
+
torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
41
|
+
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
42
|
+
|
|
43
|
+
// Optional: pin the resume window to a FIXED byte offset for the duration of
|
|
44
|
+
// one buffering episode (see getFileStats JSDoc) instead of the live, moving
|
|
45
|
+
// read position — otherwise "bytes needed" can jump up mid-poll as the window
|
|
46
|
+
// slides forward with playback/encoding progress.
|
|
47
|
+
const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
|
|
48
|
+
const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
|
|
49
|
+
|
|
50
|
+
// Awaited: with the torrent on its own thread this is a round trip, not a
|
|
51
|
+
// local lookup. Without the await the reply was the pending promise itself,
|
|
52
|
+
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
53
|
+
const stats = await torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
|
|
54
|
+
|
|
55
|
+
// Diagnostic: surface the real swarm state per poll so a cold-start download
|
|
56
|
+
// stall (0 peers / header not advancing → playback-plan blocks on the codec
|
|
57
|
+
// probe → browser timeout) is visible in the proxy log.
|
|
58
|
+
const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
|
|
59
|
+
const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
|
|
60
|
+
const header =
|
|
61
|
+
stats.headerBytes != null
|
|
62
|
+
? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
|
|
63
|
+
: "n/a";
|
|
64
|
+
// When the answer is empty, say WHICH thing is missing. Field 2026-08-05: a
|
|
65
|
+
// source reported `peers=0 file=n/a header=n/a` for minutes while that very
|
|
66
|
+
// torrent was announcing to trackers with hundreds of seeders — and the line
|
|
67
|
+
// above cannot tell apart "the torrent handle is not the live one", "the file
|
|
68
|
+
// index did not resolve" and "no file index was asked for". That is what the
|
|
69
|
+
// viewer's loading screen was showing at the time, so it has to be
|
|
70
|
+
// answerable from the log rather than by reasoning about it afterwards.
|
|
71
|
+
const emptyAnswer = stats.fileProgress == null || (stats.numPeers === 0 && torrent.done !== true);
|
|
72
|
+
const detail = emptyAnswer
|
|
73
|
+
? ` | infoHash=${String(torrent.infoHash).slice(0, 8)} files=${torrent.files?.length ?? "?"}` +
|
|
74
|
+
` askedFor=${fileIndex ?? "none"} resolved=${torrent.files?.[fileIndex ?? -1] ? "yes" : "no"}` +
|
|
75
|
+
` wires=${torrent.wires?.length ?? "?"} done=${torrent.done === true}`
|
|
76
|
+
: "";
|
|
77
|
+
logger.info(
|
|
78
|
+
`[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}${detail}`
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return reply.send(stats);
|
|
82
|
+
}
|
|
@@ -200,6 +200,18 @@ export class PieceLru {
|
|
|
200
200
|
* @param {string|number} readerId
|
|
201
201
|
* @returns {void}
|
|
202
202
|
*/
|
|
203
|
+
/**
|
|
204
|
+
* Every live reader's declared window. Read by the pool to check that the
|
|
205
|
+
* torrent has actually been asked for those pieces — WebTorrent deletes a
|
|
206
|
+
* selection of its own accord once it is fully downloaded, so a claim made
|
|
207
|
+
* once does not stay made.
|
|
208
|
+
*
|
|
209
|
+
* @returns {Array<{ from: number, to: number }>}
|
|
210
|
+
*/
|
|
211
|
+
protectedRanges() {
|
|
212
|
+
return [...this.#protected.values()].map((range) => ({ from: range.from, to: range.to }));
|
|
213
|
+
}
|
|
214
|
+
|
|
203
215
|
unprotect(readerId) {
|
|
204
216
|
this.#protected.delete(readerId);
|
|
205
217
|
}
|