@torrent-tv/proxy 2.91.0 → 2.92.0
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/bin/cli.js +3 -2
- package/package.json +18 -2
- package/routes/api/playback-plan/post.js +21 -4
- package/routes/api/sources/files/get.js +20 -28
- package/routes/api/sources/post.js +4 -2
- package/routes/api/sources/stats/get.js +4 -11
- package/routes/api/sources/viewer/post.js +16 -0
- package/routes/api/sources/warm/post.js +6 -215
- package/routes/api/subtitles/get.js +22 -125
- package/routes/api/subtitles/post.js +26 -0
- package/routes/api/transcode-sessions/post.js +24 -2
- package/routes/media/get.js +80 -0
- package/routes/stream/get.js +26 -94
- package/routes/transcode/audio-warm/get.js +9 -21
- package/routes/transcode/variant-warm/get.js +8 -14
- package/server.js +410 -46
- package/services/encode/AdmittedInput.js +136 -0
- package/services/encode/CushionReport.js +1 -76
- package/services/encode/EncodeInputs.js +142 -0
- package/services/encode/EncodeOrchestrator.js +4 -1
- package/services/encode/EncodeRun.js +13 -4
- package/services/encode/EncodeRuns.js +132 -52
- package/services/encode/InputFailures.js +37 -0
- package/services/encode/MatroskaInput.js +158 -0
- package/services/encode/OutputOpening.js +16 -64
- package/services/encode/Renditions.js +1 -17
- package/services/encode/admitted-command.js +60 -0
- package/services/encode/annex-b-input.js +37 -0
- package/services/encode/bit-payload.js +29 -0
- package/services/encode/output/OutputCatalog.js +1 -1
- package/services/encode/output/Timeline.js +11 -0
- package/services/encode/pcm-normalization.js +30 -0
- package/services/encode/piece-completeness.js +54 -1
- package/services/encode/quality/HostLoad.js +19 -73
- package/services/encode/quality/QualityOffer.js +1 -1
- package/services/encode/run-command.js +1 -589
- package/services/encode/segment-formats/mp4-boxes.js +3 -1
- package/services/encode/segment-formats/presentation-segment.js +114 -0
- package/services/media/ContainerOrchestrator.js +118 -48
- package/services/media/KeyframeTables.js +16 -115
- package/services/media/MediaReadRequests.js +120 -0
- package/services/media/ProbeRequests.js +135 -0
- package/services/media/SegmentInputs.js +48 -0
- package/services/media/SourceFile.js +6 -0
- package/services/media/SubtitleOrchestrator.js +190 -4
- package/services/media/await-plan.js +47 -0
- package/services/media/container/AsfContainer.js +217 -0
- package/services/media/container/AviContainer.js +343 -34
- package/services/media/container/CadencedPictureOrder.js +45 -0
- package/services/media/container/Container.js +7 -3
- package/services/media/container/ContainerFactory.js +10 -3
- package/services/media/container/FfprobeContainer.js +222 -0
- package/services/media/container/MatroskaContainer.js +72 -46
- package/services/media/container/Mp4Container.js +472 -139
- package/services/media/container/MpegPsContainer.js +281 -0
- package/services/media/container/MpegTsContainer.js +277 -0
- package/services/media/container/PacketIndex.js +270 -0
- package/services/media/container/PacketRecords.js +237 -0
- package/services/media/container/RetainedBytes.js +39 -0
- package/services/media/container/RetainedReads.js +51 -0
- package/services/media/container/ac3-frame.js +46 -0
- package/services/media/container/asf-packets.js +153 -0
- package/services/media/container/avc-picture-order.js +50 -0
- package/services/media/container/avi-open-dml.js +47 -0
- package/services/media/container/dts-frame.js +51 -0
- package/services/media/container/ebml-stream.js +12 -1
- package/services/media/container/ffprobe-record.js +45 -0
- package/services/media/container/flv-packet-ranges.js +27 -0
- package/services/media/container/following-packet-time.js +37 -0
- package/services/media/container/h264-configuration.js +183 -0
- package/services/media/container/h264-elementary-index.js +245 -0
- package/services/media/container/hevc-configuration.js +165 -0
- package/services/media/container/hevc-elementary-index.js +132 -0
- package/services/media/container/hevc-picture-order.js +66 -0
- package/services/media/container/latm-frame.js +99 -0
- package/services/media/container/matroska-packets.js +207 -0
- package/services/media/container/memory-unavailable.js +8 -0
- package/services/media/container/mp3-packet-dependencies.js +41 -0
- package/services/media/container/mp4-fragments.js +148 -0
- package/services/media/container/mpeg-audio-frame.js +19 -0
- package/services/media/container/mpeg-audio-reservoir.js +27 -0
- package/services/media/container/mpeg-elementary-index.js +196 -0
- package/services/media/container/mpeg-sequence-reader.js +69 -0
- package/services/media/container/mpeg-ts-packets.js +93 -0
- package/services/media/container/mpeg-video-configuration.js +50 -0
- package/services/media/container/mpeg4-picture-timing.js +108 -0
- package/services/media/container/pes-header.js +34 -0
- package/services/media/playback-declarations.js +57 -0
- package/services/media/playback-planner.js +72 -230
- package/services/media/probe-packets.js +61 -0
- package/services/media/read-playback-declarations.js +18 -0
- package/services/media/tracks/AudioTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +4 -1
- package/services/server/OutputLifecycle.js +2 -0
- package/services/server/SegmentServing.js +40 -33
- package/services/server/ViewerRequests.js +22 -41
- package/services/server/controllers/PlaybackController.js +4 -0
- package/services/server/controllers/SubtitleController.js +8 -29
- package/services/server/transcode-session-files.js +58 -99
- package/services/server/wire-outputs.js +58 -15
- package/services/storage/IndexMemory.js +85 -0
- package/services/storage/MachineBudget.js +27 -3
- package/services/storage/allowance.js +8 -1
- package/services/storage/piece-store/piece-disk-store.js +18 -3
- package/services/storage/piece-store/piece-lru.js +8 -4
- package/services/storage/piece-store/shared-piece-store.js +26 -11
- package/services/storage/segment-store/SegmentStore.js +49 -18
- package/services/storage/wire.js +7 -0
- package/services/torrent/Contents.js +52 -20
- package/services/torrent/demand/DemandRegister.js +13 -4
- package/services/torrent/demand/Window.js +13 -2
- package/services/torrent/demand-progress.js +25 -0
- package/services/torrent/download/DeadlineScheduler.js +167 -0
- package/services/torrent/download/FutureDownload.js +166 -0
- package/services/torrent/download/SwarmSelection.js +30 -59
- package/services/torrent/download/registry.js +45 -5
- package/services/torrent/download/withdraw-claim.js +2 -0
- package/services/torrent/torrent-pool.js +39 -600
- package/services/torrent/worker/channel.js +8 -1
- package/services/torrent/worker/client.js +74 -72
- package/services/torrent/worker/files-in-use.js +9 -0
- package/services/torrent/worker/handle-state.js +12 -0
- package/services/torrent/worker/held-bytes.js +82 -99
- package/services/torrent/worker/held-file-bytes.js +17 -0
- package/services/torrent/worker/piece-reader.js +67 -683
- package/services/torrent/worker/pool-adapter.js +37 -123
- package/services/torrent/worker/protocol.js +6 -17
- package/services/torrent/worker/whole-file-stats.js +2 -0
- package/services/torrent/worker/worker.js +86 -156
- package/services/transport/data-channel-handler.js +56 -13
- package/services/viewer/DownloadMaps.js +168 -0
- package/services/viewer/NativeSourceMap.js +33 -0
- package/services/viewer/PriorityMap.js +49 -40
- package/services/viewer/PriorityOrchestrator.js +42 -13
- package/services/viewer/SourcePreparation.js +278 -0
- package/services/viewer/Viewer.js +11 -0
- package/services/viewer/Viewers.js +109 -2
- package/services/viewer/playback-readiness.js +50 -63
- package/services/viewer/segment-demands.js +37 -0
- package/services/viewer/urgent-output-ready.js +26 -0
- package/services/encode/run-inputs.js +0 -53
- package/services/media/keyframe-probe.js +0 -157
- package/services/media/media-info-probe.js +0 -93
- package/services/torrent/worker/background-fill.js +0 -213
- package/services/torrent/worker/resume-warm.js +0 -107
package/bin/cli.js
CHANGED
|
@@ -442,10 +442,11 @@ try {
|
|
|
442
442
|
// in and at the same instant as them.
|
|
443
443
|
readExtra: () => {
|
|
444
444
|
const fragments = fragmentBufferCollection();
|
|
445
|
-
|
|
445
|
+
const pieces = fragments.seen === 0
|
|
446
446
|
? ""
|
|
447
447
|
: `piece buffers handed here ${fragments.seen} seen, ${fragments.collected} collected, ` +
|
|
448
448
|
`${fragments.seen - fragments.collected} still alive`;
|
|
449
|
+
return [pieces, `binary packet indexes ${started.mediaMemory.packetIndexBytes()} bytes`].filter(Boolean).join("; ");
|
|
449
450
|
},
|
|
450
451
|
// Once a minute cannot see what kills this process. Both out-of-memory
|
|
451
452
|
// kills of 2026-09-02 happened inside a single gap of the old cadence: the
|
|
@@ -641,7 +642,7 @@ try {
|
|
|
641
642
|
// reason as the transport snapshot above: the manager is built inside
|
|
642
643
|
// `startProxyServer`, with this handler already in hand.
|
|
643
644
|
onViewerPresent: (consumerId) => {
|
|
644
|
-
started?.outputParts?.viewers?.
|
|
645
|
+
started?.outputParts?.viewers?.present?.(consumerId);
|
|
645
646
|
},
|
|
646
647
|
onViewerGone: (consumerId, because) => {
|
|
647
648
|
void started?.outputParts?.lifecycle?.viewerHasGone?.(consumerId, because)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.92.0",
|
|
4
4
|
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -29,13 +29,14 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"start": "node ./bin/cli.js",
|
|
31
31
|
"dev": "node --inspect=0 --experimental-network-inspection ./bin/cli.js",
|
|
32
|
-
"test": "npm run lint && node --test",
|
|
32
|
+
"test": "npm run lint && node --require ./test/no-torrent/support/refuse-torrent.cjs --test \"test/no-torrent/**/*.test.js\"",
|
|
33
33
|
"graph": "node scripts/render-run-graph.js",
|
|
34
34
|
"lint": "biome lint ."
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@fastify/cors": "^11.2.0",
|
|
38
38
|
"@fastify/helmet": "^13.0.2",
|
|
39
|
+
"@ffprobe-installer/ffprobe": "^2.1.2",
|
|
39
40
|
"@silentbot1/nat-api": "^0.4.9",
|
|
40
41
|
"chalk": "^5.4.1",
|
|
41
42
|
"commander": "^12.1.0",
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
"node-datachannel": "^0.32.0",
|
|
47
48
|
"parse-torrent": "^11.0.23",
|
|
48
49
|
"uint8-util": "2.2.6",
|
|
50
|
+
"undici": "^7.30.0",
|
|
49
51
|
"webtorrent": "2.8.5",
|
|
50
52
|
"werift": "^0.24.2",
|
|
51
53
|
"ws": "^8.18.2"
|
|
@@ -53,6 +55,20 @@
|
|
|
53
55
|
"devDependencies": {
|
|
54
56
|
"@biomejs/biome": "^2.5.7"
|
|
55
57
|
},
|
|
58
|
+
"allowScripts": {
|
|
59
|
+
"@ffprobe-installer/darwin-arm64@5.0.1": true,
|
|
60
|
+
"@ffprobe-installer/darwin-x64@5.1.0": true,
|
|
61
|
+
"@ffprobe-installer/linux-arm@5.2.0": true,
|
|
62
|
+
"@ffprobe-installer/linux-arm64@5.2.0": true,
|
|
63
|
+
"@ffprobe-installer/linux-ia32@5.2.0": true,
|
|
64
|
+
"@ffprobe-installer/linux-x64@5.2.0": true,
|
|
65
|
+
"@torrent-tv/utp-native@2.5.3-ttv.9": true,
|
|
66
|
+
"bufferutil@4.1.0": true,
|
|
67
|
+
"ffmpeg-static@5.3.0": true,
|
|
68
|
+
"node-datachannel@0.32.3": true,
|
|
69
|
+
"utf-8-validate@6.0.6": true,
|
|
70
|
+
"ip-set": false
|
|
71
|
+
},
|
|
56
72
|
"overrides": {
|
|
57
73
|
"utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.9"
|
|
58
74
|
}
|
|
@@ -24,23 +24,37 @@ function getPayload(body) {
|
|
|
24
24
|
return {};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl }) {
|
|
27
|
+
export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl, viewers }) {
|
|
28
28
|
const payload = getPayload(req.body);
|
|
29
29
|
const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
|
|
30
30
|
const fileIndex = Number(payload.fileIndex);
|
|
31
31
|
const userAgent = typeof payload.userAgent === "string" ? payload.userAgent : "";
|
|
32
32
|
|
|
33
|
-
if (!sourceKey || !Number.
|
|
33
|
+
if (!sourceKey || !Number.isSafeInteger(fileIndex) || fileIndex < 0) {
|
|
34
34
|
return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
|
|
35
35
|
}
|
|
36
|
+
const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
|
|
37
|
+
const selection = {};
|
|
38
|
+
if (typeof payload.positionSeconds === "number" && Number.isFinite(payload.positionSeconds) && payload.positionSeconds >= 0) selection.positionSeconds = payload.positionSeconds;
|
|
39
|
+
if (typeof payload.wantsToPlay === "boolean") selection.wantsToPlay = payload.wantsToPlay;
|
|
40
|
+
if (consumerId) viewers?.selectsFile(consumerId, sourceKey, fileIndex, Date.now(), selection);
|
|
36
41
|
|
|
37
42
|
// Interface delegates to PlaybackController (orchestrator + domain). Keeps route thin.
|
|
38
43
|
const { PlaybackController } = await import("../../../services/server/controllers/PlaybackController.js");
|
|
39
44
|
const controller = new PlaybackController({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner });
|
|
45
|
+
const cancellation = new AbortController();
|
|
46
|
+
const aborted = () => cancellation.abort();
|
|
47
|
+
const closed = () => { if (!reply.raw?.writableEnded) aborted(); };
|
|
48
|
+
req.raw?.once?.("aborted", aborted);
|
|
49
|
+
reply.raw?.once?.("close", closed);
|
|
50
|
+
if (req.raw?.aborted) aborted();
|
|
40
51
|
try {
|
|
41
|
-
const
|
|
52
|
+
const params = { sourceKey, fileIndex, userAgent };
|
|
53
|
+
const plan = payload.waitForReady === true
|
|
54
|
+
? await controller.getReadyPlan(params, { signal: cancellation.signal }) : await controller.getPlan(params);
|
|
42
55
|
return reply.send(plan);
|
|
43
56
|
} catch (error) {
|
|
57
|
+
if (cancellation.signal.aborted) return;
|
|
44
58
|
if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
|
|
45
59
|
return reply.code(404).send({ error: error.message });
|
|
46
60
|
}
|
|
@@ -48,6 +62,9 @@ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, s
|
|
|
48
62
|
return reply.code(404).send({ error: error.message });
|
|
49
63
|
}
|
|
50
64
|
const message = error instanceof Error ? error.message : String(error);
|
|
51
|
-
return reply.code(500).send({ error: `Failed to prepare playback plan: ${message}
|
|
65
|
+
return reply.code(500).send({ error: `Failed to prepare playback plan: ${message}`, code: error?.code, canRetry: error?.canRetry !== false });
|
|
66
|
+
} finally {
|
|
67
|
+
req.raw?.removeListener?.("aborted", aborted);
|
|
68
|
+
reply.raw?.removeListener?.("close", closed);
|
|
52
69
|
}
|
|
53
70
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { contentsOf } from "../../../../services/torrent/Contents.js";
|
|
2
|
+
import { logger } from "../../../../utils/logger.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* List the files of a registered source (torrent file OR magnet).
|
|
@@ -7,13 +8,8 @@ import { contentsOf } from "../../../../services/torrent/Contents.js";
|
|
|
7
8
|
*
|
|
8
9
|
* The browser parses `.torrent` files locally, but a magnet URI carries no
|
|
9
10
|
* file list — the metadata comes from the swarm and can take a while to
|
|
10
|
-
* arrive on a cold magnet.
|
|
11
|
-
*
|
|
12
|
-
* premature "no peers" error while the metadata was in fact still arriving),
|
|
13
|
-
* this waits only a short per-request budget: if the metadata is not ready it
|
|
14
|
-
* returns `{ pending: true }` while the fetch continues in the background. The
|
|
15
|
-
* caller polls again until the file list comes back — mirroring the cold-
|
|
16
|
-
* torrent playback-plan poll.
|
|
11
|
+
* arrive on a cold magnet. Metadata readiness, failure or caller cancellation
|
|
12
|
+
* ends the request; elapsed time does not change the answer about a torrent.
|
|
17
13
|
*
|
|
18
14
|
* @param {import("fastify").FastifyRequest} req
|
|
19
15
|
* @param {import("fastify").FastifyReply} reply
|
|
@@ -24,10 +20,9 @@ import { contentsOf } from "../../../../services/torrent/Contents.js";
|
|
|
24
20
|
* @returns {Promise<void>}
|
|
25
21
|
*/
|
|
26
22
|
|
|
27
|
-
|
|
28
|
-
const PENDING = Symbol("pending");
|
|
23
|
+
const CANCELLED = Symbol("cancelled");
|
|
29
24
|
|
|
30
|
-
export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
25
|
+
export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers }) {
|
|
31
26
|
const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
32
27
|
if (!sourceKey) {
|
|
33
28
|
return reply.code(400).send({ error: "sourceKey is required." });
|
|
@@ -37,20 +32,13 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
|
|
|
37
32
|
return reply.code(404).send({ error: "Source key was not found." });
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// downloading between polls). Race it against the wait budget; if the wait
|
|
48
|
-
// wins, the fetch is left running for the next poll to observe.
|
|
49
|
-
let timer;
|
|
50
|
-
const waitPromise = new Promise((resolve) => {
|
|
51
|
-
timer = setTimeout(() => resolve(PENDING), maxWaitMs);
|
|
52
|
-
timer.unref?.();
|
|
53
|
-
});
|
|
35
|
+
let cancelled;
|
|
36
|
+
const waitPromise = new Promise(resolve => { cancelled = () => resolve(CANCELLED); });
|
|
37
|
+
reply.raw?.once?.("close", cancelled);
|
|
38
|
+
if (reply.raw?.destroyed) {
|
|
39
|
+
reply.raw?.removeListener?.("close", cancelled);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
54
42
|
const torrentPromise = torrentPool
|
|
55
43
|
.getTorrent(sourceRecord.sourceType, sourceRecord.source)
|
|
56
44
|
// Swallow so a rejection that loses the race is not an unhandled rejection;
|
|
@@ -61,12 +49,10 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
|
|
|
61
49
|
try {
|
|
62
50
|
result = await Promise.race([torrentPromise, waitPromise]);
|
|
63
51
|
} finally {
|
|
64
|
-
|
|
52
|
+
reply.raw?.removeListener?.("close", cancelled);
|
|
65
53
|
}
|
|
66
54
|
|
|
67
|
-
if (result ===
|
|
68
|
-
return reply.send({ pending: true });
|
|
69
|
-
}
|
|
55
|
+
if (result === CANCELLED) return;
|
|
70
56
|
if (result instanceof Error) {
|
|
71
57
|
return reply.code(502).send({ error: `Could not load torrent metadata: ${result.message}` });
|
|
72
58
|
}
|
|
@@ -80,6 +66,12 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
|
|
|
80
66
|
// only video present). It ships the paths already relative to the torrent
|
|
81
67
|
// root, so there is no stripping rule on the other side either.
|
|
82
68
|
const contents = contentsOf(torrent);
|
|
69
|
+
for (const file of contents.files()) {
|
|
70
|
+
if (file.excludedReason) logger.info(`contents request=${req.id ?? "unstated"} source=${sourceKey} ` +
|
|
71
|
+
`file=${file.fileIndex} name=${JSON.stringify(file.relativePath)} bytes=${file.length} excluded=${file.excludedReason}`);
|
|
72
|
+
}
|
|
73
|
+
const consumerId = typeof req.query?.consumerId === "string" ? req.query.consumerId.trim() : "";
|
|
74
|
+
if (consumerId) viewers?.visibleFiles(consumerId, sourceKey, contents.items.map(item => item.fileIndex));
|
|
83
75
|
return reply.send({
|
|
84
76
|
name: torrent.name ?? "",
|
|
85
77
|
infoHash: torrent.infoHash ?? "",
|
|
@@ -23,7 +23,7 @@ function getPayload(body) {
|
|
|
23
23
|
return {};
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export async function handleApiSourcesPost(req, reply, { sourceRegistry }) {
|
|
26
|
+
export async function handleApiSourcesPost(req, reply, { sourceRegistry, viewers }) {
|
|
27
27
|
const payload = getPayload(req.body);
|
|
28
28
|
const sourceType = typeof payload.sourceType === "string" ? payload.sourceType : "";
|
|
29
29
|
const source = typeof payload.source === "string" ? payload.source : "";
|
|
@@ -32,5 +32,7 @@ export async function handleApiSourcesPost(req, reply, { sourceRegistry }) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const sourceKey = await sourceRegistry.upsert(sourceType, source);
|
|
35
|
-
|
|
35
|
+
const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
|
|
36
|
+
if (consumerId) viewers?.selectsSource(consumerId, sourceKey);
|
|
37
|
+
return reply.send({ sourceKey, playbackMapVersion: 1 });
|
|
36
38
|
}
|
|
@@ -30,32 +30,25 @@ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torr
|
|
|
30
30
|
|
|
31
31
|
let torrent;
|
|
32
32
|
try {
|
|
33
|
-
|
|
34
|
-
torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
33
|
+
torrent = torrentPool.knownTorrent(sourceKey);
|
|
35
34
|
} catch (error) {
|
|
36
35
|
const message = error instanceof Error ? error.message : String(error);
|
|
37
36
|
logger.warn(`stats: getTorrent failed for ${sourceKey.slice(0, 8)}: ${message} stack=${error instanceof Error ? error.stack?.split("\n")[1]?.trim() ?? "" : ""}`);
|
|
38
37
|
return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
|
|
39
38
|
}
|
|
40
39
|
if (!torrent) {
|
|
41
|
-
|
|
42
|
-
return reply.code(500).send({ error: "Torrent instance not found" });
|
|
40
|
+
return reply.code(202).send({ pending: true });
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
46
44
|
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
47
45
|
|
|
48
|
-
// Optional: pin the resume window to a FIXED byte offset for the duration of
|
|
49
|
-
// one buffering episode (see getFileStats JSDoc) instead of the live, moving
|
|
50
|
-
// read position — otherwise "bytes needed" can jump up mid-poll as the window
|
|
51
|
-
// slides forward with playback/encoding progress.
|
|
52
|
-
const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
|
|
53
|
-
const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
|
|
54
46
|
|
|
55
47
|
// Awaited: with the torrent on its own thread this is a round trip, not a
|
|
56
48
|
// local lookup. Without the await the reply was the pending promise itself,
|
|
57
49
|
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
58
|
-
const stats = await torrentPool.getFileStats(torrent, fileIndex
|
|
50
|
+
const stats = await torrentPool.getFileStats(torrent, fileIndex);
|
|
51
|
+
if (stats?.pending || !stats) return reply.code(202).send({ pending: true });
|
|
59
52
|
|
|
60
53
|
// Diagnostic: surface the real swarm state per poll so a cold-start download
|
|
61
54
|
// stall (0 peers / header not advancing → playback-plan blocks on the codec
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Report the position and intent of a viewer playing the source directly. */
|
|
2
|
+
export async function handleApiSourceViewerPost(req, reply, { sourceRegistry, viewers }) {
|
|
3
|
+
const { sourceKey, fileIndex: indexText } = req.params;
|
|
4
|
+
const fileIndex = Number(indexText);
|
|
5
|
+
const body = req.body;
|
|
6
|
+
if (!Number.isSafeInteger(fileIndex) || fileIndex < 0 || !body || typeof body !== "object" ||
|
|
7
|
+
typeof body.consumerId !== "string" || !body.consumerId) {
|
|
8
|
+
return reply.code(400).send({ error: "A file index and consumerId are required.", canRetry: false });
|
|
9
|
+
}
|
|
10
|
+
if (!sourceRegistry.get(sourceKey)) return reply.code(404).send({ error: "Source key was not found.", canRetry: false });
|
|
11
|
+
if (!viewers.reportSource(body.consumerId, sourceKey, fileIndex, body)) {
|
|
12
|
+
return reply.code(409).send({ error: "The source selection is no longer current.",
|
|
13
|
+
code: "REQUEST_OBSOLETE", canRetry: false });
|
|
14
|
+
}
|
|
15
|
+
return reply.send({ received: true });
|
|
16
|
+
}
|
|
@@ -1,217 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { contentsOf } from "../../../../services/torrent/Contents.js";
|
|
4
|
-
|
|
5
|
-
const metadataWarmups = new Map();
|
|
6
|
-
const METADATA_RETRY_MS = 2500;
|
|
7
|
-
|
|
8
|
-
function startMetadataWarmup({ sourceKey, items, preferredFileIndex, userAgent, playbackPlanner, torrentPool, torrent }) {
|
|
9
|
-
if (!playbackPlanner || !Array.isArray(items) || items.length === 0) {
|
|
10
|
-
return false;
|
|
11
|
-
}
|
|
12
|
-
let state = metadataWarmups.get(sourceKey);
|
|
13
|
-
if (!state) {
|
|
14
|
-
state = { pending: new Set(), preferredFileIndex: null, running: false, items: [] };
|
|
15
|
-
metadataWarmups.set(sourceKey, state);
|
|
16
|
-
}
|
|
17
|
-
state.items = items;
|
|
18
|
-
for (const item of items) {
|
|
19
|
-
if (Number.isInteger(item?.fileIndex)) {
|
|
20
|
-
state.pending.add(item.fileIndex);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
if (Number.isInteger(preferredFileIndex) && state.pending.has(preferredFileIndex)) {
|
|
24
|
-
state.preferredFileIndex = preferredFileIndex;
|
|
25
|
-
}
|
|
26
|
-
if (state.running) {
|
|
27
|
-
return true;
|
|
28
|
-
}
|
|
29
|
-
state.running = true;
|
|
30
|
-
void (async () => {
|
|
31
|
-
try {
|
|
32
|
-
while (state.pending.size > 0) {
|
|
33
|
-
const fileIndex = state.preferredFileIndex !== null && state.pending.has(state.preferredFileIndex)
|
|
34
|
-
? state.preferredFileIndex
|
|
35
|
-
: state.pending.values().next().value;
|
|
36
|
-
state.preferredFileIndex = null;
|
|
37
|
-
if (!Number.isInteger(fileIndex)) {
|
|
38
|
-
break;
|
|
39
|
-
}
|
|
40
|
-
const item = state.items.find((entry) => entry.fileIndex === fileIndex);
|
|
41
|
-
if (!item) {
|
|
42
|
-
state.pending.delete(fileIndex);
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
const sidecars = contentsOf(torrent).sidecarsOf(fileIndex);
|
|
46
|
-
const sidecarFiles = [...sidecars.audio, ...sidecars.subtitles];
|
|
47
|
-
await Promise.all(sidecarFiles.map((file) =>
|
|
48
|
-
torrentPool.prefetchFileEdges(torrent, file.fileIndex, {
|
|
49
|
-
tailBytes: 0,
|
|
50
|
-
headBytes: TEXT_SUBTITLE_SIDECAR_EXTENSIONS.has(file.extension)
|
|
51
|
-
? Math.max(1, file.length)
|
|
52
|
-
: undefined,
|
|
53
|
-
timeoutMs: 10_000
|
|
54
|
-
}).catch((error) => {
|
|
55
|
-
logger.info(`warm ${sourceKey.slice(0, 8)}:${fileIndex}: sidecar metadata not ready for "${file.name}": ${error?.message ?? error}`);
|
|
56
|
-
})
|
|
57
|
-
));
|
|
58
|
-
const plan = await playbackPlanner.getPlan({
|
|
59
|
-
sourceKey,
|
|
60
|
-
fileIndex,
|
|
61
|
-
userAgent,
|
|
62
|
-
maxWaitMs: 0,
|
|
63
|
-
background: true
|
|
64
|
-
}).catch((error) => {
|
|
65
|
-
logger.info(`warm ${sourceKey.slice(0, 8)}:${fileIndex}: media metadata not ready: ${error?.message ?? error}`);
|
|
66
|
-
return null;
|
|
67
|
-
});
|
|
68
|
-
if (plan && !plan.pending) {
|
|
69
|
-
state.pending.delete(fileIndex);
|
|
70
|
-
} else if (torrent.files?.[fileIndex]?.done === true) {
|
|
71
|
-
state.pending.delete(fileIndex);
|
|
72
|
-
}
|
|
73
|
-
if (state.pending.size > 0) {
|
|
74
|
-
await new Promise((resolve) => setTimeout(resolve, METADATA_RETRY_MS));
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
} catch (error) {
|
|
78
|
-
logger.warn(`warm ${sourceKey.slice(0, 8)}: metadata queue stopped: ${error?.message ?? error}`);
|
|
79
|
-
} finally {
|
|
80
|
-
state.running = false;
|
|
81
|
-
if (state.pending.size === 0) {
|
|
82
|
-
metadataWarmups.delete(sourceKey);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
})();
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Start fetching a source before anyone asks to play it.
|
|
91
|
-
*
|
|
92
|
-
* POST /api/sources/:sourceKey/warm { fileIndex?: number, positionSeconds?: number, userAgent?: string }
|
|
93
|
-
*
|
|
94
|
-
* Everything a torrent must do before the first byte of video can be served
|
|
95
|
-
* takes seconds and none of it depends on the viewer: announce to the
|
|
96
|
-
* trackers, connect to peers, be unchoked by them, and fetch the two pieces at
|
|
97
|
-
* the file's edges that the codec probe reads. Measured 2026-08-04 on a cold
|
|
98
|
-
* 7.4 GB torrent: 6.7 s of the 10.3 s before playback was those two pieces
|
|
99
|
-
* arriving, with the swarm ramping from nothing.
|
|
100
|
-
*
|
|
101
|
-
* That work used to begin only when a file had been chosen, because it was
|
|
102
|
-
* buried inside the playback plan. It can begin as soon as the viewer has
|
|
103
|
-
* picked a TORRENT — while they are still reading the list of episodes — and
|
|
104
|
-
* then most or all of it has happened by the time they choose.
|
|
105
|
-
*
|
|
106
|
-
* Returns as soon as the work is under way. The caller is not waiting for a
|
|
107
|
-
* result; it is only saying "you may start". Failures are logged and answered
|
|
108
|
-
* as `started: false` rather than as an error, because nothing is broken if a
|
|
109
|
-
* warm-up does not happen — the ordinary path still does all of it.
|
|
110
|
-
*
|
|
111
|
-
* @param {import("fastify").FastifyRequest} req
|
|
112
|
-
* @param {import("fastify").FastifyReply} reply
|
|
113
|
-
* @param {{
|
|
114
|
-
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
115
|
-
* torrentPool: import("../../../../services/torrent/torrent-pool.js").TorrentPool,
|
|
116
|
-
* playbackPlanner?: ReturnType<import("../../../../services/media/playback-planner.js").createPlaybackPlanner>,
|
|
117
|
-
* durationOf?: ({ sourceKey: string, fileIndex: number }) => Promise<number | null>
|
|
118
|
-
* }} deps
|
|
119
|
-
* @returns {Promise<void>}
|
|
120
|
-
*/
|
|
121
|
-
export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool, playbackPlanner = null, durationOf = null }) {
|
|
1
|
+
/** Compatibility acknowledgement: source preparation is owned by present viewers. */
|
|
2
|
+
export async function handleApiSourceWarmPost(req, reply, { sourceRegistry, viewers }) {
|
|
122
3
|
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
123
|
-
if (!sourceKey) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
128
|
-
if (!sourceRecord) {
|
|
129
|
-
return reply.code(404).send({ error: "Source key was not found." });
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
|
|
133
|
-
const requestedIndex = Number(body.fileIndex);
|
|
134
|
-
const fileIndex = Number.isInteger(requestedIndex) && requestedIndex >= 0 ? requestedIndex : null;
|
|
135
|
-
// Where the viewer is about to resume, if they are resuming. The edges below
|
|
136
|
-
// are what the codec probe reads; this is what the VIEWER will read, and until
|
|
137
|
-
// now nothing asked for it before the encoder did.
|
|
138
|
-
const requestedPosition = Number(body.positionSeconds);
|
|
139
|
-
const positionSeconds = Number.isFinite(requestedPosition) && requestedPosition > 0
|
|
140
|
-
? requestedPosition
|
|
141
|
-
: 0;
|
|
142
|
-
const userAgent = typeof body.userAgent === "string" ? body.userAgent : "";
|
|
143
|
-
|
|
144
|
-
// Adding the torrent is what announces to the trackers and starts connecting
|
|
145
|
-
// to peers, and it is also what a magnet needs in order to fetch its
|
|
146
|
-
// metadata. It is awaited because everything else needs the torrent object,
|
|
147
|
-
// and because until it resolves there is nothing to report.
|
|
148
|
-
let torrent;
|
|
149
|
-
try {
|
|
150
|
-
torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
151
|
-
} catch (error) {
|
|
152
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
153
|
-
logger.warn(`warm ${sourceKey.slice(0, 8)}: could not add the torrent: ${message}`);
|
|
154
|
-
return reply.send({ started: false, swarm: false, edges: false });
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const contents = contentsOf(torrent);
|
|
158
|
-
const named = fileIndex !== null && torrent.files?.[fileIndex] ? fileIndex : null;
|
|
159
|
-
if (named !== null) {
|
|
160
|
-
const selectedSidecars = contents.sidecarsOf(named);
|
|
161
|
-
for (const file of [...selectedSidecars.audio, ...selectedSidecars.subtitles]) {
|
|
162
|
-
void torrentPool.prefetchFileEdges(torrent, file.fileIndex, {
|
|
163
|
-
tailBytes: 0,
|
|
164
|
-
headBytes: TEXT_SUBTITLE_SIDECAR_EXTENSIONS.has(file.extension)
|
|
165
|
-
? Math.max(1, file.length)
|
|
166
|
-
: undefined,
|
|
167
|
-
timeoutMs: 10_000,
|
|
168
|
-
awaited: true
|
|
169
|
-
}).catch((error) => {
|
|
170
|
-
logger.info(`warm ${sourceKey.slice(0, 8)}:${named}: selected sidecar metadata not ready for "${file.name}": ${error?.message ?? error}`);
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
const orderedItems = named === null
|
|
175
|
-
? contents.items
|
|
176
|
-
: [...contents.items.filter((item) => item.fileIndex === named), ...contents.items.filter((item) => item.fileIndex !== named)];
|
|
177
|
-
const metadataStarted = startMetadataWarmup({
|
|
178
|
-
sourceKey,
|
|
179
|
-
items: orderedItems,
|
|
180
|
-
preferredFileIndex: named,
|
|
181
|
-
userAgent,
|
|
182
|
-
playbackPlanner,
|
|
183
|
-
torrentPool,
|
|
184
|
-
torrent
|
|
185
|
-
});
|
|
186
|
-
const sidecarCount = new Set(orderedItems.flatMap((item) => {
|
|
187
|
-
const matched = contents.sidecarsOf(item.fileIndex);
|
|
188
|
-
return [...matched.audio, ...matched.subtitles].map((file) => file.fileIndex);
|
|
189
|
-
})).size;
|
|
190
|
-
const fillStarted = typeof torrentPool.fillTorrent === "function"
|
|
191
|
-
? await torrentPool.fillTorrent(torrent).catch((error) => {
|
|
192
|
-
logger.warn(`warm ${sourceKey.slice(0, 8)}: whole-torrent fill could not start: ${error?.message ?? error}`);
|
|
193
|
-
return false;
|
|
194
|
-
})
|
|
195
|
-
: false;
|
|
196
|
-
if (named !== null && positionSeconds > 0 && typeof torrentPool.warmResumePosition === "function") {
|
|
197
|
-
void Promise.resolve(
|
|
198
|
-
(typeof durationOf === "function"
|
|
199
|
-
? durationOf({ sourceKey, fileIndex: named })
|
|
200
|
-
: Promise.resolve(null)
|
|
201
|
-
).then((durationSeconds) =>
|
|
202
|
-
torrentPool.warmResumePosition(torrent, named, positionSeconds, durationSeconds)
|
|
203
|
-
)
|
|
204
|
-
).catch((error) => {
|
|
205
|
-
logger.warn(`warm ${sourceKey.slice(0, 8)}: the viewer's position failed: ${error?.message ?? error}`);
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
logger.info(
|
|
210
|
-
`warm ${sourceKey.slice(0, 8)}: swarm started for "${torrent.name}"` +
|
|
211
|
-
`, metadata for ${contents.items.length} video file(s) ${metadataStarted ? "queued" : "unavailable"}` +
|
|
212
|
-
`, ${sidecarCount} related audio/subtitle file(s)` +
|
|
213
|
-
(fillStarted ? ", whole-torrent fill enabled at TAIL urgency" : ", whole-torrent fill unavailable")
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
return reply.send({ started: true, swarm: true, edges: metadataStarted, sidecars: sidecarCount, fill: fillStarted });
|
|
4
|
+
if (!sourceKey) return reply.code(400).send({ error: "sourceKey is required." });
|
|
5
|
+
if (!sourceRegistry.get(sourceKey)) return reply.code(404).send({ error: "Source key was not found." });
|
|
6
|
+
const started = (viewers?.forSource(sourceKey).length ?? 0) > 0;
|
|
7
|
+
return reply.send({ started, swarm: started, edges: false, fill: false });
|
|
217
8
|
}
|