@torrent-tv/proxy 2.9.103 → 2.9.104
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 +5 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +45 -1
- package/services/torrent-pool.js +120 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## 2.9.104
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
- **New**: A download that stalls says so, and says which of the two possible reasons it is. The same session spent five minutes at **1 KB/s** with 186 peer connections open and trackers reporting ~300 seeders, on a torrent that was not finished, and produced no log line at all — the collapse had to be reconstructed afterwards from three unrelated counters. A torrent with an active reader that drops below 32 KB/s for ten seconds now reports how many pieces are selected and still missing, how many are marked critical, how many peers hold what we want, how many are choking us, how many are being asked and how many blocks are in flight. That separates "the swarm was never told what we need" from "it was told and will not deliver", which the previous evidence could not.
|
|
5
|
+
|
|
1
6
|
## 2.9.103
|
|
2
7
|
|
|
3
8
|
- **Fix**: Playback worked in neither 2.9.101 nor 2.9.102. Both cold-start estimates keep a window of recent samples, and the constant naming that window was used twice and declared nowhere. The session-create one runs on every new session, so `POST /api/transcode-sessions` answered 500 to every viewer and the browser then reported the first segment missing. Field session 2026-08-05: the plan succeeded in 5858 ms, the session request failed 47 ms later, the data channel closed 16 ms after that.
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* immediately when all registered consumers release them.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { createReadStream } from "node:fs";
|
|
10
|
+
import { createReadStream, readdirSync } from "node:fs";
|
|
11
11
|
import { access, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
|
|
12
12
|
import { Readable } from "node:stream";
|
|
13
13
|
import os from "node:os";
|
|
@@ -2394,6 +2394,29 @@ export class HlsSessionManager {
|
|
|
2394
2394
|
return;
|
|
2395
2395
|
}
|
|
2396
2396
|
if (code === 0) {
|
|
2397
|
+
// ffmpeg exits 0 both when it reaches the end of the file and when its
|
|
2398
|
+
// input simply stops producing bytes — over HTTP the two look identical
|
|
2399
|
+
// to it. Field 2026-08-05: the torrent's download died, the read ended,
|
|
2400
|
+
// and a run that had made 188 segments of 624 reported itself complete;
|
|
2401
|
+
// the player then consumed what was on disk and froze on the first
|
|
2402
|
+
// segment nobody was making. So the claim is checked against the
|
|
2403
|
+
// playlist we published, and a run that stopped short is a FAILURE that
|
|
2404
|
+
// can be restarted, not a finished file.
|
|
2405
|
+
const producedThrough = this.#latestProducedSegment(session);
|
|
2406
|
+
const expectedLast = session.segmentCount > 0 ? session.segmentCount - 1 : null;
|
|
2407
|
+
if (expectedLast !== null && producedThrough !== null && producedThrough < expectedLast) {
|
|
2408
|
+
session.state = "failed";
|
|
2409
|
+
session.progress.state = "failed";
|
|
2410
|
+
session.progress.updatedAt = Date.now();
|
|
2411
|
+
session.lastError =
|
|
2412
|
+
`input ended after segment #${producedThrough} of ${expectedLast} — ` +
|
|
2413
|
+
"the source stopped delivering data";
|
|
2414
|
+
logger.error(
|
|
2415
|
+
`transcode ${session.id} ${session.runLabel ?? "run#?"} encode-run ended early: ` +
|
|
2416
|
+
`${session.lastError} "${session.fileName}"`
|
|
2417
|
+
);
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2397
2420
|
session.state = "ready";
|
|
2398
2421
|
session.progress.state = "ready";
|
|
2399
2422
|
session.progress.updatedAt = Date.now();
|
|
@@ -2823,6 +2846,27 @@ export class HlsSessionManager {
|
|
|
2823
2846
|
return sorted[Math.floor(sorted.length / 2)];
|
|
2824
2847
|
}
|
|
2825
2848
|
|
|
2849
|
+
/**
|
|
2850
|
+
* The highest segment index this session has on disk, or null when it has
|
|
2851
|
+
* none. Used to tell "the file ended" from "the data ran out".
|
|
2852
|
+
*
|
|
2853
|
+
* @param {HlsSession} session
|
|
2854
|
+
* @returns {number | null}
|
|
2855
|
+
*/
|
|
2856
|
+
#latestProducedSegment(session) {
|
|
2857
|
+
let highest = null;
|
|
2858
|
+
for (const name of readdirSync(session.dirPath, { withFileTypes: false })) {
|
|
2859
|
+
if (!this.segmentFormat.isSegmentFileName(name)) {
|
|
2860
|
+
continue;
|
|
2861
|
+
}
|
|
2862
|
+
const index = this.segmentFormat.segmentIndexFromName(name);
|
|
2863
|
+
if (index >= 0 && (highest === null || index > highest)) {
|
|
2864
|
+
highest = index;
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
return highest;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2826
2870
|
/**
|
|
2827
2871
|
* How many times the viewer has moved since this session started.
|
|
2828
2872
|
*
|
package/services/torrent-pool.js
CHANGED
|
@@ -92,6 +92,73 @@ const UPLOAD_ADJUST_INTERVAL_MS = 5_000;
|
|
|
92
92
|
* outright, for two unchoke cycles, without waiting for evidence of failure.
|
|
93
93
|
*/
|
|
94
94
|
const UPLOAD_HURRY_MS = 25_000;
|
|
95
|
+
// A torrent somebody is reading, that is not finished, and is moving less than
|
|
96
|
+
// this, is not downloading. Well below the slowest real swarm seen in the field
|
|
97
|
+
// (470 KB/s two seconds after a cold add) and well above idle chatter.
|
|
98
|
+
const STALL_SPEED_BYTES = 32 * 1024;
|
|
99
|
+
// How long it must stay there before saying so, and how often to repeat.
|
|
100
|
+
const STALL_REPORT_AFTER_MS = 10_000;
|
|
101
|
+
const STALL_REPORT_INTERVAL_MS = 30_000;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What the swarm has been asked for, and what it is doing about it.
|
|
105
|
+
*
|
|
106
|
+
* Answers the question a stalled download cannot answer for itself: were the
|
|
107
|
+
* peers never told what we want, or told and not delivering? Reaches into
|
|
108
|
+
* WebTorrent's own bookkeeping because none of it is exposed — `_selections`
|
|
109
|
+
* is what the picker walks, `wire.requests` is what is actually outstanding.
|
|
110
|
+
*
|
|
111
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function describeSwarmDemand(torrent) {
|
|
115
|
+
const items = Array.isArray(torrent?._selections?._items) ? torrent._selections._items : [];
|
|
116
|
+
let selectedPieces = 0;
|
|
117
|
+
let missingSelected = 0;
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const from = Number(item?.from);
|
|
120
|
+
const to = Number(item?.to);
|
|
121
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
selectedPieces += to - from + 1;
|
|
125
|
+
for (let index = from; index <= to; index += 1) {
|
|
126
|
+
if (!torrent.bitfield?.get(index)) {
|
|
127
|
+
missingSelected += 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
|
|
133
|
+
let inFlight = 0;
|
|
134
|
+
let asking = 0;
|
|
135
|
+
let choking = 0;
|
|
136
|
+
let interested = 0;
|
|
137
|
+
for (const wire of wires) {
|
|
138
|
+
const requests = Array.isArray(wire?.requests) ? wire.requests.length : 0;
|
|
139
|
+
inFlight += requests;
|
|
140
|
+
if (requests > 0) {
|
|
141
|
+
asking += 1;
|
|
142
|
+
}
|
|
143
|
+
if (wire?.peerChoking === true) {
|
|
144
|
+
choking += 1;
|
|
145
|
+
}
|
|
146
|
+
if (wire?.amInterested === true) {
|
|
147
|
+
interested += 1;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const critical = Array.isArray(torrent?._critical)
|
|
152
|
+
? torrent._critical.reduce((count, flag) => (flag ? count + 1 : count), 0)
|
|
153
|
+
: 0;
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
`${items.length} selection(s) covering ${selectedPieces} piece(s), ` +
|
|
157
|
+
`${missingSelected} of them missing, ${critical} marked critical; ` +
|
|
158
|
+
`${wires.length} peers, ${interested} we want data from, ${choking} choking us, ` +
|
|
159
|
+
`${asking} being asked, ${inFlight} blocks in flight`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
95
162
|
|
|
96
163
|
/**
|
|
97
164
|
* Decide the client-wide upload limit (bytes/sec) from the torrents that
|
|
@@ -383,6 +450,11 @@ export class TorrentPool {
|
|
|
383
450
|
*/
|
|
384
451
|
#readPositionByTorrent = new Map();
|
|
385
452
|
|
|
453
|
+
/** When each torrent's download first fell below the stall threshold. */
|
|
454
|
+
#stallSince = new Map();
|
|
455
|
+
/** When each torrent's stall was last reported, so it is not repeated hotly. */
|
|
456
|
+
#stallReportedAt = new Map();
|
|
457
|
+
|
|
386
458
|
/**
|
|
387
459
|
* Edge prefetches currently running, keyed by infoHash and file index, so two
|
|
388
460
|
* callers asking at the same time share one.
|
|
@@ -507,6 +579,53 @@ export class TorrentPool {
|
|
|
507
579
|
this.#adjustUploadLimit();
|
|
508
580
|
}
|
|
509
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Say when a torrent that somebody is reading has stopped downloading.
|
|
584
|
+
*
|
|
585
|
+
* Field 2026-08-05: a session died 32 minutes in because the download fell to
|
|
586
|
+
* **1 KB/s for five minutes** with 186 peer connections open and trackers
|
|
587
|
+
* reporting ~300 seeders, on a torrent that was not finished. ffmpeg then ran
|
|
588
|
+
* out of input and reported itself complete at segment 188 of 624. Not one
|
|
589
|
+
* line of the log said anything was wrong — the collapse had to be recovered
|
|
590
|
+
* afterwards by hand from three unrelated counters.
|
|
591
|
+
*
|
|
592
|
+
* So the stall reports itself, and it reports the two things that tell the
|
|
593
|
+
* candidates apart: whether the swarm was ASKED for anything (pieces selected
|
|
594
|
+
* and still missing, blocks in flight) or was asked and did not answer (peers
|
|
595
|
+
* holding what we want, how many are choking us).
|
|
596
|
+
*
|
|
597
|
+
* @returns {void}
|
|
598
|
+
*/
|
|
599
|
+
#reportStalledDownloads() {
|
|
600
|
+
const now = Date.now();
|
|
601
|
+
for (const torrent of this.torrents.values()) {
|
|
602
|
+
const usage = this.fileUsageByTorrent.get(torrent);
|
|
603
|
+
if (!usage || usage.size === 0 || torrent?.done === true) {
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
const speed = typeof torrent.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
|
|
607
|
+
if (speed >= STALL_SPEED_BYTES) {
|
|
608
|
+
this.#stallSince.delete(torrent);
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const since = this.#stallSince.get(torrent) ?? now;
|
|
612
|
+
this.#stallSince.set(torrent, since);
|
|
613
|
+
if (now - since < STALL_REPORT_AFTER_MS) {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
const lastReport = this.#stallReportedAt.get(torrent) ?? 0;
|
|
617
|
+
if (now - lastReport < STALL_REPORT_INTERVAL_MS) {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
this.#stallReportedAt.set(torrent, now);
|
|
621
|
+
logger.warn(
|
|
622
|
+
`torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] download stalled at ` +
|
|
623
|
+
`${Math.round(speed / 1024)}KB/s for ${Math.round((now - since) / 1000)}s — ` +
|
|
624
|
+
describeSwarmDemand(torrent)
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
510
629
|
#adjustUploadLimit() {
|
|
511
630
|
if (!this.client || this.client.destroyed || typeof this.client.throttleUpload !== "function") {
|
|
512
631
|
return;
|
|
@@ -516,6 +635,7 @@ export class TorrentPool {
|
|
|
516
635
|
this.fileUsageByTorrent,
|
|
517
636
|
Date.now()
|
|
518
637
|
);
|
|
638
|
+
this.#reportStalledDownloads();
|
|
519
639
|
const { bytesPerSec, reason } = decideUploadLimit(active);
|
|
520
640
|
if (bytesPerSec === this.#uploadLimit) {
|
|
521
641
|
return;
|