@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.
Files changed (146) hide show
  1. package/bin/cli.js +5 -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/transport/tunnel-client.js +111 -20
  132. package/services/viewer/DownloadMaps.js +168 -0
  133. package/services/viewer/NativeSourceMap.js +33 -0
  134. package/services/viewer/PriorityMap.js +49 -40
  135. package/services/viewer/PriorityOrchestrator.js +42 -13
  136. package/services/viewer/SourcePreparation.js +278 -0
  137. package/services/viewer/Viewer.js +11 -0
  138. package/services/viewer/Viewers.js +109 -2
  139. package/services/viewer/playback-readiness.js +50 -63
  140. package/services/viewer/segment-demands.js +37 -0
  141. package/services/viewer/urgent-output-ready.js +26 -0
  142. package/services/encode/run-inputs.js +0 -53
  143. package/services/media/keyframe-probe.js +0 -157
  144. package/services/media/media-info-probe.js +0 -93
  145. package/services/torrent/worker/background-fill.js +0 -213
  146. package/services/torrent/worker/resume-warm.js +0 -107
@@ -16,7 +16,7 @@
16
16
  * append, and an empty picture for six minutes (field 2026-09-05).
17
17
  */
18
18
 
19
- import { emptyMap, mapForViewer, mergeMaps, runsOf } from "./PriorityMap.js";
19
+ import { emptyMap, mapForViewer, mergeMaps, runsOf, pauseCoefficient } from "./PriorityMap.js";
20
20
 
21
21
  export class PriorityOrchestrator {
22
22
  /** Where the map goes once it is built. @type {(published: object) => void} */
@@ -66,6 +66,7 @@ export class PriorityOrchestrator {
66
66
  /** Whether this output is what that person is consuming, as opposed to one
67
67
  * they merely hold a record on. @type {(session: object, viewer: object) => boolean} */
68
68
  #watchedBy;
69
+ #urgentReadyFor;
69
70
 
70
71
  /**
71
72
  * Who watches an output is this component's own registry, handed in as the
@@ -85,7 +86,7 @@ export class PriorityOrchestrator {
85
86
  * this layer does not know; absent, every registered viewer counts, and then
86
87
  * the per-output map says the same as the per-file one.
87
88
  */
88
- constructor({ publish, viewers, allowanceFor, watchedBy }) {
89
+ constructor({ publish, viewers, allowanceFor, watchedBy, urgentReadyFor }) {
89
90
  if (typeof viewers?.forOutput !== "function") {
90
91
  throw new TypeError("PriorityOrchestrator needs the viewer registry");
91
92
  }
@@ -93,6 +94,16 @@ export class PriorityOrchestrator {
93
94
  this.#viewers = viewers;
94
95
  this.#allowanceFor = typeof allowanceFor === "function" ? allowanceFor : () => 0;
95
96
  this.#watchedBy = typeof watchedBy === "function" ? watchedBy : () => true;
97
+ this.#urgentReadyFor = typeof urgentReadyFor === "function" ? urgentReadyFor : () => false;
98
+ }
99
+
100
+ /** Apply the viewer's same pause attenuation to related source preparation. */
101
+ priorityFor(session, viewer, priority, viewerCount, now = Date.now()) {
102
+ const allowanceSeconds = this.#allowanceFor(session);
103
+ const coefficient = pauseCoefficient({ playing: viewer.playing || viewer.waiting, viewerCount,
104
+ pauseSeconds: viewer.pausedAt === null ? 0 : (now - viewer.pausedAt) / 1000, allowanceSeconds,
105
+ urgentReady: viewer.pausedAt !== null && this.#urgentReadyFor(session, viewer, allowanceSeconds, now) });
106
+ return Math.max(1, 1 + Math.floor((priority - 1) * coefficient));
96
107
  }
97
108
 
98
109
  /**
@@ -110,17 +121,21 @@ export class PriorityOrchestrator {
110
121
  * nobody is watching or the film's length is unknown, which says the same as
111
122
  * a map with nothing in it.
112
123
  */
113
- #mapFrom({ durationSeconds, allowanceSeconds, viewers }) {
124
+ #mapFrom({ durationSeconds, allowanceSeconds, viewers, viewerCount }) {
114
125
  if (!(durationSeconds > 0) || !(viewers?.length > 0)) {
115
126
  return emptyMap(0);
116
127
  }
128
+ const count = viewerCount ?? new Set(viewers.map((one, index) => one.id ?? index)).size;
117
129
  return mergeMaps(
118
130
  viewers.map((viewer) =>
119
131
  mapForViewer({
120
132
  atSeconds: viewer.atSeconds,
121
133
  durationSeconds,
122
134
  allowanceSeconds,
123
- playing: viewer.playing !== false
135
+ playing: viewer.playing !== false,
136
+ pauseSeconds: viewer.pauseSeconds ?? 0,
137
+ urgentReady: viewer.urgentReady === true,
138
+ viewerCount: count
124
139
  })
125
140
  )
126
141
  );
@@ -139,7 +154,7 @@ export class PriorityOrchestrator {
139
154
  * @returns {import("./PriorityMap.js").PriorityMap} One number per second of
140
155
  * film, merged over everyone watching it.
141
156
  */
142
- build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers }) {
157
+ build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers, demandKey = null }) {
143
158
  const map = this.#mapFrom({ durationSeconds, allowanceSeconds, viewers });
144
159
  const key = `${sourceKey}:${fileIndex}`;
145
160
  this.#maps.set(key, map);
@@ -148,7 +163,7 @@ export class PriorityOrchestrator {
148
163
  // make it do that several times a second. Compared as stretches rather than
149
164
  // second by second, which is the same comparison over far fewer values.
150
165
  const zones = runsOf(map);
151
- const shape = JSON.stringify(zones);
166
+ const shape = JSON.stringify({ zones, demandKey });
152
167
  if (this.#last.get(key)?.shape !== shape) {
153
168
  // The length is remembered with the shape, so that a file whose viewers
154
169
  // have all gone can still be spoken for: what is published then is the
@@ -196,7 +211,12 @@ export class PriorityOrchestrator {
196
211
  const byOutput = new Map();
197
212
  for (const sessions of sessionGroups) {
198
213
  for (const session of sessions) {
199
- const key = `${session.sourceKey}:${session.fileIndex}`;
214
+ const sourceKey = session.file?.sourceKey;
215
+ const fileIndex = session.file?.fileIndex;
216
+ if (typeof sourceKey !== "string" || !sourceKey || !Number.isSafeInteger(fileIndex) || fileIndex < 0) {
217
+ throw new TypeError("A priority map requires its output file's source and file index.");
218
+ }
219
+ const key = `${sourceKey}:${fileIndex}`;
200
220
  const durationSeconds = Number(session.file?.durationSeconds) || 0;
201
221
  // The first band is as wide as an interruption this file has actually
202
222
  // shown on this swarm, never a chosen number.
@@ -204,11 +224,12 @@ export class PriorityOrchestrator {
204
224
  let held = byFile.get(key);
205
225
  if (!held) {
206
226
  held = {
207
- sourceKey: session.sourceKey,
208
- fileIndex: session.fileIndex,
227
+ sourceKey,
228
+ fileIndex,
209
229
  durationSeconds,
210
230
  allowanceSeconds,
211
- viewers: []
231
+ viewers: [],
232
+ demandKey: []
212
233
  };
213
234
  byFile.set(key, held);
214
235
  }
@@ -219,15 +240,21 @@ export class PriorityOrchestrator {
219
240
  const address = session.outputKey ?? "";
220
241
  let mine = byOutput.get(address);
221
242
  if (!mine) {
222
- mine = { durationSeconds, allowanceSeconds, viewers: [] };
243
+ mine = { durationSeconds, allowanceSeconds, viewers: [], fileKey: key };
223
244
  byOutput.set(address, mine);
224
245
  }
225
246
  for (const viewer of this.#viewers.forOutput(session).values()) {
226
247
  if (!viewer.isPresent()) {
227
248
  continue;
228
249
  }
250
+ held.demandKey.push([session.outputKey, viewer.id, viewer.audio?.trackIndex,
251
+ viewer.audio?.transcode, viewer.activeVariantId, viewer.warmingVariantId,
252
+ viewer.warmingAudioId, this.#watchedBy(session, viewer)]);
229
253
  const stated = {
254
+ id: viewer.id,
230
255
  atSeconds: viewer.positionSeconds(now) ?? 0,
256
+ pauseSeconds: viewer.pausedAt == null ? 0 : Math.max(0, (now - viewer.pausedAt) / 1000),
257
+ urgentReady: viewer.pausedAt != null && this.#urgentReadyFor(session, viewer, allowanceSeconds, now),
231
258
  // WATCHING OR WAITING, not merely playing. Three states reach this
232
259
  // one question: a viewer whose picture is advancing, a viewer
233
260
  // blocked on material we owe them, and a viewer who stopped it
@@ -236,9 +263,9 @@ export class PriorityOrchestrator {
236
263
  // on screen has its timers throttled and asks for nothing, which is
237
264
  // indistinguishable from a full cushion, so it is not consuming
238
265
  // either — told apart on the page, folded into one question here.
239
- playing: typeof viewer.wantsFilmNow === "function"
266
+ playing: viewer.pausedAt == null && (typeof viewer.wantsFilmNow === "function"
240
267
  ? viewer.wantsFilmNow()
241
- : viewer.playing === true || viewer.waiting === true
268
+ : viewer.playing === true || viewer.waiting === true)
242
269
  };
243
270
  held.viewers.push(stated);
244
271
  if (this.#watchedBy(session, viewer)) {
@@ -280,6 +307,8 @@ export class PriorityOrchestrator {
280
307
  }
281
308
  }
282
309
  for (const [address, one] of byOutput) {
310
+ // Competition is per file, even when two viewers use different outputs.
311
+ one.viewerCount = new Set((byFile.get(one.fileKey)?.viewers ?? []).map((viewer, index) => viewer.id ?? index)).size;
283
312
  this.#byOutput.set(address, this.#mapFrom(one));
284
313
  }
285
314
  }
@@ -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
  *