@torrent-tv/proxy 2.91.0 → 2.92.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/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 +66 -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
|
@@ -28,11 +28,6 @@ const SEGMENT_READ_HIGH_WATER_MARK = 4 * 1024 * 1024;
|
|
|
28
28
|
* @param {number} ms
|
|
29
29
|
* @returns {Promise<void>}
|
|
30
30
|
*/
|
|
31
|
-
function delay(ms) {
|
|
32
|
-
return new Promise((resolve) => {
|
|
33
|
-
setTimeout(resolve, ms);
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
31
|
/**
|
|
37
32
|
* Guard against path traversal by restricting file names to the known
|
|
38
33
|
* playlist and segment patterns produced by ffmpeg. Which segment names are
|
|
@@ -83,6 +78,18 @@ export class SegmentServing {
|
|
|
83
78
|
state.waitListeners.clear();
|
|
84
79
|
}
|
|
85
80
|
|
|
81
|
+
/** Observe storage and lifecycle before a request checks file availability. */
|
|
82
|
+
subscribeFileChange(sessionId) {
|
|
83
|
+
const output = isOutputName(sessionId) ? this.#host.outputs.get(sessionId) : null;
|
|
84
|
+
if (!output) return { changed: Promise.resolve(), release: () => {} };
|
|
85
|
+
const state = this.#stateFor(output);
|
|
86
|
+
let wake;
|
|
87
|
+
const changed = new Promise((resolve) => { wake = resolve; });
|
|
88
|
+
state.waitListeners.add(wake);
|
|
89
|
+
const stop = this.#host.segmentStore.subscribeChanges(output.outputKey ?? "", wake);
|
|
90
|
+
return { changed, release: () => { state.waitListeners.delete(wake); stop(); } };
|
|
91
|
+
}
|
|
92
|
+
|
|
86
93
|
/**
|
|
87
94
|
* Open a read stream for an HLS segment or playlist file from a session.
|
|
88
95
|
*
|
|
@@ -295,6 +302,14 @@ export class SegmentServing {
|
|
|
295
302
|
if (!isPlaylist && session.segmentFormat.needsSegmentRewrite) {
|
|
296
303
|
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
297
304
|
const raw = await readFile(filePath);
|
|
305
|
+
// Players may fetch the init and first media segment concurrently.
|
|
306
|
+
// Both responses must use the same retained init before rewriting.
|
|
307
|
+
if (!this.#host.segmentStore.initOf(session.outputKey ?? "")) {
|
|
308
|
+
const init = cutsAtGivenTimes(session)
|
|
309
|
+
? await this.#initFromFirstSegment(session)
|
|
310
|
+
: await readFile(path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), session.segmentFormat.initFileName));
|
|
311
|
+
if (!this.#host.segmentStore.keepInit(session.outputKey ?? "", init)) return this.#failedOrWarming(session);
|
|
312
|
+
}
|
|
298
313
|
// Self-contained pieces carry the init header; a media segment must not.
|
|
299
314
|
const bytes = cutsAtGivenTimes(session) && session.segmentFormat.stripInit
|
|
300
315
|
? session.segmentFormat.stripInit(raw)
|
|
@@ -615,6 +630,16 @@ export class SegmentServing {
|
|
|
615
630
|
}
|
|
616
631
|
|
|
617
632
|
#holdForProduction(session, fileName, isPlaylist, options) {
|
|
633
|
+
let ranked = null;
|
|
634
|
+
if (!isPlaylist) {
|
|
635
|
+
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
636
|
+
const address = session.outputKey ?? "";
|
|
637
|
+
const { rank, topRank } = this.#host.encodeOrchestrator.rankAt(address, index);
|
|
638
|
+
ranked = { address, rank, topRank };
|
|
639
|
+
if (Number.isFinite(index) && topRank > 0 && rank === 0) {
|
|
640
|
+
return { kind: "superseded", ranked };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
618
643
|
// The file is not there and production has ended in an error: nothing is
|
|
619
644
|
// going to write it, so the viewer is told instead of held.
|
|
620
645
|
if (this.#host.encodeRuns.hasFailed(session)) {
|
|
@@ -625,10 +650,8 @@ export class SegmentServing {
|
|
|
625
650
|
// the truthful answer: nothing is broken and there is nothing for the
|
|
626
651
|
// viewer to retry. Only what is NOT on disk is held for it — a piece that
|
|
627
652
|
// exists is whole, and it is served whatever the encoder is doing.
|
|
628
|
-
return { kind: "warming-up" };
|
|
653
|
+
return { kind: "warming-up", ranked };
|
|
629
654
|
}
|
|
630
|
-
/** @type {{ address: string, rank: number, topRank: number } | null} */
|
|
631
|
-
let ranked = null;
|
|
632
655
|
if (!isPlaylist) {
|
|
633
656
|
this.#explainHold(session, fileName, "the file is not on disk");
|
|
634
657
|
}
|
|
@@ -654,22 +677,6 @@ export class SegmentServing {
|
|
|
654
677
|
// discarded at the one point where a viewer measurably waits for a named
|
|
655
678
|
// segment; it goes back out with the answer, because the wait is measured
|
|
656
679
|
// by whoever holds the request.
|
|
657
|
-
const address = session.outputKey ?? "";
|
|
658
|
-
const { rank, topRank } = this.#host.encodeOrchestrator.rankAt(address, requestedIndex);
|
|
659
|
-
ranked = { address, rank, topRank };
|
|
660
|
-
const nobodyIsComing = topRank > 0 && rank === 0;
|
|
661
|
-
if (
|
|
662
|
-
Number.isFinite(requestedIndex) &&
|
|
663
|
-
requestedIndex < (this.#host.encodeRuns.earliestStartOf(session) ?? 0) &&
|
|
664
|
-
nobodyIsComing &&
|
|
665
|
-
this.#host.encodeRuns.liveRunsOf(session).length > 0
|
|
666
|
-
) {
|
|
667
|
-
logger.info(
|
|
668
|
-
`transcode ${session.id} segment #${requestedIndex} is ${(this.#host.encodeRuns.earliestStartOf(session) ?? 0) - requestedIndex} ` +
|
|
669
|
-
"segments behind the run and in nobody's zone; answered as absent rather than held"
|
|
670
|
-
);
|
|
671
|
-
return { kind: "not-found", ranked };
|
|
672
|
-
}
|
|
673
680
|
this.#noteWanted(session, requestedIndex);
|
|
674
681
|
}
|
|
675
682
|
return { kind: "warming-up", ranked };
|
|
@@ -996,9 +1003,7 @@ export class SegmentServing {
|
|
|
996
1003
|
}
|
|
997
1004
|
|
|
998
1005
|
/**
|
|
999
|
-
*
|
|
1000
|
-
* header, or until the session fails, or until the startup timeout elapses.
|
|
1001
|
-
* Throws with message `"HLS playlist is still warming up."` on timeout.
|
|
1006
|
+
* Wait for a valid HLS playlist or a terminal output state, without polling.
|
|
1002
1007
|
*
|
|
1003
1008
|
* @param {HlsSession} session
|
|
1004
1009
|
* @returns {Promise<void>}
|
|
@@ -1016,9 +1021,9 @@ export class SegmentServing {
|
|
|
1016
1021
|
}
|
|
1017
1022
|
|
|
1018
1023
|
const playlistPath = path.join(this.#host.segmentStore.pathFor(session.outputKey ?? ""), PLAYLIST_FILE_NAME);
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1024
|
+
while (this.#host.outputs.get(session.id) === session) {
|
|
1025
|
+
const change = this.subscribeFileChange(session.id);
|
|
1026
|
+
try {
|
|
1022
1027
|
if (this.#host.encodeRuns.hasFailed(session)) {
|
|
1023
1028
|
throw new Error(this.#host.encodeRuns.lastErrorOf(session) || "ffmpeg failed to start HLS session.");
|
|
1024
1029
|
}
|
|
@@ -1031,10 +1036,12 @@ export class SegmentServing {
|
|
|
1031
1036
|
} catch (_error) {
|
|
1032
1037
|
// Playlist is not ready yet.
|
|
1033
1038
|
}
|
|
1034
|
-
await
|
|
1039
|
+
await change.changed;
|
|
1040
|
+
} finally {
|
|
1041
|
+
change.release();
|
|
1042
|
+
}
|
|
1035
1043
|
}
|
|
1036
|
-
|
|
1037
|
-
throw new Error("HLS playlist is still warming up.");
|
|
1044
|
+
throw new Error("HLS output was disposed before its playlist became available.");
|
|
1038
1045
|
}
|
|
1039
1046
|
|
|
1040
1047
|
/**
|
|
@@ -71,6 +71,7 @@ export class ViewerRequests {
|
|
|
71
71
|
* @returns {Promise<import("../encode/output/EncodedOutput.js").EncodedOutput>}
|
|
72
72
|
*/
|
|
73
73
|
async createOrGetSession({ consumerId = "", ...request }) {
|
|
74
|
+
request.signal?.throwIfAborted();
|
|
74
75
|
const requestStartedAt = Date.now();
|
|
75
76
|
// What this viewer's link last measured, where the request does not say:
|
|
76
77
|
// a viewer who comes back to open another film has a link this proxy has
|
|
@@ -90,6 +91,7 @@ export class ViewerRequests {
|
|
|
90
91
|
// state nothing has every encoder on it stopped.
|
|
91
92
|
const claim = consumerId
|
|
92
93
|
? (candidate) => {
|
|
94
|
+
request.signal?.throwIfAborted();
|
|
93
95
|
alreadyOn = this.#host.viewers.forOutput(candidate).has(consumerId);
|
|
94
96
|
const answer = alreadyOn
|
|
95
97
|
? { admitted: true, reason: "already watching it", speedX: null }
|
|
@@ -105,6 +107,7 @@ export class ViewerRequests {
|
|
|
105
107
|
viewerLinkMbps,
|
|
106
108
|
claim
|
|
107
109
|
});
|
|
110
|
+
request.signal?.throwIfAborted();
|
|
108
111
|
if (consumerId) {
|
|
109
112
|
const joined = existed && !alreadyOn;
|
|
110
113
|
// What THIS viewer wants of the sound, which an output they are joining
|
|
@@ -558,6 +561,7 @@ export class ViewerRequests {
|
|
|
558
561
|
}
|
|
559
562
|
|
|
560
563
|
const sourceMeasurements = new Map();
|
|
564
|
+
const sourceForecasts = new Map();
|
|
561
565
|
const tracks = [];
|
|
562
566
|
for (const output of outputs) {
|
|
563
567
|
const trackProgress = output === session ? progress : this.#host.encodeRuns.progressOf(output);
|
|
@@ -580,6 +584,7 @@ export class ViewerRequests {
|
|
|
580
584
|
index,
|
|
581
585
|
startSeconds: timeline.publishedStartOf(index),
|
|
582
586
|
endSeconds: timeline.publishedStartOf(index + 1),
|
|
587
|
+
sourceInputs: this.#host.sourceInputsFor?.(output, index) ?? undefined,
|
|
583
588
|
mediaRanges: ranges && output.segmentFormat?.servedMediaRanges ?
|
|
584
589
|
output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined
|
|
585
590
|
};
|
|
@@ -596,34 +601,25 @@ export class ViewerRequests {
|
|
|
596
601
|
if (sourceMeasurements.has(sourceId)) {
|
|
597
602
|
continue;
|
|
598
603
|
}
|
|
599
|
-
const sourceFile = fileIndex === output.file.fileIndex
|
|
600
|
-
? output.file
|
|
601
|
-
: this.#host.sourceFiles.get(output.file.sourceKey, fileIndex);
|
|
602
604
|
let stats = null;
|
|
603
605
|
try {
|
|
604
|
-
stats = await this.#host.getSourceStats?.(output.file.sourceKey, fileIndex
|
|
606
|
+
stats = await this.#host.getSourceStats?.(output.file.sourceKey, fileIndex,
|
|
607
|
+
{ forecast: !sourceForecasts.has(output.file.sourceKey) }) ?? null;
|
|
605
608
|
} catch {
|
|
606
609
|
stats = null;
|
|
607
610
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
stats
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
const downloaded = Number(stats?.fileDownloaded);
|
|
616
|
-
const sourceDuration = Number(sourceFile?.durationSeconds);
|
|
611
|
+
if (!sourceForecasts.has(output.file.sourceKey)) sourceForecasts.set(output.file.sourceKey, stats?.downloadForecast ?? { ranges: [] });
|
|
612
|
+
const sharedForecast = sourceForecasts.get(output.file.sourceKey);
|
|
613
|
+
const downloadForecast = sharedForecast.global ? { measuredAt: sharedForecast.measuredAt,
|
|
614
|
+
ranges: !stats ? [] : sharedForecast.ranges.map(range => ({ ...range,
|
|
615
|
+
start: Math.max(0, range.start - stats.fileOffset),
|
|
616
|
+
end: Math.min(stats.fileLength, range.end - stats.fileOffset)
|
|
617
|
+
})).filter(range => range.end > range.start) } : sharedForecast;
|
|
617
618
|
sourceMeasurements.set(sourceId, {
|
|
618
619
|
id: sourceId,
|
|
619
|
-
|
|
620
|
-
complete: Number.isFinite(fileLength) && fileLength > 0 && downloaded >= fileLength,
|
|
621
|
-
bytesPerMediaSecond: Number.isFinite(fileLength) && fileLength > 0 &&
|
|
622
|
-
Number.isFinite(sourceDuration) && sourceDuration > 0
|
|
623
|
-
? fileLength / sourceDuration
|
|
624
|
-
: 0,
|
|
625
|
-
readings: sourceTrend,
|
|
620
|
+
complete: stats?.fileAvailable === true,
|
|
626
621
|
residence: stats?.residence,
|
|
622
|
+
downloadForecast,
|
|
627
623
|
});
|
|
628
624
|
}
|
|
629
625
|
|
|
@@ -661,12 +657,6 @@ export class ViewerRequests {
|
|
|
661
657
|
measurement.tracks.delete(id);
|
|
662
658
|
}
|
|
663
659
|
}
|
|
664
|
-
const activeDownloadServices = new Set([...sourceMeasurements.values()].map(({ serviceId }) => serviceId));
|
|
665
|
-
for (const id of measurement.downloads.keys()) {
|
|
666
|
-
if (!activeDownloadServices.has(id)) {
|
|
667
|
-
measurement.downloads.delete(id);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
660
|
const bufferedAheadSeconds = Number(viewerReading?.bufferedAheadSec);
|
|
671
661
|
const positionSeconds = Number.isFinite(viewerReading?.positionSeconds)
|
|
672
662
|
? viewerReading.positionSeconds
|
|
@@ -727,7 +717,11 @@ export class ViewerRequests {
|
|
|
727
717
|
bufferLimitSeconds: input.bufferLimitSeconds,
|
|
728
718
|
lookaheadSeconds: input.lookaheadSeconds,
|
|
729
719
|
link: input.linkReadings,
|
|
730
|
-
sources: input.sources,
|
|
720
|
+
sources: input.sources.map(({ id, complete, residence, downloadForecast }) => ({
|
|
721
|
+
id, complete, residenceRanges: residence?.length ?? 0,
|
|
722
|
+
forecastAt: downloadForecast?.measuredAt ?? null, plannedPieces: downloadForecast?.ranges.length ?? 0,
|
|
723
|
+
unknownPieces: downloadForecast?.ranges.filter(range => range.availableAt === null).length ?? 0
|
|
724
|
+
})),
|
|
731
725
|
tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
|
|
732
726
|
id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
|
|
733
727
|
}))
|
|
@@ -745,25 +739,12 @@ export class ViewerRequests {
|
|
|
745
739
|
const id = typeof consumerId === "string" ? consumerId : "";
|
|
746
740
|
let state = byViewer.get(id);
|
|
747
741
|
if (!state) {
|
|
748
|
-
state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds),
|
|
742
|
+
state = { link: new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds), tracks: new Map() };
|
|
749
743
|
byViewer.set(id, state);
|
|
750
744
|
}
|
|
751
745
|
return state;
|
|
752
746
|
}
|
|
753
747
|
|
|
754
|
-
#downloadRateReadings(state, serviceId, downloadSpeed, now) {
|
|
755
|
-
let trend = state.downloads.get(serviceId);
|
|
756
|
-
if (!trend) {
|
|
757
|
-
trend = new this.#host.playbackReadiness.RateTrend(this.#host.lookaheadSeconds);
|
|
758
|
-
state.downloads.set(serviceId, trend);
|
|
759
|
-
}
|
|
760
|
-
const speed = Number(downloadSpeed);
|
|
761
|
-
if (Number.isFinite(speed) && speed >= 0) {
|
|
762
|
-
trend.add(now, speed);
|
|
763
|
-
}
|
|
764
|
-
return trend.snapshot();
|
|
765
|
-
}
|
|
766
|
-
|
|
767
748
|
#trackRateReadings(state, output, now) {
|
|
768
749
|
const id = output.outputKey;
|
|
769
750
|
let trend = state.tracks.get(id);
|
|
@@ -34,6 +34,10 @@ export class PlaybackController {
|
|
|
34
34
|
return this.playbackPlanner.getPlan(params);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
async getReadyPlan(params, options) {
|
|
38
|
+
return this.playbackPlanner.getReadyPlan(params, options);
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
async refreshAudioTracks(params) {
|
|
38
42
|
return this.playbackPlanner.refreshAudioTracks(params);
|
|
39
43
|
}
|
|
@@ -7,23 +7,6 @@
|
|
|
7
7
|
* language detection) stays in orchestrator/domain.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
const EXTERNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
11
|
-
|
|
12
|
-
function readFileFully(file, maxBytes) {
|
|
13
|
-
return new Promise((resolve, reject) => {
|
|
14
|
-
const stream = file.createReadStream();
|
|
15
|
-
const chunks = [];
|
|
16
|
-
let total = 0;
|
|
17
|
-
stream.on("data", (chunk) => {
|
|
18
|
-
total += chunk.length;
|
|
19
|
-
if (total > maxBytes) { stream.destroy(); reject(new Error("subtitle file exceeds size cap")); return; }
|
|
20
|
-
chunks.push(chunk);
|
|
21
|
-
});
|
|
22
|
-
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
23
|
-
stream.on("error", reject);
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
10
|
export class SubtitleController {
|
|
28
11
|
/**
|
|
29
12
|
* @param {object} deps
|
|
@@ -45,25 +28,19 @@ export class SubtitleController {
|
|
|
45
28
|
* Serve external subtitle file or embedded track.
|
|
46
29
|
* Returns { vtt, language, headers } or { error, status }.
|
|
47
30
|
*/
|
|
48
|
-
async getSubtitle({ sourceKey, fileIndex, trackIndex, since, after }) {
|
|
31
|
+
async getSubtitle({ sourceKey, fileIndex, trackIndex, since, after, signal }) {
|
|
49
32
|
const rec = this.sourceRegistry.get(sourceKey);
|
|
50
33
|
if (!rec) return { error: "Source key was not found.", status: 404 };
|
|
51
|
-
const torrent =
|
|
34
|
+
const torrent = this.torrentPool.knownTorrent(sourceKey);
|
|
35
|
+
if (!torrent) return { pending: true, status: 202 };
|
|
52
36
|
const file = torrent.files[fileIndex];
|
|
53
37
|
if (!file) return { error: "File index was not found in torrent.", status: 404 };
|
|
54
38
|
|
|
55
39
|
const hasTrack = trackIndex !== undefined && trackIndex !== "" && Number.isFinite(Number(trackIndex));
|
|
56
40
|
if (!hasTrack) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const bytes = await readFileFully(file, EXTERNAL_MAX_BYTES);
|
|
61
|
-
const converted = this.orchestrator.fileAsVtt(bytes, ext);
|
|
62
|
-
if (!converted) return { error: `Unsupported subtitle format: ${ext}`, status: 422 };
|
|
63
|
-
return { ...converted, headers: {} };
|
|
64
|
-
} catch (e) {
|
|
65
|
-
return { error: `Could not read subtitle file: ${e?.message ?? e}`, status: 502 };
|
|
66
|
-
}
|
|
41
|
+
const document = await this.orchestrator.waitForDocument(sourceKey, fileIndex, signal);
|
|
42
|
+
if (document.kind === "terminal") return { error: document.message ?? document.reason, status: 422 };
|
|
43
|
+
return { ...document.value, headers: {} };
|
|
67
44
|
}
|
|
68
45
|
|
|
69
46
|
const idx = Number(trackIndex);
|
|
@@ -72,6 +49,8 @@ export class SubtitleController {
|
|
|
72
49
|
// Resolve via orchestrator (domain: cluster walk or MP4 sample ranges)
|
|
73
50
|
const tracks = await this.orchestrator.getTracks(torrent, fileIndex, sourceKey);
|
|
74
51
|
const track = Array.isArray(tracks) ? tracks.find((c) => c.declaredIndex === idx) ?? null : null;
|
|
52
|
+
if (!track && this.orchestrator.hasTrackDeclaration(sourceKey, fileIndex)) return { error: "Subtitle track index was not found.", status: 422 };
|
|
53
|
+
if (track?.isTextBased?.() === false) return { error: "The subtitle track is not supported.", status: 422 };
|
|
75
54
|
// Also try domain's declaredIndex-agnostic lookup via getCues path — keep compat with existing subtitle-cues declaredIndex
|
|
76
55
|
let held = null;
|
|
77
56
|
try {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { logger } from "../../utils/logger.js";
|
|
2
2
|
import { bandOf, waits } from "./WaitLedger.js";
|
|
3
3
|
|
|
4
|
-
const HOLD_HEADER = "x-hold-ms";
|
|
5
4
|
/**
|
|
6
5
|
* Which viewer is asking, or an empty string where the transport names nobody.
|
|
7
6
|
*
|
|
@@ -57,8 +56,7 @@ export function refusedAsStale(req, reply, viewerRequests, fileName) {
|
|
|
57
56
|
`[hold] ${fileName} refused: made in generation ${stated} of ${consumerId}, ` +
|
|
58
57
|
`a viewing they have left and whose window for new requests has passed`
|
|
59
58
|
);
|
|
60
|
-
reply.
|
|
61
|
-
reply.code(503).send({ error: "Made for a viewing that has been left." });
|
|
59
|
+
reply.code(409).send({ error: "Made for a viewing that has been left.", reason: "request-obsolete", canRetry: false });
|
|
62
60
|
return true;
|
|
63
61
|
}
|
|
64
62
|
|
|
@@ -83,22 +81,8 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
|
|
|
83
81
|
// a plain HTTP transport, where no loader of ours builds the URL, and then
|
|
84
82
|
// the single shared position decides as it always did.
|
|
85
83
|
const consumerId = consumerOf(req);
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
// deadline on RESPONSE HEADERS and raises -12889 ("No response for media
|
|
89
|
-
// file") when it passes — it then cancels in-flight requests, probes
|
|
90
|
-
// neighbouring positions and can restart the stream from the beginning. That
|
|
91
|
-
// is exactly the post-seek "player thrashing" seen in the field, because a
|
|
92
|
-
// seek restarts ffmpeg and the first segment then takes far longer than 3.5 s
|
|
93
|
-
// to appear. Holding the connection for 30 s (as this did) guaranteed the
|
|
94
|
-
// timeout on every seek. A short hold keeps the fast path intact (a ready or
|
|
95
|
-
// nearly-ready segment is still served on the first request) while a slow one
|
|
96
|
-
// gets a prompt retryable answer, which resets the player's own deadline.
|
|
97
|
-
// hls.js is unaffected: it consumes the 503 through its retry policy, whose
|
|
98
|
-
// budget the client widens to match (see hls-player.js fragLoadPolicy).
|
|
99
|
-
// Instrumented wait. `clientAborted` flips when the player drops the
|
|
100
|
-
// connection while we are still holding it — the single most informative
|
|
101
|
-
// signal about its real patience, and observable only from this side.
|
|
84
|
+
// Readiness or a terminal state ends the wait. A caller leaving cancels it;
|
|
85
|
+
// elapsed time is not evidence that media preparation failed.
|
|
102
86
|
const holdStartedAt = Date.now();
|
|
103
87
|
let clientAborted = false;
|
|
104
88
|
let onClientAbort = () => {};
|
|
@@ -108,17 +92,26 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
|
|
|
108
92
|
resolve();
|
|
109
93
|
};
|
|
110
94
|
});
|
|
111
|
-
|
|
112
|
-
|
|
95
|
+
const onResponseClose = () => { if (!reply.raw?.writableEnded) onClientAbort(); };
|
|
96
|
+
req.raw.on("aborted", onClientAbort);
|
|
97
|
+
reply.raw?.on("close", onResponseClose);
|
|
113
98
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
99
|
+
let result;
|
|
100
|
+
try {
|
|
101
|
+
result = await waitForSessionFile(serving, sessionId, fileName, {
|
|
102
|
+
until: clientGone,
|
|
103
|
+
consumerId
|
|
104
|
+
});
|
|
105
|
+
} finally {
|
|
106
|
+
req.raw.off("aborted", onClientAbort);
|
|
107
|
+
reply.raw?.off("close", onResponseClose);
|
|
108
|
+
}
|
|
121
109
|
const heldMs = Date.now() - holdStartedAt;
|
|
110
|
+
if (clientAborted || result.kind === "cancelled") {
|
|
111
|
+
logger.info(`[hold] request=${req.id ?? "unknown"} ${fileName} cancelled after ${heldMs}ms`);
|
|
112
|
+
result.stream?.destroy?.();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
122
115
|
if (result.isPlaylist !== true) {
|
|
123
116
|
const outcome = clientAborted
|
|
124
117
|
? "client-aborted"
|
|
@@ -156,36 +149,10 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
|
|
|
156
149
|
`[hold] ${fileName} refused: the viewer is at ` +
|
|
157
150
|
`${viewerRequests.viewerPositionOf(sessionId, consumerId).toFixed(1)}s and this is not the segment there`
|
|
158
151
|
);
|
|
159
|
-
reply.
|
|
160
|
-
return reply.code(503).send({ error: "Superseded by a seek." });
|
|
161
|
-
}
|
|
162
|
-
if (result.kind === "warming-up") {
|
|
163
|
-
// The segment is still being produced (e.g. just after a seek-restart).
|
|
164
|
-
// Return a retryable 503 — never 202, which hls.js cannot consume as a
|
|
165
|
-
// media segment — so the player retries the fetch shortly.
|
|
166
|
-
reply.header("Retry-After", "1");
|
|
167
|
-
// `Retry-After` tells the player to re-request THIS segment after a short
|
|
168
|
-
// pause. Without it a bare 503 reads as "nothing here", and the player goes
|
|
169
|
-
// looking elsewhere: because our synthetic VOD playlist lists every segment
|
|
170
|
-
// of the file, it believes they all exist and SCANS them (field log: one
|
|
171
|
-
// user seek produced probes at #617, #717, #732…). That scan is what used
|
|
172
|
-
// to steer the encoder off the real target. Whether iOS's native player
|
|
173
|
-
// honours the hint is not guaranteed — its behaviour is closed — but this
|
|
174
|
-
// is the standard, correct way to say "wait, don't look elsewhere", and
|
|
175
|
-
// hls.js already retries the same fragment regardless.
|
|
176
|
-
reply.header("Retry-After", "1");
|
|
177
|
-
// SAY WHY, to the viewer and not only to the log. The same refusal is
|
|
178
|
-
// written into `proxy.log` with the rank the priority map gave this
|
|
179
|
-
// segment; the page had no access to that and told the viewer it did not
|
|
180
|
-
// know why. Field 2026-09-14: `rank 1 of 100` — nothing was making it —
|
|
181
|
-
// printed sixty seconds before the page gave up saying the opposite.
|
|
182
|
-
return reply.code(503).send({
|
|
183
|
-
error: "Transcode segment is still being produced.",
|
|
184
|
-
reason: warmingReason(result.ranked ?? null)
|
|
185
|
-
});
|
|
152
|
+
return reply.code(409).send({ error: "Superseded by a seek.", reason: "request-obsolete", canRetry: false });
|
|
186
153
|
}
|
|
187
154
|
if (result.kind === "failed") {
|
|
188
|
-
return reply.code(500).send({ error: result.message });
|
|
155
|
+
return reply.code(500).send({ error: result.message, canRetry: false });
|
|
189
156
|
}
|
|
190
157
|
|
|
191
158
|
if (result.isPlaylist) {
|
|
@@ -230,8 +197,7 @@ function holdWhileSending(reply, stream, release) {
|
|
|
230
197
|
/**
|
|
231
198
|
* Ask `serving.getFileStream()` again each time the file may have become
|
|
232
199
|
* available — a segment's publication, or a waited-on invalidation — until it
|
|
233
|
-
* is, the session fails, the
|
|
234
|
-
* requester goes.
|
|
200
|
+
* is, the session fails, the request becomes obsolete, or the requester goes.
|
|
235
201
|
*
|
|
236
202
|
* @param {object} serving - `services/server/SegmentServing.js`
|
|
237
203
|
* @param {string} sessionId
|
|
@@ -246,8 +212,29 @@ function holdWhileSending(reply, stream, release) {
|
|
|
246
212
|
* viewers, and the seek epoch belongs to all of them.
|
|
247
213
|
* @returns {Promise<Awaited<ReturnType<import("./SegmentServing.js").SegmentServing["getFileStream"]>>>}
|
|
248
214
|
*/
|
|
249
|
-
export async function
|
|
250
|
-
|
|
215
|
+
export async function waitForRequestedFile(req, reply, serving, sessionId, fileName, consumerId) {
|
|
216
|
+
let cancel;
|
|
217
|
+
const until = new Promise(resolve => { cancel = resolve; });
|
|
218
|
+
let gone = false;
|
|
219
|
+
const aborted = () => { gone = true; cancel(); };
|
|
220
|
+
const closed = () => { if (!reply.raw?.writableEnded) aborted(); };
|
|
221
|
+
req.raw?.on?.("aborted", aborted);
|
|
222
|
+
reply.raw?.on?.("close", closed);
|
|
223
|
+
if (req.raw?.aborted || reply.raw?.destroyed) aborted();
|
|
224
|
+
try {
|
|
225
|
+
const result = await waitForSessionFile(serving, sessionId, fileName, { until, consumerId });
|
|
226
|
+
if (gone) {
|
|
227
|
+
result.stream?.destroy?.();
|
|
228
|
+
return { kind: "cancelled" };
|
|
229
|
+
}
|
|
230
|
+
return result;
|
|
231
|
+
} finally {
|
|
232
|
+
req.raw?.off?.("aborted", aborted);
|
|
233
|
+
reply.raw?.off?.("close", closed);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function waitForSessionFile(serving, sessionId, fileName, { until = null, consumerId = "" } = {}) {
|
|
251
238
|
let gone = false;
|
|
252
239
|
const requesterGone = until ? until.then(() => { gone = true; }) : new Promise(() => {});
|
|
253
240
|
// The viewer's position when this request was made. A seek makes every held
|
|
@@ -259,8 +246,14 @@ export async function waitForSessionFile(serving, sessionId, fileName, { holdMs,
|
|
|
259
246
|
let seekEpoch = serving.seekEpoch(sessionId);
|
|
260
247
|
/** @type {{ address: string, rank: number, topRank: number } | null} */
|
|
261
248
|
let lastRanked = null;
|
|
262
|
-
while (!gone
|
|
249
|
+
while (!gone) {
|
|
250
|
+
const change = serving.subscribeFileChange(sessionId);
|
|
251
|
+
try {
|
|
263
252
|
const result = await serving.getFileStream(sessionId, fileName, { consumerId });
|
|
253
|
+
if (gone) {
|
|
254
|
+
result.stream?.destroy?.();
|
|
255
|
+
return { kind: "cancelled" };
|
|
256
|
+
}
|
|
264
257
|
if (result.kind !== "warming-up") {
|
|
265
258
|
return result;
|
|
266
259
|
}
|
|
@@ -282,44 +275,10 @@ export async function waitForSessionFile(serving, sessionId, fileName, { holdMs,
|
|
|
282
275
|
// patience is still counted against what it was promised. Kept across the
|
|
283
276
|
// polls because the timeout path has no result of its own.
|
|
284
277
|
lastRanked = result.ranked ?? lastRanked;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
// Playlists and init files are not segment publications.
|
|
289
|
-
await Promise.race([delay(Math.min(300, remaining)), requesterGone]);
|
|
278
|
+
await Promise.race([change.changed, requesterGone]);
|
|
279
|
+
} finally {
|
|
280
|
+
change.release();
|
|
290
281
|
}
|
|
291
282
|
}
|
|
292
|
-
return { kind: "
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Resolve after a given number of milliseconds.
|
|
297
|
-
*
|
|
298
|
-
* @param {number} ms
|
|
299
|
-
* @returns {Promise<void>}
|
|
300
|
-
*/
|
|
301
|
-
function delay(ms) {
|
|
302
|
-
return new Promise((resolve) => {
|
|
303
|
-
setTimeout(resolve, ms);
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* What to tell the VIEWER when a segment is not there yet.
|
|
309
|
-
*
|
|
310
|
-
* In their terms — the film, and what is being done about it — never in ours: a
|
|
311
|
-
* viewer cannot read a log, does not know what a rank is, and is not helped by
|
|
312
|
-
* being told which of our parts is waiting for which other.
|
|
313
|
-
*
|
|
314
|
-
* @param {{ rank: number, topRank: number } | null} ranked - What the priority
|
|
315
|
-
* map thought of this segment while the request was held.
|
|
316
|
-
* @returns {string}
|
|
317
|
-
*/
|
|
318
|
-
function warmingReason(ranked) {
|
|
319
|
-
if (ranked && ranked.rank < ranked.topRank) {
|
|
320
|
-
// The map is working somewhere else in the film. That is the answer the
|
|
321
|
-
// failure of 2026-09-14 needed and nobody was given.
|
|
322
|
-
return "This part of the film is not being prepared yet — the proxy is working further along.";
|
|
323
|
-
}
|
|
324
|
-
return "This part of the film is still being prepared.";
|
|
283
|
+
return { kind: "cancelled", ranked: lastRanked };
|
|
325
284
|
}
|