@torrent-tv/proxy 2.15.0 → 2.15.2
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
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.15.2
|
|
2
|
+
|
|
3
|
+
- **Fix**: A segment request that can never be answered is answered as absent instead of being held for a minute. Changing audio track makes hls.js ask the new stream for segment #0 before anything else; the run was at #354, the repair reaches sixty segments back and no further, no seek was coming, and an encoder only moves forward — so the request was unanswerable from the moment it arrived, and holding it simply spent the player's own patience. Measured 2026-08-15: the track was made ready in 7.1 s at the viewer's position, and the viewer then watched a spinner for **63 s** — sixty of them the hold, the rest the player recovering after it failed. Deliberately narrower than the refusal 2.14.1 shipped and 2.14.2 withdrew: a request within the repair's reach, or one with a seek on its way, is still held, because for those the encoder is about to be moved there.
|
|
4
|
+
|
|
5
|
+
## 2.15.1
|
|
6
|
+
|
|
7
|
+
- **Fix**: A magnet that never found its metadata no longer makes the same film unplayable from its own `.torrent`. One infohash is one torrent, so the second add is refused and the pool takes the one already there — which is right when it is ready and wrong when it is not: a magnet whose swarm has not answered has no file list, and everything bound to it is answered 404. Measured 2026-08-15 on the addon host: a magnet with no reachable trackers was added first, the film's own `.torrent` then joined that empty torrent instead of replacing it, `/stream` answered 404, the encoder died on its first read, and the film stayed unplayable until the proxy was restarted. A `.torrent` carries the file list, the piece hashes and the trackers outright, so when it meets a torrent with no metadata it now replaces it; two magnets still wait, because neither has anything the other lacks.
|
|
8
|
+
|
|
1
9
|
## 2.15.0
|
|
2
10
|
|
|
3
11
|
- **New**: An audio track is prepared before the player is told to change to it — `GET /transcode/:id/a/:track/warm?position=<seconds>`, the same shape the quality rung has had since 2.12.0. Changing track makes the player discard the audio it holds, and it cannot show a frame until the new track covers the playhead: switching first and producing second therefore put the track's whole cold start on screen as a spinner over a stopped picture. Prepared first, the player finds the bytes already made. A track prepared for a change the viewer then did not make is stopped, as a warmed rung is.
|
package/package.json
CHANGED
|
@@ -6067,6 +6067,35 @@ export class HlsSessionManager {
|
|
|
6067
6067
|
// at this position (server-side seeking). The caller long-polls.
|
|
6068
6068
|
if (!isPlaylist) {
|
|
6069
6069
|
const requestedIndex = session.segmentFormat.segmentIndexFromName(fileName);
|
|
6070
|
+
// Unanswerable, and known to be: behind a run that only moves forward,
|
|
6071
|
+
// too far behind for the repair to fetch it, and no seek on its way to
|
|
6072
|
+
// move the encoder there. Holding it changes nothing about whether it can
|
|
6073
|
+
// be produced — it only spends the player's patience.
|
|
6074
|
+
//
|
|
6075
|
+
// This is what a track change costs when it is held instead: measured
|
|
6076
|
+
// 2026-08-15, hls.js asked the new track for segment #0 while the run was
|
|
6077
|
+
// at #354, the request was held for the full minute, and only when it
|
|
6078
|
+
// failed did the player move to the segment it actually needed — 63 s of
|
|
6079
|
+
// spinner after a track that had been made ready in 7.
|
|
6080
|
+
//
|
|
6081
|
+
// Narrow on purpose. A request behind the head is USUALLY temporary: the
|
|
6082
|
+
// repair moves the encoder back for anything within its reach, and a
|
|
6083
|
+
// reported seek is about to move it anyway. Refusing those was 2.14.1,
|
|
6084
|
+
// and it left a viewer retrying a 404 for ever.
|
|
6085
|
+
if (
|
|
6086
|
+
Number.isFinite(requestedIndex) &&
|
|
6087
|
+
requestedIndex < (session.encodeStartIndex ?? 0) &&
|
|
6088
|
+
(session.encodeStartIndex ?? 0) - requestedIndex > BEHIND_HEAD_REPAIR_MAX_SEGMENTS &&
|
|
6089
|
+
session.ffmpeg != null &&
|
|
6090
|
+
session.seekTarget == null &&
|
|
6091
|
+
session.seekSettleTimer == null
|
|
6092
|
+
) {
|
|
6093
|
+
logger.info(
|
|
6094
|
+
`transcode ${session.id} segment #${requestedIndex} is ${(session.encodeStartIndex ?? 0) - requestedIndex} ` +
|
|
6095
|
+
`segments behind the run and beyond the repair's reach; answered as absent rather than held`
|
|
6096
|
+
);
|
|
6097
|
+
return { kind: "not-found" };
|
|
6098
|
+
}
|
|
6070
6099
|
this.#ensureEncodingFor(
|
|
6071
6100
|
session,
|
|
6072
6101
|
requestedIndex,
|
package/services/torrent-pool.js
CHANGED
|
@@ -315,6 +315,30 @@ function computeDefaultDiskCap(storePath) {
|
|
|
315
315
|
* @param {string} source - Magnet URI or base64-encoded .torrent file.
|
|
316
316
|
* @returns {string | Buffer}
|
|
317
317
|
*/
|
|
318
|
+
/**
|
|
319
|
+
* What to do when WebTorrent refuses an add because that infohash is already
|
|
320
|
+
* here: take the one that exists, replace it, or wait for it to be ready.
|
|
321
|
+
*
|
|
322
|
+
* The same content arrives as a `.torrent` and as a magnet — different pool
|
|
323
|
+
* keys, one swarm — so a duplicate is ordinary. What is NOT ordinary is a
|
|
324
|
+
* duplicate with no metadata: a magnet whose swarm has not answered has no file
|
|
325
|
+
* list, and every request bound to it is answered 404 for as long as it lives.
|
|
326
|
+
* If this add carries metadata — a `.torrent` holds the files, the piece hashes
|
|
327
|
+
* and the trackers outright — waiting for the swarm to supply what is already
|
|
328
|
+
* in our hands is waiting for nothing. Measured 2026-08-15: one magnet with no
|
|
329
|
+
* reachable peers made the same film unplayable from its own `.torrent` until
|
|
330
|
+
* the proxy was restarted.
|
|
331
|
+
*
|
|
332
|
+
* @param {{ existingIsReady: boolean, incomingHasMetadata: boolean }} params
|
|
333
|
+
* @returns {"adopt" | "replace" | "wait"}
|
|
334
|
+
*/
|
|
335
|
+
export function duplicateAddDecision({ existingIsReady, incomingHasMetadata }) {
|
|
336
|
+
if (existingIsReady) {
|
|
337
|
+
return "adopt";
|
|
338
|
+
}
|
|
339
|
+
return incomingHasMetadata ? "replace" : "wait";
|
|
340
|
+
}
|
|
341
|
+
|
|
318
342
|
function decodeTorrentSource(sourceType, source) {
|
|
319
343
|
if (sourceType === "magnet") {
|
|
320
344
|
return source;
|
|
@@ -972,11 +996,58 @@ export class TorrentPool {
|
|
|
972
996
|
this.#pending.delete(key);
|
|
973
997
|
resolve(existing);
|
|
974
998
|
};
|
|
975
|
-
|
|
999
|
+
const decision = duplicateAddDecision({
|
|
1000
|
+
existingIsReady: existing.ready === true,
|
|
1001
|
+
incomingHasMetadata: Buffer.isBuffer(torrentId)
|
|
1002
|
+
});
|
|
1003
|
+
if (decision === "adopt") {
|
|
976
1004
|
settle();
|
|
977
|
-
|
|
978
|
-
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
// The one already here has no metadata, and THIS add carries it —
|
|
1008
|
+
// a `.torrent` holds the file list, the piece hashes and the
|
|
1009
|
+
// trackers outright. Waiting for the other one to become ready is
|
|
1010
|
+
// waiting on the swarm to supply what is already in our hands, and
|
|
1011
|
+
// when the swarm cannot (a magnet with no reachable peers) it never
|
|
1012
|
+
// arrives: measured 2026-08-15, a magnet added first left every
|
|
1013
|
+
// later attempt at the same infohash — including the complete
|
|
1014
|
+
// `.torrent` — bound to an empty torrent, `/stream` answering 404
|
|
1015
|
+
// and playback impossible until the proxy was restarted.
|
|
1016
|
+
if (decision === "replace") {
|
|
1017
|
+
logger.info(
|
|
1018
|
+
`torrent-pool: [${dupMatch[1].slice(0, 8)}] replacing a torrent with no metadata ` +
|
|
1019
|
+
`with the .torrent just given, which has it`
|
|
1020
|
+
);
|
|
1021
|
+
// Everything this class remembers about the old one goes with it,
|
|
1022
|
+
// or a later request finds it through a map and works with a
|
|
1023
|
+
// torrent the client no longer has.
|
|
1024
|
+
for (const [otherKey, value] of this.torrents) {
|
|
1025
|
+
if (value === existing) {
|
|
1026
|
+
this.torrents.delete(otherKey);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
this.fileUsageByTorrent.delete(existing);
|
|
1030
|
+
this.#lastAccess.delete(existing);
|
|
1031
|
+
this.#readPositionByTorrent.delete(existing);
|
|
1032
|
+
this.client.remove(existing, { destroyStore: true }, () => {
|
|
1033
|
+
this.client.add(torrentId, {
|
|
1034
|
+
store: SharedPieceStore,
|
|
1035
|
+
storeCacheSlots: 0,
|
|
1036
|
+
storeOpts: { memoryBytes: this.#memoryBytes }
|
|
1037
|
+
}, (replacement) => {
|
|
1038
|
+
this.torrents.set(key, replacement);
|
|
1039
|
+
this.#lastAccess.set(replacement, Date.now());
|
|
1040
|
+
this.#attachSwarmDiagnostics(dupMatch[1].slice(0, 8), replacement);
|
|
1041
|
+
this.#pending.delete(key);
|
|
1042
|
+
resolve(replacement);
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
return;
|
|
979
1046
|
}
|
|
1047
|
+
// Both are magnets: there is nothing here the other does not have,
|
|
1048
|
+
// so wait for the swarm — the caller's own bound answers if it
|
|
1049
|
+
// takes too long.
|
|
1050
|
+
existing.once("ready", settle);
|
|
980
1051
|
return;
|
|
981
1052
|
}
|
|
982
1053
|
}
|
|
@@ -108,12 +108,11 @@ test("a burst of different segments behind the run is a scan, and moves nothing"
|
|
|
108
108
|
for (const index of scanned) {
|
|
109
109
|
session.firstWantedAt.set(index, Date.now() - 1000);
|
|
110
110
|
}
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
111
|
+
// Asked ONCE each, which is what a scan is: the player opens them together
|
|
112
|
+
// and abandons them together. Field log 2026-08-02 — #178, #681, #725, #807,
|
|
113
|
+
// #74, #245, #387 within half a second, none of them repeated.
|
|
114
|
+
for (const index of scanned) {
|
|
115
|
+
await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(index), { requestSeq: 1 });
|
|
117
116
|
}
|
|
118
117
|
|
|
119
118
|
assert.equal(
|
|
@@ -219,3 +218,34 @@ test("a request ahead of the run is not touched", async (t) => {
|
|
|
219
218
|
"the running encode may yet reach it; restarting on a far request is what produced nine restarts in a minute"
|
|
220
219
|
);
|
|
221
220
|
});
|
|
221
|
+
|
|
222
|
+
test("a request far beyond the repair's reach is answered at once, not held", async (t) => {
|
|
223
|
+
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
224
|
+
t.after(async () => {
|
|
225
|
+
await manager.disposeAll();
|
|
226
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
227
|
+
});
|
|
228
|
+
// What a track change does: hls.js asks the new stream for segment #0 while
|
|
229
|
+
// the run is hundreds of segments in. The repair cannot reach it, no seek is
|
|
230
|
+
// coming, and the run only moves forward — so it can never be produced.
|
|
231
|
+
// Measured 2026-08-15: held for the full minute, and the viewer watched a
|
|
232
|
+
// spinner for 63 s after a track that had been made ready in 7.
|
|
233
|
+
const answer = await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(0), { requestSeq: 1 });
|
|
234
|
+
|
|
235
|
+
assert.equal(answer.kind, "not-found", "answered, so the player can move on to what it can have");
|
|
236
|
+
assert.equal(session.seekTarget, null, "and the encoder was not sent to the start of the film for it");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a request just behind the run is still held, because the repair will fetch it", async (t) => {
|
|
240
|
+
const { manager, session, dirPath } = await managerWithRunAhead();
|
|
241
|
+
t.after(async () => {
|
|
242
|
+
await manager.disposeAll();
|
|
243
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
244
|
+
});
|
|
245
|
+
const name = fmp4Format.segmentFileName(WANTED);
|
|
246
|
+
session.firstWantedAt.set(WANTED, Date.now() - 1000);
|
|
247
|
+
|
|
248
|
+
const answer = await manager.getFileStream(SESSION_ID, name, { requestSeq: 1 });
|
|
249
|
+
|
|
250
|
+
assert.equal(answer.kind, "warming-up", "within reach: the encoder is about to be moved there");
|
|
251
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file One infohash, two sources: what happens when the second add is refused.
|
|
3
|
+
*
|
|
4
|
+
* The same film arrives as a magnet and as a `.torrent`. WebTorrent refuses the
|
|
5
|
+
* second add — one infohash, one torrent — and the pool then has to decide what
|
|
6
|
+
* to hand back. Adopting the one already there is right when it is ready, and
|
|
7
|
+
* wrong when it is not: a magnet whose swarm has not answered has no file list,
|
|
8
|
+
* so everything bound to it is answered 404.
|
|
9
|
+
*
|
|
10
|
+
* Measured 2026-08-15 on the addon host: a magnet with no reachable trackers
|
|
11
|
+
* was added first and never became ready; the same film's own `.torrent`, which
|
|
12
|
+
* carries the file list, the piece hashes and the trackers, then joined that
|
|
13
|
+
* empty torrent instead of replacing it. `/stream` answered 404, the encoder
|
|
14
|
+
* died on its first read, and the film stayed unplayable until the proxy was
|
|
15
|
+
* restarted — one bad magnet poisoning every later attempt at that infohash.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { duplicateAddDecision } from "../services/torrent-pool.js";
|
|
21
|
+
|
|
22
|
+
test("a ready torrent is adopted, whatever the new source carries", () => {
|
|
23
|
+
assert.equal(
|
|
24
|
+
duplicateAddDecision({ existingIsReady: true, incomingHasMetadata: true }),
|
|
25
|
+
"adopt",
|
|
26
|
+
"one swarm per infohash: a second copy would download the same pieces twice"
|
|
27
|
+
);
|
|
28
|
+
assert.equal(
|
|
29
|
+
duplicateAddDecision({ existingIsReady: true, incomingHasMetadata: false }),
|
|
30
|
+
"adopt"
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("an unready torrent is replaced when this add brings the metadata", () => {
|
|
35
|
+
assert.equal(
|
|
36
|
+
duplicateAddDecision({ existingIsReady: false, incomingHasMetadata: true }),
|
|
37
|
+
"replace",
|
|
38
|
+
"the .torrent holds what the swarm was being asked for; waiting for it is waiting for nothing"
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("two magnets wait, because neither has anything the other lacks", () => {
|
|
43
|
+
assert.equal(
|
|
44
|
+
duplicateAddDecision({ existingIsReady: false, incomingHasMetadata: false }),
|
|
45
|
+
"wait",
|
|
46
|
+
"replacing here would only restart the same search, and the caller's own bound answers"
|
|
47
|
+
);
|
|
48
|
+
});
|