@torrent-tv/proxy 2.99.11 → 2.99.13
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/package.json +1 -1
- package/services/media/container/MatroskaContainer.js +125 -27
- package/services/media/container/PacketIndex.js +10 -3
- package/services/media/container/PacketRecords.js +27 -7
- package/services/media/container/PacketRecordsChain.js +132 -0
- package/services/media/container/matroska-packets.js +59 -12
- package/services/torrent/download/registry.js +107 -33
- package/services/torrent/worker/worker.js +4 -2
- package/services/viewer/playback-readiness.js +6 -1
package/package.json
CHANGED
|
@@ -44,7 +44,8 @@ import { ContainerTrack } from "../tracks/ContainerTrack.js";
|
|
|
44
44
|
import { iterateElements, readFloat, readUint } from "./ebml-reader.js";
|
|
45
45
|
import { ElementReader } from "./ebml-stream.js";
|
|
46
46
|
import { firstFrameOffset, readBlockHeader, walkHeldClusters } from "./matroska-clusters.js";
|
|
47
|
-
import {
|
|
47
|
+
import { buildMatroskaIndex, matroskaIntervalCovered, walkMatroskaPackets } from "./matroska-packets.js";
|
|
48
|
+
import { PacketRecordsChain } from "./PacketRecordsChain.js";
|
|
48
49
|
import { RetainedReads } from "./RetainedReads.js";
|
|
49
50
|
import { isUnavailable } from "./unavailable.js";
|
|
50
51
|
import {
|
|
@@ -153,7 +154,17 @@ export class MatroskaContainer extends Container {
|
|
|
153
154
|
/** @type {Array<import("../tracks/ContainerTrack.js").ContainerTrack> | null} */
|
|
154
155
|
#tracks = null;
|
|
155
156
|
#packets = null;
|
|
156
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Per track, the readings of its packets in file order: each began where a
|
|
159
|
+
* request first needed it, records that track alone, and adjoins the next
|
|
160
|
+
* once it reaches it. One table per track per stretch of the file, and no
|
|
161
|
+
* packet in two of them (torrent-tv/meta#178).
|
|
162
|
+
*
|
|
163
|
+
* @type {Map<number, Array<{ startAt: number, at: number, cluster: object | null, packets: Map<number, import("./PacketRecords.js").PacketRecords>, next?: object, reachedEnd?: boolean }>>}
|
|
164
|
+
*/
|
|
165
|
+
#packetReadings = new Map();
|
|
166
|
+
/** Per track, the walk under way: one at a time, so two cannot record one block. */
|
|
167
|
+
#packetWalks = new Map();
|
|
157
168
|
|
|
158
169
|
/**
|
|
159
170
|
* The Cues reading: undefined until read, null when the file has none.
|
|
@@ -175,8 +186,8 @@ export class MatroskaContainer extends Container {
|
|
|
175
186
|
}
|
|
176
187
|
|
|
177
188
|
packetIndexBytes() {
|
|
178
|
-
return [...this.#
|
|
179
|
-
[...(
|
|
189
|
+
return [...this.#packetReadings.values()].flat().reduce((sum, reading) => sum +
|
|
190
|
+
[...(reading.packets?.values() ?? [])].reduce((bytes, records) => bytes + records.allocatedBytes, 0), 0);
|
|
180
191
|
}
|
|
181
192
|
|
|
182
193
|
static detect(head) {
|
|
@@ -653,15 +664,19 @@ export class MatroskaContainer extends Container {
|
|
|
653
664
|
* One preceding cluster retains preroll; one following cluster covers demux
|
|
654
665
|
* lookahead beyond the presentation cut. No packet payload is parsed here.
|
|
655
666
|
*/
|
|
656
|
-
async supportsOriginalSourceRanges() {
|
|
667
|
+
async supportsOriginalSourceRanges(interval = null) {
|
|
657
668
|
const cues = await this.readCues();
|
|
658
669
|
if (!cues?.points.length) return false;
|
|
659
670
|
const video = ContainerTrack.firstUsable(await this.readTracks(), "video");
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
// on Home Assistant,
|
|
664
|
-
//
|
|
671
|
+
// Sound alone is read from its own packets, as an AVI's is
|
|
672
|
+
// (`AviContainer`). From the original file it was given whole clusters,
|
|
673
|
+
// picture included: 260 MB for 23 pieces of a soundtrack, 38 MB for one of
|
|
674
|
+
// 171 KB, on Home Assistant (r44, r46). Proxy 2.99.6 did so and no film
|
|
675
|
+
// started: every stretch the download map asked for kept a table for every
|
|
676
|
+
// track, 38.7 MB in a minute (r47); the soundtrack is now one chain of
|
|
677
|
+
// tables of its own (`#readTrackPackets`, torrent-tv/meta#178). Asked of
|
|
678
|
+
// the file as a whole, the answer is the picture's.
|
|
679
|
+
if (video && interval?.trackIds?.length && !interval.trackIds.includes(video.trackNumber)) return false;
|
|
665
680
|
return cues.points.some(point => point.positions.some(position => !video || position.track === video.trackNumber));
|
|
666
681
|
}
|
|
667
682
|
|
|
@@ -741,27 +756,110 @@ export class MatroskaContainer extends Container {
|
|
|
741
756
|
if (!layout) throw new Error("Matroska Segment is absent.");
|
|
742
757
|
const tracks = await this.readTracks();
|
|
743
758
|
const info = await this.readMediaInfo();
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
759
|
+
const declared = new Map(tracks.filter(track => ["video", "audio", "subtitle"].includes(track.type))
|
|
760
|
+
.map(track => [track.trackNumber, track]));
|
|
761
|
+
// The tracks this request reads: those it names, the picture and sound
|
|
762
|
+
// when it names none, and every track for the whole file.
|
|
763
|
+
const requested = [...declared.values()].filter(track => interval?.trackIds ? interval.trackIds.includes(track.trackNumber)
|
|
764
|
+
: !interval || ["video", "audio"].includes(track.type));
|
|
765
|
+
const start = interval ? await this.#packetStart(layout, tracks, interval) : (layout.firstClusterAt ?? layout.segmentEnd);
|
|
766
|
+
// The last frame ends where the film does, on the packets' own clock.
|
|
767
|
+
const endSeconds = info.durationSeconds === null ? null : info.durationSeconds + (info.startTimeSeconds ?? 0);
|
|
768
|
+
const streams = new Map();
|
|
769
|
+
const complete = new Set();
|
|
770
|
+
let whole = true;
|
|
771
|
+
for (const track of requested) {
|
|
772
|
+
const chain = await this.#readTrackPackets(layout, tracks, track, start, interval);
|
|
773
|
+
streams.set(track.trackNumber, chain.records);
|
|
774
|
+
if (chain.reachesEnd) complete.add(track.trackNumber);
|
|
775
|
+
if (!chain.reachesEnd || chain.startAt !== layout.firstClusterAt) whole = false;
|
|
761
776
|
}
|
|
777
|
+
const index = buildMatroskaIndex({ declared, streams, endSeconds, complete, covered: interval ?? null,
|
|
778
|
+
packetMemory: this.packetMemory });
|
|
779
|
+
if (!interval && whole) this.#packets = index;
|
|
762
780
|
return index;
|
|
763
781
|
}
|
|
764
782
|
|
|
783
|
+
/**
|
|
784
|
+
* One track's packets from `start` on, read as far as the interval needs.
|
|
785
|
+
*
|
|
786
|
+
* The reading that holds `start` goes on; with none, one begins there. A
|
|
787
|
+
* reading that reaches the next begins nothing new: the two adjoin and the
|
|
788
|
+
* walk goes on where the next one stopped. Before this, every request the
|
|
789
|
+
* download map made for a stretch not yet read began a reading of its own
|
|
790
|
+
* with a table for every track of the file; a 25-minute film's soundtrack
|
|
791
|
+
* asked 147 of them and held 38.7 MB of mostly empty tables in its first
|
|
792
|
+
* minute, and the rest was refused memory (r47, torrent-tv/meta#178).
|
|
793
|
+
*
|
|
794
|
+
* @returns {Promise<{ records: PacketRecordsChain, startAt: number, reachesEnd: boolean }>}
|
|
795
|
+
*/
|
|
796
|
+
async #readTrackPackets(layout, tracks, track, start, interval) {
|
|
797
|
+
const id = track.trackNumber;
|
|
798
|
+
const previous = this.#packetWalks.get(id) ?? Promise.resolve();
|
|
799
|
+
let finished;
|
|
800
|
+
const walk = previous.then(() => new Promise(resolve => { finished = resolve; }));
|
|
801
|
+
this.#packetWalks.set(id, walk);
|
|
802
|
+
await previous;
|
|
803
|
+
try {
|
|
804
|
+
const readings = this.#packetReadings.get(id) ?? [];
|
|
805
|
+
this.#packetReadings.set(id, readings);
|
|
806
|
+
const position = reading => reading.cluster ? reading.cluster.cursor : reading.at;
|
|
807
|
+
let first = readings.find(reading => reading.startAt <= start && position(reading) >= start);
|
|
808
|
+
if (!first) {
|
|
809
|
+
first = { startAt: start, at: start, cluster: null };
|
|
810
|
+
readings.push(first);
|
|
811
|
+
readings.sort((left, right) => left.startAt - right.startAt);
|
|
812
|
+
}
|
|
813
|
+
const chainOf = () => {
|
|
814
|
+
const parts = [first];
|
|
815
|
+
while (parts.at(-1).next) parts.push(parts.at(-1).next);
|
|
816
|
+
return parts;
|
|
817
|
+
};
|
|
818
|
+
const viewOf = parts => new PacketRecordsChain(parts.map(reading => reading.packets?.get(id))
|
|
819
|
+
.filter(Boolean));
|
|
820
|
+
// Asked after every block of a walk: the reading being extended answers
|
|
821
|
+
// alone once it holds the packets the question looks at.
|
|
822
|
+
const declared = new Map([[id, track]]);
|
|
823
|
+
const asked = interval ? { ...interval, trackIds: [id] } : null;
|
|
824
|
+
const depth = track.type === "video" ? track.reorderDepth : 0;
|
|
825
|
+
let current = null;
|
|
826
|
+
const covered = () => {
|
|
827
|
+
if (!asked) return false;
|
|
828
|
+
const own = current?.packets?.get(id);
|
|
829
|
+
if (own && Number.isSafeInteger(depth) && own.length > depth) {
|
|
830
|
+
return matroskaIntervalCovered(declared, new Map([[id, own]]), asked);
|
|
831
|
+
}
|
|
832
|
+
const parts = chainOf();
|
|
833
|
+
if (!parts.every(reading => reading.packets)) return false;
|
|
834
|
+
return matroskaIntervalCovered(declared, new Map([[id, viewOf(parts)]]), asked);
|
|
835
|
+
};
|
|
836
|
+
for (;;) {
|
|
837
|
+
current = chainOf().at(-1);
|
|
838
|
+
if (current.reachedEnd) break;
|
|
839
|
+
const following = readings.find(reading => reading.startAt > current.startAt);
|
|
840
|
+
const outcome = await walkMatroskaPackets({ readRange: this.readRange, fileSize: this.fileSize,
|
|
841
|
+
portionBytes: this.portionBytes, layout, tracks, trackIds: [id], interval, packetMemory: this.packetMemory,
|
|
842
|
+
state: current, until: following?.startAt ?? null, isCovered: covered });
|
|
843
|
+
if (outcome !== "until") break;
|
|
844
|
+
current.next = following;
|
|
845
|
+
}
|
|
846
|
+
// The request reads as far as its interval reaches, not every reading
|
|
847
|
+
// adjoined since: each of its packets is looked at once per request.
|
|
848
|
+
const parts = chainOf();
|
|
849
|
+
if (asked && Number.isSafeInteger(depth)) {
|
|
850
|
+
const reaching = parts.findIndex(reading => {
|
|
851
|
+
const own = reading.packets.get(id);
|
|
852
|
+
return own.length > depth && matroskaIntervalCovered(declared, new Map([[id, own]]), asked);
|
|
853
|
+
});
|
|
854
|
+
if (reaching >= 0) parts.length = reaching + 1;
|
|
855
|
+
}
|
|
856
|
+
return { records: viewOf(parts), startAt: first.startAt, reachesEnd: parts.at(-1).reachedEnd === true };
|
|
857
|
+
} finally {
|
|
858
|
+
finished();
|
|
859
|
+
if (this.#packetWalks.get(id) === walk) this.#packetWalks.delete(id);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
765
863
|
async #packetStart(layout, tracks, interval) {
|
|
766
864
|
const first = layout.firstClusterAt ?? layout.segmentEnd;
|
|
767
865
|
if (!Number.isFinite(interval?.from) || interval.from <= 0) return first;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { PacketRecords } from "./PacketRecords.js";
|
|
2
|
+
import { PacketRecordsChain } from "./PacketRecordsChain.js";
|
|
2
3
|
import { IndexMemoryUnavailable } from "./memory-unavailable.js";
|
|
3
4
|
import { isMp3Codec, mp3PacketFacts } from "./mp3-packet-dependencies.js";
|
|
4
5
|
|
|
@@ -95,13 +96,19 @@ export class PacketIndex {
|
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* The progressive parser and its interval views share one packet allocation.
|
|
101
|
+
* A chain of a container's own tables was checked record by record as each
|
|
102
|
+
* was written; read again in full for every request, a two-hour soundtrack
|
|
103
|
+
* would be 340 thousand records per request (torrent-tv/meta#178).
|
|
104
|
+
*/
|
|
99
105
|
sharePackets(id, records) {
|
|
100
106
|
const track = this.#track(id);
|
|
101
|
-
|
|
107
|
+
const chain = records instanceof PacketRecordsChain;
|
|
108
|
+
if (track.complete || track.packets.length || !(records instanceof PacketRecords || chain)) {
|
|
102
109
|
throw new TypeError("Only an empty unfinished track may adopt packet records.");
|
|
103
110
|
}
|
|
104
|
-
for (const packet of records) validatePacket(packet);
|
|
111
|
+
if (!chain) for (const packet of records) validatePacket(packet);
|
|
105
112
|
track.packets.dispose();
|
|
106
113
|
track.packets = records;
|
|
107
114
|
}
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
import { IndexMemoryUnavailable } from "./memory-unavailable.js";
|
|
4
4
|
|
|
5
5
|
const BLOCK_BYTES = 65536;
|
|
6
|
+
// A table's first blocks, of records and of their addresses, and each block
|
|
7
|
+
// after it twice the one before, up to BLOCK_BYTES. Taken whole from the first
|
|
8
|
+
// record, the 128 KiB of a table that held a few hundred records was most of a
|
|
9
|
+
// Matroska soundtrack read in stretches: 144 tables of one 25-minute film held
|
|
10
|
+
// 18.9 MB for 3.7 MB of records (r47 reproduced, torrent-tv/meta#178).
|
|
11
|
+
const FIRST_BLOCK_BYTES = 4096;
|
|
6
12
|
const ADDRESS_BYTES = 8;
|
|
7
13
|
const HEADER_BYTES = 56;
|
|
8
14
|
|
|
@@ -78,6 +84,12 @@ export class PacketRecords {
|
|
|
78
84
|
const block = this.#blocks[blockIndex];
|
|
79
85
|
this.#blocks.length = blockIndex + 1;
|
|
80
86
|
this.#addresses.length = Math.ceil(value * ADDRESS_BYTES / BLOCK_BYTES);
|
|
87
|
+
// The last address block back to the size its records need, as it grew.
|
|
88
|
+
const lastAddresses = this.#addresses.at(-1);
|
|
89
|
+
const neededAddresses = ((value - 1) % (BLOCK_BYTES / ADDRESS_BYTES) + 1) * ADDRESS_BYTES;
|
|
90
|
+
let addressBytes = FIRST_BLOCK_BYTES;
|
|
91
|
+
while (addressBytes < neededAddresses) addressBytes *= 2;
|
|
92
|
+
if (lastAddresses.length > addressBytes) this.#addresses[this.#addresses.length - 1] = Buffer.from(lastAddresses.subarray(0, addressBytes));
|
|
81
93
|
const flags = block.readUInt32LE(at + 48);
|
|
82
94
|
this.#used = at + HEADER_BYTES + block.readUInt32LE(at + 52) * 16 + ((flags & 2) ? 32 : 0) + ((flags & 8) ? 4 : 0) + ((flags & 16) ? 4 : 0);
|
|
83
95
|
this.#bytes = [...this.#blocks, ...this.#addresses].reduce((sum, buffer) => sum + buffer.length, 0);
|
|
@@ -121,17 +133,25 @@ export class PacketRecords {
|
|
|
121
133
|
const bytes = HEADER_BYTES + packet.ranges.length * 16 + (packet.expectedHash ? 32 : 0) + (packet.streamId !== undefined ? 4 : 0) + (packet.decodeFromIndex !== undefined ? 4 : 0);
|
|
122
134
|
let block = this.#blocks.at(-1);
|
|
123
135
|
const newBlock = !block || block.length - this.#used < bytes;
|
|
136
|
+
const blockBytes = newBlock ? Math.max(bytes, Math.min(BLOCK_BYTES, block ? block.length * 2 : FIRST_BLOCK_BYTES)) : 0;
|
|
124
137
|
const perAddressBlock = BLOCK_BYTES / ADDRESS_BYTES;
|
|
125
138
|
const addressBlockIndex = Math.floor(this.#length / perAddressBlock);
|
|
126
|
-
const
|
|
127
|
-
const
|
|
139
|
+
const addressAt = (this.#length % perAddressBlock) * ADDRESS_BYTES;
|
|
140
|
+
const heldAddresses = this.#addresses[addressBlockIndex];
|
|
141
|
+
// An address block is the same span of records at any size; a full one
|
|
142
|
+
// below BLOCK_BYTES is copied into one twice its size.
|
|
143
|
+
const addressBytes = !heldAddresses ? FIRST_BLOCK_BYTES
|
|
144
|
+
: addressAt >= heldAddresses.length ? Math.min(BLOCK_BYTES, heldAddresses.length * 2) : 0;
|
|
145
|
+
const newAddresses = addressBytes > 0;
|
|
146
|
+
const addressGrowth = newAddresses ? addressBytes - (heldAddresses?.length ?? 0) : 0;
|
|
147
|
+
const growth = blockBytes + addressGrowth;
|
|
128
148
|
const prepaid = Math.min(growth, this.#prepaid);
|
|
129
149
|
if (growth > prepaid && !this.#reserve(growth - prepaid)) throw new IndexMemoryUnavailable(growth - prepaid);
|
|
130
150
|
this.#prepaid -= prepaid;
|
|
131
151
|
let payloadAllocation, addressAllocation;
|
|
132
152
|
try {
|
|
133
|
-
if (newBlock) payloadAllocation = Buffer.alloc(
|
|
134
|
-
if (newAddresses) addressAllocation = Buffer.alloc(
|
|
153
|
+
if (newBlock) payloadAllocation = Buffer.alloc(blockBytes);
|
|
154
|
+
if (newAddresses) addressAllocation = Buffer.alloc(addressBytes);
|
|
135
155
|
} catch (error) {
|
|
136
156
|
if (growth) this.#release(growth);
|
|
137
157
|
throw error;
|
|
@@ -143,11 +163,11 @@ export class PacketRecords {
|
|
|
143
163
|
this.#used = 0;
|
|
144
164
|
}
|
|
145
165
|
if (newAddresses) {
|
|
146
|
-
|
|
147
|
-
this.#
|
|
166
|
+
heldAddresses?.copy(addressAllocation);
|
|
167
|
+
this.#addresses[addressBlockIndex] = addressAllocation;
|
|
168
|
+
this.#bytes += addressGrowth;
|
|
148
169
|
}
|
|
149
170
|
const address = this.#addresses[addressBlockIndex];
|
|
150
|
-
const addressAt = (this.#length % perAddressBlock) * ADDRESS_BYTES;
|
|
151
171
|
address.writeUInt32LE(this.#blocks.length - 1, addressAt);
|
|
152
172
|
address.writeUInt32LE(this.#used, addressAt + 4);
|
|
153
173
|
const at = this.#used;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Packet records of one track read as one list across adjoining tables.
|
|
3
|
+
*
|
|
4
|
+
* A Matroska track's packets are read in stretches: wherever a viewer is, the
|
|
5
|
+
* download map asks first, and a stretch begins at the cue before it. Each
|
|
6
|
+
* stretch keeps its own table, and when one reaches the next they adjoin. A
|
|
7
|
+
* request reads the stretches it spans as one list through this view; nothing
|
|
8
|
+
* is copied, and the tables stay the container's (torrent-tv/meta#178).
|
|
9
|
+
*
|
|
10
|
+
* Read-only apart from what the index derives per request: a duration worked
|
|
11
|
+
* out from the following packet is written to the table holding the packet,
|
|
12
|
+
* and an MP3 decoder dependency to the table holding it when its first packet
|
|
13
|
+
* is in the same table. One that reaches into an earlier table is kept in this
|
|
14
|
+
* view only, since a table states its dependencies by its own positions.
|
|
15
|
+
*/
|
|
16
|
+
export class PacketRecordsChain {
|
|
17
|
+
#parts;
|
|
18
|
+
#offsets;
|
|
19
|
+
#fixed;
|
|
20
|
+
#dependencies = new Map();
|
|
21
|
+
|
|
22
|
+
/** @param {import("./PacketRecords.js").PacketRecords[]} parts - In file order; every part but the last adjoins the next and no longer grows. */
|
|
23
|
+
constructor(parts) {
|
|
24
|
+
if (!Array.isArray(parts) || parts.length === 0) throw new TypeError("A packet chain needs at least one table.");
|
|
25
|
+
this.#parts = parts;
|
|
26
|
+
this.#offsets = [];
|
|
27
|
+
let offset = 0;
|
|
28
|
+
for (const part of parts) {
|
|
29
|
+
this.#offsets.push(offset);
|
|
30
|
+
offset += part.length;
|
|
31
|
+
}
|
|
32
|
+
this.#fixed = this.#offsets.at(-1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get length() { return this.#fixed + this.#parts.at(-1).length; }
|
|
36
|
+
get allocatedBytes() { return 0; }
|
|
37
|
+
|
|
38
|
+
get ptsAscending() {
|
|
39
|
+
for (let part = 0; part < this.#parts.length; part++) {
|
|
40
|
+
const records = this.#parts[part];
|
|
41
|
+
if (!records.ptsAscending) return false;
|
|
42
|
+
const next = this.#parts[part + 1];
|
|
43
|
+
if (next?.length && records.length && next.ptsAt(0) < records.ptsAt(records.length - 1)) return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get longestDuration() { return Math.max(...this.#parts.map(part => part.longestDuration)); }
|
|
49
|
+
|
|
50
|
+
/** The table holding `index` and the position within it. */
|
|
51
|
+
#locate(index) {
|
|
52
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= this.length) throw new RangeError(`No packet record ${index}.`);
|
|
53
|
+
if (index >= this.#fixed) return [this.#parts.length - 1, index - this.#fixed];
|
|
54
|
+
let low = 0, high = this.#parts.length - 1;
|
|
55
|
+
while (low < high) {
|
|
56
|
+
const middle = (low + high + 1) >>> 1;
|
|
57
|
+
if (this.#offsets[middle] <= index) low = middle;
|
|
58
|
+
else high = middle - 1;
|
|
59
|
+
}
|
|
60
|
+
return [low, index - this.#offsets[low]];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
ptsAt(index) { const [part, at] = this.#locate(index); return this.#parts[part].ptsAt(at); }
|
|
64
|
+
durationAt(index) { const [part, at] = this.#locate(index); return this.#parts[part].durationAt(at); }
|
|
65
|
+
keyframeAt(index) { const [part, at] = this.#locate(index); return this.#parts[part].keyframeAt(at); }
|
|
66
|
+
derivedDurationAt(index) { const [part, at] = this.#locate(index); return this.#parts[part].derivedDurationAt(at); }
|
|
67
|
+
|
|
68
|
+
setDuration(index, seconds, derived = false) {
|
|
69
|
+
const [part, at] = this.#locate(index);
|
|
70
|
+
this.#parts[part].setDuration(at, seconds, derived);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setDecodeFromIndex(index, first) {
|
|
74
|
+
if (!Number.isSafeInteger(first) || first < 0 || first > index) throw new TypeError("Invalid decoder dependency address.");
|
|
75
|
+
const [part, at] = this.#locate(index);
|
|
76
|
+
const offset = this.#offsets[part];
|
|
77
|
+
if (first >= offset) {
|
|
78
|
+
this.#parts[part].setDecodeFromIndex(at, first - offset);
|
|
79
|
+
this.#dependencies.delete(index);
|
|
80
|
+
} else {
|
|
81
|
+
this.#dependencies.set(index, first);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
at(index) {
|
|
86
|
+
if (index < 0) index += this.length;
|
|
87
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= this.length) return undefined;
|
|
88
|
+
const [part, at] = this.#locate(index);
|
|
89
|
+
const packet = this.#parts[part].at(at);
|
|
90
|
+
if (this.#dependencies.has(index)) {
|
|
91
|
+
packet.decodeFromIndex = this.#dependencies.get(index);
|
|
92
|
+
delete packet.decodeDependencyUnknown;
|
|
93
|
+
} else if (packet.decodeFromIndex !== undefined) {
|
|
94
|
+
packet.decodeFromIndex += this.#offsets[part];
|
|
95
|
+
}
|
|
96
|
+
return packet;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
firstAtOrAfter(seconds) {
|
|
100
|
+
if (!this.ptsAscending) throw new Error("Records whose times go back cannot be searched by time.");
|
|
101
|
+
let low = 0, high = this.length;
|
|
102
|
+
while (low < high) {
|
|
103
|
+
const middle = (low + high) >>> 1;
|
|
104
|
+
if (this.ptsAt(middle) < seconds) low = middle + 1;
|
|
105
|
+
else high = middle;
|
|
106
|
+
}
|
|
107
|
+
return low;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The tables are the container's: a request's index lets go of nothing. */
|
|
111
|
+
dispose() {}
|
|
112
|
+
releaseUnusedCapacity() {}
|
|
113
|
+
|
|
114
|
+
*[Symbol.iterator]() { for (let index = 0; index < this.length; index++) yield this.at(index); }
|
|
115
|
+
map(callback) { return Array.from(this, callback); }
|
|
116
|
+
filter(callback) { return Array.from(this).filter(callback); }
|
|
117
|
+
some(callback) { for (const packet of this) if (callback(packet)) return true; return false; }
|
|
118
|
+
every(callback) { for (const packet of this) if (!callback(packet)) return false; return true; }
|
|
119
|
+
reduce(callback, initial) {
|
|
120
|
+
let value = initial;
|
|
121
|
+
for (const packet of this) value = callback(value, packet);
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
slice(start = 0, end = this.length) {
|
|
125
|
+
const length = this.length;
|
|
126
|
+
if (start < 0) start = Math.max(0, length + start);
|
|
127
|
+
if (end < 0) end = Math.max(0, length + end);
|
|
128
|
+
const records = [];
|
|
129
|
+
for (let index = start; index < Math.min(end, length); index++) records.push(this.at(index));
|
|
130
|
+
return records;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -10,17 +10,51 @@ const CLUSTER = 0x1f43b675;
|
|
|
10
10
|
const LEVEL_ONE = new Set([CLUSTER, 0x1c53bb6b, 0x114d9b74, 0x1549a966, 0x1654ae6b, 0x1043a770, 0x1941a469, 0x1254c367]);
|
|
11
11
|
|
|
12
12
|
/** Index frame addresses by reading structural headers, never frame bodies. */
|
|
13
|
-
export async function readMatroskaPackets(
|
|
13
|
+
export async function readMatroskaPackets(params) {
|
|
14
|
+
const { tracks, endSeconds, interval, packetMemory, state = {} } = params;
|
|
15
|
+
const outcome = await walkMatroskaPackets({ ...params, state });
|
|
16
|
+
const declared = recordedTracks(tracks, params.trackIds);
|
|
17
|
+
const complete = outcome === "end";
|
|
18
|
+
return buildMatroskaIndex({ declared, streams: state.packets, endSeconds,
|
|
19
|
+
complete: complete ? new Set(declared.keys()) : new Set(), covered: complete ? null : interval, packetMemory });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The tracks a walk records: every media track, or only those named.
|
|
24
|
+
*
|
|
25
|
+
* @param {object[]} tracks
|
|
26
|
+
* @param {number[]} [trackIds]
|
|
27
|
+
* @returns {Map<number, object>}
|
|
28
|
+
*/
|
|
29
|
+
function recordedTracks(tracks, trackIds) {
|
|
30
|
+
return new Map(tracks.filter(track => ["video", "audio", "subtitle"].includes(track.type) &&
|
|
31
|
+
(!trackIds || trackIds.includes(track.trackNumber))).map(track => [track.trackNumber, track]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Walk the clusters from where `state` stopped, recording the packets of the
|
|
36
|
+
* tracks it records, until the interval is covered (`isCovered`, by default
|
|
37
|
+
* over this state's own records), the walk reaches `until` — where another
|
|
38
|
+
* reading of the same tracks begins — or the segment ends.
|
|
39
|
+
*
|
|
40
|
+
* Only the named tracks are recorded: a soundtrack read on its own kept the
|
|
41
|
+
* picture's table as well, half of 37.9 MB on r47's order (torrent-tv/meta#178).
|
|
42
|
+
*
|
|
43
|
+
* @returns {Promise<"covered" | "until" | "end">}
|
|
44
|
+
*/
|
|
45
|
+
export async function walkMatroskaPackets({ readRange, fileSize, portionBytes, layout, tracks, trackIds, interval, packetMemory, state, until = null, isCovered = null }) {
|
|
14
46
|
const reader = new ElementReader({ read: readRange, fileSize, portionBytes });
|
|
15
|
-
const declared =
|
|
47
|
+
const declared = recordedTracks(tracks, trackIds);
|
|
16
48
|
if (!state.packets) {
|
|
17
49
|
state.packets = new Map([...declared.keys()].map(id => [id, new PacketRecords(packetMemory)]));
|
|
18
50
|
state.at ??= layout.firstClusterAt ?? layout.segmentEnd;
|
|
19
51
|
}
|
|
20
52
|
const packets = state.packets;
|
|
21
|
-
|
|
53
|
+
const covered = isCovered ?? (() => intervalCovered(declared, packets, interval));
|
|
54
|
+
const limit = Math.min(until ?? layout.segmentEnd, layout.segmentEnd);
|
|
55
|
+
if (covered()) return "covered";
|
|
22
56
|
let at = state.at;
|
|
23
|
-
while (at <
|
|
57
|
+
while (at < limit) {
|
|
24
58
|
const element = state.cluster?.element ?? await validHeader(reader, at, layout.segmentEnd);
|
|
25
59
|
if (element.id !== CLUSTER) {
|
|
26
60
|
if (element.end === null) throw new Error("An unknown-sized Matroska element is not a Cluster.");
|
|
@@ -80,14 +114,16 @@ export async function readMatroskaPackets({ readRange, fileSize, portionBytes, l
|
|
|
80
114
|
cursor = child.end;
|
|
81
115
|
state.cluster.cursor = cursor;
|
|
82
116
|
state.cluster.clusterTicks = clusterTicks;
|
|
83
|
-
if (
|
|
117
|
+
if (covered()) return "covered";
|
|
84
118
|
}
|
|
85
119
|
if (cursor <= at) throw new Error("Matroska packet walk did not advance.");
|
|
86
120
|
at = element.end ?? cursor;
|
|
87
121
|
state.at = at;
|
|
88
122
|
state.cluster = null;
|
|
89
123
|
}
|
|
90
|
-
|
|
124
|
+
if (at < layout.segmentEnd) return "until";
|
|
125
|
+
state.reachedEnd = true;
|
|
126
|
+
return "end";
|
|
91
127
|
}
|
|
92
128
|
|
|
93
129
|
function paddingForFrame(seconds, duration, count, frame) {
|
|
@@ -110,17 +146,28 @@ function intervalCovered(declared, packets, interval) {
|
|
|
110
146
|
});
|
|
111
147
|
}
|
|
112
148
|
|
|
113
|
-
|
|
149
|
+
/** Whether the interval's requested tracks reach past its end; exported for a container's own chains. */
|
|
150
|
+
export { intervalCovered as matroskaIntervalCovered };
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A packet index over the given streams of the declared tracks.
|
|
154
|
+
*
|
|
155
|
+
* `complete` names the tracks no packet follows; `covered` is the interval
|
|
156
|
+
* the other requested tracks are known to reach past. A declared track with no
|
|
157
|
+
* stream is declared empty.
|
|
158
|
+
*/
|
|
159
|
+
export function buildMatroskaIndex({ declared, streams, endSeconds, complete, covered, packetMemory }) {
|
|
114
160
|
const index = new PacketIndex();
|
|
115
161
|
for (const [id, track] of declared) {
|
|
116
162
|
index.declareTrack(id, { type: track.type, codecId: track.codecId, codecRanges: track.codecRanges ?? [], prerollSeconds: track.seekPrerollSeconds ?? 0, reorderDepth: track.reorderDepth ?? 0 });
|
|
117
|
-
const stream =
|
|
163
|
+
const stream = streams.get(id);
|
|
164
|
+
if (!stream) continue;
|
|
118
165
|
let needsFollowing = false;
|
|
119
166
|
for (let position = 0; position < stream.length; position++) {
|
|
120
167
|
if (stream.durationAt(position) === 0 || stream.derivedDurationAt(position)) { needsFollowing = true; break; }
|
|
121
168
|
}
|
|
122
169
|
if (needsFollowing) {
|
|
123
|
-
const endFor = followingPacketTime(stream, complete ? endSeconds : null, packetMemory);
|
|
170
|
+
const endFor = followingPacketTime(stream, complete.has(id) ? endSeconds : null, packetMemory);
|
|
124
171
|
try { for (let position = 0; position < stream.length; position++) {
|
|
125
172
|
if (stream.durationAt(position) !== 0 && !stream.derivedDurationAt(position)) continue;
|
|
126
173
|
const pts = stream.ptsAt(position), end = endFor(pts);
|
|
@@ -128,9 +175,9 @@ function buildIndex(declared, packets, endSeconds, complete, interval, packetMem
|
|
|
128
175
|
} } finally { endFor.dispose(); }
|
|
129
176
|
}
|
|
130
177
|
index.sharePackets(id, stream);
|
|
131
|
-
if (complete) index.complete(id);
|
|
132
|
-
else if (
|
|
133
|
-
index.coverThrough(id,
|
|
178
|
+
if (complete.has(id)) index.complete(id);
|
|
179
|
+
else if (covered && (covered.trackIds ? covered.trackIds.includes(id) : ["video", "audio"].includes(track.type))) {
|
|
180
|
+
index.coverThrough(id, covered.to);
|
|
134
181
|
}
|
|
135
182
|
}
|
|
136
183
|
return index;
|
|
@@ -20,6 +20,7 @@ import { SwarmSelection } from "./SwarmSelection.js";
|
|
|
20
20
|
import { SelectionLimit } from "./SelectionLimit.js";
|
|
21
21
|
import { futureDownload } from "./FutureDownload.js";
|
|
22
22
|
import { urgencyName } from "../demand/index.js";
|
|
23
|
+
import { pieceStoreOf } from "../piece-store-of.js";
|
|
23
24
|
|
|
24
25
|
/** @type {WeakMap<object, { register: DemandRegister, selection: SwarmSelection }>} */
|
|
25
26
|
const byTorrent = new WeakMap();
|
|
@@ -30,44 +31,117 @@ let publishingSelections = false;
|
|
|
30
31
|
/** What the last reconciliation told the swarm to fetch first, for the pool to say. */
|
|
31
32
|
let firstClassStated = "none";
|
|
32
33
|
|
|
33
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The last forecast that finished on the maps it was begun on: its result, the
|
|
36
|
+
* demand and withdrawal revisions of each torrent it was worked out on, and
|
|
37
|
+
* whether nothing has changed since; overdue once a missing piece outlived its
|
|
38
|
+
* forecast time.
|
|
39
|
+
*
|
|
40
|
+
* @type {{ result: Map<object, { measuredAt: number, arrivals: Map<number, number | null> }>,
|
|
41
|
+
* versions: Map<object, { demand: number, withdrawal: number }>, describes: () => boolean, overdue?: boolean } | null}
|
|
42
|
+
*/
|
|
34
43
|
let latestForecast = null;
|
|
44
|
+
/** Who is told that a forecast failed: this layer decides from demand and storage and writes no log of its own. */
|
|
45
|
+
let reportForecastFailure = () => {};
|
|
35
46
|
|
|
36
47
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* (`measuredAt`), and a newer one is worked out beside the answer whenever the
|
|
42
|
-
* maps or the held pieces have changed since, one at a time. Awaited afresh on
|
|
43
|
-
* every report, it held a waiting viewer's answer 3-11.6 s on Home Assistant
|
|
44
|
-
* while it took about a quarter of a second of work, the machine 83-94 % busy:
|
|
45
|
-
* the page asks again only once answered, so the hold after a seek to 100 s
|
|
46
|
-
* lasted 52.5 s (r51, torrent-tv/meta#178). Only the first report waits, when
|
|
47
|
-
* there is no forecast yet. A forecast whose maps change while it is worked out
|
|
48
|
-
* is not kept.
|
|
49
|
-
*
|
|
50
|
-
* @returns {Promise<Map<object, { measuredAt: number, arrivals: Map<number, number | null> }>>}
|
|
48
|
+
* Say where a failed forecast is reported.
|
|
49
|
+
*
|
|
50
|
+
* @param {(error: unknown) => void} report
|
|
51
|
+
* @returns {void}
|
|
51
52
|
*/
|
|
52
|
-
export function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
53
|
+
export function onForecastFailure(report) {
|
|
54
|
+
reportForecastFailure = report;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* When this torrent's wanted pieces are forecast to be held, as far as the last
|
|
59
|
+
* finished forecast says so for the map it now has; null when that forecast
|
|
60
|
+
* does not describe it.
|
|
61
|
+
*
|
|
62
|
+
* NEVER AWAITED. Awaited afresh on every report, the forecast held a waiting
|
|
63
|
+
* viewer's answer 3-11.6 s on Home Assistant while it took about a quarter of a
|
|
64
|
+
* second of work, the machine 83-94 % busy: the page asks again only once
|
|
65
|
+
* answered, so the hold after a seek to 100 s lasted 52.5 s (r51,
|
|
66
|
+
* torrent-tv/meta#178). A newer forecast is worked out beside the answer,
|
|
67
|
+
* one at a time, whenever the maps, the withdrawals or the held pieces have
|
|
68
|
+
* changed since the last one.
|
|
69
|
+
*
|
|
70
|
+
* ONLY FOR THE MAP IT WAS WORKED OUT ON. The order pieces come in follows the
|
|
71
|
+
* map, so a forecast made before the map changed says nothing of the new one,
|
|
72
|
+
* and neither does one made before a piece was withdrawn: that piece goes back
|
|
73
|
+
* into the queue ahead of others. Such a torrent gets null, and the caller
|
|
74
|
+
* estimates its missing bytes from the measured rate, as before any forecast.
|
|
75
|
+
*
|
|
76
|
+
* AND WHAT IS HELD IS READ NOW, FROM THE STORE. A piece held now is available
|
|
77
|
+
* now whatever was forecast. A piece still missing whose forecast time has
|
|
78
|
+
* passed did not come when forecast — its peer stopped, or it failed
|
|
79
|
+
* verification — and is unknown, not available: read as `max(0, at - now)`,
|
|
80
|
+
* 2.99.11's stale time made a missing piece count as held.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} torrent
|
|
83
|
+
* @param {{ now?: number, findStore?: (torrent: object) => object | null }} [options]
|
|
84
|
+
* @returns {{ measuredAt: number, arrivals: Map<number, number | null> } | null}
|
|
85
|
+
*/
|
|
86
|
+
export function downloadForecastOf(torrent, { now = Date.now(), findStore = pieceStoreOf } = {}) {
|
|
87
|
+
const entry = byTorrent.get(torrent);
|
|
88
|
+
if (!entry) return null;
|
|
89
|
+
refreshForecast();
|
|
90
|
+
const forecast = latestForecast?.result.get(torrent);
|
|
91
|
+
const version = latestForecast?.versions.get(entry);
|
|
92
|
+
if (!forecast || !version || version.demand !== entry.register.revision ||
|
|
93
|
+
version.withdrawal !== entry.withdrawalRevision) return null;
|
|
94
|
+
const store = findStore(torrent);
|
|
95
|
+
const arrivals = new Map();
|
|
96
|
+
let overdue = false;
|
|
97
|
+
for (const [piece, at] of forecast.arrivals) {
|
|
98
|
+
const held = store?.locationOf ? store.locationOf(piece) !== "missing" : torrent.bitfield?.get(piece) === true;
|
|
99
|
+
if (!held && Number.isFinite(at) && at <= now) overdue = true;
|
|
100
|
+
arrivals.set(piece, held ? now : Number.isFinite(at) && at > now ? at : null);
|
|
101
|
+
}
|
|
102
|
+
// A piece that did not come when forecast is a change no event announces:
|
|
103
|
+
// the next forecast is begun on it, as on one.
|
|
104
|
+
if (overdue) {
|
|
105
|
+
latestForecast.overdue = true;
|
|
106
|
+
refreshForecast();
|
|
69
107
|
}
|
|
70
|
-
return
|
|
108
|
+
return { measuredAt: forecast.measuredAt, arrivals };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Begin a forecast of every live map unless one is being worked out or the
|
|
113
|
+
* last one still describes them.
|
|
114
|
+
*
|
|
115
|
+
* A failed forecast is said and leaves the last one in place; the next report
|
|
116
|
+
* begins another. Left unhandled, its rejection would end nobody's wait and
|
|
117
|
+
* reach the thread's own handler as an unhandled rejection.
|
|
118
|
+
*
|
|
119
|
+
* @returns {void}
|
|
120
|
+
*/
|
|
121
|
+
function refreshForecast() {
|
|
122
|
+
if (futurePending || (latestForecast?.describes() && !latestForecast.overdue)) return;
|
|
123
|
+
const entries = [...live];
|
|
124
|
+
const revisions = entries.map(entry => entry.register.revision);
|
|
125
|
+
const withdrawals = entries.map(entry => entry.withdrawalRevision);
|
|
126
|
+
const verified = entries.map(entry => entry.verifiedRevision);
|
|
127
|
+
const isCurrent = () => entries.length === live.size && !entries.some((entry, index) =>
|
|
128
|
+
!live.has(entry) || entry.register.revision !== revisions[index] ||
|
|
129
|
+
entry.withdrawalRevision !== withdrawals[index]);
|
|
130
|
+
// Worked out again once a piece has arrived as well: the pieces queued
|
|
131
|
+
// behind it now come sooner.
|
|
132
|
+
const describes = () => isCurrent() && entries.every((entry, index) => entry.verifiedRevision === verified[index]);
|
|
133
|
+
const versions = new Map(entries.map((entry, index) =>
|
|
134
|
+
[entry, { demand: revisions[index], withdrawal: withdrawals[index] }]));
|
|
135
|
+
futurePending = futureDownload(entries, { isCurrent }).then(result => {
|
|
136
|
+
if (isCurrent()) latestForecast = { result, versions, describes };
|
|
137
|
+
}, error => {
|
|
138
|
+
try { reportForecastFailure(error); } catch { /* a failing report must not end the next forecast */ }
|
|
139
|
+
}).finally(() => { futurePending = null; });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The forecast being worked out, for checks that wait for it to finish. */
|
|
143
|
+
export function forecastSettled() {
|
|
144
|
+
return futurePending ?? Promise.resolve();
|
|
71
145
|
}
|
|
72
146
|
|
|
73
147
|
/**
|
|
@@ -28,7 +28,7 @@ import { Readable } from "node:stream";
|
|
|
28
28
|
import { heldFileBytes } from "./held-file-bytes.js";
|
|
29
29
|
import { createSendStream } from "./channel.js";
|
|
30
30
|
import { readFragments, supplyFiguresFor } from "./piece-reader.js";
|
|
31
|
-
import { demandFor,
|
|
31
|
+
import { demandFor, downloadForecastOf, onForecastFailure, selectionLimitOf } from "../download/registry.js";
|
|
32
32
|
import { heldRangesOf, ownsItsMemory, readHeldBytes, readHeldRanges } from "./held-bytes.js";
|
|
33
33
|
import { CompletedFiles, completedFilesRoot } from "../../storage/files/CompletedFiles.js";
|
|
34
34
|
import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../../storage/files/piece-from-whole-file.js";
|
|
@@ -79,6 +79,8 @@ const { SharedPieceStore, collectStoreStats, findSharedStore, machineReserveByte
|
|
|
79
79
|
// been asked for anything yet.
|
|
80
80
|
const dhtBootstrap = await resolveDhtBootstrap();
|
|
81
81
|
|
|
82
|
+
onForecastFailure(error => logger.warn(`download: the arrival forecast failed, the last one stands: ${error?.stack ?? error}`));
|
|
83
|
+
|
|
82
84
|
const pool = new TorrentPool({
|
|
83
85
|
memoryBytes: workerData?.memoryBytes,
|
|
84
86
|
dhtBootstrap,
|
|
@@ -473,7 +475,7 @@ async function runCommand(command, params, id) {
|
|
|
473
475
|
// interval invented from one point is exactly what this work removes.
|
|
474
476
|
const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
|
|
475
477
|
const forecastAt = performance.now();
|
|
476
|
-
const forecast = file && params.forecast === true ? (
|
|
478
|
+
const forecast = file && params.forecast === true ? downloadForecastOf(torrent) : null;
|
|
477
479
|
const forecastMs = params.forecast === true ? performance.now() - forecastAt : 0;
|
|
478
480
|
const stats = pool.getFileStats(torrent, params.fileIndex);
|
|
479
481
|
const ranges = [];
|
|
@@ -350,7 +350,12 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
350
350
|
for (const range of source.downloadForecast.ranges ?? []) {
|
|
351
351
|
if (!Number.isFinite(range?.availableAt) || !Number.isSafeInteger(range.start) ||
|
|
352
352
|
!Number.isSafeInteger(range.end) || range.start < 0 || range.end <= range.start) continue;
|
|
353
|
-
|
|
353
|
+
// A forecast time already past says nothing of when the bytes come:
|
|
354
|
+
// held bytes are in the residence, and missing ones are estimated
|
|
355
|
+
// below. Read as an arrival at zero, a piece forecast to come and
|
|
356
|
+
// still missing counted as held (torrent-tv/meta#178).
|
|
357
|
+
if (range.availableAt <= now) continue;
|
|
358
|
+
const arrival = (range.availableAt - now) / 1000;
|
|
354
359
|
// The entire protocol piece becomes readable after verification;
|
|
355
360
|
// bytes inside it never arrive proportionally to their file address.
|
|
356
361
|
sourceByteService.get(id).push({ start: range.start, end: range.end, begin: arrival, finish: arrival });
|