@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.
Files changed (145) hide show
  1. package/bin/cli.js +3 -2
  2. package/package.json +18 -2
  3. package/routes/api/playback-plan/post.js +21 -4
  4. package/routes/api/sources/files/get.js +20 -28
  5. package/routes/api/sources/post.js +4 -2
  6. package/routes/api/sources/stats/get.js +4 -11
  7. package/routes/api/sources/viewer/post.js +16 -0
  8. package/routes/api/sources/warm/post.js +6 -215
  9. package/routes/api/subtitles/get.js +22 -125
  10. package/routes/api/subtitles/post.js +26 -0
  11. package/routes/api/transcode-sessions/post.js +24 -2
  12. package/routes/media/get.js +80 -0
  13. package/routes/stream/get.js +26 -94
  14. package/routes/transcode/audio-warm/get.js +9 -21
  15. package/routes/transcode/variant-warm/get.js +8 -14
  16. package/server.js +410 -46
  17. package/services/encode/AdmittedInput.js +136 -0
  18. package/services/encode/CushionReport.js +1 -76
  19. package/services/encode/EncodeInputs.js +142 -0
  20. package/services/encode/EncodeOrchestrator.js +4 -1
  21. package/services/encode/EncodeRun.js +13 -4
  22. package/services/encode/EncodeRuns.js +132 -52
  23. package/services/encode/InputFailures.js +37 -0
  24. package/services/encode/MatroskaInput.js +158 -0
  25. package/services/encode/OutputOpening.js +16 -64
  26. package/services/encode/Renditions.js +1 -17
  27. package/services/encode/admitted-command.js +60 -0
  28. package/services/encode/annex-b-input.js +37 -0
  29. package/services/encode/bit-payload.js +29 -0
  30. package/services/encode/output/OutputCatalog.js +1 -1
  31. package/services/encode/output/Timeline.js +11 -0
  32. package/services/encode/pcm-normalization.js +30 -0
  33. package/services/encode/piece-completeness.js +54 -1
  34. package/services/encode/quality/HostLoad.js +19 -73
  35. package/services/encode/quality/QualityOffer.js +1 -1
  36. package/services/encode/run-command.js +1 -589
  37. package/services/encode/segment-formats/mp4-boxes.js +3 -1
  38. package/services/encode/segment-formats/presentation-segment.js +114 -0
  39. package/services/media/ContainerOrchestrator.js +118 -48
  40. package/services/media/KeyframeTables.js +16 -115
  41. package/services/media/MediaReadRequests.js +120 -0
  42. package/services/media/ProbeRequests.js +135 -0
  43. package/services/media/SegmentInputs.js +48 -0
  44. package/services/media/SourceFile.js +6 -0
  45. package/services/media/SubtitleOrchestrator.js +190 -4
  46. package/services/media/await-plan.js +47 -0
  47. package/services/media/container/AsfContainer.js +217 -0
  48. package/services/media/container/AviContainer.js +343 -34
  49. package/services/media/container/CadencedPictureOrder.js +45 -0
  50. package/services/media/container/Container.js +7 -3
  51. package/services/media/container/ContainerFactory.js +10 -3
  52. package/services/media/container/FfprobeContainer.js +222 -0
  53. package/services/media/container/MatroskaContainer.js +72 -46
  54. package/services/media/container/Mp4Container.js +472 -139
  55. package/services/media/container/MpegPsContainer.js +281 -0
  56. package/services/media/container/MpegTsContainer.js +277 -0
  57. package/services/media/container/PacketIndex.js +270 -0
  58. package/services/media/container/PacketRecords.js +237 -0
  59. package/services/media/container/RetainedBytes.js +39 -0
  60. package/services/media/container/RetainedReads.js +51 -0
  61. package/services/media/container/ac3-frame.js +46 -0
  62. package/services/media/container/asf-packets.js +153 -0
  63. package/services/media/container/avc-picture-order.js +50 -0
  64. package/services/media/container/avi-open-dml.js +47 -0
  65. package/services/media/container/dts-frame.js +51 -0
  66. package/services/media/container/ebml-stream.js +12 -1
  67. package/services/media/container/ffprobe-record.js +45 -0
  68. package/services/media/container/flv-packet-ranges.js +27 -0
  69. package/services/media/container/following-packet-time.js +37 -0
  70. package/services/media/container/h264-configuration.js +183 -0
  71. package/services/media/container/h264-elementary-index.js +245 -0
  72. package/services/media/container/hevc-configuration.js +165 -0
  73. package/services/media/container/hevc-elementary-index.js +132 -0
  74. package/services/media/container/hevc-picture-order.js +66 -0
  75. package/services/media/container/latm-frame.js +99 -0
  76. package/services/media/container/matroska-packets.js +207 -0
  77. package/services/media/container/memory-unavailable.js +8 -0
  78. package/services/media/container/mp3-packet-dependencies.js +41 -0
  79. package/services/media/container/mp4-fragments.js +148 -0
  80. package/services/media/container/mpeg-audio-frame.js +19 -0
  81. package/services/media/container/mpeg-audio-reservoir.js +27 -0
  82. package/services/media/container/mpeg-elementary-index.js +196 -0
  83. package/services/media/container/mpeg-sequence-reader.js +69 -0
  84. package/services/media/container/mpeg-ts-packets.js +93 -0
  85. package/services/media/container/mpeg-video-configuration.js +50 -0
  86. package/services/media/container/mpeg4-picture-timing.js +108 -0
  87. package/services/media/container/pes-header.js +34 -0
  88. package/services/media/playback-declarations.js +57 -0
  89. package/services/media/playback-planner.js +72 -230
  90. package/services/media/probe-packets.js +61 -0
  91. package/services/media/read-playback-declarations.js +18 -0
  92. package/services/media/tracks/AudioTrack.js +9 -0
  93. package/services/media/tracks/TextSubtitleTrack.js +4 -1
  94. package/services/server/OutputLifecycle.js +2 -0
  95. package/services/server/SegmentServing.js +40 -33
  96. package/services/server/ViewerRequests.js +22 -41
  97. package/services/server/controllers/PlaybackController.js +4 -0
  98. package/services/server/controllers/SubtitleController.js +8 -29
  99. package/services/server/transcode-session-files.js +58 -99
  100. package/services/server/wire-outputs.js +58 -15
  101. package/services/storage/IndexMemory.js +85 -0
  102. package/services/storage/MachineBudget.js +27 -3
  103. package/services/storage/allowance.js +8 -1
  104. package/services/storage/piece-store/piece-disk-store.js +18 -3
  105. package/services/storage/piece-store/piece-lru.js +8 -4
  106. package/services/storage/piece-store/shared-piece-store.js +26 -11
  107. package/services/storage/segment-store/SegmentStore.js +49 -18
  108. package/services/storage/wire.js +7 -0
  109. package/services/torrent/Contents.js +52 -20
  110. package/services/torrent/demand/DemandRegister.js +13 -4
  111. package/services/torrent/demand/Window.js +13 -2
  112. package/services/torrent/demand-progress.js +25 -0
  113. package/services/torrent/download/DeadlineScheduler.js +167 -0
  114. package/services/torrent/download/FutureDownload.js +166 -0
  115. package/services/torrent/download/SwarmSelection.js +30 -59
  116. package/services/torrent/download/registry.js +45 -5
  117. package/services/torrent/download/withdraw-claim.js +2 -0
  118. package/services/torrent/torrent-pool.js +39 -600
  119. package/services/torrent/worker/channel.js +8 -1
  120. package/services/torrent/worker/client.js +74 -72
  121. package/services/torrent/worker/files-in-use.js +9 -0
  122. package/services/torrent/worker/handle-state.js +12 -0
  123. package/services/torrent/worker/held-bytes.js +82 -99
  124. package/services/torrent/worker/held-file-bytes.js +17 -0
  125. package/services/torrent/worker/piece-reader.js +67 -683
  126. package/services/torrent/worker/pool-adapter.js +37 -123
  127. package/services/torrent/worker/protocol.js +6 -17
  128. package/services/torrent/worker/whole-file-stats.js +2 -0
  129. package/services/torrent/worker/worker.js +86 -156
  130. package/services/transport/data-channel-handler.js +56 -13
  131. package/services/viewer/DownloadMaps.js +168 -0
  132. package/services/viewer/NativeSourceMap.js +33 -0
  133. package/services/viewer/PriorityMap.js +49 -40
  134. package/services/viewer/PriorityOrchestrator.js +42 -13
  135. package/services/viewer/SourcePreparation.js +278 -0
  136. package/services/viewer/Viewer.js +11 -0
  137. package/services/viewer/Viewers.js +109 -2
  138. package/services/viewer/playback-readiness.js +50 -63
  139. package/services/viewer/segment-demands.js +37 -0
  140. package/services/viewer/urgent-output-ready.js +26 -0
  141. package/services/encode/run-inputs.js +0 -53
  142. package/services/media/keyframe-probe.js +0 -157
  143. package/services/media/media-info-probe.js +0 -93
  144. package/services/torrent/worker/background-fill.js +0 -213
  145. package/services/torrent/worker/resume-warm.js +0 -107
@@ -0,0 +1,278 @@
1
+ /** Source preparation is derived from viewers before any output exists. */
2
+ export class SourcePreparation {
3
+ #viewers;
4
+ #candidates;
5
+ #related;
6
+ #priorityFor;
7
+ #inspect;
8
+ #withdraw;
9
+ #failed;
10
+ #reprice;
11
+ #inputReady;
12
+ #readyChanged;
13
+ #work = new Map();
14
+ #revision = 0;
15
+ #completedRevision = null;
16
+ #pending = null;
17
+ #candidateAbort = new AbortController();
18
+
19
+ constructor({ viewers, candidatesFor, relatedFilesFor = () => [], priorityFor = (_viewer, priority) => priority,
20
+ inputReady = async () => false, readyChanged = () => {}, inspect, withdraw, reprice = () => {}, failed }) {
21
+ this.#viewers = viewers;
22
+ this.#candidates = candidatesFor;
23
+ this.#related = relatedFilesFor;
24
+ this.#priorityFor = priorityFor;
25
+ this.#inspect = inspect;
26
+ this.#withdraw = withdraw;
27
+ this.#failed = failed;
28
+ this.#reprice = reprice;
29
+ this.#inputReady = inputReady;
30
+ this.#readyChanged = readyChanged;
31
+ }
32
+
33
+ refresh() {
34
+ this.#revision++;
35
+ this.#candidateAbort.abort();
36
+ this.#candidateAbort = new AbortController();
37
+ for (const [key, work] of this.#work) {
38
+ const demand = this.demandFor(work);
39
+ if (demand) { this.#reprice(work, demand); continue; }
40
+ this.#work.delete(key);
41
+ this.#withdraw(work);
42
+ }
43
+ if (!this.#pending) {
44
+ let succeeded = false;
45
+ this.#pending = this.#run().then(() => { succeeded = true; }).catch(error => this.#failed?.(error)).finally(() => {
46
+ this.#pending = null;
47
+ if (succeeded && this.#completedRevision !== this.#revision) void this.refresh();
48
+ });
49
+ }
50
+ return this.#pending;
51
+ }
52
+
53
+ /** Current owners determine priority; old work never restores a withdrawn read. */
54
+ demandFor(work) {
55
+ if (work.selected && work.statement === "packets" &&
56
+ work.positions !== this.#positionsFor(work.sourceKey, work.fileIndex, work.ownerId)) return null;
57
+ if (work.role === "subtitle-embedded" && ["packets", "subtitle-cues"].includes(work.statement) &&
58
+ work.positions !== this.#positionsFor(work.sourceKey, work.fileIndex, work.ownerId, true)) return null;
59
+ const owners = this.#viewers.forSource(work.sourceKey).filter(viewer => {
60
+ if (work.ownerId !== undefined && viewer.id !== work.ownerId) return false;
61
+ if (work.role === "subtitle-embedded" && (viewer.subtitle?.fileIndex !== work.fileIndex ||
62
+ viewer.subtitle.trackIndex !== work.trackIndex)) return false;
63
+ const selected = viewer.source.selectedFileIndex;
64
+ if (work.ownerFileIndex !== undefined) return selected === work.ownerFileIndex &&
65
+ (work.role !== "source-rest" || viewer.outputs.size === 0);
66
+ if (work.statement !== "packets") return selected === null || selected === work.fileIndex;
67
+ if (selected !== null) return selected === work.fileIndex && viewer.outputs.size === 0 && work.selected;
68
+ return !work.selected && viewer.source.visibleFileIndices.includes(work.fileIndex);
69
+ });
70
+ if (!owners.length) return null;
71
+ const selected = work.ownerFileIndex === undefined && owners.some(viewer => viewer.source.selectedFileIndex !== null);
72
+ const base = work.role === "source-rest" ? 1 : work.role === "next-episode" ? 5 : work.ownerFileIndex !== undefined ? 10 : 100;
73
+ const demands = owners.map(viewer => {
74
+ const chosenSubtitle = ["subtitle", "subtitle-embedded"].includes(work.role) && viewer.subtitle?.fileIndex === work.fileIndex;
75
+ const ownBase = chosenSubtitle ? 100 : base;
76
+ const priority = this.#priorityFor(viewer, ownBase, work);
77
+ return { priority, urgent: (selected || chosenSubtitle) && priority === ownBase };
78
+ });
79
+ const priority = Math.max(...demands.map(demand => demand.priority));
80
+ const urgent = demands.some(demand => demand.urgent);
81
+ return { priority,
82
+ urgent, deadlineAt: urgent ? 0 : Infinity, order: work.order ?? 0 };
83
+ }
84
+
85
+ accepts(work) { return this.#work.get(work.key) === work && this.demandFor(work) !== null; }
86
+
87
+ /** A retired torrent cannot retain completed preparation or accept late reads. */
88
+ forget(sourceKey) {
89
+ this.#revision++;
90
+ this.#candidateAbort.abort();
91
+ this.#candidateAbort = new AbortController();
92
+ for (const [key, work] of this.#work) {
93
+ if (work.sourceKey !== sourceKey) continue;
94
+ this.#work.delete(key);
95
+ this.#withdraw(work);
96
+ }
97
+ }
98
+
99
+ urgentReadyFor(viewer) {
100
+ if (!this.subtitleReadyFor(viewer)) return false;
101
+ return [...this.#work.values()].some(work => work.selected && work.ownerId === viewer.id &&
102
+ work.urgentReady === true && viewer.source?.sourceKey === work.sourceKey &&
103
+ viewer.source.selectedFileIndex === work.fileIndex && viewer.outputs.size === 0 &&
104
+ work.positions === this.#positionsFor(work.sourceKey, work.fileIndex, viewer.id));
105
+ }
106
+
107
+ subtitleReadyFor(viewer) {
108
+ const selection = viewer.subtitle;
109
+ if (!selection) return true;
110
+ return [...this.#work.values()].some(work => work.sourceKey === selection.sourceKey &&
111
+ work.fileIndex === selection.fileIndex && ["result", "terminal"].includes(work.result) &&
112
+ (selection.trackIndex === null ? work.statement === "subtitle-file" && work.ownerFileIndex === viewer.source.selectedFileIndex
113
+ : work.statement === "subtitle-cues" && work.ownerId === viewer.id && work.trackIndex === selection.trackIndex &&
114
+ work.positions === this.#positionsFor(work.sourceKey, work.fileIndex, viewer.id, true)));
115
+ }
116
+
117
+ async bytesChanged(sourceKey, fileIndex) {
118
+ let changed = false;
119
+ for (const work of this.#work.values()) {
120
+ if (!work.selected || work.sourceKey !== sourceKey || work.fileIndex !== fileIndex || !work.inputRanges) continue;
121
+ const revision = work.readinessRevision = (work.readinessRevision ?? 0) + 1;
122
+ const ready = await this.#inputReady(work);
123
+ if (revision !== work.readinessRevision || !this.accepts(work)) continue;
124
+ if (work.urgentReady !== ready) {
125
+ work.urgentReady = ready;
126
+ changed = true;
127
+ this.#readyChanged(work, ready);
128
+ }
129
+ }
130
+ if (changed) for (const work of this.#work.values()) {
131
+ const demand = this.demandFor(work);
132
+ if (demand) this.#reprice(work, demand);
133
+ }
134
+ }
135
+
136
+ ownsFile(sourceKey, fileIndex) {
137
+ return [...this.#work.values()].some(work => work.sourceKey === sourceKey &&
138
+ work.fileIndex === fileIndex && this.demandFor(work));
139
+ }
140
+
141
+ #positionsFor(sourceKey, fileIndex, ownerId, includeOutputs = false) {
142
+ return JSON.stringify(this.#viewers.forSource(sourceKey)
143
+ .filter(viewer => viewer.source.selectedFileIndex === fileIndex && (includeOutputs || viewer.outputs.size === 0) &&
144
+ (ownerId === undefined || viewer.id === ownerId))
145
+ .map(viewer => [viewer.id, viewer.position?.seconds ?? 0]).sort((left, right) => left[0].localeCompare(right[0])));
146
+ }
147
+
148
+ result(work, result) {
149
+ const current = this.#work.get(work.key);
150
+ if (current !== work || !this.demandFor(work)) return;
151
+ current.result = result.kind;
152
+ if (!["needs-ranges", "needs-memory"].includes(result.kind)) void this.refresh();
153
+ }
154
+
155
+ async #run() {
156
+ let revision;
157
+ do {
158
+ revision = this.#revision;
159
+ for (const sourceKey of this.#viewers.sourceKeys()) {
160
+ let candidates;
161
+ try { candidates = await this.#availableCandidates(sourceKey, this.#candidateAbort.signal); }
162
+ catch (error) {
163
+ if (error?.name === "AbortError") break;
164
+ throw error;
165
+ }
166
+ if (revision !== this.#revision) break;
167
+ const people = this.#viewers.forSource(sourceKey);
168
+ const selected = people.map(viewer => viewer.source.selectedFileIndex).filter(index => index !== null);
169
+ const files = [...new Set([...candidates, ...selected])];
170
+ const related = [...new Set(selected)].flatMap(ownerFileIndex =>
171
+ this.#related(sourceKey, ownerFileIndex).map(file => ({ ...file, ownerFileIndex })));
172
+ for (const viewer of people) {
173
+ if (viewer.subtitle?.trackIndex !== null && viewer.subtitle?.trackIndex !== undefined &&
174
+ viewer.subtitle.fileIndex === viewer.source.selectedFileIndex) {
175
+ related.push({ fileIndex: viewer.subtitle.fileIndex, ownerFileIndex: viewer.source.selectedFileIndex,
176
+ ownerId: viewer.id, trackIndex: viewer.subtitle.trackIndex, role: "subtitle-embedded" });
177
+ }
178
+ }
179
+ for (const [key, work] of this.#work) {
180
+ if (work.sourceKey === sourceKey && !files.includes(work.fileIndex) &&
181
+ !related.some(file => file.fileIndex === work.fileIndex && file.ownerFileIndex === work.ownerFileIndex)) {
182
+ this.#work.delete(key);
183
+ this.#withdraw(work);
184
+ }
185
+ }
186
+ const metadata = [];
187
+ for (const [order, fileIndex] of files.entries()) {
188
+ for (const statement of ["tracks", "media-info"]) {
189
+ if (revision !== this.#revision) break;
190
+ const work = this.#request({ sourceKey, fileIndex, statement, order });
191
+ if (!this.demandFor(work)) continue;
192
+ metadata.push(work);
193
+ await this.#read(work);
194
+ }
195
+ }
196
+ if (revision !== this.#revision || metadata.some(work => !["result", "terminal"].includes(work.result))) continue;
197
+ for (const [order, fileIndex] of files.entries()) {
198
+ if (revision !== this.#revision) break;
199
+ const selections = [{ selected: false }, ...people
200
+ .filter(viewer => viewer.source.selectedFileIndex === fileIndex && viewer.outputs.size === 0)
201
+ .map(viewer => ({ selected: true, ownerId: viewer.id }))];
202
+ for (const selection of selections) {
203
+ const work = this.#request({ sourceKey, fileIndex, statement: "packets", ...selection, order });
204
+ if (this.demandFor(work)) await this.#read(work);
205
+ }
206
+ }
207
+ for (const [order, file] of related.entries()) {
208
+ if (revision !== this.#revision) break;
209
+ if (file.role === "subtitle") {
210
+ await this.#read(this.#request({ sourceKey, ...file, statement: "subtitle-file", order: files.length + order }));
211
+ continue;
212
+ }
213
+ const declarations = [];
214
+ for (const statement of ["tracks", "media-info"]) {
215
+ const work = this.#request({ sourceKey, ...file, statement, order: files.length + order });
216
+ declarations.push(work);
217
+ await this.#read(work);
218
+ }
219
+ if (declarations.some(work => work.result !== "result")) continue;
220
+ const packets = this.#request({ sourceKey, ...file, statement: "packets", order: files.length + order });
221
+ await this.#read(packets);
222
+ if (file.role === "subtitle-embedded" && packets.result === "result") {
223
+ await this.#read(this.#request({ sourceKey, ...file, statement: "subtitle-cues", order: files.length + order }));
224
+ }
225
+ }
226
+ }
227
+ } while (revision !== this.#revision);
228
+ this.#completedRevision = revision;
229
+ }
230
+
231
+ #request(address) {
232
+ if (address.selected && address.statement === "packets") {
233
+ address = { ...address, positions: this.#positionsFor(address.sourceKey, address.fileIndex, address.ownerId) };
234
+ }
235
+ if (address.role === "subtitle-embedded" && ["packets", "subtitle-cues"].includes(address.statement)) {
236
+ address = { ...address, positions: this.#positionsFor(address.sourceKey, address.fileIndex, address.ownerId, true) };
237
+ }
238
+ const key = JSON.stringify([address.sourceKey, address.fileIndex, address.statement, address.selected ?? null,
239
+ address.ownerFileIndex ?? null, address.role ?? null, address.positions ?? null, address.ownerId ?? null, address.trackIndex ?? null]);
240
+ let work = this.#work.get(key);
241
+ if (work && work.order !== address.order) {
242
+ work.order = address.order;
243
+ const demand = this.demandFor(work);
244
+ if (demand) this.#reprice(work, demand);
245
+ }
246
+ if (!work) {
247
+ work = { ...address, key, requestId: `prepare:${key}`, result: null };
248
+ if (this.demandFor(work)) this.#work.set(key, work);
249
+ }
250
+ return work;
251
+ }
252
+
253
+ #availableCandidates(sourceKey, signal) {
254
+ return new Promise((resolve, reject) => {
255
+ let done = false;
256
+ const finish = (error, value) => {
257
+ if (done) return;
258
+ done = true;
259
+ signal.removeEventListener("abort", aborted);
260
+ if (error) reject(error); else resolve(value);
261
+ };
262
+ const aborted = () => finish(new DOMException("Source preparation was cancelled.", "AbortError"));
263
+ signal.addEventListener("abort", aborted, { once: true });
264
+ if (signal.aborted) { aborted(); return; }
265
+ Promise.resolve().then(() => {
266
+ if (signal.aborted) throw new DOMException("Source preparation was cancelled.", "AbortError");
267
+ return this.#candidates(sourceKey);
268
+ }).then(value => finish(null, value), error => finish(error));
269
+ });
270
+ }
271
+
272
+ async #read(work) {
273
+ if (work.result !== null || !this.demandFor(work)) return;
274
+ const result = await this.#inspect(work);
275
+ if (this.#work.get(work.key) === work && this.demandFor(work)) work.result = result.kind;
276
+ if (work.selected && work.inputRanges) await this.bytesChanged(work.sourceKey, work.fileIndex);
277
+ }
278
+ }
@@ -192,6 +192,8 @@ export class Viewer {
192
192
  * @type {Set<string>}
193
193
  */
194
194
  this.outputs = new Set();
195
+ /** Source selection exists before any prepared output. */
196
+ this.source = null;
195
197
  /**
196
198
  * Which output answers THIS viewer, and for how long that stands.
197
199
  *
@@ -225,6 +227,7 @@ export class Viewer {
225
227
  * @type {Set<string>}
226
228
  */
227
229
  this.wantsCuesFor = new Set();
230
+ this.subtitle = null;
228
231
  // WHETHER THE PICTURE IS MOVING, and separately WHETHER THIS VIEWER IS
229
232
  // BLOCKED ON US. Two booleans, because there are three states and one
230
233
  // boolean cannot hold them:
@@ -254,6 +257,8 @@ export class Viewer {
254
257
  // the instant they arrive, without the position ever being invented.
255
258
  this.playing = false;
256
259
  this.waiting = true;
260
+ /** When explicit paused state began; waiting for media is not a pause. */
261
+ this.pausedAt = null;
257
262
  // Whether the page carrying this viewer is ON SCREEN, and whether the
258
263
  // picture has been pulled out of it.
259
264
  //
@@ -312,6 +317,7 @@ export class Viewer {
312
317
  return;
313
318
  }
314
319
  this.#place(seconds, now);
320
+ if (this.pausedAt !== null) this.pausedAt = now;
315
321
  // The buffer's history belongs to the place they left.
316
322
  this.bufferReadings = [];
317
323
  // WHAT THEY HELD AT THE PLACE THEY LEFT SAYS NOTHING ABOUT THE PLACE THEY
@@ -475,6 +481,11 @@ export class Viewer {
475
481
  // material, which is the direction that costs an encoder rather than a
476
482
  // viewer.
477
483
  this.waiting = waiting === undefined ? this.playing === false && held === 0 : waiting === true;
484
+ if (!this.playing && !this.waiting) {
485
+ if (this.pausedAt === null) this.pausedAt = now;
486
+ } else {
487
+ this.pausedAt = null;
488
+ }
478
489
  this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
479
490
  this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
480
491
  this.seen(now);
@@ -50,6 +50,79 @@ export class Viewers {
50
50
  this.#onChange = typeof onChange === "function" ? onChange : () => {};
51
51
  }
52
52
 
53
+ /** Register presence before source metadata or an output exists. */
54
+ present(consumerId, now = Date.now()) {
55
+ if (typeof consumerId !== "string" || !consumerId) throw new TypeError("A viewer needs a name.");
56
+ const known = this.#byId.get(consumerId);
57
+ const viewer = known ?? new Viewer(consumerId, now);
58
+ viewer.seen(now);
59
+ viewer.gone = false;
60
+ this.#byId.set(consumerId, viewer);
61
+ if (!known) this.#onChange();
62
+ return viewer;
63
+ }
64
+
65
+ /** Bind the same viewer to their source before metadata preparation. */
66
+ selectsSource(consumerId, sourceKey, now = Date.now()) {
67
+ if (typeof sourceKey !== "string" || !sourceKey) throw new TypeError("A selected source needs a key.");
68
+ const viewer = this.present(consumerId, now);
69
+ if (viewer.source?.sourceKey !== sourceKey) {
70
+ viewer.source = { sourceKey, visibleFileIndices: [], selectedFileIndex: null };
71
+ viewer.subtitle = null;
72
+ this.#onChange();
73
+ }
74
+ return viewer;
75
+ }
76
+
77
+ selectsFile(consumerId, sourceKey, fileIndex, now = Date.now(), selection = {}) {
78
+ if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) throw new TypeError("A selected file needs its source index.");
79
+ const viewer = this.selectsSource(consumerId, sourceKey, now);
80
+ const changed = viewer.source.selectedFileIndex !== fileIndex;
81
+ if (changed) {
82
+ viewer.source.selectedFileIndex = fileIndex;
83
+ viewer.source.generation = 0;
84
+ viewer.subtitle = null;
85
+ }
86
+ if (changed || Number.isFinite(selection.positionSeconds)) viewer.moveTo(selection.positionSeconds ?? 0, now);
87
+ if (typeof selection.wantsToPlay === "boolean") {
88
+ viewer.playing = false;
89
+ viewer.waiting = selection.wantsToPlay;
90
+ viewer.pausedAt = selection.wantsToPlay ? null : viewer.pausedAt ?? now;
91
+ }
92
+ if (changed || Object.keys(selection).length) this.#onChange();
93
+ return viewer;
94
+ }
95
+
96
+ visibleFiles(consumerId, sourceKey, indices, now = Date.now()) {
97
+ if (!Array.isArray(indices) || indices.some(index => !Number.isSafeInteger(index) || index < 0)) {
98
+ throw new TypeError("Visible files need source indices.");
99
+ }
100
+ const viewer = this.selectsSource(consumerId, sourceKey, now);
101
+ const visible = [...new Set(indices)];
102
+ if (JSON.stringify(viewer.source.visibleFileIndices) !== JSON.stringify(visible)) {
103
+ viewer.source.visibleFileIndices = visible;
104
+ this.#onChange();
105
+ }
106
+ return viewer;
107
+ }
108
+
109
+ /** Accept a source report only while its original selection is still present. */
110
+ reportSource(consumerId, sourceKey, fileIndex, report, now = Date.now()) {
111
+ const viewer = this.get(consumerId);
112
+ if (!viewer || viewer.gone || viewer.outputs.size !== 0 ||
113
+ viewer.source?.sourceKey !== sourceKey || viewer.source.selectedFileIndex !== fileIndex) return false;
114
+ if (report.generation !== undefined) {
115
+ if (!Number.isSafeInteger(report.generation) || report.generation < (viewer.source.generation ?? 0)) return false;
116
+ viewer.source.generation = report.generation;
117
+ }
118
+ if (report.seek === true && Number.isFinite(report.positionSeconds) && report.positionSeconds >= 0) {
119
+ viewer.moveTo(report.positionSeconds, now);
120
+ }
121
+ viewer.report(report, now);
122
+ this.#onChange();
123
+ return true;
124
+ }
125
+
53
126
  /**
54
127
  * This viewer, watching this output.
55
128
  *
@@ -106,6 +179,15 @@ export class Viewers {
106
179
  return this.#byId.get(consumerId) ?? null;
107
180
  }
108
181
 
182
+ sourceKeys() {
183
+ return new Set([...this.#byId.values()].filter(viewer => viewer.isPresent() && viewer.source)
184
+ .map(viewer => viewer.source.sourceKey));
185
+ }
186
+
187
+ forSource(sourceKey) {
188
+ return [...this.#byId.values()].filter(viewer => viewer.isPresent() && viewer.source?.sourceKey === sourceKey);
189
+ }
190
+
109
191
  /**
110
192
  * A viewer of one output, without creating one.
111
193
  *
@@ -244,7 +326,7 @@ export class Viewers {
244
326
  return false;
245
327
  }
246
328
  viewer.outputs.delete(output.id);
247
- if (viewer.outputs.size === 0) {
329
+ if (viewer.outputs.size === 0 && !viewer.source) {
248
330
  // Watching nothing at all: this is a statement that they are gone, and
249
331
  // not merely that this one output is no longer theirs.
250
332
  viewer.markGone();
@@ -287,7 +369,7 @@ export class Viewers {
287
369
  for (const [consumerId, viewer] of this.#byId) {
288
370
  if (!viewer.outputs.delete(outputId)) continue;
289
371
  if (viewer.activeVariantId === outputId) viewer.activeVariantId = null;
290
- if (viewer.outputs.size === 0) {
372
+ if (viewer.outputs.size === 0 && !viewer.source) {
291
373
  viewer.markGone();
292
374
  this.#byId.delete(consumerId);
293
375
  }
@@ -334,6 +416,31 @@ export class Viewers {
334
416
  return true;
335
417
  }
336
418
 
419
+ /** Record the chosen subtitle without creating or restoring a viewer. */
420
+ selectsSubtitle(consumerId, sourceKey, fileIndex, trackIndex = null) {
421
+ const viewer = this.get(consumerId);
422
+ if (!viewer || viewer.gone || viewer.source?.sourceKey !== sourceKey ||
423
+ !Number.isSafeInteger(fileIndex) || fileIndex < 0 ||
424
+ (trackIndex !== null && (!Number.isSafeInteger(trackIndex) || trackIndex < 0))) return false;
425
+ if (trackIndex !== null && viewer.source.selectedFileIndex !== fileIndex) return false;
426
+ const selection = { sourceKey, fileIndex, trackIndex };
427
+ if (JSON.stringify(viewer.subtitle) !== JSON.stringify(selection)) {
428
+ viewer.subtitle = selection;
429
+ this.#onChange();
430
+ }
431
+ return true;
432
+ }
433
+
434
+ clearsSubtitle(consumerId, sourceKey) {
435
+ const viewer = this.get(consumerId);
436
+ if (!viewer || viewer.gone || viewer.source?.sourceKey !== sourceKey) return false;
437
+ if (viewer.subtitle !== null) {
438
+ viewer.subtitle = null;
439
+ this.#onChange();
440
+ }
441
+ return true;
442
+ }
443
+
337
444
  /**
338
445
  * Who has subtitles switched on for this file.
339
446
  *
@@ -3,7 +3,7 @@
3
3
  * exhausting the continuous media available for the required tracks.
4
4
  *
5
5
  * This module contains the playback decision's only model. Its inputs are
6
- * measurements and physical limits: source bytes and download rate, encoded
6
+ * measurements and physical limits: mapped whole-piece arrivals, encoded
7
7
  * media and encoder rate, segment sizes and client-link rate, existing
8
8
  * continuous media, and measured supply interruptions. Each service integral
9
9
  * extends its latest measured rate until the next observation. The forecast
@@ -99,8 +99,8 @@ export class RateTrend {
99
99
  * @param {number} input.lookaheadSeconds
100
100
  * @param {number} input.now
101
101
  * @param {boolean} [input.requiredAudio]
102
- * @param {Array<{ id: string, serviceId?: string, complete: boolean, bytesPerMediaSecond: number,
103
- * readings: Array<{ at: number, value: number }> }>} input.sources
102
+ * @param {Array<{ id: string, complete: boolean,
103
+ * downloadForecast?: { ranges: Array<{ start: number, end: number, availableAt: number | null }> } }>} input.sources
104
104
  * @param {Array<{ id: string, sourceIds: string[], processedSeconds: number,
105
105
  * bitsPerMediaSecond: number, readings: Array<{ at: number, value: number }>,
106
106
  * clockOffsetSeconds?: number, clientRanges?: Array<{ start: number, end: number }>,
@@ -137,10 +137,10 @@ export function predictPlaybackReadiness(input = {}) {
137
137
  ]))
138
138
  ]);
139
139
  const zero = mediaTime(0n, unit);
140
- const segmentsOf = tracks.map((track, trackIndex) =>
140
+ const segmentsOf = tracks.map((track, trackIndex) => predictedMediaSegments(
141
141
  (Array.isArray(track.segments) ? track.segments : []).map((segment) => ({
142
142
  ...segment, ranges: playerRanges(segment.mediaRanges, rescale(clocks[trackIndex], unit), unit)
143
- })));
143
+ })), unit));
144
144
  const origin = latest(zero, ...segmentsOf.map((segments) =>
145
145
  segments.find(({ index }) => index === 0)?.ranges?.[0]?.start ?? zero));
146
146
  const at = latest(origin, rescale(fromSeconds(finiteNonNegative(input.positionSeconds)), unit));
@@ -182,7 +182,7 @@ export function predictPlaybackReadiness(input = {}) {
182
182
  curve: rateCurve(track.readings, now),
183
183
  bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
184
184
  new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
185
- segments: predictedMediaSegments(segmentsOf[trackIndex], unit)
185
+ segments: segmentsOf[trackIndex]
186
186
  .filter((segment) => Number.isInteger(segment?.index) &&
187
187
  Number.isFinite(segment?.startSeconds) && Number.isFinite(segment?.endSeconds) &&
188
188
  segment.endSeconds > segment.startSeconds && segment.endSeconds > position)
@@ -192,19 +192,8 @@ export function predictPlaybackReadiness(input = {}) {
192
192
  return result(false, null, buffered, reserve, null, "timeline-unavailable", 0);
193
193
  }
194
194
  const sourceState = new Map();
195
- const downloadServices = new Map();
196
195
  for (const [id, source] of sources) {
197
- const serviceId = typeof source.serviceId === "string" ? source.serviceId : id;
198
- const service = downloadServices.get(serviceId);
199
- if (!service || (!service.curve && source.complete !== true)) {
200
- downloadServices.set(serviceId, {
201
- curve: source.complete === true ? null : rateCurve(source.readings, now)
202
- });
203
- }
204
- sourceState.set(id, {
205
- source,
206
- serviceId
207
- });
196
+ sourceState.set(id, { source });
208
197
  }
209
198
  const link = rateCurve(input.linkReadings, now);
210
199
  let unknownReason = null;
@@ -246,19 +235,28 @@ export function predictPlaybackReadiness(input = {}) {
246
235
  Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.end > range.start));
247
236
  const encodeWork = produced ? 0 : remainingProcessing.reduce((sum, part) => sum + part.end - part.start, 0);
248
237
 
249
- const sourceIntervals = new Map();
238
+ const sourceByteIntervals = new Map();
250
239
  if (!produced && encodeWork > 0) {
251
- for (const sourceId of Array.isArray(track.sourceIds) ? new Set(track.sourceIds) : []) {
240
+ const exactInputs = Array.isArray(segment.sourceInputs) ? segment.sourceInputs : null;
241
+ const requiredSources = new Set([
242
+ ...(Array.isArray(track.sourceIds) ? track.sourceIds : []),
243
+ ...(exactInputs ?? []).map(input => input?.sourceId)
244
+ ]);
245
+ for (const sourceId of requiredSources) {
252
246
  const source = sourceState.get(sourceId);
253
247
  if (!source) {
254
248
  unknownReason ??= "source-measurement-unavailable";
255
249
  } else if (source.source.complete !== true) {
256
- const bytesPerMediaSecond = Number(source.source.bytesPerMediaSecond);
257
- if (!(bytesPerMediaSecond > 0)) {
258
- unknownReason ??= "download-rate-unavailable";
259
- } else {
260
- sourceIntervals.set(sourceId, remainingProcessing);
250
+ if (exactInputs) {
251
+ const inputs = exactInputs.filter(input => input?.sourceId === sourceId);
252
+ const ranges = inputs.flatMap(input => Array.isArray(input.ranges) ? input.ranges : []);
253
+ if (!ranges.length || inputs.some(input => !Array.isArray(input.ranges) || !input.ranges.length) ||
254
+ ranges.some(range => !range || !Number.isSafeInteger(range.start) || !Number.isSafeInteger(range.end) || range.start < 0 || range.end <= range.start)) {
255
+ unknownReason ??= "source-input-ranges-unavailable";
256
+ } else sourceByteIntervals.set(sourceId, ranges);
257
+ continue;
261
258
  }
259
+ unknownReason ??= "source-input-ranges-unavailable";
262
260
  }
263
261
  }
264
262
  }
@@ -272,8 +270,7 @@ export function predictPlaybackReadiness(input = {}) {
272
270
  ahead: Math.max(0, segment.startSeconds - position),
273
271
  sizeBits,
274
272
  encodeWork,
275
- remainingProcessing,
276
- sourceIntervals,
273
+ sourceByteIntervals,
277
274
  produced
278
275
  });
279
276
  }
@@ -295,17 +292,20 @@ export function predictPlaybackReadiness(input = {}) {
295
292
  // scheduled in media order, independently of browser storage capacity.
296
293
  // The earliest legal start is the supremum of completion lateness against
297
294
  // presentation deadlines, not a search over guessed delay candidates.
298
- const sourceFinish = new Map([...downloadServices.keys()].map((id) => [id, 0]));
299
- const sourceMediaService = new Map();
295
+ const sourceByteService = new Map();
300
296
  for (const [id, { source }] of sourceState) {
301
- const density = Number(source.bytesPerMediaSecond);
302
- if (density > 0 && Array.isArray(source.residence)) {
303
- // Initial time-to-byte mapping uses measured file-average density.
304
- // Held bytes are not downloaded again. Reading costs are already
305
- // included in effective processing speed, rather than charged twice.
306
- sourceMediaService.set(id, source.residence.filter(({ location, start, end }) =>
307
- location !== "missing" && Number.isFinite(start) && Number.isFinite(end) && end > start)
308
- .map(({ start, end }) => ({ start: start / density, end: end / density, begin: 0, finish: 0 })));
297
+ sourceByteService.set(id, Array.isArray(source.residence) ? source.residence
298
+ .filter(({ location, start, end }) => location !== "missing" && Number.isFinite(start) && Number.isFinite(end) && end > start)
299
+ .map(({ start, end }) => ({ start, end, begin: 0, finish: 0 })) : []);
300
+ if (source.downloadForecast) {
301
+ for (const range of source.downloadForecast.ranges ?? []) {
302
+ if (!Number.isFinite(range?.availableAt) || !Number.isSafeInteger(range.start) ||
303
+ !Number.isSafeInteger(range.end) || range.start < 0 || range.end <= range.start) continue;
304
+ const arrival = Math.max(0, (range.availableAt - now) / 1000);
305
+ // The entire protocol piece becomes readable after verification;
306
+ // bytes inside it never arrive proportionally to their file address.
307
+ sourceByteService.get(id).push({ start: range.start, end: range.end, begin: arrival, finish: arrival });
308
+ }
309
309
  }
310
310
  }
311
311
  const trackFinish = trackState.map(({ track }) => finiteNonNegative(track.startupRemainingSeconds));
@@ -313,36 +313,23 @@ export function predictPlaybackReadiness(input = {}) {
313
313
  const completions = [];
314
314
  const productions = [];
315
315
  for (const job of jobs) {
316
- const { track, trackIndex, segment, sizeBits, encodeWork, sourceIntervals, remainingProcessing } = job;
316
+ const { track, trackIndex, segment, sizeBits, encodeWork, sourceByteIntervals } = job;
317
317
  const sourceArrivals = [];
318
- for (const [sourceId, interval] of [...sourceIntervals].flatMap(([id, ranges]) =>
319
- ranges.map((range) => [id, range]))) {
320
- const source = sourceState.get(sourceId);
321
- const served = sourceMediaService.get(sourceId) ?? [];
322
- for (const part of uncoveredIntervals(interval, served)) {
323
- const supply = downloadServices.get(source.serviceId)?.curve;
324
- if (!supply) return result(false, null, buffered, reserve, null, "download-rate-unavailable", preparedSegments);
325
- const begin = sourceFinish.get(source.serviceId) ?? 0;
326
- const end = supply.finish(
327
- (part.end - part.start) * Number(source.source.bytesPerMediaSecond), begin);
328
- sourceFinish.set(source.serviceId, end);
329
- served.push({ ...part, begin, finish: end });
318
+ for (const [sourceId, ranges] of sourceByteIntervals) {
319
+ const served = sourceByteService.get(sourceId) ?? [];
320
+ for (const interval of ranges) {
321
+ if (uncoveredIntervals(interval, served).length) return result(false, null, buffered, reserve, null, "download-schedule-unavailable", preparedSegments);
322
+ for (const part of served.filter(part => part.end > interval.start && part.start < interval.end)) {
323
+ sourceArrivals.push({ at: part.finish });
324
+ }
330
325
  }
331
- sourceMediaService.set(sourceId, served);
332
- sourceArrivals.push(...served.filter((part) => part.end > interval.start && part.start < interval.end)
333
- .flatMap((part) => [Math.max(part.start, interval.start), Math.min(part.end, interval.end)]
334
- .map((media) => ({ media, at: media === part.start ? part.begin : part.begin +
335
- (part.finish - part.begin) * (media - part.start) / (part.end - part.start) }))));
326
+ sourceByteService.set(sourceId, served);
336
327
  }
337
- // Max-plus composition of fluid arrival and processing. Between measured
338
- // boundaries both are linear, so their supremum occurs at an endpoint.
339
- // Reused input keeps its original arrival times; it is neither downloaded
340
- // twice nor charged the queue start of a later request.
328
+ // Production accepts a complete held segment input. It cannot overlap its
329
+ // own source download; reused input retains its original arrival time.
341
330
  const processing = trackState[trackIndex].curve;
342
- const producedAt = job.produced || encodeWork === 0 ? 0 : Math.max(
343
- processing.finish(encodeWork, trackFinish[trackIndex]),
344
- ...sourceArrivals.map(({ media, at }) => processing.finish(
345
- remainingProcessing.reduce((sum, part) => sum + Math.max(0, part.end - Math.max(part.start, media)), 0), at)));
331
+ const admittedAt = Math.max(trackFinish[trackIndex], ...sourceArrivals.map(({ at }) => at));
332
+ const producedAt = job.produced || encodeWork === 0 ? 0 : processing.finish(encodeWork, admittedAt);
346
333
  trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
347
334
  productions.push({ at: producedAt, trackIndex, segment, requiresSource: sourceArrivals.some(({ at }) => at > 0) });
348
335
  const deliveryAt = link.finish(sizeBits, Math.max(transferFinish, producedAt));