@torrent-tv/proxy 2.90.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 +5 -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/transport/tunnel-client.js +111 -20
- 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/server.js
CHANGED
|
@@ -12,6 +12,12 @@ import fastifyHelmet from "@fastify/helmet";
|
|
|
12
12
|
import getPort from "get-port";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { handleHealthGet } from "./routes/health/get.js";
|
|
15
|
+
import { handleMediaGet } from "./routes/media/get.js";
|
|
16
|
+
import { ProbeRequests } from "./services/media/ProbeRequests.js";
|
|
17
|
+
import { IndexMemory } from "./services/storage/IndexMemory.js";
|
|
18
|
+
import { probePackets } from "./services/media/probe-packets.js";
|
|
19
|
+
import { segmentDemands, sourceDeadline } from "./services/viewer/segment-demands.js";
|
|
20
|
+
import { SourcePreparation } from "./services/viewer/SourcePreparation.js";
|
|
15
21
|
import { handleHealthzGet } from "./routes/healthz/get.js";
|
|
16
22
|
import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
|
|
17
23
|
import { handleApiLinkProbeGet } from "./routes/api/link-probe/get.js";
|
|
@@ -19,11 +25,13 @@ import { handleApiSourcesPost } from "./routes/api/sources/post.js";
|
|
|
19
25
|
import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
20
26
|
import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
21
27
|
import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
|
|
28
|
+
import { handleApiSourceViewerPost } from "./routes/api/sources/viewer/post.js";
|
|
22
29
|
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
23
30
|
import { handleApiPlaybackPlanAudioTracksPost } from "./routes/api/playback-plan/audio-tracks/post.js";
|
|
24
31
|
import { handleApiClientLogsPost } from "./routes/api/client-logs/post.js";
|
|
25
32
|
import { createClientLogConsole, createClientLogFiles } from "./utils/client-log-file.js";
|
|
26
33
|
import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
|
|
34
|
+
import { handleApiSubtitlesPost } from "./routes/api/subtitles/post.js";
|
|
27
35
|
import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
|
|
28
36
|
import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
|
|
29
37
|
import { handleApiTranscodeSessionReleasePost } from "./routes/api/transcode-sessions/release/post.js";
|
|
@@ -44,7 +52,12 @@ import { KeyframeTables } from "./services/media/KeyframeTables.js";
|
|
|
44
52
|
import { contentsOf } from "./services/torrent/Contents.js";
|
|
45
53
|
import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
|
|
46
54
|
import { containerOrchestrator, CONTAINER_HEAD_BYTES } from "./services/media/ContainerOrchestrator.js";
|
|
47
|
-
import {
|
|
55
|
+
import { readPlaybackDeclarations } from "./services/media/read-playback-declarations.js";
|
|
56
|
+
import { DownloadMaps } from "./services/viewer/DownloadMaps.js";
|
|
57
|
+
import { nativeSourceMap } from "./services/viewer/NativeSourceMap.js";
|
|
58
|
+
import { MediaReadRequests } from "./services/media/MediaReadRequests.js";
|
|
59
|
+
import { SegmentInputs } from "./services/media/SegmentInputs.js";
|
|
60
|
+
import { pauseCoefficient, viewerStartsOn } from "./services/viewer/PriorityMap.js";
|
|
48
61
|
import { coalescing } from "./utils/coalesce.js";
|
|
49
62
|
import {
|
|
50
63
|
warmSubtitleCues,
|
|
@@ -138,6 +151,17 @@ export async function startProxyServer({
|
|
|
138
151
|
const torrentPool = new WorkerTorrentPool({
|
|
139
152
|
memoryBytes,
|
|
140
153
|
stateDir,
|
|
154
|
+
onSourceForgotten: ({ sourceKey }) => {
|
|
155
|
+
sourcePreparation?.forget(sourceKey);
|
|
156
|
+
mediaReads.forget(sourceKey);
|
|
157
|
+
probeReads.forget(sourceKey);
|
|
158
|
+
subtitles.forget(sourceKey);
|
|
159
|
+
keyframeTables.forget(sourceKey);
|
|
160
|
+
playbackPlanner.forget(sourceKey);
|
|
161
|
+
indexMemory.forget(sourceKey);
|
|
162
|
+
downloadMaps.retire(sourceKey);
|
|
163
|
+
outputParts.encodeInputs?.bytesChanged();
|
|
164
|
+
},
|
|
141
165
|
// Pieces arriving is ANNOUNCED by the thread that owns the swarm; what is
|
|
142
166
|
// done about it — walking a file's new subtitle clusters and pushing what
|
|
143
167
|
// came out — happens here, because that is a reading of what the file says
|
|
@@ -148,11 +172,16 @@ export async function startProxyServer({
|
|
|
148
172
|
// after the declarations below have run.
|
|
149
173
|
onPiecesArrived: ({ sourceKey, fileIndexes }) => {
|
|
150
174
|
for (const fileIndex of fileIndexes) {
|
|
175
|
+
mediaReads.bytesChanged(sourceKey, fileIndex);
|
|
176
|
+
probeReads.bytesChanged(sourceKey, fileIndex);
|
|
177
|
+
void sourcePreparation?.bytesChanged(sourceKey, fileIndex).catch(error =>
|
|
178
|
+
logger.warn(`source input readiness failed: ${error.message}`));
|
|
151
179
|
void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
|
|
152
180
|
// A keyframe table somebody asked for while its bytes had not arrived
|
|
153
181
|
// is read again now; nothing was recorded for that wait.
|
|
154
182
|
keyframeTables.readAgainIfUnanswered({ sourceKey, fileIndex, logName: String(fileIndex) });
|
|
155
183
|
}
|
|
184
|
+
outputParts.encodeInputs?.bytesChanged();
|
|
156
185
|
}
|
|
157
186
|
});
|
|
158
187
|
const selectedPort = await getPort({
|
|
@@ -275,11 +304,8 @@ export async function startProxyServer({
|
|
|
275
304
|
const heldFileFor = async (sourceKey, fileIndex, known = null) => {
|
|
276
305
|
let torrent = known;
|
|
277
306
|
if (!torrent) {
|
|
278
|
-
|
|
279
|
-
if (!
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
307
|
+
torrent = torrentPool.knownTorrent(sourceKey);
|
|
308
|
+
if (!torrent) return null;
|
|
283
309
|
}
|
|
284
310
|
const file = torrent?.files?.[fileIndex];
|
|
285
311
|
if (!file || !(file.length > 0)) {
|
|
@@ -329,6 +355,7 @@ export async function startProxyServer({
|
|
|
329
355
|
return;
|
|
330
356
|
}
|
|
331
357
|
for (const entry of await warmSubtitleCues(file)) {
|
|
358
|
+
if (subtitles.hasPacketCues(sourceKey, fileIndex, entry.trackIndex)) continue;
|
|
332
359
|
const span = entry.spanStartSeconds === null
|
|
333
360
|
? "empty"
|
|
334
361
|
: `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
|
|
@@ -345,6 +372,7 @@ export async function startProxyServer({
|
|
|
345
372
|
}
|
|
346
373
|
});
|
|
347
374
|
const subtitles = new SubtitleOrchestrator(containerOrchestrator, {
|
|
375
|
+
publish: entry => onSubtitleCues?.(entry),
|
|
348
376
|
warm: async (torrent, fileIndex, sourceKey) => {
|
|
349
377
|
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
350
378
|
return file ? warmSubtitleCues(file) : [];
|
|
@@ -374,39 +402,132 @@ export async function startProxyServer({
|
|
|
374
402
|
// every answer carried back across the channel — which is what made "where is
|
|
375
403
|
// this fact kept" a question at all.
|
|
376
404
|
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
405
|
+
// Missing metadata bytes are demand in the same file map as playback.
|
|
406
|
+
// Readers never start their own downloads or wait for source bytes.
|
|
407
|
+
const downloadMaps = new DownloadMaps({
|
|
408
|
+
publish: (map) => torrentPool.setPriorityMap(map),
|
|
409
|
+
resolvePlayback: async (map) => {
|
|
410
|
+
if (map.zones.length === 0) return [];
|
|
411
|
+
const params = await containerOver(map);
|
|
412
|
+
if (!params) return [];
|
|
413
|
+
const tracks = await containerOrchestrator.inspect(params, "tracks");
|
|
414
|
+
if (tracks.kind !== "result") return [];
|
|
415
|
+
const container = containerOrchestrator.known(map.sourceKey, map.fileIndex);
|
|
416
|
+
if (typeof container?.readPacketIndex !== "function") return [];
|
|
417
|
+
const media = await containerOrchestrator.inspect(params, "media-info");
|
|
418
|
+
if (media.kind !== "result") return [];
|
|
419
|
+
const shift = Number(media.value?.startTimeSeconds) || 0;
|
|
420
|
+
const converted = [];
|
|
421
|
+
const demands = [];
|
|
422
|
+
for (const output of outputParts.outputs.values()) {
|
|
423
|
+
if (output.file.sourceKey !== map.sourceKey) continue;
|
|
424
|
+
const consumers = [...outputParts.viewers.forOutput(output).values()].filter(viewer =>
|
|
425
|
+
viewer.isPresent() && !outputParts.outputs.supersededBy(output, viewer.activeVariantId ?? null));
|
|
426
|
+
if (consumers.length === 0) continue;
|
|
427
|
+
const inventory = outputParts.segmentStore.sizesOf(output.outputKey);
|
|
428
|
+
let bytes = 0, seconds = 0;
|
|
429
|
+
for (const [index, size] of inventory) {
|
|
430
|
+
const span = output.timeline.publishedStartOf(index + 1) - output.timeline.publishedStartOf(index);
|
|
431
|
+
if (span > 0) { bytes += size; seconds += span; }
|
|
432
|
+
}
|
|
433
|
+
const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
|
|
434
|
+
.filter(value => Number.isFinite(value) && value > 0);
|
|
435
|
+
const encodeSpeed = outputParts.encodeCost.latestSpeedReadingOf(output)?.speed;
|
|
436
|
+
for (const demand of segmentDemands(output, map.fileIndex, map.zones)) {
|
|
437
|
+
if (inventory.has(demand.index)) continue;
|
|
438
|
+
const deadlineAt = sourceDeadline(demand, { encodeSpeed,
|
|
439
|
+
outputBytes: seconds > 0 ? bytes / seconds * (demand.to - demand.from) : null,
|
|
440
|
+
linkMbps: links.length ? Math.min(...links) : null });
|
|
441
|
+
const leadSeconds = Number.isFinite(demand.deadlineAt) ? Math.max(0, (demand.deadlineAt - deadlineAt) / 1000) : 0;
|
|
442
|
+
demands.push({ ...demand, deadlineAt, leadSeconds, outputKey: output.outputKey,
|
|
443
|
+
sourceInterval: output.timeline.sourceInterval(demand.index, demand.index, shift) });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
|
|
447
|
+
for (const zone of demands) {
|
|
448
|
+
const selected = zone.tracks.map(choice => ({ choice, track: tracks.value.filter(track => track.type === choice.type)[choice.index] }));
|
|
449
|
+
if (selected.some(({ track }) => !track)) continue;
|
|
450
|
+
const wanted = selected.map(({ track }) => track);
|
|
451
|
+
const modes = new Map(selected.map(({ choice, track }) => [track, choice.mode]));
|
|
452
|
+
const interval = { ...zone.sourceInterval, trackIds: wanted.map(track => track.trackNumber),
|
|
453
|
+
modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
|
|
454
|
+
const intervalParams = await containerOver({ ...map, packetInterval: interval,
|
|
455
|
+
requestId: `download:${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`,
|
|
456
|
+
demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
|
|
457
|
+
if (!intervalParams) continue;
|
|
458
|
+
const packets = await containerOrchestrator.inspect(intervalParams, "packets");
|
|
459
|
+
if (packets.kind !== "result") continue;
|
|
460
|
+
const input = new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
|
|
461
|
+
if (input.kind !== "result") continue;
|
|
462
|
+
const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
|
|
463
|
+
for (const [byteStart, byteEnd] of input.ranges) converted.push({ ...demand,
|
|
464
|
+
downloadInterval: { from: zone.owner.from, to: zone.owner.to }, byteStart, byteEnd });
|
|
465
|
+
}
|
|
466
|
+
return converted;
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
const mediaReads = new MediaReadRequests({
|
|
470
|
+
read: (params, statement) => statement === "subtitle-file" ? subtitles.inspectFile(params)
|
|
471
|
+
: statement === "subtitle-cues" ? subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, statement),
|
|
472
|
+
failed: (params, statement, error) => logger.warn(`media source=${params.sourceKey} file=${params.fileIndex} statement=${statement} retry failed: ${error?.message ?? error}`)
|
|
473
|
+
});
|
|
474
|
+
const probeReads = new ProbeRequests({
|
|
475
|
+
publish: async (result) => {
|
|
476
|
+
if (result.statement.startsWith("ffprobe:")) return;
|
|
477
|
+
await downloadMaps.metadata(result);
|
|
478
|
+
if (result.result.kind === "result") await downloadMaps.refresh(result.sourceKey, result.fileIndex);
|
|
479
|
+
},
|
|
480
|
+
failed: (request, error) => logger.warn(`media request=${request.key} probe failed: ${error?.message ?? error}`)
|
|
481
|
+
});
|
|
482
|
+
const indexMemory = new IndexMemory({
|
|
483
|
+
reviseBudget: () => { void outputParts.machineBudget.revise().catch(error => logger.warn(`packet index budget: ${error.message}`)); },
|
|
484
|
+
changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); }
|
|
485
|
+
});
|
|
486
|
+
const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand }) => {
|
|
382
487
|
const record = sourceRegistry.get(sourceKey);
|
|
383
488
|
if (!record) {
|
|
384
489
|
return null;
|
|
385
490
|
}
|
|
386
|
-
const torrent =
|
|
491
|
+
const torrent = torrentPool.knownTorrent(sourceKey);
|
|
387
492
|
const file = torrent?.files?.[fileIndex];
|
|
388
493
|
if (!file || !(file.length > 0)) {
|
|
389
494
|
return null;
|
|
390
495
|
}
|
|
391
|
-
|
|
392
|
-
await torrentPool.prefetchFileEdges(torrent, fileIndex, {
|
|
393
|
-
headBytes: CONTAINER_HEAD_BYTES,
|
|
394
|
-
tailBytes,
|
|
395
|
-
timeoutMs: 60_000
|
|
396
|
-
});
|
|
397
|
-
} catch {
|
|
398
|
-
// A prefetch that failed is not a reason to skip the read: the read
|
|
399
|
-
// fetches what it needs itself, only more slowly.
|
|
400
|
-
}
|
|
401
|
-
return {
|
|
496
|
+
const params = {
|
|
402
497
|
sourceKey,
|
|
403
498
|
fileIndex,
|
|
499
|
+
requestId,
|
|
500
|
+
packetInterval,
|
|
501
|
+
packetMemory: indexMemory.forFile(sourceKey, fileIndex),
|
|
502
|
+
downloadInterval: demand ? { from: demand.from, to: demand.to } : null,
|
|
404
503
|
readRange: (start, end) =>
|
|
405
|
-
torrentPool.
|
|
504
|
+
torrentPool.readHeldOf(torrent, fileIndex, start, Math.min(end, file.length - 1)),
|
|
406
505
|
fileSize: file.length,
|
|
506
|
+
probe: (statement, onRecord) => probeReads.read({ sourceKey, fileIndex, statement: `ffprobe:${statement}`,
|
|
507
|
+
probe: ({ requestId, signal }) => {
|
|
508
|
+
const url = new URL("/media", outputParts.localBaseUrl);
|
|
509
|
+
url.searchParams.set("sourceKey", sourceKey);
|
|
510
|
+
url.searchParams.set("fileIndex", String(fileIndex));
|
|
511
|
+
url.searchParams.set("readId", requestId);
|
|
512
|
+
return probePackets({ url: url.toString(), statement, signal, onRecord });
|
|
513
|
+
} }),
|
|
407
514
|
label: String(file.name ?? ""),
|
|
515
|
+
onTracks: (tracks) => contentsOf(torrent).noteVideo(fileIndex, tracks.some((track) => track.type === "video")),
|
|
516
|
+
onReadStart: () => ({ storage: mediaReads.revision(sourceKey, fileIndex), memory: mediaReads.memoryRevision(), demand: downloadMaps.epoch(sourceKey, fileIndex) }),
|
|
517
|
+
onReadResult: async (statement, result, revision) => {
|
|
518
|
+
if (revision.demand !== downloadMaps.epoch(sourceKey, fileIndex)) return;
|
|
519
|
+
if (demand && !downloadMaps.wantsInterval(sourceKey, fileIndex, demand)) return;
|
|
520
|
+
const finished = mediaReads.record(params, statement, result, revision.storage, revision.memory);
|
|
521
|
+
await downloadMaps.metadata({ sourceKey, fileIndex, statement: requestId ? `${statement}:${requestId}` : statement, result,
|
|
522
|
+
...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}) });
|
|
523
|
+
if (finished && result.kind === "result") {
|
|
524
|
+
void downloadMaps.refresh(sourceKey, fileIndex).catch(error =>
|
|
525
|
+
logger.warn(`download map source=${sourceKey} file=${fileIndex} metadata refresh failed: ${error?.message ?? error}`));
|
|
526
|
+
}
|
|
527
|
+
},
|
|
408
528
|
portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined
|
|
409
529
|
};
|
|
530
|
+
return params;
|
|
410
531
|
};
|
|
411
532
|
const keyframeTables = new KeyframeTables({
|
|
412
533
|
// Read by the same container that answers the track table and the media
|
|
@@ -429,7 +550,68 @@ export async function startProxyServer({
|
|
|
429
550
|
};
|
|
430
551
|
}
|
|
431
552
|
});
|
|
553
|
+
let sourcePreparation;
|
|
432
554
|
const outputParts = wireOutputs({
|
|
555
|
+
indexMemory,
|
|
556
|
+
readMetadataActivity: () => containerOrchestrator.activity(),
|
|
557
|
+
onViewerChanged: () => { void sourcePreparation?.refresh(); },
|
|
558
|
+
sourceInputsFor: (output, index) => downloadMaps.inputsForOutput(output.file.sourceKey, output.outputKey, index),
|
|
559
|
+
readSourceMedia: async (params) => {
|
|
560
|
+
await playbackPlanner.getPlan(params);
|
|
561
|
+
const media = playbackPlanner.getCachedMediaInfo(params);
|
|
562
|
+
if (media) return media;
|
|
563
|
+
const error = new Error("Source media declarations require more available bytes.");
|
|
564
|
+
error.code = "MEDIA_BYTES_UNAVAILABLE";
|
|
565
|
+
error.canRetry = true;
|
|
566
|
+
throw error;
|
|
567
|
+
},
|
|
568
|
+
resolveEncodeInput: async (output, fromIndex, toIndex) => {
|
|
569
|
+
const grid = output.timeline?.published ?? output.timeline?.boundaries;
|
|
570
|
+
const from = grid?.[fromIndex], to = grid?.[toIndex + 1];
|
|
571
|
+
if (!Number.isFinite(from) || !(to > from)) return { kind: "terminal", reason: "output-interval-not-declared" };
|
|
572
|
+
const selected = new Map();
|
|
573
|
+
for (const type of ["video", "audio"]) {
|
|
574
|
+
const spec = output.spec[type];
|
|
575
|
+
if (!spec) continue;
|
|
576
|
+
const list = selected.get(spec.fileIndex) ?? [];
|
|
577
|
+
list.push({ type, index: type === "video" ? 0 : spec.trackIndex,
|
|
578
|
+
mode: type === "video" ? spec.encode ? "transcode" : "copy" : spec.transcode ? "transcode" : "copy" });
|
|
579
|
+
selected.set(spec.fileIndex, list);
|
|
580
|
+
}
|
|
581
|
+
const sources = [];
|
|
582
|
+
for (const [fileIndex, choices] of selected) {
|
|
583
|
+
const params = await containerOver({ sourceKey: output.file.sourceKey, fileIndex });
|
|
584
|
+
if (!params) return { kind: "needs-source" };
|
|
585
|
+
const tracks = await containerOrchestrator.inspect(params, "tracks");
|
|
586
|
+
if (tracks.kind !== "result") return tracks;
|
|
587
|
+
const container = containerOrchestrator.known(params.sourceKey, fileIndex);
|
|
588
|
+
if (typeof container?.readPacketIndex !== "function") return { kind: "needs-index", reason: "packet-index-not-yet-available" };
|
|
589
|
+
const modes = new Map(), wanted = [];
|
|
590
|
+
for (const choice of choices) {
|
|
591
|
+
const track = tracks.value.filter(track => track.type === choice.type)[choice.index];
|
|
592
|
+
if (!track) return { kind: "terminal", reason: "selected-track-is-absent" };
|
|
593
|
+
wanted.push(track); modes.set(track, choice.mode);
|
|
594
|
+
}
|
|
595
|
+
const media = await containerOrchestrator.inspect(params, "media-info");
|
|
596
|
+
if (media.kind !== "result") return media;
|
|
597
|
+
const timeShiftSeconds = Number(media.value?.startTimeSeconds) || 0;
|
|
598
|
+
const sourceInterval = output.timeline.sourceInterval(fromIndex, toIndex, timeShiftSeconds);
|
|
599
|
+
params.packetInterval = { ...sourceInterval,
|
|
600
|
+
trackIds: wanted.map(track => track.trackNumber),
|
|
601
|
+
modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
|
|
602
|
+
const packets = await containerOrchestrator.inspect(params, "packets");
|
|
603
|
+
if (packets.kind !== "result") return packets;
|
|
604
|
+
const input = new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({
|
|
605
|
+
...sourceInterval, mode: track => modes.get(track) });
|
|
606
|
+
if (input.kind !== "result") return input;
|
|
607
|
+
sources.push({ sourceKey: params.sourceKey, fileIndex, input, timeShiftSeconds });
|
|
608
|
+
}
|
|
609
|
+
return { kind: "result", sources };
|
|
610
|
+
},
|
|
611
|
+
readEncodeRanges: async (source, ranges, maxBytes) => {
|
|
612
|
+
const torrent = torrentPool.knownTorrent(source.sourceKey);
|
|
613
|
+
return torrent ? torrentPool.readHeldRangesOf(torrent, source.fileIndex, ranges, maxBytes) : null;
|
|
614
|
+
},
|
|
433
615
|
enabled: transcodeAudio,
|
|
434
616
|
keyframeTables,
|
|
435
617
|
ffmpegBin,
|
|
@@ -492,27 +674,36 @@ export async function startProxyServer({
|
|
|
492
674
|
// seconds into bytes, what to ask the swarm for, what to keep in memory —
|
|
493
675
|
// is its own business.
|
|
494
676
|
setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
|
|
677
|
+
void sourcePreparation?.refresh();
|
|
495
678
|
const record = sourceRegistry.get(sourceKey);
|
|
496
679
|
if (!record) {
|
|
497
680
|
return;
|
|
498
681
|
}
|
|
499
682
|
try {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
683
|
+
if (zones.length === 0) {
|
|
684
|
+
mediaReads.retain(sourceKey, fileIndex, params => params.requestId?.startsWith("prepare:"));
|
|
685
|
+
if (!outputParts.viewers.forSource(sourceKey).length) probeReads.forget(sourceKey, fileIndex);
|
|
686
|
+
await downloadMaps.forget(sourceKey, fileIndex, { keepPreparation: true });
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
await downloadMaps.playback({ sourceKey, fileIndex, durationSeconds, zones });
|
|
690
|
+
mediaReads.retain(sourceKey, fileIndex, params => !params.requestId?.startsWith("download:") ||
|
|
691
|
+
zones.some(zone => zone.from === params.downloadInterval?.from && zone.to === params.downloadInterval?.to));
|
|
692
|
+
} catch (error) {
|
|
693
|
+
logger.warn(`download map source=${sourceKey} file=${fileIndex} publication failed: ${error?.message ?? error}`);
|
|
504
694
|
}
|
|
505
695
|
},
|
|
506
|
-
getSourceStats: async (sourceKey, fileIndex) => {
|
|
696
|
+
getSourceStats: async (sourceKey, fileIndex, options) => {
|
|
507
697
|
const record = sourceRegistry.get(sourceKey);
|
|
508
698
|
if (!record) {
|
|
509
699
|
return null;
|
|
510
700
|
}
|
|
511
701
|
try {
|
|
512
|
-
const torrent =
|
|
702
|
+
const torrent = torrentPool.knownTorrent(sourceKey);
|
|
703
|
+
if (!torrent) return null;
|
|
513
704
|
// Awaited for the same reason as the stats route: this now crosses a
|
|
514
705
|
// thread boundary and returns a promise.
|
|
515
|
-
return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
|
|
706
|
+
return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null, options);
|
|
516
707
|
} catch {
|
|
517
708
|
return null;
|
|
518
709
|
}
|
|
@@ -567,12 +758,173 @@ export async function startProxyServer({
|
|
|
567
758
|
// decides anything, keeps the segments whose closure is proven, and removes
|
|
568
759
|
// the one piece per output that was being written when the process died.
|
|
569
760
|
outputParts.lifecycle.adoptSegmentsLeftBehind();
|
|
761
|
+
const publishNative = work => {
|
|
762
|
+
if (!work.nativeInput || !sourcePreparation.accepts(work)) return Promise.resolve();
|
|
763
|
+
const viewers = outputParts.viewers.forSource(work.sourceKey)
|
|
764
|
+
.filter(viewer => viewer.source.selectedFileIndex === work.fileIndex && viewer.outputs.size === 0);
|
|
765
|
+
const zones = nativeSourceMap({ ...work.nativeInput, viewers,
|
|
766
|
+
allowanceSeconds: outputParts.segmentDurationSec,
|
|
767
|
+
urgentReadyFor: viewer => sourcePreparation.urgentReadyFor(viewer) });
|
|
768
|
+
return downloadMaps.native({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, zones });
|
|
769
|
+
};
|
|
770
|
+
sourcePreparation = new SourcePreparation({
|
|
771
|
+
viewers: outputParts.viewers,
|
|
772
|
+
candidatesFor: async sourceKey => {
|
|
773
|
+
const record = sourceRegistry.get(sourceKey);
|
|
774
|
+
if (!record) return [];
|
|
775
|
+
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
776
|
+
return contentsOf(torrent).items.map(item => item.fileIndex);
|
|
777
|
+
},
|
|
778
|
+
relatedFilesFor: (sourceKey, fileIndex) => {
|
|
779
|
+
const torrent = torrentPool.knownTorrent(sourceKey);
|
|
780
|
+
if (!torrent) return [];
|
|
781
|
+
const contents = contentsOf(torrent);
|
|
782
|
+
const sidecars = contents.sidecarsOf(fileIndex);
|
|
783
|
+
const items = contents.items;
|
|
784
|
+
const current = items.findIndex(item => item.fileIndex === fileIndex);
|
|
785
|
+
const next = current >= 0 ? items[current + 1] : null;
|
|
786
|
+
return [{ fileIndex, role: "source-rest" },
|
|
787
|
+
...sidecars.audio.map(file => ({ fileIndex: file.fileIndex, role: "audio" })),
|
|
788
|
+
...sidecars.subtitles.map(file => ({ fileIndex: file.fileIndex, role: "subtitle" })),
|
|
789
|
+
...(next?.episode ? [{ fileIndex: next.fileIndex, role: "next-episode" }] : [])];
|
|
790
|
+
},
|
|
791
|
+
priorityFor: (viewer, priority, work) => {
|
|
792
|
+
if (!sourcePreparation.subtitleReadyFor(viewer)) return priority;
|
|
793
|
+
const fileIndex = work.ownerFileIndex ?? work.fileIndex;
|
|
794
|
+
const output = [...outputParts.outputs.values()].find(output => output.file?.sourceKey === work.sourceKey &&
|
|
795
|
+
output.file?.fileIndex === fileIndex && outputParts.viewers.forOutput(output).has(viewer.id) &&
|
|
796
|
+
!outputParts.outputs.supersededBy(output, viewer.activeVariantId ?? null));
|
|
797
|
+
const viewerCount = outputParts.viewers.forSource(work.sourceKey)
|
|
798
|
+
.filter(person => person.source.selectedFileIndex === fileIndex).length;
|
|
799
|
+
if (!output) {
|
|
800
|
+
const coefficient = pauseCoefficient({ playing: viewer.playing || viewer.waiting, viewerCount,
|
|
801
|
+
pauseSeconds: viewer.pausedAt === null ? 0 : (Date.now() - viewer.pausedAt) / 1000,
|
|
802
|
+
allowanceSeconds: outputParts.segmentDurationSec,
|
|
803
|
+
urgentReady: sourcePreparation.urgentReadyFor(viewer) });
|
|
804
|
+
return Math.max(1, 1 + Math.floor((priority - 1) * coefficient));
|
|
805
|
+
}
|
|
806
|
+
return outputParts.priority.priorityFor(output, viewer, priority, viewerCount);
|
|
807
|
+
},
|
|
808
|
+
inputReady: async work => {
|
|
809
|
+
const torrent = torrentPool.knownTorrent(work.sourceKey);
|
|
810
|
+
if (!torrent || !work.inputRanges?.length) return false;
|
|
811
|
+
const held = await torrentPool.heldRangesOf(torrent, work.fileIndex);
|
|
812
|
+
return work.inputRanges.every(([start, end]) => held.some(([from, to]) => from <= start && to >= end));
|
|
813
|
+
},
|
|
814
|
+
readyChanged: (work, ready) => logger.info(`source urgent request=${work.requestId} viewer=${work.ownerId} ` +
|
|
815
|
+
`source=${work.sourceKey} file=${work.fileIndex} inputReady=${ready}`),
|
|
816
|
+
inspect: async work => {
|
|
817
|
+
const params = await containerOver(work);
|
|
818
|
+
if (!params) return { kind: "needs-source" };
|
|
819
|
+
params.isCurrent = () => sourcePreparation.accepts(work);
|
|
820
|
+
if (work.statement === "packets" && work.role === "source-rest" && sourcePreparation.accepts(work)) {
|
|
821
|
+
const file = torrentPool.knownTorrent(work.sourceKey)?.files?.[work.fileIndex];
|
|
822
|
+
if (file?.length > 0) {
|
|
823
|
+
const statement = `${work.statement}:${work.requestId}:input`;
|
|
824
|
+
await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, statement,
|
|
825
|
+
result: { kind: "needs-ranges", ranges: [[0, file.length - 1]], requestId: statement },
|
|
826
|
+
scope: "preparation", ...sourcePreparation.demandFor(work) });
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (["packets", "subtitle-cues"].includes(work.statement) && (work.selected || work.role === "subtitle-embedded")) {
|
|
830
|
+
const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
831
|
+
if (info.kind !== "result") return info;
|
|
832
|
+
const positions = outputParts.viewers.forSource(work.sourceKey)
|
|
833
|
+
.filter(viewer => viewer.source.selectedFileIndex === work.fileIndex &&
|
|
834
|
+
(work.ownerId === undefined || viewer.id === work.ownerId))
|
|
835
|
+
.map(viewer => viewer.positionSeconds() ?? 0);
|
|
836
|
+
const from = (positions.length ? Math.min(...positions) : 0) + (Number(info.value?.startTimeSeconds) || 0);
|
|
837
|
+
params.packetInterval = { from, to: from + outputParts.segmentDurationSec };
|
|
838
|
+
if (work.role === "subtitle-embedded") {
|
|
839
|
+
const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
|
|
840
|
+
const track = tracks.find(track => track.type === "subtitle" && track.declaredIndex === work.trackIndex);
|
|
841
|
+
if (!track || track.isTextBased?.() !== true) return { kind: "terminal", reason: "subtitle-track-not-supported" };
|
|
842
|
+
params.packetInterval.trackIds = [track.trackNumber];
|
|
843
|
+
params.subtitleTrackIndex = work.trackIndex;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
params.onReadResult = async (statement, result, revision) => {
|
|
847
|
+
if (!sourcePreparation.accepts(work)) return;
|
|
848
|
+
mediaReads.record(params, statement, result, revision.storage, revision.memory);
|
|
849
|
+
const inputStatement = `${work.statement}:${work.requestId}:input`;
|
|
850
|
+
if (statement === "packets" && work.role === "source-rest" && result.kind === "result") {
|
|
851
|
+
const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
|
|
852
|
+
const info = await containerOrchestrator.inspect({ ...params, onReadResult: undefined }, "media-info");
|
|
853
|
+
if (!sourcePreparation.accepts(work)) return;
|
|
854
|
+
if (info.kind === "result") {
|
|
855
|
+
work.nativeInput = { index: result.value, tracks, durationSeconds: info.value.durationSeconds,
|
|
856
|
+
startTimeSeconds: info.value.startTimeSeconds ?? 0 };
|
|
857
|
+
await publishNative(work);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (statement === "packets" && work.role !== "source-rest" && result.kind === "result" && (work.selected || work.ownerFileIndex !== undefined)) {
|
|
861
|
+
const tracks = await containerOrchestrator.containerFor(params).then(container => container.readTracks());
|
|
862
|
+
if (!sourcePreparation.accepts(work)) return;
|
|
863
|
+
const bounds = tracks.filter(track => ["video", "audio"].includes(track.type))
|
|
864
|
+
.map(track => result.value.boundsOf(track.trackNumber)).filter(Boolean);
|
|
865
|
+
const interval = params.packetInterval ?? (bounds.length ? {
|
|
866
|
+
from: Math.max(0, Math.min(...bounds.map(bound => bound.start))), to: Math.max(...bounds.map(bound => bound.end))
|
|
867
|
+
} : null);
|
|
868
|
+
if (interval && interval.to > interval.from) {
|
|
869
|
+
const input = work.role === "subtitle-embedded"
|
|
870
|
+
? result.value.inputFor({ trackId: params.packetInterval.trackIds[0], ...interval })
|
|
871
|
+
: new SegmentInputs({ index: result.value, tracks }).forInterval(interval);
|
|
872
|
+
if (work.selected) work.inputRanges = input.kind === "result" ? input.ranges : null;
|
|
873
|
+
await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
|
|
874
|
+
statement: inputStatement, result: input.kind === "result" ? { kind: "needs-ranges", ranges: input.ranges, requestId: inputStatement } : input,
|
|
875
|
+
scope: "preparation", ...sourcePreparation.demandFor(work) });
|
|
876
|
+
if (work.selected) await sourcePreparation.bytesChanged(work.sourceKey, work.fileIndex);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (!sourcePreparation.accepts(work)) return;
|
|
880
|
+
await downloadMaps.metadata({ sourceKey: work.sourceKey, fileIndex: work.fileIndex,
|
|
881
|
+
statement: `${work.statement}:${work.requestId}`, result, scope: "preparation", ...sourcePreparation.demandFor(work) });
|
|
882
|
+
sourcePreparation.result(work, result);
|
|
883
|
+
if (["subtitle-file", "subtitle-cues"].includes(statement)) outputParts.encodeRuns.planEncodersSoon();
|
|
884
|
+
};
|
|
885
|
+
return work.statement === "subtitle-file"
|
|
886
|
+
? subtitles.inspectFile(params) : work.statement === "subtitle-cues"
|
|
887
|
+
? subtitles.inspectPackets(params) : containerOrchestrator.inspect(params, work.statement);
|
|
888
|
+
},
|
|
889
|
+
withdraw: work => {
|
|
890
|
+
if (work.role === "source-rest" && work.statement === "packets") {
|
|
891
|
+
void downloadMaps.native({ sourceKey: work.sourceKey, fileIndex: work.fileIndex, zones: [] })
|
|
892
|
+
.catch(error => logger.warn(`native source map withdrawal failed: ${error.message}`));
|
|
893
|
+
}
|
|
894
|
+
mediaReads.retain(work.sourceKey, work.fileIndex, params => params.requestId !== work.requestId);
|
|
895
|
+
void downloadMaps.withdrawMetadata(work.sourceKey, work.fileIndex, `${work.statement}:${work.requestId}`)
|
|
896
|
+
.catch(error => logger.warn(`source preparation request=${work.requestId} withdrawal failed: ${error.message}`));
|
|
897
|
+
void downloadMaps.withdrawMetadata(work.sourceKey, work.fileIndex, `${work.statement}:${work.requestId}:input`)
|
|
898
|
+
.catch(error => logger.warn(`source preparation request=${work.requestId} input withdrawal failed: ${error.message}`));
|
|
899
|
+
if (!sourcePreparation.ownsFile(work.sourceKey, work.fileIndex)) {
|
|
900
|
+
probeReads.withdraw(work.sourceKey, work.fileIndex);
|
|
901
|
+
void downloadMaps.withdrawMetadata(work.sourceKey, work.fileIndex, "codec-probe")
|
|
902
|
+
.catch(error => logger.warn(`source preparation request=${work.requestId} probe withdrawal failed: ${error.message}`));
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
reprice: (work, demand) => {
|
|
906
|
+
if (work.nativeInput) void publishNative(work)
|
|
907
|
+
.catch(error => logger.warn(`native source map repricing failed: ${error.message}`));
|
|
908
|
+
void downloadMaps.repriceMetadata(work.sourceKey, work.fileIndex, `${work.statement}:${work.requestId}`, demand)
|
|
909
|
+
.catch(error => logger.warn(`source preparation request=${work.requestId} repricing failed: ${error.message}`));
|
|
910
|
+
void downloadMaps.repriceMetadata(work.sourceKey, work.fileIndex, `${work.statement}:${work.requestId}:input`, demand)
|
|
911
|
+
.catch(error => logger.warn(`source preparation request=${work.requestId} input repricing failed: ${error.message}`));
|
|
912
|
+
},
|
|
913
|
+
failed: error => logger.warn(`source preparation failed: ${error?.message ?? error}`)
|
|
914
|
+
});
|
|
915
|
+
outputParts.subtitleReadyFor = viewer => sourcePreparation.subtitleReadyFor(viewer);
|
|
570
916
|
const playbackPlanner = createPlaybackPlanner({
|
|
571
917
|
ffmpegBin,
|
|
572
918
|
transcodeAudioEnabled: transcodeAudio,
|
|
573
919
|
localBaseUrl: outputParts.localBaseUrl,
|
|
574
920
|
sourceRegistry,
|
|
575
921
|
torrentPool,
|
|
922
|
+
subscribeSource: (sourceKey, fileIndex, listener) => mediaReads.subscribe(sourceKey, fileIndex, listener),
|
|
923
|
+
readDeclarations: async ({ sourceKey, fileIndex }) => {
|
|
924
|
+
const params = await containerOver({ sourceKey, fileIndex });
|
|
925
|
+
if (!params) return { kind: "terminal", reason: "source-forgotten" };
|
|
926
|
+
return readPlaybackDeclarations(containerOrchestrator, params);
|
|
927
|
+
},
|
|
576
928
|
sidecarsFromTorrent: (torrent, fileIndex) => contentsOf(torrent).sidecarsOf(fileIndex),
|
|
577
929
|
// What a file declares about its own tracks, parsed on this thread from the
|
|
578
930
|
// header the swarm delivered. The planner used to ask the torrent pool for
|
|
@@ -593,6 +945,13 @@ export async function startProxyServer({
|
|
|
593
945
|
});
|
|
594
946
|
|
|
595
947
|
app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
|
|
948
|
+
app.get("/media", async (req, reply) => handleMediaGet(req, reply, {
|
|
949
|
+
torrentPool,
|
|
950
|
+
onMissing: ({ sourceKey, fileIndex, start, end, requestId }) => {
|
|
951
|
+
logger.info(`media request=${requestId} ${sourceKey}:${fileIndex} needs bytes ${start}-${end}`);
|
|
952
|
+
probeReads.needs(requestId, start, end);
|
|
953
|
+
}
|
|
954
|
+
}));
|
|
596
955
|
app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
|
|
597
956
|
// Off unless --delivery-sink was given; see the route for why it exists.
|
|
598
957
|
app.get("/api/delivery-sink", async (req, reply) =>
|
|
@@ -601,28 +960,23 @@ export async function startProxyServer({
|
|
|
601
960
|
// Bytes a browser can time its link with before any film has been chosen.
|
|
602
961
|
app.get("/api/link-probe", async (req, reply) => handleApiLinkProbeGet(req, reply));
|
|
603
962
|
app.post("/api/sources", async (req, reply) =>
|
|
604
|
-
handleApiSourcesPost(req, reply, { sourceRegistry })
|
|
963
|
+
handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers })
|
|
605
964
|
);
|
|
606
965
|
app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
|
|
607
966
|
handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool })
|
|
608
967
|
);
|
|
609
968
|
app.get("/api/sources/:sourceKey/files", async (req, reply) =>
|
|
610
|
-
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool })
|
|
969
|
+
handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool, viewers: outputParts.viewers })
|
|
611
970
|
);
|
|
612
971
|
app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
|
|
613
972
|
handleApiSourceWarmPost(req, reply, {
|
|
614
973
|
sourceRegistry,
|
|
615
|
-
|
|
616
|
-
playbackPlanner,
|
|
617
|
-
// How long the file runs, read on this thread from the header. The warm
|
|
618
|
-
// turns a position in seconds into a byte offset and needs it; it used to
|
|
619
|
-
// read the container itself, in the torrent thread, to find out.
|
|
620
|
-
durationOf: async (params) => {
|
|
621
|
-
const over = await containerOver(params);
|
|
622
|
-
return over ? (await containerOrchestrator.getMediaInfo(over))?.durationSeconds ?? null : null;
|
|
623
|
-
}
|
|
974
|
+
viewers: outputParts.viewers
|
|
624
975
|
})
|
|
625
976
|
);
|
|
977
|
+
app.post("/api/sources/:sourceKey/files/:fileIndex/viewer", async (req, reply) =>
|
|
978
|
+
handleApiSourceViewerPost(req, reply, { sourceRegistry, viewers: outputParts.viewers })
|
|
979
|
+
);
|
|
626
980
|
// The browser's own log, kept beside the proxy's: a file per session when
|
|
627
981
|
// the proxy writes its log to a file, its console otherwise. See the route's
|
|
628
982
|
// own file for why it is here and not only on the registry server.
|
|
@@ -633,17 +987,25 @@ export async function startProxyServer({
|
|
|
633
987
|
handleApiClientLogsPost(req, reply, { clientLogs })
|
|
634
988
|
);
|
|
635
989
|
app.post("/api/playback-plan", async (req, reply) =>
|
|
636
|
-
handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
|
|
990
|
+
handleApiPlaybackPlanPost(req, reply, { playbackPlanner, viewers: outputParts.viewers })
|
|
637
991
|
);
|
|
638
992
|
app.post("/api/playback-plan/audio-tracks", async (req, reply) =>
|
|
639
993
|
handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner })
|
|
640
994
|
);
|
|
995
|
+
app.post("/api/subtitles", async (req, reply) => handleApiSubtitlesPost(req, reply, {
|
|
996
|
+
viewers: outputParts.viewers, sourceRegistry,
|
|
997
|
+
subtitleTracksFor: (sourceKey, fileIndex) =>
|
|
998
|
+
(containerOrchestrator.tracks.get(`${sourceKey}:${fileIndex}`) ?? []).filter(track => track.type === "subtitle"),
|
|
999
|
+
subtitleFilesFor: (sourceKey, fileIndex) => {
|
|
1000
|
+
const torrent = torrentPool.knownTorrent(sourceKey);
|
|
1001
|
+
return torrent && Number.isSafeInteger(fileIndex)
|
|
1002
|
+
? contentsOf(torrent).sidecarsOf(fileIndex).subtitles.map(file => file.fileIndex) : [];
|
|
1003
|
+
}
|
|
1004
|
+
}));
|
|
641
1005
|
app.get("/api/subtitles", async (req, reply) =>
|
|
642
1006
|
handleApiSubtitlesGet(req, reply, {
|
|
643
1007
|
sourceRegistry,
|
|
644
1008
|
torrentPool,
|
|
645
|
-
ffmpegBin,
|
|
646
|
-
localBaseUrl: outputParts.localBaseUrl,
|
|
647
1009
|
viewers: outputParts.viewers,
|
|
648
1010
|
subtitles
|
|
649
1011
|
})
|
|
@@ -662,7 +1024,8 @@ export async function startProxyServer({
|
|
|
662
1024
|
})
|
|
663
1025
|
);
|
|
664
1026
|
app.post("/api/transcode-sessions", async (req, reply) =>
|
|
665
|
-
handleApiTranscodeSessionsPost(req, reply, { viewerRequests: outputParts.viewerRequests, renditions: outputParts.renditions, quality: outputParts.quality, outputs: outputParts.outputs, lookaheadSeconds: outputParts.lookaheadSeconds, sourceRegistry, torrentPool
|
|
1027
|
+
handleApiTranscodeSessionsPost(req, reply, { viewerRequests: outputParts.viewerRequests, renditions: outputParts.renditions, quality: outputParts.quality, outputs: outputParts.outputs, lookaheadSeconds: outputParts.lookaheadSeconds, sourceRegistry, torrentPool,
|
|
1028
|
+
subscribeSource: (sourceKey, fileIndex, listener) => mediaReads.subscribe(sourceKey, fileIndex, listener) })
|
|
666
1029
|
);
|
|
667
1030
|
app.post("/api/transcode-sessions/:sessionId/release", async (req, reply) =>
|
|
668
1031
|
handleApiTranscodeSessionReleasePost(req, reply, { lifecycle: outputParts.lifecycle })
|
|
@@ -724,6 +1087,7 @@ export async function startProxyServer({
|
|
|
724
1087
|
// file: could THIS host sustain it? Answered from the startup benchmarks
|
|
725
1088
|
// and a description, so it needs no torrent and costs milliseconds.
|
|
726
1089
|
outputParts,
|
|
1090
|
+
mediaMemory: { packetIndexBytes: () => indexMemory.packetBytes() },
|
|
727
1091
|
// The browser only ever knows a source by its REGISTRY key (a hash of the
|
|
728
1092
|
// raw request bytes, scoped to one API session) — never the torrent
|
|
729
1093
|
// pool's own key (the content's infohash, shared across a magnet and a
|