@torrent-tv/proxy 2.83.3 → 2.83.5
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/CHANGELOG.md +32 -0
- package/package.json +1 -1
- package/research/handover-reader-claims-removal-2026-09-12.md +166 -0
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -0
- package/research/priority-map-is-the-truth-2026-09-12.md +225 -0
- package/routes/api/sources/files/get.js +23 -9
- package/routes/api/sources/warm/post.js +26 -22
- package/routes/api/transcode-sessions/post.js +1 -49
- package/routes/stream/get.js +2 -36
- package/services/controllers/SubtitleController.js +0 -3
- package/services/download/withdraw-claim.js +80 -0
- package/services/hls-session-manager.js +17 -110
- package/services/orchestrators/EncodeOrchestrator.js +133 -0
- package/services/piece-store/piece-disk-store.js +24 -1
- package/services/piece-store/shared-piece-store.js +71 -1
- package/services/playback-planner.js +12 -13
- package/services/priority/PriorityOrchestrator.js +40 -6
- package/services/torrent/Contents.js +324 -0
- package/services/torrent/files.js +8 -1
- package/services/torrent-pool.js +378 -102
- package/services/torrent-worker/client.js +0 -24
- package/services/torrent-worker/piece-reader.js +30 -1
- package/services/torrent-worker/pool-adapter.js +3 -33
- package/services/torrent-worker/protocol.js +0 -4
- package/services/torrent-worker/worker.js +60 -60
- package/test/file-edges.test.js +191 -0
- package/test/input-lost-quiets-the-plan.test.js +261 -0
- package/test/logger-repeats.test.js +120 -0
- package/test/priority-map-emptied.test.js +207 -0
- package/test/read-survives-withdrawal.test.js +164 -0
- package/test/source-files-route.test.js +103 -0
- package/test/stream-route.test.js +4 -8
- package/test/swarm-follows-readers.test.js +96 -14
- package/test/torrent-contents.test.js +235 -0
- package/test/upload-hurry.test.js +66 -28
- package/test/withdraw-piece-claim.test.js +212 -0
- package/utils/logger.js +105 -7
- package/services/torrent-worker/file-claims.js +0 -91
- package/test/file-claims.test.js +0 -64
|
@@ -33,6 +33,20 @@ import { contentionPenalty } from "../encode/contention.js";
|
|
|
33
33
|
import { waits } from "../priority/WaitLedger.js";
|
|
34
34
|
import { SegmentDemand } from "../encode/SegmentDemand.js";
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* How long nothing is placed on an output whose input has just gone, and the
|
|
38
|
+
* ceiling that doubling reaches.
|
|
39
|
+
*
|
|
40
|
+
* Both are chosen, and are named here as chosen rather than dressed as
|
|
41
|
+
* measurements: what they bound is not how long the data takes to come back —
|
|
42
|
+
* that is the swarm's business and nobody here can know it — but how often it
|
|
43
|
+
* is worth asking. They replace the same two figures, with the same values,
|
|
44
|
+
* that lived in the session manager and governed only the dead run's own retry
|
|
45
|
+
* while the plan placed fresh runs beside it every half second.
|
|
46
|
+
*/
|
|
47
|
+
const INPUT_QUIET_BASE_MS = 2_000;
|
|
48
|
+
const INPUT_QUIET_MAX_MS = 15_000;
|
|
49
|
+
|
|
36
50
|
export class EncodeOrchestrator {
|
|
37
51
|
/** Output address to what has been made of it. @type {Map<string, CoverageMap>} */
|
|
38
52
|
#coverage = new Map();
|
|
@@ -47,6 +61,20 @@ export class EncodeOrchestrator {
|
|
|
47
61
|
/** How runs have ended, by cause. @type {Map<string, number>} */
|
|
48
62
|
#endings = new Map();
|
|
49
63
|
|
|
64
|
+
/**
|
|
65
|
+
* How many times in a row an output's run has died because its input was not
|
|
66
|
+
* there. Cleared by any other ending, which is the input answering.
|
|
67
|
+
*
|
|
68
|
+
* @type {Map<string, number>}
|
|
69
|
+
*/
|
|
70
|
+
#inputLostAttempts = new Map();
|
|
71
|
+
|
|
72
|
+
/** Output address to the time before which nothing is placed on it. @type {Map<string, number>} */
|
|
73
|
+
#quietUntil = new Map();
|
|
74
|
+
|
|
75
|
+
/** The wake-up per output, so a quiet output is reconsidered. @type {Map<string, NodeJS.Timeout>} */
|
|
76
|
+
#quietTimers = new Map();
|
|
77
|
+
|
|
50
78
|
/** The last state said out loud, so an unchanged state is not repeated. */
|
|
51
79
|
#lastDescribed = "";
|
|
52
80
|
|
|
@@ -97,6 +125,7 @@ export class EncodeOrchestrator {
|
|
|
97
125
|
refetchSecPerFilmSecond = () => 0,
|
|
98
126
|
startingSpeedFor = () => 0,
|
|
99
127
|
segmentStore = null,
|
|
128
|
+
planSoon = null,
|
|
100
129
|
logger,
|
|
101
130
|
now
|
|
102
131
|
}) {
|
|
@@ -124,6 +153,9 @@ export class EncodeOrchestrator {
|
|
|
124
153
|
this.segmentSeconds = segmentSeconds;
|
|
125
154
|
this.logger = logger;
|
|
126
155
|
this.now = typeof now === "function" ? now : Date.now;
|
|
156
|
+
// Asked for, not commanded: this class decides, and something else owns the
|
|
157
|
+
// loop that calls it. Absent in a test, where the clock is the test's own.
|
|
158
|
+
this.planSoon = typeof planSoon === "function" ? planSoon : () => undefined;
|
|
127
159
|
}
|
|
128
160
|
|
|
129
161
|
/**
|
|
@@ -366,6 +398,20 @@ export class EncodeOrchestrator {
|
|
|
366
398
|
});
|
|
367
399
|
}
|
|
368
400
|
}
|
|
401
|
+
// ITS INPUT WAS NOT THERE A MOMENT AGO, so nothing is placed yet. The sweep
|
|
402
|
+
// above still runs — a claim left by a dead run must be released whatever
|
|
403
|
+
// the reason — and only the placing waits. This is the one thing that makes
|
|
404
|
+
// the delay bind: it used to be timed against the dead run, which the plan
|
|
405
|
+
// does not consult, so a fresh run went to the same place as fast as ffmpeg
|
|
406
|
+
// could fail there.
|
|
407
|
+
//
|
|
408
|
+
// AND ONLY WHILE NOTHING IS PRODUCING THERE. A run still alive on this
|
|
409
|
+
// output is proof the input can be read, whatever a run beside it met, so
|
|
410
|
+
// the wait must not silence an output that is working.
|
|
411
|
+
const quietMs = this.#quietFor(address);
|
|
412
|
+
if (quietMs > 0 && this.runsOn(address).every((run) => !run.isAlive)) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
369
415
|
// ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
|
|
370
416
|
//
|
|
371
417
|
// Two viewers a few seconds apart state stretches that overlap, and the plan
|
|
@@ -696,6 +742,93 @@ export class EncodeOrchestrator {
|
|
|
696
742
|
this.#runs.set(ended.address, remaining);
|
|
697
743
|
}
|
|
698
744
|
this.#endings.set(ended.ending, (this.#endings.get(ended.ending) ?? 0) + 1);
|
|
745
|
+
this.#noteInputAvailability(ended);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Remember, per output, that its input was not there — and until when nothing
|
|
750
|
+
* is to be placed on it.
|
|
751
|
+
*
|
|
752
|
+
* **Why the plan has to hold this.** A run whose input has gone is not alive,
|
|
753
|
+
* so the plan sees the stretch it held as free and places another run there at
|
|
754
|
+
* once — and the next one dies the same way, because nothing about the state
|
|
755
|
+
* has changed. There WAS a delay for exactly this, doubling from 2 s to 15 s,
|
|
756
|
+
* and it governed only the dead run's own retry while the plan went on placing
|
|
757
|
+
* fresh ones beside it. The comment beside that timer predicted this in as
|
|
758
|
+
* many words and the code did not prevent it. Field 2026-09-12: 2432 ffmpeg
|
|
759
|
+
* starts in 23 minutes, one every 0.57 s, for 61 minutes, against a delay that
|
|
760
|
+
* had long since reached its 15 s cap.
|
|
761
|
+
*
|
|
762
|
+
* So the delay lives where the decision is taken. It is not a cure for the
|
|
763
|
+
* input being away — that is the store's claim being withdrawn and the piece
|
|
764
|
+
* being fetched again — it is what stops one absent input from costing a
|
|
765
|
+
* thousand processes and a quarter of a million log lines while it is away.
|
|
766
|
+
*
|
|
767
|
+
* @param {{ address: string, ending: string }} ended
|
|
768
|
+
* @returns {void}
|
|
769
|
+
*/
|
|
770
|
+
#noteInputAvailability(ended) {
|
|
771
|
+
if (ended.ending !== ENCODE_EXIT.INPUT_LOST) {
|
|
772
|
+
// CLEARED ONLY BY PROOF THAT THE INPUT WAS THERE, which is a segment
|
|
773
|
+
// having come out of it. Any other ending was nearly the rule here and is
|
|
774
|
+
// wrong: at the moment of failure several runs end at once, and one of
|
|
775
|
+
// them ending `gone` or `stopped` without producing a thing says nothing
|
|
776
|
+
// about the input — it would have lifted the wait the one beside it had
|
|
777
|
+
// just set, which is the storm again with an extra step.
|
|
778
|
+
const produced =
|
|
779
|
+
ended.firstOutputMs !== null && ended.firstOutputMs !== undefined
|
|
780
|
+
? true
|
|
781
|
+
: Number.isFinite(ended.reached) && Number.isFinite(ended.from) && ended.reached >= ended.from;
|
|
782
|
+
if (produced) {
|
|
783
|
+
this.#inputLostAttempts.delete(ended.address);
|
|
784
|
+
this.#quietUntil.delete(ended.address);
|
|
785
|
+
}
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const attempts = (this.#inputLostAttempts.get(ended.address) ?? 0) + 1;
|
|
789
|
+
this.#inputLostAttempts.set(ended.address, attempts);
|
|
790
|
+
const delayMs = Math.min(
|
|
791
|
+
INPUT_QUIET_MAX_MS,
|
|
792
|
+
INPUT_QUIET_BASE_MS * 2 ** Math.min(attempts - 1, 6)
|
|
793
|
+
);
|
|
794
|
+
this.#quietUntil.set(ended.address, this.now() + delayMs);
|
|
795
|
+
this.logger.info(
|
|
796
|
+
`encode-plan on ${ended.address}: its input was not there (attempt ${attempts}) — ` +
|
|
797
|
+
`placing nothing for ${Math.round(delayMs / 1000)}s`
|
|
798
|
+
);
|
|
799
|
+
// THE WAKE-UP, because a plan that refuses to act needs something to ask it
|
|
800
|
+
// again: nothing about the state changes while the data is away, so no event
|
|
801
|
+
// would arrive to reconsider it.
|
|
802
|
+
const timer = setTimeout(() => {
|
|
803
|
+
this.#quietTimers.delete(ended.address);
|
|
804
|
+
this.planSoon();
|
|
805
|
+
}, delayMs);
|
|
806
|
+
timer.unref?.();
|
|
807
|
+
const previous = this.#quietTimers.get(ended.address);
|
|
808
|
+
if (previous) {
|
|
809
|
+
clearTimeout(previous);
|
|
810
|
+
}
|
|
811
|
+
this.#quietTimers.set(ended.address, timer);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* How long this output still has to wait before anything is placed on it, in
|
|
816
|
+
* milliseconds; zero when it may be planned now.
|
|
817
|
+
*
|
|
818
|
+
* @param {string} address
|
|
819
|
+
* @returns {number}
|
|
820
|
+
*/
|
|
821
|
+
#quietFor(address) {
|
|
822
|
+
const until = this.#quietUntil.get(address);
|
|
823
|
+
if (!Number.isFinite(until)) {
|
|
824
|
+
return 0;
|
|
825
|
+
}
|
|
826
|
+
const left = until - this.now();
|
|
827
|
+
if (left <= 0) {
|
|
828
|
+
this.#quietUntil.delete(address);
|
|
829
|
+
return 0;
|
|
830
|
+
}
|
|
831
|
+
return left;
|
|
699
832
|
}
|
|
700
833
|
|
|
701
834
|
/**
|
|
@@ -83,6 +83,17 @@ export class PieceDiskStore {
|
|
|
83
83
|
/** Where the live readers stand, from whoever holds that fact. @type {() => number[]} */
|
|
84
84
|
#readHeads;
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Said whenever this tier stops holding a piece, whatever the reason.
|
|
88
|
+
*
|
|
89
|
+
* It states THIS tier's own fact and nothing more — not whether the bytes can
|
|
90
|
+
* still be had elsewhere, which this class has no way of knowing. Whoever
|
|
91
|
+
* listens decides what the loss means.
|
|
92
|
+
*
|
|
93
|
+
* @type {(index: number) => void}
|
|
94
|
+
*/
|
|
95
|
+
#onForgotten;
|
|
96
|
+
|
|
86
97
|
/**
|
|
87
98
|
* @param {object} params
|
|
88
99
|
* @param {string} params.directory - Where this torrent's pieces live.
|
|
@@ -95,12 +106,21 @@ export class PieceDiskStore {
|
|
|
95
106
|
* means nobody has said yet, and nothing is evicted until somebody does.
|
|
96
107
|
* @param {() => number} [params.now]
|
|
97
108
|
*/
|
|
98
|
-
constructor({
|
|
109
|
+
constructor({
|
|
110
|
+
directory,
|
|
111
|
+
name,
|
|
112
|
+
chunkLength,
|
|
113
|
+
allowanceBytes = null,
|
|
114
|
+
now = Date.now,
|
|
115
|
+
readHeads = () => [],
|
|
116
|
+
onForgotten = () => undefined
|
|
117
|
+
}) {
|
|
99
118
|
this.#directory = path.join(directory, name);
|
|
100
119
|
this.#chunkLength = chunkLength;
|
|
101
120
|
this.#allowanceBytes = Number.isFinite(allowanceBytes) && allowanceBytes >= 0 ? allowanceBytes : null;
|
|
102
121
|
this.#now = now;
|
|
103
122
|
this.#readHeads = typeof readHeads === "function" ? readHeads : () => [];
|
|
123
|
+
this.#onForgotten = typeof onForgotten === "function" ? onForgotten : () => undefined;
|
|
104
124
|
this.#adoptWhatIsAlreadyHere();
|
|
105
125
|
}
|
|
106
126
|
|
|
@@ -345,6 +365,9 @@ export class PieceDiskStore {
|
|
|
345
365
|
}
|
|
346
366
|
});
|
|
347
367
|
this.#removing.set(index, removal);
|
|
368
|
+
// SAID AFTER THE FACT IS TRUE, so a listener that asks this tier whether it
|
|
369
|
+
// holds the piece is told the same thing this method has just made so.
|
|
370
|
+
this.#onForgotten(index);
|
|
348
371
|
}
|
|
349
372
|
|
|
350
373
|
/**
|
|
@@ -321,6 +321,11 @@ export class SharedPieceStore {
|
|
|
321
321
|
admittedWithoutSlot: 0,
|
|
322
322
|
evictedOnRevise: 0,
|
|
323
323
|
spillFailures: 0,
|
|
324
|
+
// Claims withdrawn: pieces this store stopped being able to produce at all,
|
|
325
|
+
// and said so. It is the figure that says whether the bargain the eviction
|
|
326
|
+
// states — "a seek back re-downloads it" — is being honoured, and before
|
|
327
|
+
// 2026-09-12 it was not honoured once, because nothing was ever said.
|
|
328
|
+
withdrawn: 0,
|
|
324
329
|
// Whether the store is doing its job or being asked to hold more than it
|
|
325
330
|
// has room for. An eviction that had to take a piece a reader declared it
|
|
326
331
|
// wants is the second, and it comes back from disk moments later
|
|
@@ -413,6 +418,31 @@ export class SharedPieceStore {
|
|
|
413
418
|
*/
|
|
414
419
|
#isElsewhere = null;
|
|
415
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Said when this store can no longer produce a piece AT ALL — not resident,
|
|
423
|
+
* not on disk, and not inside a file held whole.
|
|
424
|
+
*
|
|
425
|
+
* **Why it has to exist.** This store holds the bytes, so it owns the fact
|
|
426
|
+
* "this proxy has piece N". Something else kept a second copy of that fact —
|
|
427
|
+
* the torrent's own completion bitfield — and nothing reconciled them: the
|
|
428
|
+
* disk tier drops a piece behind every read head, correctly, and the bitfield
|
|
429
|
+
* goes on saying the piece is verified. A read then concludes the piece is
|
|
430
|
+
* had, asks for it, is told it is absent, and fails; and it is never
|
|
431
|
+
* re-downloaded either, because the library does not fetch what it believes
|
|
432
|
+
* it already owns. Field 2026-09-12: a film played 80 seconds, the encoder
|
|
433
|
+
* ran on to 725 s, `forgetBehind` dropped some 565 spilled pieces including
|
|
434
|
+
* piece 0, the encoder restarted and re-opened its input at byte 0, and
|
|
435
|
+
* `/stream` answered `0 of 2363497962 bytes: Piece 0 is verified but absent
|
|
436
|
+
* from the store` for 92 minutes while the browser retried one segment.
|
|
437
|
+
*
|
|
438
|
+
* So the bitfield becomes a projection of this fact, maintained by this
|
|
439
|
+
* announcement. This store does not know what a torrent is, who listens, or
|
|
440
|
+
* what they do about it — the same shape as `readPieceElsewhere` above.
|
|
441
|
+
*
|
|
442
|
+
* @type {((what: object) => void) | null}
|
|
443
|
+
*/
|
|
444
|
+
#onPieceGone = null;
|
|
445
|
+
|
|
416
446
|
/** Whether the last revision had to exceed the machine's share. */
|
|
417
447
|
#beyondTheMachine = false;
|
|
418
448
|
/**
|
|
@@ -477,6 +507,9 @@ export class SharedPieceStore {
|
|
|
477
507
|
this.#isElsewhere = typeof options.isPieceElsewhere === "function"
|
|
478
508
|
? options.isPieceElsewhere
|
|
479
509
|
: null;
|
|
510
|
+
this.#onPieceGone = typeof options.onPieceGone === "function"
|
|
511
|
+
? options.onPieceGone
|
|
512
|
+
: null;
|
|
480
513
|
// Plain data the layer above needs to answer those two, and which this
|
|
481
514
|
// store is handed anyway: where each file of the torrent begins and how
|
|
482
515
|
// long the whole of it is.
|
|
@@ -496,7 +529,12 @@ export class SharedPieceStore {
|
|
|
496
529
|
allowanceBytes: null,
|
|
497
530
|
// Where the live readers stand, so what goes first is decided by them and
|
|
498
531
|
// not by which piece happened to be touched longest ago.
|
|
499
|
-
readHeads: () => this.#lru.readHeads()
|
|
532
|
+
readHeads: () => this.#lru.readHeads(),
|
|
533
|
+
// EVERY WAY A PIECE LEAVES THE DISK COMES THROUGH HERE — behind the read
|
|
534
|
+
// heads, over the allowance, dropped as a duplicate, or forgotten while a
|
|
535
|
+
// spill finished. One listener instead of a list of call sites to
|
|
536
|
+
// remember, which is what let the announcement be missed at three of them.
|
|
537
|
+
onForgotten: (index) => this.#announceGoneIfNowhere(index)
|
|
500
538
|
});
|
|
501
539
|
liveStores.add(this);
|
|
502
540
|
}
|
|
@@ -1131,6 +1169,38 @@ export class SharedPieceStore {
|
|
|
1131
1169
|
: null;
|
|
1132
1170
|
}
|
|
1133
1171
|
|
|
1172
|
+
/**
|
|
1173
|
+
* Say a piece has gone, but only once it has gone from everywhere.
|
|
1174
|
+
*
|
|
1175
|
+
* The disk tier announces its own loss, which is not the same statement: a
|
|
1176
|
+
* piece dropped as a duplicate of a file held whole has not been lost at all,
|
|
1177
|
+
* and one still resident is about to be spilled again rather than gone. Only
|
|
1178
|
+
* the third case — neither tier, no whole file — is a withdrawal of the claim
|
|
1179
|
+
* that this proxy has those bytes.
|
|
1180
|
+
*
|
|
1181
|
+
* Nothing is said while the store is closing: the torrent it belongs to is
|
|
1182
|
+
* being torn down, and a claim withdrawn against a dying torrent reaches
|
|
1183
|
+
* either a destroyed object or, worse, one that has already been added back.
|
|
1184
|
+
*
|
|
1185
|
+
* @param {number} index
|
|
1186
|
+
* @returns {void}
|
|
1187
|
+
*/
|
|
1188
|
+
#announceGoneIfNowhere(index) {
|
|
1189
|
+
if (this.#closed || this.#onPieceGone === null) {
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
if (this.#buffers.has(index) || this.#disk.has(index) || this.isInWholeFiles(index)) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
this.#counters.withdrawn += 1;
|
|
1196
|
+
try {
|
|
1197
|
+
this.#onPieceGone({ index, files: this.#files, name: this.#name });
|
|
1198
|
+
} catch {
|
|
1199
|
+
// silent-ok: whoever listens is diagnosing or bookkeeping, and a listener
|
|
1200
|
+
// that throws must not be what fails an eviction the store needs to make.
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1134
1204
|
/**
|
|
1135
1205
|
* Whether a spilled piece is now a duplicate of bytes held elsewhere.
|
|
1136
1206
|
*
|
|
@@ -10,7 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
11
|
import { Container } from "./container/Container.js";
|
|
12
12
|
import { buildAudioInventory } from "./audio-inventory.js";
|
|
13
|
-
import {
|
|
13
|
+
import { contentsOf } from "./torrent/Contents.js";
|
|
14
14
|
import {
|
|
15
15
|
parseFfmpegDurationSeconds,
|
|
16
16
|
parseFfmpegStartTimeSeconds,
|
|
@@ -339,22 +339,18 @@ export function createPlaybackPlanner({
|
|
|
339
339
|
/**
|
|
340
340
|
* The files beside this picture that belong to it, in three groups.
|
|
341
341
|
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
342
|
+
* Asked of what the torrent says about itself, which works the grouping out
|
|
343
|
+
* once and keeps it. It used to be worked out here on every call, and again
|
|
344
|
+
* on the warm-up's own path over the same list, so one opened film paired the
|
|
345
|
+
* same files several times and neither side could be sure of the other's
|
|
346
|
+
* answer.
|
|
346
347
|
*
|
|
347
348
|
* @param {object} torrent
|
|
348
349
|
* @param {number} fileIndex
|
|
349
350
|
* @returns {{ audio: object[], subtitles: object[], images: object[] }}
|
|
350
351
|
*/
|
|
351
352
|
function sidecarsOf(torrent, fileIndex) {
|
|
352
|
-
return
|
|
353
|
-
files: torrent?.files ?? [],
|
|
354
|
-
videoIndex: fileIndex,
|
|
355
|
-
torrentName: typeof torrent?.name === "string" ? torrent.name : "",
|
|
356
|
-
videoCount: countVideoFiles(torrent?.files ?? [])
|
|
357
|
-
});
|
|
353
|
+
return contentsOf(torrent ?? {}).sidecarsOf(fileIndex);
|
|
358
354
|
}
|
|
359
355
|
|
|
360
356
|
async function withContainerDefaults(torrent, fileIndex, subtitleTracks) {
|
|
@@ -630,7 +626,10 @@ export function createPlaybackPlanner({
|
|
|
630
626
|
// codecs. A transient empty probe must NOT be cached: otherwise the wrong
|
|
631
627
|
// plan (file treated as directly playable) sticks permanently for this
|
|
632
628
|
// file, and an unsupported codec like xvid gets copied → black video.
|
|
633
|
-
|
|
629
|
+
// Awaited in the literal sense: this answer cannot be given until the
|
|
630
|
+
// file has said what is in it, and a person is watching a loading screen
|
|
631
|
+
// for as long as that takes.
|
|
632
|
+
await torrentPool.prefetchFileEdges(torrent, fileIndex, { awaited: true });
|
|
634
633
|
edgesReadyMs = Date.now() - planEntryMs;
|
|
635
634
|
// The keyframe index reads the tail of the file, which the probe has just
|
|
636
635
|
// waited for as well. Started here it overlaps the probe instead of
|
|
@@ -653,7 +652,7 @@ export function createPlaybackPlanner({
|
|
|
653
652
|
) {
|
|
654
653
|
attempt += 1;
|
|
655
654
|
await delay(Math.min(3_000, 500 + attempt * 250));
|
|
656
|
-
await torrentPool.prefetchFileEdges(torrent, fileIndex);
|
|
655
|
+
await torrentPool.prefetchFileEdges(torrent, fileIndex, { awaited: true });
|
|
657
656
|
probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
|
|
658
657
|
}
|
|
659
658
|
const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
|
|
@@ -22,7 +22,13 @@ export class PriorityOrchestrator {
|
|
|
22
22
|
/** Where the map goes once it is built. @type {(published: object) => void} */
|
|
23
23
|
#publish;
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* The last map published per film and file, so an unchanged one is not
|
|
27
|
+
* resent — and what it was about, so that a file everybody has left can be
|
|
28
|
+
* published as wanting nothing.
|
|
29
|
+
*
|
|
30
|
+
* @type {Map<string, { shape: string, sourceKey: string, fileIndex: number, durationSeconds: number }>}
|
|
31
|
+
*/
|
|
26
32
|
#last = new Map();
|
|
27
33
|
|
|
28
34
|
/** The last map BUILT per film and file, for whoever reads instead of being
|
|
@@ -138,13 +144,33 @@ export class PriorityOrchestrator {
|
|
|
138
144
|
// second by second, which is the same comparison over far fewer values.
|
|
139
145
|
const zones = runsOf(map);
|
|
140
146
|
const shape = JSON.stringify(zones);
|
|
141
|
-
if (this.#last.get(key) !== shape) {
|
|
142
|
-
|
|
147
|
+
if (this.#last.get(key)?.shape !== shape) {
|
|
148
|
+
// The length is remembered with the shape, so that a file whose viewers
|
|
149
|
+
// have all gone can still be spoken for: what is published then is the
|
|
150
|
+
// same statement with nothing in it, and it has to name the file it is
|
|
151
|
+
// about.
|
|
152
|
+
this.#last.set(key, { shape, sourceKey, fileIndex, durationSeconds });
|
|
143
153
|
this.#publish({ sourceKey, fileIndex, durationSeconds, zones });
|
|
144
154
|
}
|
|
145
155
|
return map;
|
|
146
156
|
}
|
|
147
157
|
|
|
158
|
+
/**
|
|
159
|
+
* NOBODY WANTS ANYTHING OF THIS FILE ANY MORE — said, rather than left to be
|
|
160
|
+
* inferred from silence.
|
|
161
|
+
*
|
|
162
|
+
* The map used to be deleted from this class's memory and published nowhere,
|
|
163
|
+
* so what had been stated about that file on the swarm's behalf stood until
|
|
164
|
+
* the torrent itself was removed. Read from the register, a film nobody had
|
|
165
|
+
* watched for an hour was indistinguishable from one being watched now.
|
|
166
|
+
*
|
|
167
|
+
* @param {{ sourceKey: string, fileIndex: number, durationSeconds: number }} what
|
|
168
|
+
* @returns {void}
|
|
169
|
+
*/
|
|
170
|
+
#publishNothingFor({ sourceKey, fileIndex, durationSeconds }) {
|
|
171
|
+
this.#publish({ sourceKey, fileIndex, durationSeconds, zones: [] });
|
|
172
|
+
}
|
|
173
|
+
|
|
148
174
|
/**
|
|
149
175
|
* Build and publish the map for every file anybody is watching.
|
|
150
176
|
*
|
|
@@ -222,8 +248,12 @@ export class PriorityOrchestrator {
|
|
|
222
248
|
// for it and called from nowhere.
|
|
223
249
|
for (const key of [...this.#maps.keys()]) {
|
|
224
250
|
if (!byFile.has(key)) {
|
|
251
|
+
const last = this.#last.get(key);
|
|
225
252
|
this.#maps.delete(key);
|
|
226
253
|
this.#last.delete(key);
|
|
254
|
+
if (last) {
|
|
255
|
+
this.#publishNothingFor(last);
|
|
256
|
+
}
|
|
227
257
|
}
|
|
228
258
|
}
|
|
229
259
|
for (const address of [...this.#byOutput.keys()]) {
|
|
@@ -232,9 +262,13 @@ export class PriorityOrchestrator {
|
|
|
232
262
|
}
|
|
233
263
|
}
|
|
234
264
|
for (const one of byFile.values()) {
|
|
235
|
-
// A file of unknown length cannot be divided into zones
|
|
236
|
-
//
|
|
237
|
-
|
|
265
|
+
// A file of unknown length cannot be divided into zones at all. A file
|
|
266
|
+
// whose viewers have all gone CAN be spoken for, and must be: `build`
|
|
267
|
+
// with nobody watching produces a map with nothing in it, which is the
|
|
268
|
+
// truth and is what withdraws what was stated for them. Skipped, as it
|
|
269
|
+
// was, the last thing said about that file stood for as long as the
|
|
270
|
+
// torrent did.
|
|
271
|
+
if (one.durationSeconds > 0) {
|
|
238
272
|
this.build(one);
|
|
239
273
|
}
|
|
240
274
|
}
|