@torrent-tv/proxy 2.74.0 → 2.75.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.
@@ -10,21 +10,21 @@
10
10
  * - Language 0x22B59C default "eng", LanguageBCP47 0x22B59D MUST — when present, Language ignored
11
11
  * - CodecID 0x86, CodecPrivate 0x63A2, Name 0x536E, TrackType 0x83 (1 video, 2 audio, 17 subtitle)
12
12
  *
13
- * Keyframe reading is delegated to matroska.js and byte-level EBML walking to
14
- * ebml-reader.js. Everything this container states about its own subtitles —
15
- * the Tracks walk, the Cues table, the cluster positions it names, and the
16
- * blocks inside a cluster — is read in this module: each of those is RFC 9559
17
- * speaking about Matroska, and the class is the only way in.
13
+ * Byte-level EBML element walking is `ebml-reader.js`, which is the format's
14
+ * grammar and not this container's own statements. Everything Matroska DOES
15
+ * state about itself — the Tracks walk, the Cues, the keyframe times they name,
16
+ * the cluster positions, the blocks inside a cluster —
17
+ * is read in this module: each of those is RFC 9559 speaking about Matroska,
18
+ * and the class is the only way in.
18
19
  */
19
20
 
20
21
  import { Container } from "./Container.js";
21
- import { isMatroska, readMatroskaKeyframeTimes } from "../container-index/matroska.js";
22
22
  import { VideoTrack } from "../tracks/VideoTrack.js";
23
23
  import { AudioTrack } from "../tracks/AudioTrack.js";
24
24
  import { TextSubtitleTrack, TEXT_CODECS_MATROSKA } from "../tracks/TextSubtitleTrack.js";
25
25
  import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
26
26
  import { ContainerTrack } from "../tracks/ContainerTrack.js";
27
- import { findElement, iterateElements, readFloat, readUint, readVint } from "../container-index/ebml-reader.js";
27
+ import { findElement, iterateElements, readFloat, readUint, readVint } from "./ebml-reader.js";
28
28
 
29
29
  const HEAD_BYTES = 64 * 1024;
30
30
  /** Enough to read any cluster's own element header. */
@@ -101,6 +101,20 @@ export class MatroskaContainer extends Container {
101
101
  return isMatroska(head);
102
102
  }
103
103
 
104
+ /**
105
+ * The keyframe times this container's own index states, in ascending seconds.
106
+ *
107
+ * Static so a caller that has bytes and no container can ask; the instance
108
+ * form is {@link Container#readKeyframeIndex}.
109
+ *
110
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
111
+ * @param {number} fileSize
112
+ * @returns {Promise<number[]|null>} Null where the container has no index.
113
+ */
114
+ static readKeyframeTimes(readRange, fileSize) {
115
+ return readMatroskaKeyframeTimes(readRange, fileSize);
116
+ }
117
+
104
118
  /**
105
119
  * This container's subtitle tracks, its Cues table and the cluster positions
106
120
  * they name — RFC 9559 §5.1.4 and §5.1.3.
@@ -344,6 +358,17 @@ export class MatroskaContainer extends Container {
344
358
  * @returns {Promise<Map<number, {startSeconds: number, endSeconds: number|null, text: string}[]>>}
345
359
  * Track number to the cues found in THIS pass.
346
360
  */
361
+ async readHeldCues(plan, track, progress) {
362
+ const found = await this.walkHeldClusters(plan, progress.walked);
363
+ return {
364
+ found,
365
+ // Every track is filled by the same walk, so this is a fact about the
366
+ // FILE and reads the same whichever track asked.
367
+ covered: progress.walked.size,
368
+ indexed: track?.clusterPositions?.length ?? 0
369
+ };
370
+ }
371
+
347
372
  async walkHeldClusters(plan, walked) {
348
373
  /** @type {Map<number, object[]>} */
349
374
  const found = new Map();
@@ -1153,3 +1178,308 @@ function harvestCluster(bytes, trackNumber, secondsPerTick) {
1153
1178
  payload: block.payload
1154
1179
  }));
1155
1180
  }
1181
+
1182
+ // ---------------------------------------------------------------------------
1183
+ // RFC 9559 speaking about Matroska: the Cues table that says where a keyframe is.
1184
+ // Here because the class is the only way in.
1185
+ // ---------------------------------------------------------------------------
1186
+
1187
+ // Element ids, from the Matroska specification.
1188
+ const ID_CUE_TIME = 0xb3;
1189
+ // TrackType 1 is video; 2 is audio, 17 subtitles, and the rest are rarer still.
1190
+ const TRACK_TYPE_VIDEO = 1;
1191
+
1192
+ // How much of the file start to read. Must cover the EBML header, the SeekHead
1193
+ // and Info; 64 KB is generous for every real muxer (the file measured needed
1194
+ // under 4 KB) while still trivial to fetch.
1195
+ // Cap on the Cues read. A two-hour film indexes to tens of KB; anything beyond
1196
+ // this is not a normal index and not worth pulling over a torrent.
1197
+ // Cap on a Tracks read, for the rare file whose Tracks element sits outside the
1198
+ // head window. Track entries are small, so a file with dozens of them still
1199
+ // fits well inside this.
1200
+ const MAX_TRACKS_BYTES = 1024 * 1024;
1201
+ // Matroska's default timestamp scale (nanoseconds per tick) when Info omits it.
1202
+
1203
+ /**
1204
+ * Whether this looks like a Matroska file (the EBML magic `0x1A45DFA3`).
1205
+ *
1206
+ * @param {Buffer} head
1207
+ * @returns {boolean}
1208
+ */
1209
+ function isMatroska(head) {
1210
+ return head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3;
1211
+ }
1212
+
1213
+ /**
1214
+ * Locate the Segment element and the SeekHead entries inside it.
1215
+ *
1216
+ * Seek positions are relative to the start of Segment's payload, not to the
1217
+ * file, so that base has to come back with them.
1218
+ *
1219
+ * @param {Buffer} head
1220
+ * @returns {{ segmentDataOffset: number, entries: Map<number, number> } | null}
1221
+ */
1222
+ function readSeekHead(head) {
1223
+ let segmentDataOffset = -1;
1224
+ for (const element of iterateElements(head)) {
1225
+ if (element.id === ID_SEGMENT) {
1226
+ segmentDataOffset = element.dataOffset;
1227
+ break;
1228
+ }
1229
+ }
1230
+ if (segmentDataOffset < 0) {
1231
+ return null;
1232
+ }
1233
+
1234
+ const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
1235
+ if (!seekHead) {
1236
+ return null;
1237
+ }
1238
+
1239
+ const entries = new Map();
1240
+ const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
1241
+ for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
1242
+ if (seek.id !== ID_SEEK) {
1243
+ continue;
1244
+ }
1245
+ const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
1246
+ let targetId = null;
1247
+ let position = null;
1248
+ for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
1249
+ if (field.id === ID_SEEK_ID) {
1250
+ targetId = readUint(head, field.dataOffset, field.size);
1251
+ } else if (field.id === ID_SEEK_POSITION) {
1252
+ position = readUint(head, field.dataOffset, field.size);
1253
+ }
1254
+ }
1255
+ if (targetId !== null && position !== null) {
1256
+ entries.set(targetId, position);
1257
+ }
1258
+ }
1259
+ return { segmentDataOffset, entries };
1260
+ }
1261
+
1262
+ /**
1263
+ * Timestamp scale (nanoseconds per tick) declared in Info, or the default.
1264
+ *
1265
+ * @param {Buffer} head
1266
+ * @param {number} segmentDataOffset
1267
+ * @returns {number}
1268
+ */
1269
+ function readTimestampScale(head, segmentDataOffset) {
1270
+ const info = findElement(head, ID_INFO, [], segmentDataOffset);
1271
+ if (!info) {
1272
+ return DEFAULT_TIMESTAMP_SCALE;
1273
+ }
1274
+ const infoEnd = Math.min(head.length, info.dataOffset + info.size);
1275
+ for (const field of iterateElements(head, info.dataOffset, infoEnd)) {
1276
+ if (field.id === ID_TIMESTAMP_SCALE) {
1277
+ const scale = readUint(head, field.dataOffset, field.size);
1278
+ return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
1279
+ }
1280
+ }
1281
+ return DEFAULT_TIMESTAMP_SCALE;
1282
+ }
1283
+
1284
+ /**
1285
+ * The number of the first video track, from a Tracks payload.
1286
+ *
1287
+ * The FIRST one, because that is the track ffmpeg is told to copy (`0:v:0`).
1288
+ *
1289
+ * @param {Buffer} buffer
1290
+ * @param {{ dataOffset: number, size: number }} tracks
1291
+ * @returns {number | null}
1292
+ */
1293
+ function readVideoTrackNumber(buffer, tracks) {
1294
+ const tracksEnd = Math.min(buffer.length, tracks.dataOffset + tracks.size);
1295
+ for (const entry of iterateElements(buffer, tracks.dataOffset, tracksEnd)) {
1296
+ if (entry.id !== ID_TRACK_ENTRY) {
1297
+ continue;
1298
+ }
1299
+ const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
1300
+ let number = null;
1301
+ let type = null;
1302
+ for (const field of iterateElements(buffer, entry.dataOffset, entryEnd)) {
1303
+ if (field.id === ID_TRACK_NUMBER) {
1304
+ number = readUint(buffer, field.dataOffset, field.size);
1305
+ } else if (field.id === ID_TRACK_TYPE) {
1306
+ type = readUint(buffer, field.dataOffset, field.size);
1307
+ }
1308
+ }
1309
+ if (number !== null && type === TRACK_TYPE_VIDEO) {
1310
+ return number;
1311
+ }
1312
+ }
1313
+ return null;
1314
+ }
1315
+
1316
+ /**
1317
+ * Cue times (seconds, ascending) of ONE track, from a Cues payload.
1318
+ *
1319
+ * The track is the whole point, and leaving it out is what this reader got
1320
+ * wrong until 2026-08-18. A CuePoint belongs to the track named inside its
1321
+ * CueTrackPositions, and a muxer indexes whatever tracks it likes: RFC 9559
1322
+ * says each keyframe of a video track SHOULD be referenced, and that the Cues
1323
+ * Element "can be used to index every single timestamp of every Block or they
1324
+ * can be indexed selectively". Both field files index their SUBTITLE tracks as
1325
+ * well — `Minions.and.Monsters.1080p.mkv` has 2778 video entries every 2.002 s
1326
+ * plus 4669 across four subtitle tracks; `Moana.2 … MegaPeer.mkv` has 1055
1327
+ * video entries plus 5007 across five.
1328
+ *
1329
+ * Read without the track, those extra times enter the cut list as though they
1330
+ * were keyframes. ffmpeg can only cut a COPIED picture at a real keyframe at or
1331
+ * after the time it is given, so every cut asked for at one of them lands late
1332
+ * — which is exactly what the field measured: on the first file every deviation
1333
+ * was 2.002 s, that file's own keyframe spacing, and on the second the median
1334
+ * was 6.3 s with a worst case of 21 s. Never once negative.
1335
+ *
1336
+ * @param {Buffer} cues
1337
+ * @param {number} timestampScale - Nanoseconds per tick.
1338
+ * @param {number | null} trackNumber - Null keeps every entry, which is right
1339
+ * only for a file that indexes one track.
1340
+ * @returns {number[]}
1341
+ */
1342
+ function readCueTimes(cues, timestampScale, trackNumber) {
1343
+ const times = [];
1344
+ const secondsPerTick = timestampScale / 1e9;
1345
+ for (const point of iterateElements(cues)) {
1346
+ if (point.id !== ID_CUE_POINT) {
1347
+ continue;
1348
+ }
1349
+ const pointEnd = Math.min(cues.length, point.dataOffset + point.size);
1350
+ let time = null;
1351
+ let belongsToTrack = trackNumber === null;
1352
+ for (const field of iterateElements(cues, point.dataOffset, pointEnd)) {
1353
+ if (field.id === ID_CUE_TIME) {
1354
+ time = readUint(cues, field.dataOffset, field.size) * secondsPerTick;
1355
+ continue;
1356
+ }
1357
+ if (field.id !== ID_CUE_TRACK_POSITIONS || belongsToTrack) {
1358
+ continue;
1359
+ }
1360
+ const positionsEnd = Math.min(pointEnd, field.dataOffset + field.size);
1361
+ for (const inner of iterateElements(cues, field.dataOffset, positionsEnd)) {
1362
+ if (inner.id === ID_CUE_TRACK && readUint(cues, inner.dataOffset, inner.size) === trackNumber) {
1363
+ belongsToTrack = true;
1364
+ break;
1365
+ }
1366
+ }
1367
+ }
1368
+ if (time !== null && belongsToTrack) {
1369
+ times.push(time);
1370
+ }
1371
+ }
1372
+ times.sort((left, right) => left - right);
1373
+ return times;
1374
+ }
1375
+
1376
+ /**
1377
+ * Read the keyframe times of a Matroska file using only two point reads.
1378
+ *
1379
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
1380
+ * Inclusive byte range reader; returns null when the range is unavailable.
1381
+ * @param {number} fileSize
1382
+ * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
1383
+ * carries no usable index (see the module doc for when that happens).
1384
+ */
1385
+ async function readMatroskaKeyframeTimes(readRange, fileSize) {
1386
+ const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
1387
+ if (!head || !isMatroska(head)) {
1388
+ return null;
1389
+ }
1390
+
1391
+ const seekHead = readSeekHead(head);
1392
+ if (!seekHead) {
1393
+ return null; // No SeekHead — a streamed or truncated mux.
1394
+ }
1395
+
1396
+ const cuesRelative = seekHead.entries.get(ID_CUES);
1397
+ if (cuesRelative === undefined) {
1398
+ return null; // Indexless file: live capture, interrupted write, damaged upload.
1399
+ }
1400
+
1401
+ // SeekHead positions are relative to Segment's payload.
1402
+ const cuesOffset = seekHead.segmentDataOffset + cuesRelative;
1403
+ if (!Number.isFinite(cuesOffset) || cuesOffset <= 0 || cuesOffset >= fileSize) {
1404
+ return null;
1405
+ }
1406
+
1407
+ // The element header states the payload size, but reading it costs a round
1408
+ // trip; fetch a bounded window instead and let the parser stop at the end of
1409
+ // what it got. Cues sits near the file end, so the window is clamped there.
1410
+ const cuesEnd = Math.min(fileSize - 1, cuesOffset + MAX_CUES_BYTES);
1411
+ const cuesChunk = await readRange(cuesOffset, cuesEnd);
1412
+ if (!cuesChunk || cuesChunk.length === 0) {
1413
+ return null;
1414
+ }
1415
+
1416
+ // The window starts exactly at the Cues element, so its own header comes
1417
+ // first; step over it to reach the CuePoints.
1418
+ const cuesElement = [...iterateElements(cuesChunk, 0, cuesChunk.length)][0];
1419
+ if (!cuesElement || cuesElement.id !== ID_CUES) {
1420
+ return null;
1421
+ }
1422
+ const payloadEnd = Math.min(cuesChunk.length, cuesElement.dataOffset + cuesElement.size);
1423
+ const payload = cuesChunk.subarray(cuesElement.dataOffset, payloadEnd);
1424
+
1425
+ // Whose entries to keep. Tracks sits near the head and is normally inside the
1426
+ // bytes already fetched; when it is not, SeekHead says where it is and one
1427
+ // more short read gets it. Nothing is fetched twice and nothing is scanned.
1428
+ const videoTrack = await readVideoTrack(readRange, head, seekHead, fileSize);
1429
+ const timestampScale = readTimestampScale(head, seekHead.segmentDataOffset);
1430
+ const times = readCueTimes(payload, timestampScale, videoTrack);
1431
+ if (times.length > 0) {
1432
+ return times;
1433
+ }
1434
+ if (videoTrack === null) {
1435
+ return null;
1436
+ }
1437
+ // The filter left nothing, and that is not an answer about the file: a table
1438
+ // exists, and this reader simply failed to recognise which of its entries
1439
+ // belong to the picture — a track numbered one way in Tracks and another in
1440
+ // the cue points, or an entry with no CueTrack at all. Returning null here
1441
+ // would put an EVEN grid on a copied picture, which is the failure this
1442
+ // module exists to prevent, so the unfiltered table is used instead: less
1443
+ // exact than the picture's own keyframes, better than a grid that has nothing
1444
+ // to do with the file.
1445
+ const unfiltered = readCueTimes(payload, timestampScale, null);
1446
+ return unfiltered.length > 0 ? unfiltered : null;
1447
+ }
1448
+
1449
+ /**
1450
+ * The video track's number — from the head when it is there, and from one extra
1451
+ * short read when it is not.
1452
+ *
1453
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
1454
+ * @param {Buffer} head
1455
+ * @param {{ segmentDataOffset: number, entries: Map<number, number> }} seekHead
1456
+ * @param {number} fileSize
1457
+ * @returns {Promise<number | null>} Null when Tracks cannot be read at all, and
1458
+ * then every cue entry is kept — right for a file that indexes only its
1459
+ * picture, wrong for one that does not, and nothing here can tell them apart.
1460
+ * Refusing the index instead would put an even grid on a copied picture,
1461
+ * which is the failure this reader exists to prevent.
1462
+ */
1463
+ async function readVideoTrack(readRange, head, seekHead, fileSize) {
1464
+ const inHead = findElement(head, ID_TRACKS, [], seekHead.segmentDataOffset);
1465
+ if (inHead && inHead.dataOffset + inHead.size <= head.length) {
1466
+ return readVideoTrackNumber(head, inHead);
1467
+ }
1468
+ const relative = seekHead.entries.get(ID_TRACKS);
1469
+ if (relative === undefined) {
1470
+ return null;
1471
+ }
1472
+ const offset = seekHead.segmentDataOffset + relative;
1473
+ if (!Number.isFinite(offset) || offset <= 0 || offset >= fileSize) {
1474
+ return null;
1475
+ }
1476
+ const chunk = await readRange(offset, Math.min(fileSize - 1, offset + MAX_TRACKS_BYTES));
1477
+ if (!chunk || chunk.length === 0) {
1478
+ return null;
1479
+ }
1480
+ const element = [...iterateElements(chunk, 0, chunk.length)][0];
1481
+ if (!element || element.id !== ID_TRACKS) {
1482
+ return null;
1483
+ }
1484
+ return readVideoTrackNumber(chunk, { dataOffset: element.dataOffset, size: element.size });
1485
+ }