@torrent-tv/proxy 2.9.96 → 2.9.98
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 +10 -0
- package/package.json +1 -1
- package/routes/stream/get.js +7 -1
- package/services/hls-session-manager.js +34 -1
- package/services/torrent-pool.js +58 -12
- package/test/upload-hurry.test.js +56 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2.9.98
|
|
2
|
+
|
|
3
|
+
- **Fix**: The upload is no longer raised at moments when nobody wants a byte. A torrent with no reader was counted as starving whenever its download read low — which it always does while the encoder is held back for running ahead of the viewer. Measured: four cycles of 512 KB/s and back in three minutes, each reported as `earn unchoke … down=0KB/s`. Starvation now requires somebody to be waiting.
|
|
4
|
+
- **Fix**: A session start no longer looks like a burst of seeks. The codec probe and the keyframe index read through the same route as the encoder and visit the first bytes and the last ones, which from byte offsets alone is indistinguishable from a viewer dragging the slider — two spurious "the viewer moved" per start, and more on every encoder restart. The encoder's input URL now says that it is the read that follows the viewer, and only that read counts.
|
|
5
|
+
- **Chore**: The per-run ffmpeg log line abbreviates the list of cut times to its count and its two ends. There is one cut per segment — 830 on a two-hour film, about 7 KB of log per run — and the list is only ever consulted for whether cutting was explicit, where it starts and how far it reaches.
|
|
6
|
+
|
|
7
|
+
## 2.9.97
|
|
8
|
+
|
|
9
|
+
- **Fix**: The generous upload of 2.9.96 did not actually reach the moment it was written for. Only torrents with a registered reader were shown to the upload policy, and the first thing done with a new torrent — fetching the file's head and tail for the codec probe — reads through `createReadStream` without registering one. So for the whole of that wait, 8.36 s of the 11.46 s before playback in the measured session, the torrent looked unused and the upload stayed at the near-silent idle floor, during the exact seconds peers decide whether to serve us. A torrent in a hurry now counts whether or not anything is reading it. The selection is a named function of its own so it can be tested without a live swarm — the fault was in which torrents were considered, not in what was decided about them.
|
|
10
|
+
|
|
1
11
|
## 2.9.96
|
|
2
12
|
|
|
3
13
|
- **New**: The proxy uploads generously at the two moments a viewer is provably waiting — when a torrent is added, and when the viewer seeks — for 25 s, which is two of BitTorrent's unchoke cycles. Peers serve those who serve them: each re-ranks its takers about every 10 s and opens a few slots to whoever uploaded most, plus one at random, so uploading a token 8-50 KB/s means being picked at random, one slot per cycle. Measured on a session where 96 peers were already connected within 2 s: 64 KB/s after 2 s, 1.6 MB/s after 4 s, 4.8 MB/s after 8 s — and the 16 MB the codec probe needs took **8.36 s of the 11.46 s** before playback could start. The existing reciprocity boost could not help, because it waits for the download to be all but dead (below 200 KB/s) with peers visibly choking us, and a ramp is neither: in that same session it first moved the limit 13.3 s after the torrent was added and reached the generous rate at 43.7 s, both after the wait they were meant to shorten. Seeding policy is otherwise unchanged — near-silence when nothing is being watched, a token upload while reading.
|
package/package.json
CHANGED
package/routes/stream/get.js
CHANGED
|
@@ -144,8 +144,14 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool
|
|
|
144
144
|
// byte offset) downloads first instead of waiting behind the sequential
|
|
145
145
|
// backlog — this is what caused ~15-18 s stalls when seeking into an
|
|
146
146
|
// undownloaded region.
|
|
147
|
+
// Only the encoder's own input read tracks where the viewer is. Everything
|
|
148
|
+
// else that comes through here — the codec probe, the keyframe index, a
|
|
149
|
+
// subtitle fetch — reads the file's edges, and treating those as a viewer
|
|
150
|
+
// position made every session start and every encoder restart look like a
|
|
151
|
+
// burst of seeks (measured: two spurious "the viewer moved" per start).
|
|
147
152
|
torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0, undefined, {
|
|
148
|
-
wholeFileRead: range === null
|
|
153
|
+
wholeFileRead: range === null,
|
|
154
|
+
isPlaybackRead: req.query.reader === "playback"
|
|
149
155
|
});
|
|
150
156
|
|
|
151
157
|
const start = range ? range.start : 0;
|
|
@@ -672,6 +672,33 @@ function computeSegmentBoundaries({ transcodeVideo, durationSeconds, segDur, key
|
|
|
672
672
|
* @returns {number[] | null} Times relative to the run start, or null when the
|
|
673
673
|
* boundaries cannot serve (missing, or the index is outside them).
|
|
674
674
|
*/
|
|
675
|
+
/**
|
|
676
|
+
* The ffmpeg command as one readable line.
|
|
677
|
+
*
|
|
678
|
+
* Everything is shown as passed except the list of cut times, which is one
|
|
679
|
+
* value per segment — 830 of them on a two-hour film, about 7 KB of log for a
|
|
680
|
+
* single run, repeated on every restart. The count and the two ends say
|
|
681
|
+
* everything the list is ever consulted for: whether cutting was explicit at
|
|
682
|
+
* all, how far it reaches, and where it starts.
|
|
683
|
+
*
|
|
684
|
+
* @param {string[]} args
|
|
685
|
+
* @returns {string}
|
|
686
|
+
*/
|
|
687
|
+
export function describeFfmpegArgs(args) {
|
|
688
|
+
const parts = [];
|
|
689
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
690
|
+
const value = args[index];
|
|
691
|
+
if (value === "-segment_times" && typeof args[index + 1] === "string") {
|
|
692
|
+
const times = args[index + 1].split(",");
|
|
693
|
+
parts.push(value, `<${times.length} cuts ${times[0]}..${times[times.length - 1]}>`);
|
|
694
|
+
index += 1;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
parts.push(value);
|
|
698
|
+
}
|
|
699
|
+
return parts.join(" ");
|
|
700
|
+
}
|
|
701
|
+
|
|
675
702
|
export function segmentCutTimesFrom(boundaries, startIndex) {
|
|
676
703
|
if (!Array.isArray(boundaries) || boundaries.length < 2) {
|
|
677
704
|
return null;
|
|
@@ -999,6 +1026,12 @@ export class HlsSessionManager {
|
|
|
999
1026
|
const readWindowBytes = await this.#readWindowBytesFor(sourceKey, fileIndex, durationSeconds);
|
|
1000
1027
|
if (readWindowBytes > 0) {
|
|
1001
1028
|
inputUrl.searchParams.set("windowBytes", String(readWindowBytes));
|
|
1029
|
+
// This read, and only this read, follows the viewer. The codec probe and
|
|
1030
|
+
// the keyframe index also go through `/stream`, and they jump between the
|
|
1031
|
+
// first bytes and the last ones — which is indistinguishable, from byte
|
|
1032
|
+
// offsets alone, from someone dragging the slider. Saying so here is
|
|
1033
|
+
// cheaper and more truthful than guessing from the offsets.
|
|
1034
|
+
inputUrl.searchParams.set("reader", "playback");
|
|
1002
1035
|
}
|
|
1003
1036
|
if (!hasDuration) {
|
|
1004
1037
|
logger.warn(
|
|
@@ -2132,7 +2165,7 @@ export class HlsSessionManager {
|
|
|
2132
2165
|
// were verified to handle a copied AC-3 track on this very host, so the
|
|
2133
2166
|
// arguments that run actually received are the missing evidence. One line
|
|
2134
2167
|
// per run, and a run happens at most every few seconds.
|
|
2135
|
-
logger.info(`transcode ${session.id} ffmpeg ${args
|
|
2168
|
+
logger.info(`transcode ${session.id} ffmpeg ${describeFfmpegArgs(args)}`);
|
|
2136
2169
|
|
|
2137
2170
|
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
2138
2171
|
cwd: session.dirPath,
|
package/services/torrent-pool.js
CHANGED
|
@@ -115,6 +115,38 @@ const UPLOAD_HURRY_MS = 25_000;
|
|
|
115
115
|
* @param {{ floor?: number, idleFloor?: number, boost?: number, starvingSpeed?: number, chokedThreshold?: number, now?: number }} [opts]
|
|
116
116
|
* @returns {{ bytesPerSec: number, reason: string }}
|
|
117
117
|
*/
|
|
118
|
+
/**
|
|
119
|
+
* The torrents the upload policy is allowed to see.
|
|
120
|
+
*
|
|
121
|
+
* A torrent with a reader qualifies for the obvious reason. A torrent in a
|
|
122
|
+
* HURRY qualifies even without one, and that case is the important one: the
|
|
123
|
+
* first thing done with a new torrent is fetching the file's head and tail for
|
|
124
|
+
* the codec probe, and that read goes straight to `createReadStream` without
|
|
125
|
+
* registering a reader. Judged by readers alone the torrent looks unused for
|
|
126
|
+
* the whole of that wait — 8.36 s of the 11.46 s before playback in the session
|
|
127
|
+
* measured 2026-08-04 — so the upload stayed at the near-silent idle floor
|
|
128
|
+
* during the exact seconds peers were deciding whether to serve us.
|
|
129
|
+
*
|
|
130
|
+
* @param {Iterable<{ hurryUntil?: number }>} torrents
|
|
131
|
+
* @param {Map<object, { size: number }>} usageByTorrent - fileIndex sets, keyed by torrent.
|
|
132
|
+
* @param {number} now
|
|
133
|
+
* @returns {object[]}
|
|
134
|
+
*/
|
|
135
|
+
export function torrentsForUploadPolicy(torrents, usageByTorrent, now) {
|
|
136
|
+
const chosen = [];
|
|
137
|
+
for (const torrent of torrents) {
|
|
138
|
+
const usage = usageByTorrent?.get?.(torrent);
|
|
139
|
+
const hasReader = Boolean(usage && usage.size > 0);
|
|
140
|
+
if (hasReader || (torrent?.hurryUntil ?? 0) > now) {
|
|
141
|
+
// Recorded so the policy can tell "nothing is arriving and somebody is
|
|
142
|
+
// waiting" from "nothing is arriving because nobody asked".
|
|
143
|
+
torrent.hasActiveReader = hasReader;
|
|
144
|
+
chosen.push(torrent);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return chosen;
|
|
148
|
+
}
|
|
149
|
+
|
|
118
150
|
export function decideUploadLimit(activeTorrents, opts = {}) {
|
|
119
151
|
const floor = opts.floor ?? UPLOAD_FLOOR_BYTES;
|
|
120
152
|
const idleFloor = opts.idleFloor ?? UPLOAD_IDLE_FLOOR_BYTES;
|
|
@@ -148,7 +180,13 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
|
|
|
148
180
|
// iterate the piece array and throw on webtorrent 3.x when a piece is null
|
|
149
181
|
// (deselected / mid-verify), which would crash this timer every cycle.
|
|
150
182
|
const notDone = torrent?.done !== true;
|
|
151
|
-
|
|
183
|
+
// A torrent nobody is reading is not starving, however still its download
|
|
184
|
+
// looks. The encoder is held back once it is far enough ahead of the
|
|
185
|
+
// viewer, and while it is held nothing is requested — measured
|
|
186
|
+
// 2026-08-04: four cycles of 512 -> 50 KB/s in three minutes, each
|
|
187
|
+
// reported as `earn unchoke ... down=0KB/s`, all of them raising the
|
|
188
|
+
// upload at moments when no byte was wanted by anyone.
|
|
189
|
+
const starving = notDone && torrent?.hasActiveReader !== false && downloadSpeed < starvingSpeed;
|
|
152
190
|
if (starving && chokedInterested >= chokedThreshold) {
|
|
153
191
|
const name = typeof torrent?.name === "string" ? torrent.name : "?";
|
|
154
192
|
return {
|
|
@@ -465,10 +503,11 @@ export class TorrentPool {
|
|
|
465
503
|
if (!this.client || this.client.destroyed || typeof this.client.throttleUpload !== "function") {
|
|
466
504
|
return;
|
|
467
505
|
}
|
|
468
|
-
const active =
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
506
|
+
const active = torrentsForUploadPolicy(
|
|
507
|
+
this.torrents.values(),
|
|
508
|
+
this.fileUsageByTorrent,
|
|
509
|
+
Date.now()
|
|
510
|
+
);
|
|
472
511
|
const { bytesPerSec, reason } = decideUploadLimit(active);
|
|
473
512
|
if (bytesPerSec === this.#uploadLimit) {
|
|
474
513
|
return;
|
|
@@ -1096,9 +1135,11 @@ export class TorrentPool {
|
|
|
1096
1135
|
* @param {number} fileIndex
|
|
1097
1136
|
* @param {number} byteStart - Start offset within the file.
|
|
1098
1137
|
* @param {number} [windowBytes] - Unused; kept so callers need not change.
|
|
1099
|
-
* @param {{ wholeFileRead?: boolean }} [options] -
|
|
1100
|
-
* request that carried no byte range, i.e. one that
|
|
1101
|
-
* 0 rather than asking to read from there.
|
|
1138
|
+
* @param {{ wholeFileRead?: boolean, isPlaybackRead?: boolean }} [options] -
|
|
1139
|
+
* `wholeFileRead` marks a request that carried no byte range, i.e. one that
|
|
1140
|
+
* merely opens the file at 0 rather than asking to read from there.
|
|
1141
|
+
* `isPlaybackRead` marks the encoder's input read, the only one that
|
|
1142
|
+
* follows the viewer. See the guards below.
|
|
1102
1143
|
* @returns {void}
|
|
1103
1144
|
*/
|
|
1104
1145
|
prioritizeByteRange(
|
|
@@ -1153,10 +1194,15 @@ export class TorrentPool {
|
|
|
1153
1194
|
const isJump =
|
|
1154
1195
|
previousStart === undefined || Math.abs(safeStart - previousStart) > PRIORITY_WINDOW_BYTES;
|
|
1155
1196
|
if (isJump) {
|
|
1156
|
-
// A jump
|
|
1157
|
-
// else, and the pieces at the new
|
|
1158
|
-
// that are choking us — the same
|
|
1159
|
-
|
|
1197
|
+
// A jump in the read that follows the viewer is a seek. Whatever the
|
|
1198
|
+
// swarm was giving us was for somewhere else, and the pieces at the new
|
|
1199
|
+
// position have to be earned from peers that are choking us — the same
|
|
1200
|
+
// standing start as a fresh torrent. A jump in any OTHER read is the
|
|
1201
|
+
// codec probe or the keyframe index visiting the ends of the file, and
|
|
1202
|
+
// nobody is waiting on those the way a viewer waits on a seek.
|
|
1203
|
+
if (options.isPlaybackRead) {
|
|
1204
|
+
this.#markHurry(torrent, "the viewer moved");
|
|
1205
|
+
}
|
|
1160
1206
|
const percent = ((safeStart / fileLength) * 100).toFixed(1);
|
|
1161
1207
|
logger.info(
|
|
1162
1208
|
`torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] read position -> ` +
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import test from "node:test";
|
|
19
19
|
import assert from "node:assert/strict";
|
|
20
|
-
import { decideUploadLimit } from "../services/torrent-pool.js";
|
|
20
|
+
import { decideUploadLimit, torrentsForUploadPolicy } from "../services/torrent-pool.js";
|
|
21
21
|
|
|
22
22
|
const NOW = 1_000_000;
|
|
23
23
|
const healthy = (extra = {}) => ({
|
|
@@ -64,3 +64,58 @@ test("the reciprocity boost still works when no hurry is on", () => {
|
|
|
64
64
|
assert.equal(decision.bytesPerSec, 512 * 1024);
|
|
65
65
|
assert.match(decision.reason, /earn unchoke/);
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
test("a torrent with no reader still reaches the policy while it is in a hurry", () => {
|
|
69
|
+
// The moment that matters: a torrent has just been added and its head and
|
|
70
|
+
// tail are being fetched for the codec probe. That read goes straight to
|
|
71
|
+
// `createReadStream`, so no reader is registered — and the selection only
|
|
72
|
+
// ever kept torrents that had one, which is where the gap was.
|
|
73
|
+
const hurrying = { name: "film.mkv", hurryUntil: NOW + 20_000 };
|
|
74
|
+
const idle = { name: "other.mkv" };
|
|
75
|
+
const usage = new Map();
|
|
76
|
+
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
torrentsForUploadPolicy([hurrying, idle], usage, NOW),
|
|
79
|
+
[hurrying],
|
|
80
|
+
"a torrent with no reader yet was ignored"
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
assert.deepEqual(
|
|
84
|
+
torrentsForUploadPolicy([{ ...hurrying, hurryUntil: NOW - 1 }, idle], usage, NOW),
|
|
85
|
+
[],
|
|
86
|
+
"and stops counting once the rush is over"
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const read = { name: "watched.mkv" };
|
|
90
|
+
usage.set(read, new Set([0]));
|
|
91
|
+
assert.deepEqual(
|
|
92
|
+
torrentsForUploadPolicy([read], usage, NOW),
|
|
93
|
+
[read],
|
|
94
|
+
"a torrent being read still counts, hurry or not"
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a torrent nobody is reading is not treated as starving", () => {
|
|
99
|
+
// The encoder is suspended once it is far enough ahead, and while it is
|
|
100
|
+
// suspended nothing is requested — so the download reads zero without anyone
|
|
101
|
+
// waiting. Measured before this guard: four cycles of 512 -> 50 KB/s in three
|
|
102
|
+
// minutes, every one of them reported as `earn unchoke ... down=0KB/s`.
|
|
103
|
+
const idleButChoked = {
|
|
104
|
+
name: "film.mkv",
|
|
105
|
+
hasActiveReader: false,
|
|
106
|
+
wires: [
|
|
107
|
+
{ amInterested: true, peerChoking: true },
|
|
108
|
+
{ amInterested: true, peerChoking: true }
|
|
109
|
+
],
|
|
110
|
+
downloadSpeed: 0,
|
|
111
|
+
done: false
|
|
112
|
+
};
|
|
113
|
+
assert.equal(decideUploadLimit([idleButChoked], { now: NOW }).bytesPerSec, 50 * 1024);
|
|
114
|
+
|
|
115
|
+
const waiting = { ...idleButChoked, hasActiveReader: true };
|
|
116
|
+
assert.equal(
|
|
117
|
+
decideUploadLimit([waiting], { now: NOW }).bytesPerSec,
|
|
118
|
+
512 * 1024,
|
|
119
|
+
"a reader that IS waiting must still earn unchoke slots"
|
|
120
|
+
);
|
|
121
|
+
});
|