@torrent-tv/proxy 2.65.0 → 2.66.1
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/server.js +24 -1
- package/services/hls-session-manager.js +123 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.64.10
|
|
2
|
+
|
|
3
|
+
- **Fix**: When a re-encode cannot keep up and nothing LOWER is on offer, the budget asks for the COPY instead of leaving the viewer where they are. "Cheaper" in that rule meant "fewer pixels", and it had no way to express that a copied rung costs no encoder at all, whatever its size — so it looked down, found every rung below refused, and gave up. Field 2026-08-31 and it cost the whole film: a 444x240 ultrafast encode ran at 0.43-0.94x for fifty minutes while the source's own 1038p sat on offer beside it, copied and free; the line `nothing lower is on offer; leaving the picture alone` printed fifty times and the picture stood still 161 times for 940 seconds. On a copied source the way out is up, and it is both the fastest thing the host can serve and the best picture it has (`research/session-2026-08-31-seeks-and-the-copy.md`).
|
|
4
|
+
|
|
5
|
+
## 2.66.0
|
|
6
|
+
|
|
7
|
+
- **New**: A soundtrack that ships beside the picture is fetched WHOLE once the swarm has capacity to spare, so a later switch to it does not wait. It is a twentieth of the picture — 30 MB against 566 MB on the field torrent — and having it on disk is the difference between an instant switch and one that pays for its own first pieces. The moment it starts is not a guess about the swarm: it is when the encoder is already as far ahead of the viewer as `--lookahead` lets it get, which is the cushion figure the session already measures and prints. Once per file, and only for a soundtrack in a file of its own.
|
|
8
|
+
- **Chore**: That fetch is a bounded READ of the file's length, deliberately not `file.select()`. Selecting a whole file alongside the readers' own moving windows is the mistake `#syncSelections` in `torrent-pool.js` was written against — a claim covering everything always outranked the window, and a seek to 89.1 % of a 4.7 GB film waited 93 s while the swarm fetched 2.47 GB in file order. The comment there records it; this goes through the same path the edge warm-up uses.
|
|
9
|
+
|
|
1
10
|
## 2.65.0
|
|
2
11
|
|
|
3
12
|
- **New**: A soundtrack that ships as its own file beside the picture is offered like any other. Releases commonly put a dub in `Rus Sound/<name>.mka` and subtitles in `Sub/[team]/<name>.ass`; until now the video played with its original sound and nothing said the rest of the release existed. The pairing is by name — equal base names, or a shared release hash — and a torrent holding exactly one picture takes every sidecar beside it, since there is nothing else they could belong to. A torrent with several episodes never relaxes that: a wrong pairing would put the sound of one episode over the picture of another, and nothing downstream could notice.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -192,7 +192,30 @@ export async function startProxyServer({
|
|
|
192
192
|
getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params),
|
|
193
193
|
// The file's audio tracks, for the master playlist's rendition group. Already
|
|
194
194
|
// probed for the browser's audio menu; read from there rather than probed again.
|
|
195
|
-
getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params)
|
|
195
|
+
getCachedAudioTracks: (params) => playbackPlanner.getCachedAudioTracks(params),
|
|
196
|
+
// Pull one whole file onto the disk. Used for a soundtrack that ships beside
|
|
197
|
+
// the picture, once the encoder is as far ahead of the viewer as it is
|
|
198
|
+
// allowed to get — the one moment the swarm's capacity is demonstrably
|
|
199
|
+
// spare. A bounded read of the file's whole length, NOT `file.select()`:
|
|
200
|
+
// selecting a file alongside the readers' own windows is what made a seek
|
|
201
|
+
// wait 93 s while the swarm fetched 2.47 GB in file order (see
|
|
202
|
+
// `#syncSelections` in `torrent-pool.js`).
|
|
203
|
+
fetchWholeFile: async ({ sourceKey, fileIndex }) => {
|
|
204
|
+
const record = sourceRegistry.get(sourceKey);
|
|
205
|
+
if (!record) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
209
|
+
const file = torrent?.files?.[fileIndex];
|
|
210
|
+
if (!file || !Number.isFinite(file.length) || file.length <= 0) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await torrentPool.prefetchFileEdges(torrent, fileIndex, {
|
|
214
|
+
headBytes: file.length,
|
|
215
|
+
tailBytes: 0,
|
|
216
|
+
timeoutMs: 600_000
|
|
217
|
+
});
|
|
218
|
+
}
|
|
196
219
|
});
|
|
197
220
|
const playbackPlanner = createPlaybackPlanner({
|
|
198
221
|
ffmpegBin,
|
|
@@ -1626,6 +1626,7 @@ export class HlsSessionManager {
|
|
|
1626
1626
|
tonemapSupported = false,
|
|
1627
1627
|
getCachedMediaInfo = null,
|
|
1628
1628
|
getCachedAudioTracks = null,
|
|
1629
|
+
fetchWholeFile = null,
|
|
1629
1630
|
segmentFormatId = undefined,
|
|
1630
1631
|
stateDir = "",
|
|
1631
1632
|
getTorrentTotals}) {
|
|
@@ -1645,6 +1646,12 @@ export class HlsSessionManager {
|
|
|
1645
1646
|
// The file's audio tracks, for the master playlist's rendition group. Same
|
|
1646
1647
|
// inventory the browser's audio menu is built from.
|
|
1647
1648
|
this.getCachedAudioTracks = typeof getCachedAudioTracks === "function" ? getCachedAudioTracks : null;
|
|
1649
|
+
// Fetch one whole file of a source, as a bounded read rather than a
|
|
1650
|
+
// selection. Used to pull a soundtrack that ships beside the picture onto
|
|
1651
|
+
// the disk while the swarm has capacity to spare — see
|
|
1652
|
+
// `#fetchSpareSoundtracks`. Optional: a proxy wired without it simply reads
|
|
1653
|
+
// such a soundtrack when it is played.
|
|
1654
|
+
this.fetchWholeFile = typeof fetchWholeFile === "function" ? fetchWholeFile : null;
|
|
1648
1655
|
// Optional async accessor for a source's live download stats, used by the
|
|
1649
1656
|
// realtime budget to tell a CPU limit from a download-starved input:
|
|
1650
1657
|
// (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
|
|
@@ -3588,6 +3595,82 @@ export class HlsSessionManager {
|
|
|
3588
3595
|
`${viewers} viewer(s) holding up to ` +
|
|
3589
3596
|
`${deepestBuffer === null ? "?" : deepestBuffer.toFixed(1)}s`
|
|
3590
3597
|
);
|
|
3598
|
+
this.#fetchSpareSoundtracks(session, aheadOfPicture);
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
/**
|
|
3602
|
+
* Fetch the soundtracks that ship beside this picture, whole, while the swarm
|
|
3603
|
+
* has capacity to spare.
|
|
3604
|
+
*
|
|
3605
|
+
* WHY IT WAITS FOR THE CUSHION. A soundtrack nobody has chosen is worth having
|
|
3606
|
+
* on disk — it is a twentieth of the picture (30 MB against 566 MB on the
|
|
3607
|
+
* field torrent) and having it makes every later switch instant instead of
|
|
3608
|
+
* paying for its first pieces. But fetching it takes swarm capacity from the
|
|
3609
|
+
* picture, and there is exactly one moment when that capacity is demonstrably
|
|
3610
|
+
* spare: when the encoder is already as far ahead of the viewer as it is
|
|
3611
|
+
* allowed to get. That is not a guess about the swarm — it is the measurement
|
|
3612
|
+
* the line above just printed.
|
|
3613
|
+
*
|
|
3614
|
+
* WHY IT IS A READ AND NOT A SELECTION. `file.select()` claims every piece of
|
|
3615
|
+
* a file at once, and `#syncSelections` in `torrent-pool.js` records what that
|
|
3616
|
+
* cost when it was done alongside the readers' own windows: a claim covering
|
|
3617
|
+
* everything always outranked the window, and a seek to 89.1% of a 4.7 GB film
|
|
3618
|
+
* waited 93 s while the swarm fetched 2.47 GB in file order. So this goes
|
|
3619
|
+
* through the same bounded read the edge warm-up uses, which claims a moving
|
|
3620
|
+
* window like any other reader and gives it back when it ends.
|
|
3621
|
+
*
|
|
3622
|
+
* Once per file, and only for a soundtrack in a file of its own — the
|
|
3623
|
+
* picture's own tracks are already in the bytes being played.
|
|
3624
|
+
*
|
|
3625
|
+
* @param {HlsSession} session
|
|
3626
|
+
* @param {number} aheadOfPicture - Seconds of film ready ahead of the viewer.
|
|
3627
|
+
* @returns {void}
|
|
3628
|
+
*/
|
|
3629
|
+
#fetchSpareSoundtracks(session, aheadOfPicture) {
|
|
3630
|
+
if (typeof this.fetchWholeFile !== "function") {
|
|
3631
|
+
return;
|
|
3632
|
+
}
|
|
3633
|
+
// The encoder is held at this distance and no further, so reaching it is the
|
|
3634
|
+
// signal that nothing more is being asked of the swarm on the picture's
|
|
3635
|
+
// behalf.
|
|
3636
|
+
if (!(aheadOfPicture >= this.lookaheadSeconds)) {
|
|
3637
|
+
return;
|
|
3638
|
+
}
|
|
3639
|
+
const inventory = this.getCachedAudioTracks?.({
|
|
3640
|
+
sourceKey: session.sourceKey,
|
|
3641
|
+
fileIndex: session.fileIndex
|
|
3642
|
+
}) ?? [];
|
|
3643
|
+
if (!(this.spareSoundtracksFetched instanceof Set)) {
|
|
3644
|
+
this.spareSoundtracksFetched = new Set();
|
|
3645
|
+
}
|
|
3646
|
+
const wanted = new Set(
|
|
3647
|
+
inventory
|
|
3648
|
+
.filter((entry) => entry?.kind === "sidecar" && Number.isInteger(entry.fileIndex))
|
|
3649
|
+
.map((entry) => entry.fileIndex)
|
|
3650
|
+
);
|
|
3651
|
+
for (const fileIndex of wanted) {
|
|
3652
|
+
const key = `${session.sourceKey}:${fileIndex}`;
|
|
3653
|
+
if (this.spareSoundtracksFetched.has(key)) {
|
|
3654
|
+
continue;
|
|
3655
|
+
}
|
|
3656
|
+
this.spareSoundtracksFetched.add(key);
|
|
3657
|
+
logger.info(
|
|
3658
|
+
`transcode ${session.id.slice(0, 8)} the picture is ${Math.round(aheadOfPicture)}s ahead of ` +
|
|
3659
|
+
`the viewer, so file ${fileIndex} — a soundtrack beside it — is fetched whole now; ` +
|
|
3660
|
+
"a switch to it will not wait for the swarm"
|
|
3661
|
+
);
|
|
3662
|
+
// Not awaited: nothing depends on it finishing, and a failure costs only
|
|
3663
|
+
// that the switch pays for its own pieces, as it did before this existed.
|
|
3664
|
+
Promise.resolve(this.fetchWholeFile({ sourceKey: session.sourceKey, fileIndex })).catch(
|
|
3665
|
+
(error) => {
|
|
3666
|
+
logger.info(
|
|
3667
|
+
`transcode: fetching soundtrack file ${fileIndex} whole failed ` +
|
|
3668
|
+
`(${error instanceof Error ? error.message : String(error)}) — ` +
|
|
3669
|
+
"it will be read when it is played"
|
|
3670
|
+
);
|
|
3671
|
+
}
|
|
3672
|
+
);
|
|
3673
|
+
}
|
|
3591
3674
|
}
|
|
3592
3675
|
|
|
3593
3676
|
/**
|
|
@@ -4357,6 +4440,27 @@ export class HlsSessionManager {
|
|
|
4357
4440
|
const next = this.#splicableHeights(base)
|
|
4358
4441
|
.find((height) => height < current && offered.includes(height));
|
|
4359
4442
|
if (next === undefined) {
|
|
4443
|
+
// Nothing lower — but "lower" is not the same question as "cheaper", and
|
|
4444
|
+
// on a source that is COPIED the answer is above, not below. A copied
|
|
4445
|
+
// rung costs no encoder at all, whatever its size, so when a re-encode
|
|
4446
|
+
// cannot keep up it is both the fastest thing this host can serve AND the
|
|
4447
|
+
// best picture it has.
|
|
4448
|
+
//
|
|
4449
|
+
// Field 2026-08-31, and it cost the viewer the whole film: an ultrafast
|
|
4450
|
+
// 444x240 encode ran at 0.43-0.94x for fifty minutes while the source's
|
|
4451
|
+
// own 1038p sat on offer beside it, copied and free. This line printed
|
|
4452
|
+
// fifty times — "nothing lower is on offer; leaving the picture alone" —
|
|
4453
|
+
// and the picture stood still 161 times for 940 seconds. The rescue was
|
|
4454
|
+
// on the screen the whole time and the rule could only look down.
|
|
4455
|
+
const copied = this.#copiedHeightOf(base);
|
|
4456
|
+
if (copied > 0 && copied !== current && offered.includes(copied)) {
|
|
4457
|
+
logger.info(
|
|
4458
|
+
`[budget] transcode ${session.id} ${reasonText} at ${current}p and nothing lower is on offer, ` +
|
|
4459
|
+
`but ${copied}p is COPIED on this file — no encoder at all, and a better picture. ` +
|
|
4460
|
+
`Asking for it instead of leaving the viewer on an encode that cannot keep up`
|
|
4461
|
+
);
|
|
4462
|
+
return this.#askQualityHeight(base, copied, reasonText);
|
|
4463
|
+
}
|
|
4360
4464
|
logger.info(
|
|
4361
4465
|
`[budget] transcode ${session.id} ${reasonText} at ${current}p, but nothing lower is on offer ` +
|
|
4362
4466
|
`for "${session.fileName}"; leaving the picture alone`
|
|
@@ -4366,6 +4470,25 @@ export class HlsSessionManager {
|
|
|
4366
4470
|
return this.#askQualityHeight(base, next, reasonText);
|
|
4367
4471
|
}
|
|
4368
4472
|
|
|
4473
|
+
/**
|
|
4474
|
+
* The height this family serves by COPY, or zero when every rung is encoded.
|
|
4475
|
+
*
|
|
4476
|
+
* The one rung whose cost does not depend on the machine: the source's own
|
|
4477
|
+
* height, on a base whose video is not re-encoded. `offeredHeights` never
|
|
4478
|
+
* withdraws it for that reason, so it is always available as somewhere to
|
|
4479
|
+
* return to — which is exactly what {@link HlsSessionManager##askLowerHeight}
|
|
4480
|
+
* had no way to say.
|
|
4481
|
+
*
|
|
4482
|
+
* @param {HlsSession} base
|
|
4483
|
+
* @returns {number}
|
|
4484
|
+
*/
|
|
4485
|
+
#copiedHeightOf(base) {
|
|
4486
|
+
if (!base || base.transcodeVideo === true) {
|
|
4487
|
+
return 0;
|
|
4488
|
+
}
|
|
4489
|
+
return Math.round(Number(base.sourceHeight) || 0);
|
|
4490
|
+
}
|
|
4491
|
+
|
|
4369
4492
|
/**
|
|
4370
4493
|
* Record a request to the viewer's player to move to another variant.
|
|
4371
4494
|
*
|