@torrent-tv/proxy 2.101.42 → 2.102.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.
package/bin/cli.js CHANGED
@@ -406,7 +406,8 @@ try {
406
406
  // Late-bound for the same reason: what the connections carry beyond the
407
407
  // film is counted by the data-channel handler, built further down.
408
408
  serviceShare: () => dataChannelHandler?.serviceShare?.() ?? null,
409
- onStateChanged: () => void stateReporter.changed()
409
+ onStateChanged: () => void stateReporter.changed(),
410
+ onChaptersChanged: (event) => dataChannelHandler?.publishChaptersChanged(event)
410
411
  });
411
412
  app = started.app;
412
413
  actualPort = started.port;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.101.42",
3
+ "version": "2.102.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -0,0 +1,8 @@
1
+ /** Snapshot only; chapter discovery never delays playback preparation. */
2
+ export function handleApiSourceChaptersGet(req, reply, { chapterController }) {
3
+ const sourceKey = String(req.params?.sourceKey ?? "");
4
+ const rawIndex = req.query?.fileIndex;
5
+ const fileIndex = typeof rawIndex === "string" && /^\d+$/.test(rawIndex) ? Number(rawIndex) : NaN;
6
+ const result = chapterController.get({ sourceKey, fileIndex });
7
+ return reply.header("Cache-Control", "no-store").code(result.status).send(result.body);
8
+ }
package/server.js CHANGED
@@ -31,6 +31,9 @@ import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
31
31
  import { handleApiSourceFingerprintGet } from "./routes/api/sources/fingerprint/get.js";
32
32
  import { handleApiSourceContainerMetadataGet } from "./routes/api/sources/container-metadata/get.js";
33
33
  import { handleApiSourceCoverGet } from "./routes/api/sources/cover/get.js";
34
+ import { handleApiSourceChaptersGet } from "./routes/api/sources/chapters/get.js";
35
+ import { ChapterOrchestrator } from "./services/media/ChapterOrchestrator.js";
36
+ import { ChapterController } from "./services/server/controllers/ChapterController.js";
34
37
  import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
35
38
  import { handleApiSourceViewerPost } from "./routes/api/sources/viewer/post.js";
36
39
  import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
@@ -127,7 +130,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
127
130
  * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
128
131
  */
129
132
  export async function startProxyServer({
130
- host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
133
+ host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, onChaptersChanged, diagnostics, budgetPolicy,
131
134
  deliverySink = false,
132
135
  // Whether a memory high-water earns a heap snapshot (`--heap-snapshots`).
133
136
  heapSnapshots = false,
@@ -224,6 +227,7 @@ export async function startProxyServer({
224
227
  onSourceForgotten: ({ sourceKey }) => {
225
228
  sourcePreparation?.forget(sourceKey);
226
229
  mediaReads.forget(sourceKey);
230
+ chapters.forget(sourceKey);
227
231
  probeReads.forget(sourceKey);
228
232
  subtitles.forget(sourceKey);
229
233
  keyframeTables.forget(sourceKey);
@@ -382,6 +386,24 @@ export async function startProxyServer({
382
386
  * @param {object} [known] - The torrent handle when the caller already has it.
383
387
  * @returns {Promise<import("./services/media/SubtitleCues.js").HeldFile | null>}
384
388
  */
389
+ const chapters = new ChapterOrchestrator({
390
+ containers: containerOrchestrator,
391
+ subscribe: (sourceKey, fileIndex, listener) => mediaReads.subscribe(sourceKey, fileIndex, listener),
392
+ withdraw: (sourceKey, fileIndex) => {
393
+ mediaReads.retain(sourceKey, fileIndex, (_params, statement) => statement !== "chapters");
394
+ void downloadMaps.withdrawMetadata(sourceKey, fileIndex, "chapters").catch(error => logger.warn(`chapter demand withdrawal failed: ${error.message}`));
395
+ },
396
+ failed: (error) => logger.warn(`chapter request failed: ${error.message}`),
397
+ onChanged: (event) => onChaptersChanged?.(event),
398
+ fileFor: async (sourceKey, fileIndex) => {
399
+ if (!sourceRegistry.get(sourceKey)) return null;
400
+ const torrent = torrentPool.knownTorrent(sourceKey);
401
+ const file = torrent?.files?.[fileIndex];
402
+ if (!file) return torrent ? { invalid: true } : null;
403
+ return containerOver({ sourceKey, fileIndex });
404
+ }
405
+ });
406
+ const chapterController = new ChapterController({ sourceRegistry, chapters });
385
407
  const heldFileFor = async (sourceKey, fileIndex, known = null) => {
386
408
  let torrent = known;
387
409
  if (!torrent) {
@@ -859,7 +881,9 @@ export async function startProxyServer({
859
881
  format: containerOrchestrator.known(sourceKey, fileIndex)?.formatName ?? "unrecognised" });
860
882
  }
861
883
  const finished = mediaReads.record(params, statement, result, revision.storage, revision.memory);
862
- await downloadMaps.metadata({ sourceKey, fileIndex, statement: requestId ? `${statement}:${requestId}` : statement, result,
884
+ if (statement === "chapters" && result.passive) await downloadMaps.withdrawMetadata(sourceKey, fileIndex, statement);
885
+ else await downloadMaps.metadata({ sourceKey, fileIndex, statement: requestId ? `${statement}:${requestId}` : statement, result,
886
+ ...(statement === "chapters" ? { priority: 1, urgent: false, deadlineAt: null } : {}),
863
887
  ...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}) });
864
888
  if (finished && result.kind === "result") {
865
889
  void downloadMaps.refresh(sourceKey, fileIndex).catch(error =>
@@ -1429,6 +1453,9 @@ export async function startProxyServer({
1429
1453
  probeReads.needs(requestId, start, end);
1430
1454
  }
1431
1455
  }));
1456
+ app.get("/api/sources/:sourceKey/chapters", async (req, reply) =>
1457
+ handleApiSourceChaptersGet(req, reply, { chapterController })
1458
+ );
1432
1459
  app.get("/healthz", async (req, reply) => handleHealthzGet(req, reply, { version }));
1433
1460
  // Off unless --delivery-sink was given; see the route for why it exists.
1434
1461
  app.get("/api/delivery-sink", async (req, reply) =>
@@ -128,10 +128,7 @@ export function configurationKeyOf(fingerprint, encoder) {
128
128
  return [
129
129
  ...common,
130
130
  `device=${device?.model ?? UNKNOWN}`,
131
- `driver=${device?.driver ?? UNKNOWN}`,
132
- `cpu=${fingerprint?.cpu ?? UNKNOWN}`,
133
- `threads=${fingerprint?.threads ?? UNKNOWN}`,
134
- ...(Number.isFinite(fingerprint?.boundCores) ? [`bound=${fingerprint.boundCores}`] : [])
131
+ `driver=${device?.driver ?? UNKNOWN}`
135
132
  ].join(";");
136
133
  }
137
134
 
@@ -0,0 +1,104 @@
1
+ /** Selected-file snapshots; retries belong to the shared media request queue. */
2
+ import { chapterResult } from "./container/chapters.js";
3
+ import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ export class ChapterOrchestrator {
7
+ constructor({ containers, fileFor, subscribe = () => () => {}, withdraw = () => {}, now = Date.now, onChanged = () => {}, failed = () => {} }) {
8
+ Object.assign(this, { containers, fileFor, subscribe, withdraw, now, onChanged, failed });
9
+ this.files = new Map();
10
+ }
11
+
12
+ get(sourceKey, fileIndex) {
13
+ for (const old of this.files.values()) if (old.expires < this.now()) this.forget(old.sourceKey, old.fileIndex);
14
+ const key = `${sourceKey}:${fileIndex}`;
15
+ let entry = this.files.get(key);
16
+ if (!entry) {
17
+ entry = { sourceKey, fileIndex, generation: randomUUID(), reading: null, requested: false, bytes: 0 };
18
+ entry.dispose = () => {
19
+ entry.allocation?.release(entry.bytes + (entry.candidate?.length ?? 0));
20
+ entry.allocation?.dispose();
21
+ entry.bytes = 0;
22
+ entry.candidate = null;
23
+ entry.buffer = null;
24
+ };
25
+ this.files.set(key, entry);
26
+ entry.unsubscribe = this.subscribe(sourceKey, fileIndex, (event) => {
27
+ if (event.kind === "cancelled") this.forget(sourceKey, fileIndex);
28
+ else if (!entry.requested && event.kind === "bytes-changed") this.warm(entry);
29
+ });
30
+ }
31
+ entry.expires = this.now() + 3600000;
32
+ if (!entry.requested) this.warm(entry);
33
+ return { ...(entry.buffer ? JSON.parse(entry.buffer.toString("utf8")) : chapterResult("pending")), sourceKey, fileIndex,
34
+ generation: entry.generation, revision: entry.revision ?? 0 };
35
+ }
36
+
37
+ update(entry, result) {
38
+ if (this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) !== entry) return;
39
+ let answer;
40
+ if (result.value?.version === 1) answer = result.value;
41
+ else if (result.kind === "needs-ranges") answer = chapterResult("pending", { missingRange: result.ranges[0] });
42
+ else if (result.kind === "needs-memory") answer = chapterResult("pending", { resource: "memory", requiredBytes: result.bytes });
43
+ else if (result.reason === "request-obsolete") return;
44
+ else if (["format-not-supported", "chapter-not-supported"].includes(result.reason)) {
45
+ answer = chapterResult("unsupported", { reason: result.chapterReason ?? result.reason });
46
+ } else answer = chapterResult("error", { reason: result.message ?? result.reason });
47
+ const buffer = result.value && entry.candidate ? entry.candidate : Buffer.from(JSON.stringify(answer));
48
+ const charged = buffer === entry.candidate ? buffer.length : 0;
49
+ if (entry.candidate && !charged) entry.allocation?.release(entry.candidate.length);
50
+ entry.candidate = null;
51
+ if (entry.buffer?.equals(buffer)) {
52
+ entry.allocation?.release(charged);
53
+ return;
54
+ }
55
+ entry.allocation?.release(entry.bytes);
56
+ entry.bytes = charged;
57
+ entry.buffer = buffer;
58
+ entry.revision = (entry.revision ?? 0) + 1;
59
+ this.onChanged({ sourceKey: entry.sourceKey, fileIndex: entry.fileIndex, revision: entry.revision });
60
+ }
61
+
62
+ warm(entry) {
63
+ if (entry.reading) return;
64
+ entry.reading = (async () => {
65
+ const params = await this.fileFor(entry.sourceKey, entry.fileIndex);
66
+ if (!params || this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) !== entry) return;
67
+ entry.requested = true;
68
+ if (params.invalid) return this.update(entry, { kind: "terminal", reason: "file-index-not-found" });
69
+ const publish = params.onReadResult;
70
+ entry.allocation ??= params.packetMemory?.forRecord?.(entry, "declarations");
71
+ params.acceptChapterResult = (value) => {
72
+ const serialized = JSON.stringify(value);
73
+ const bytes = Buffer.byteLength(serialized);
74
+ if (entry.allocation?.reserve(bytes) === false) throw new IndexMemoryUnavailable(bytes);
75
+ entry.candidate = Buffer.from(serialized);
76
+ };
77
+ params.isCurrent = () => {
78
+ if (entry.expires <= this.now()) this.forget(entry.sourceKey, entry.fileIndex);
79
+ return this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) === entry;
80
+ };
81
+ params.onReadResult = async (statement, result, revision) => {
82
+ await publish?.(statement, result, revision);
83
+ this.update(entry, result);
84
+ };
85
+ await this.containers.inspect(params, "chapters");
86
+ })().catch((error) => {
87
+ this.failed(error);
88
+ if (this.files.get(`${entry.sourceKey}:${entry.fileIndex}`) === entry) {
89
+ entry.requested = false;
90
+ this.update(entry, { kind: "terminal", reason: "chapter-request-failed", message: error.message });
91
+ }
92
+ }).finally(() => { entry.reading = null; });
93
+ }
94
+
95
+ forget(sourceKey, fileIndex) {
96
+ for (const [key, entry] of this.files) {
97
+ if (entry.sourceKey !== sourceKey || (fileIndex !== undefined && entry.fileIndex !== fileIndex)) continue;
98
+ this.files.delete(key);
99
+ entry.dispose();
100
+ entry.unsubscribe?.();
101
+ this.withdraw(entry.sourceKey, entry.fileIndex);
102
+ }
103
+ }
104
+ }
@@ -20,6 +20,7 @@ import { BytesUnavailable, isUnavailable, strictReader } from "./container/unava
20
20
  import { OSHASH_EDGE_BYTES, hasOshash, oshash } from "./oshash.js";
21
21
  import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
22
22
  import { OutsideReadableEdges } from "./container/work-tags.js";
23
+ import { ChapterUnsupported } from "./container/chapters.js";
23
24
  import { logger } from "../../utils/logger.js";
24
25
 
25
26
  /**
@@ -139,7 +140,7 @@ export class ContainerOrchestrator {
139
140
 
140
141
  /** Read one statement without converting missing bytes into an empty answer. */
141
142
  async inspect(params, statement = "tracks") {
142
- if (!["container", "tracks", "media-info", "keyframes", "packets", "source-navigation", "source-ranges", "fingerprint", "work-tags", "cover"].includes(statement)) {
143
+ if (!["container", "tracks", "media-info", "keyframes", "packets", "source-navigation", "source-ranges", "fingerprint", "work-tags", "cover", "chapters"].includes(statement)) {
143
144
  throw new TypeError(`Unknown media statement: ${statement}`);
144
145
  }
145
146
  const key = `${params.sourceKey}:${params.fileIndex}`;
@@ -232,11 +233,20 @@ export class ContainerOrchestrator {
232
233
  value = await container.readPacketIndex(params.packetInterval, { isCurrent: params.isCurrent, account: params.walkAccount });
233
234
  if (value === null) return { kind: "terminal", reason: "request-obsolete", requestId };
234
235
  await value?.prepareAudioDependencies?.(params.packetInterval, container.readRange);
236
+ } else if (statement === "chapters") {
237
+ value = await container.readChapters({ read: params.readHeld ?? params.readRange,
238
+ isCurrent: params.isCurrent,
239
+ ranges: params.heldRanges ? await params.heldRanges() : undefined });
240
+ if (params.isCurrent?.() === false) return { kind: "terminal", reason: "request-obsolete", requestId };
241
+ params.acceptChapterResult?.(value);
242
+ if (!value.complete && value.pending) return { kind: "needs-ranges", ranges: [[value.pending.start, value.pending.end]], value, requestId };
235
243
  } else {
236
244
  throw new TypeError(`Unknown media statement: ${statement}`);
237
245
  }
238
246
  return { kind: "result", value, requestId };
239
247
  } catch (error) {
248
+ if (statement === "chapters" && params.isCurrent?.() === false) return { kind: "terminal", reason: "request-obsolete", requestId };
249
+ if (error instanceof ChapterUnsupported) return { kind: "terminal", reason: "chapter-not-supported", chapterReason: error.message, requestId };
240
250
  if (error instanceof IndexMemoryUnavailable) {
241
251
  const result = { kind: "needs-memory", bytes: error.bytes, requestId };
242
252
  logger.info(`media request=${requestId} needs memory=${error.bytes}`);
@@ -248,7 +258,8 @@ export class ContainerOrchestrator {
248
258
  return { kind: "terminal", reason: "outside-edges", requestId };
249
259
  }
250
260
  if (isUnavailable(error)) {
251
- const result = { kind: "needs-ranges", ranges: [[error.start, error.end]], requestId };
261
+ const result = { kind: "needs-ranges", ranges: [[error.start, error.end]], requestId,
262
+ ...(error.passive ? { passive: true } : {}) };
252
263
  logger.info(`media request=${requestId} needs bytes=${error.start}-${error.end}`);
253
264
  return result;
254
265
  }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { isUnavailable, strictReader } from "./unavailable.js";
16
16
  import { textList } from "./work-tags.js";
17
+ import { chapterResult } from "./chapters.js";
17
18
 
18
19
  /**
19
20
  * What one file declares about itself. Every field is either a value the
@@ -29,6 +30,10 @@ import { textList } from "./work-tags.js";
29
30
  */
30
31
 
31
32
  export class Container {
33
+ /** Read chapters only through the caller's non-fetching held-byte reader. */
34
+ async readChapters(_held) {
35
+ return chapterResult("unsupported", { reason: `chapter-container:${this.formatName}` });
36
+ }
32
37
  /**
33
38
  * One strict available-byte reader and one container per
34
39
  * file — the one `ContainerOrchestrator` keeps.
@@ -34,6 +34,7 @@
34
34
 
35
35
  import { FFMPEG_STREAM_SEARCH_SECONDS } from "./ffmpeg-stream-search.js";
36
36
  import { Container } from "./Container.js";
37
+ import { readMatroskaChapters } from "./matroska-chapters.js";
37
38
  import { VideoTrack } from "../tracks/VideoTrack.js";
38
39
  import { h264Configuration } from "./h264-configuration.js";
39
40
  import { hevcConfiguration } from "./hevc-configuration.js";
@@ -144,6 +145,9 @@ function readString(buf, el) {
144
145
  */
145
146
 
146
147
  export class MatroskaContainer extends Container {
148
+ async readChapters(held) {
149
+ return readMatroskaChapters(this, held);
150
+ }
147
151
  /** @type {Buffer | null} */
148
152
  #declarations = null;
149
153
 
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { Container } from "./Container.js";
20
20
  import { RetainedBytes } from "./RetainedBytes.js";
21
+ import { readMp4Chapters } from "./mp4-chapters.js";
21
22
  import { VideoTrack } from "../tracks/VideoTrack.js";
22
23
  import { AudioTrack } from "../tracks/AudioTrack.js";
23
24
  import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
@@ -265,6 +266,9 @@ function decoderSpecificInfoOf(buf, start, end) {
265
266
 
266
267
  export class Mp4Container extends Container {
267
268
  #tracks = null;
269
+ async readChapters(held) {
270
+ return readMp4Chapters(this, held);
271
+ }
268
272
  get formatName() {
269
273
  return "mp4";
270
274
  }
@@ -0,0 +1,104 @@
1
+ /** File chapter declarations and bounded, non-fetching byte access. */
2
+ import { BytesUnavailable, strictReader } from "./unavailable.js";
3
+
4
+ export class ChapterUnsupported extends Error {
5
+ constructor(reason) { super(reason); this.name = "ChapterUnsupported"; }
6
+ }
7
+
8
+ export function chapterResult(state, extra = {}) {
9
+ return { version: 1, state, complete: state !== "pending", selectedEditionId: null,
10
+ editions: [], sections: [], tailContent: "unknown", ...extra };
11
+ }
12
+
13
+ /** Limits bound hostile metadata, not the media file's size. */
14
+ export const CHAPTER_LIMITS = { entries: 4096, depth: 64, textBytes: 8192, readBytes: 8 * 1024 * 1024, elements: 100000 };
15
+
16
+ export class ChapterBytes {
17
+ constructor(held, size) {
18
+ this.held = held;
19
+ this.size = size;
20
+ this.read = strictReader(held.read, size);
21
+ this.used = 0;
22
+ this.elements = 0;
23
+ this.window = null;
24
+ this.windowAt = 0;
25
+ }
26
+
27
+ async bytes(start, length) {
28
+ if (this.held.isCurrent?.() === false) throw new Error("Chapter request was withdrawn");
29
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(length) || start < 0 || length < 0 || start + length > this.size) {
30
+ throw new Error("Chapter byte range is outside the file");
31
+ }
32
+ if (length === 0) return Buffer.alloc(0);
33
+ if (this.window && start >= this.windowAt && start + length <= this.windowAt + this.window.length) {
34
+ return this.window.subarray(start - this.windowAt, start - this.windowAt + length);
35
+ }
36
+ const range = this.held.ranges?.find(([first, last]) => first <= start && last >= start + length - 1);
37
+ if (this.held.ranges && !range) {
38
+ const error = new BytesUnavailable(start, start + length - 1, 0);
39
+ error.passive = this.passiveMissing === true;
40
+ throw error;
41
+ }
42
+ // Header traversal must not copy adjacent video payloads into the metadata budget.
43
+ const span = length;
44
+ this.used += span;
45
+ if (this.used > CHAPTER_LIMITS.readBytes) throw new ChapterUnsupported("chapter-read-budget");
46
+ if (++this.elements % 64 === 0) await new Promise((resolve) => setImmediate(resolve));
47
+ try { this.window = await this.read(start, start + span - 1); }
48
+ catch (error) {
49
+ if (error?.name === "BytesUnavailable") error.passive = this.passiveMissing === true;
50
+ throw error;
51
+ }
52
+ this.windowAt = start;
53
+ return this.window.subarray(0, length);
54
+ }
55
+
56
+ step(depth = 0) {
57
+ if (this.held.isCurrent?.() === false) throw new Error("Chapter request was withdrawn");
58
+ if (++this.elements > CHAPTER_LIMITS.elements || depth > CHAPTER_LIMITS.depth) {
59
+ throw new ChapterUnsupported("chapter-structure-budget");
60
+ }
61
+ }
62
+ }
63
+
64
+ export function chapterSeconds(value, units) {
65
+ const seconds = Number(value / BigInt(units)) + Number(value % BigInt(units)) / units;
66
+ if (!Number.isFinite(seconds) || seconds > Number.MAX_SAFE_INTEGER) throw new Error("Chapter time is outside the supported numeric range");
67
+ return seconds;
68
+ }
69
+
70
+ export function classifyChapter(titles) {
71
+ const types = new Set();
72
+ for (const { text } of titles) {
73
+ const title = text.trim().toLowerCase();
74
+ if (/^(op(?:ening)?|opening credits|opening theme|オープニング)$/.test(title)) types.add("opening");
75
+ if (/^(ed|ending|ending theme|エンディング)$/.test(title)) types.add("ending");
76
+ if (/^(credits|end credits|closing credits|титры)$/.test(title)) types.add("credits");
77
+ if (/^(post[- ]credits?(?: scene)?|after[- ]credits?(?: scene)?)$/.test(title)) types.add("post-credits");
78
+ }
79
+ const inferred = types.size === 1;
80
+ return { type: inferred ? [...types][0] : "chapter",
81
+ classification: inferred ? "title-hint" : "unclassified",
82
+ confidence: inferred ? "unconfirmed" : "declared" };
83
+ }
84
+
85
+ /** Build navigation intervals without promoting inferred ends to file facts. */
86
+ export function navigationSections(edition, duration = null) {
87
+ const out = [];
88
+ const walk = (chapters) => {
89
+ for (const chapter of chapters) {
90
+ if (chapter.enabled && !chapter.hidden) {
91
+ const next = chapters.filter((other) => other.startSeconds > chapter.startSeconds)
92
+ .reduce((end, other) => Math.min(end, other.startSeconds), Infinity);
93
+ const inferred = Number.isFinite(next) ? next : duration;
94
+ out.push({ ...chapter, children: undefined, ...classifyChapter(chapter.titles),
95
+ displayEndSeconds: chapter.endSeconds ?? inferred,
96
+ endSource: chapter.endSeconds !== null ? "declared" : inferred !== null ? "navigation" : "unknown",
97
+ automaticSkipAllowed: false, evidence: "file-chapter" });
98
+ }
99
+ walk(chapter.children);
100
+ }
101
+ };
102
+ walk(edition.chapters);
103
+ return out.sort((a, b) => a.startSeconds - b.startSeconds || a.order - b.order);
104
+ }
@@ -0,0 +1,213 @@
1
+ /** RFC 9559 §§5.1.7, 6.6, 11.1.1 and 20: chapter metadata only. */
2
+ import { readVint } from "./ebml-reader.js";
3
+ import { ChapterBytes, ChapterUnsupported, CHAPTER_LIMITS, chapterResult, chapterSeconds, navigationSections } from "./chapters.js";
4
+
5
+ const SEGMENT = 0x18538067;
6
+ const CHAPTERS = 0x1043a770;
7
+ const SEEK_HEAD = 0x114d9b74;
8
+ const CLUSTER = 0x1f43b675;
9
+ const TOP_LEVEL = new Set([SEEK_HEAD, CHAPTERS, CLUSTER, 0x1549a966, 0x1654ae6b, 0x1c53bb6b, 0x1941a469, 0x1254c367]);
10
+
11
+ async function header(reader, at, limit) {
12
+ reader.step();
13
+ const vint = async (position, marker) => {
14
+ const first = (await reader.bytes(position, 1))[0];
15
+ let n = 1;
16
+ while (n <= 8 && !(first & (0x80 >> (n - 1)))) n++;
17
+ if (n > (marker ? 4 : 8) || position + n > limit) throw new Error("Invalid chapter EBML header");
18
+ const bytes = await reader.bytes(position, n);
19
+ const value = readVint(bytes, 0, marker);
20
+ const mask = 2 ** (8 - n) - 1;
21
+ return { ...value, unknown: !marker && (bytes[0] & mask) === mask && bytes.subarray(1).every((byte) => byte === 255) };
22
+ };
23
+ const id = await vint(at, true);
24
+ const size = await vint(at + id.length, false);
25
+ const dataOffset = at + id.length + size.length;
26
+ const unknown = size.unknown;
27
+ const end = unknown ? null : dataOffset + size.value;
28
+ if (!Number.isSafeInteger(dataOffset) || (!unknown && (!Number.isSafeInteger(end) || end > limit))) throw new Error("Chapter element exceeds its parent");
29
+ return { id: id.value, at, dataOffset, end, size: unknown ? null : size.value };
30
+ }
31
+
32
+ async function* children(reader, master, depth = 0) {
33
+ reader.step(depth);
34
+ if (master.end === null) throw new Error("Unknown size is not valid for this chapter element");
35
+ for (let at = master.dataOffset; at < master.end;) {
36
+ const child = await header(reader, at, master.end);
37
+ if (child.end === null) throw new Error("Unknown-sized chapter child");
38
+ yield child;
39
+ at = child.end;
40
+ }
41
+ }
42
+
43
+ async function integer(reader, field, fallback = null) {
44
+ if (field.size > 8) throw new Error("Chapter integer exceeds eight bytes");
45
+ if (field.size === 0) return fallback;
46
+ const bytes = await reader.bytes(field.dataOffset, field.size);
47
+ let value = 0n;
48
+ for (const byte of bytes) value = value * 256n + BigInt(byte);
49
+ return value;
50
+ }
51
+
52
+ async function text(reader, field) {
53
+ if (field.size > CHAPTER_LIMITS.textBytes) throw new ChapterUnsupported("chapter-title-budget");
54
+ return new TextDecoder("utf-8", { fatal: true }).decode(await reader.bytes(field.dataOffset, field.size)).replace(/\0+$/, "");
55
+ }
56
+
57
+ async function display(reader, field, depth) {
58
+ let value = null;
59
+ const languages = [], bcp47 = [], countries = [];
60
+ for await (const part of children(reader, field, depth)) {
61
+ if (part.id === 0x85) value = await text(reader, part);
62
+ if (part.id === 0x437c) languages.push(await text(reader, part));
63
+ if (part.id === 0x437d) bcp47.push(await text(reader, part));
64
+ if (part.id === 0x437e) countries.push(await text(reader, part));
65
+ }
66
+ if (value === null) throw new Error("ChapterDisplay has no ChapString");
67
+ return { text: value, languages: bcp47.length ? bcp47 : languages.length ? languages : ["eng"],
68
+ countries: bcp47.length ? [] : countries, languageSource: bcp47.length ? "bcp47" : "matroska" };
69
+ }
70
+
71
+ async function atom(reader, field, editionId, parentId, count, depth) {
72
+ if (++count.value > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-count-budget");
73
+ let uid = null, start = null, end = null, linkedSegment = null, linkedEdition = null;
74
+ let hidden = false, enabled = true;
75
+ const titles = [], childFields = [], trackNumbers = [], processCodecs = [];
76
+ for await (const part of children(reader, field, depth)) {
77
+ if (part.id === 0x73c4) uid = await integer(reader, part);
78
+ if (part.id === 0x91) start = await integer(reader, part, 0n);
79
+ if (part.id === 0x92) end = await integer(reader, part, 0n);
80
+ if (part.id === 0x98) hidden = (await integer(reader, part, 0n)) !== 0n;
81
+ if (part.id === 0x4598) enabled = (await integer(reader, part, 1n)) !== 0n;
82
+ if (part.id === 0x6e67) {
83
+ if (part.size !== 16) throw new Error("ChapterSegmentUUID must contain sixteen bytes");
84
+ linkedSegment = (await reader.bytes(part.dataOffset, part.size)).toString("hex");
85
+ }
86
+ if (part.id === 0x6ebc) linkedEdition = String(await integer(reader, part));
87
+ if (part.id === 0x80) titles.push(await display(reader, part, depth + 1));
88
+ if (part.id === 0xb6) childFields.push(part);
89
+ if (part.id === 0x8f) {
90
+ for await (const track of children(reader, part, depth + 1)) if (track.id === 0x89) trackNumbers.push(String(await integer(reader, track)));
91
+ }
92
+ if (part.id === 0x6944) {
93
+ let codec = 0n;
94
+ for await (const command of children(reader, part, depth + 1)) if (command.id === 0x6955) codec = await integer(reader, command, 0n);
95
+ processCodecs.push(String(codec));
96
+ }
97
+ }
98
+ if (uid === null || uid === 0n || start === null) throw new Error("ChapterAtom requires a nonzero ChapterUID and ChapterTimeStart");
99
+ if (end !== null && end < start) throw new Error("ChapterTimeEnd precedes ChapterTimeStart");
100
+ const id = `${editionId}:chapter:${uid}`;
101
+ const chapter = { id, uid: String(uid), editionId, parentId, order: count.value,
102
+ startNanoseconds: String(start), endNanoseconds: end === null ? null : String(end),
103
+ startSeconds: chapterSeconds(start, 1e9), endSeconds: end === null ? null : chapterSeconds(end, 1e9),
104
+ hidden, enabled, titles, linkedSegment, linkedEdition, trackNumbers, processCodecs, children: [] };
105
+ for (const child of childFields) {
106
+ const nested = await atom(reader, child, editionId, id, count, depth + 1);
107
+ if (BigInt(nested.startNanoseconds) < start || (end !== null && BigInt(nested.startNanoseconds) > end)) throw new Error("Nested chapter starts outside its parent");
108
+ chapter.children.push(nested);
109
+ }
110
+ return chapter;
111
+ }
112
+
113
+ async function editionsOf(reader, chapters) {
114
+ const editions = [], count = { value: 0 };
115
+ for await (const field of children(reader, chapters)) {
116
+ if (field.id !== 0x45b9) continue;
117
+ let uid = null, hidden = false, isDefault = false, ordered = false;
118
+ const atoms = [];
119
+ for await (const part of children(reader, field, 1)) {
120
+ if (part.id === 0x45bc) uid = await integer(reader, part);
121
+ if (part.id === 0x45bd) hidden = (await integer(reader, part, 0n)) !== 0n;
122
+ if (part.id === 0x45db) isDefault = (await integer(reader, part, 0n)) !== 0n;
123
+ if (part.id === 0x45dd) ordered = (await integer(reader, part, 0n)) !== 0n;
124
+ if (part.id === 0xb6) atoms.push(part);
125
+ }
126
+ const id = uid === null ? `matroska:edition-at:${field.at}` : `matroska:edition:${uid}`;
127
+ const edition = { id, uid: uid === null ? null : String(uid), hidden, isDefault, ordered, chapters: [] };
128
+ for (const part of atoms) edition.chapters.push(await atom(reader, part, id, null, count, 2));
129
+ if (!edition.chapters.length) throw new Error("EditionEntry has no ChapterAtom");
130
+ editions.push(edition);
131
+ }
132
+ if (!editions.length) throw new Error("Chapters has no EditionEntry");
133
+ const ids = new Set();
134
+ const check = (chapters) => {
135
+ for (const chapter of chapters) {
136
+ if (ids.has(chapter.uid)) throw new Error("Duplicate ChapterUID");
137
+ ids.add(chapter.uid); check(chapter.children);
138
+ }
139
+ };
140
+ for (const edition of editions) check(edition.chapters);
141
+ return editions;
142
+ }
143
+
144
+ export async function readMatroskaChapters(container, held) {
145
+ const reader = new ChapterBytes(held, container.fileSize);
146
+ let at = 0, segment = null;
147
+ while (at < reader.size) {
148
+ const field = await header(reader, at, reader.size);
149
+ if (field.id === SEGMENT) { segment = field; break; }
150
+ if (field.end === null) throw new Error("Unknown-sized element before Segment");
151
+ at = field.end;
152
+ }
153
+ if (!segment) throw new Error("Matroska Segment is missing");
154
+ const limit = segment.end ?? reader.size;
155
+ const visits = new Set();
156
+ const seekHeads = [];
157
+ const seek = async (field) => {
158
+ if (visits.has(field.at)) return null;
159
+ visits.add(field.at);
160
+ for await (const entry of children(reader, field)) {
161
+ if (entry.id !== 0x4dbb) continue;
162
+ let id = null, position = null;
163
+ for await (const part of children(reader, entry)) {
164
+ if (part.id === 0x53ab) id = await integer(reader, part);
165
+ if (part.id === 0x53ac) position = await integer(reader, part, 0n);
166
+ }
167
+ if (id === null || position === null) throw new Error("Incomplete chapter Seek entry");
168
+ if (id !== BigInt(CHAPTERS) && id !== BigInt(SEEK_HEAD)) continue;
169
+ const offset = segment.dataOffset + Number(position);
170
+ if (!Number.isSafeInteger(offset) || offset < segment.dataOffset || offset >= limit) throw new Error("Chapter SeekPosition is outside Segment");
171
+ reader.passiveMissing = false;
172
+ const target = await header(reader, offset, limit);
173
+ if (BigInt(target.id) !== id) throw new Error("Chapter SeekID does not match its target");
174
+ if (target.id === CHAPTERS) return target;
175
+ seekHeads.push(target);
176
+ }
177
+ return null;
178
+ };
179
+ let chapters = null;
180
+ let passedCluster = false;
181
+ at = segment.dataOffset;
182
+ while (at < limit && !chapters) {
183
+ // Without a declared location, traversing unavailable media must not
184
+ // download every Cluster just to discover whether chapters follow it.
185
+ reader.passiveMissing = passedCluster;
186
+ const field = await header(reader, at, limit);
187
+ reader.passiveMissing = false;
188
+ if (field.id === CHAPTERS) { chapters = field; break; }
189
+ if (field.id === CLUSTER) passedCluster = true;
190
+ if (field.id === SEEK_HEAD) {
191
+ chapters = await seek(field);
192
+ while (!chapters && seekHeads.length) chapters = await seek(seekHeads.shift());
193
+ if (chapters) break;
194
+ }
195
+ if (field.end !== null) { at = field.end; continue; }
196
+ if (field.id !== CLUSTER) throw new Error("Unknown-sized non-Cluster element inside Segment");
197
+ // An unknown-sized Cluster ends at the next Segment-level element (RFC 8794 §6.2).
198
+ at = field.dataOffset;
199
+ reader.passiveMissing = true;
200
+ while (at < limit) {
201
+ const child = await header(reader, at, limit);
202
+ if (TOP_LEVEL.has(child.id)) break;
203
+ if (child.end === null) throw new ChapterUnsupported("unknown-sized-cluster-child");
204
+ at = child.end;
205
+ }
206
+ }
207
+ if (!chapters) return chapterResult("absent");
208
+ const editions = await editionsOf(reader, chapters);
209
+ const selected = editions.find((edition) => edition.isDefault) ?? editions[0];
210
+ if (selected.ordered) return chapterResult("unsupported", { reason: "ordered-edition-playback", editions, selectedEditionId: selected.id });
211
+ return chapterResult("available", { editions, selectedEditionId: selected.id,
212
+ sections: navigationSections(selected, container.mediaInfo?.durationSeconds ?? null) });
213
+ }
@@ -0,0 +1,310 @@
1
+ /** QuickTime Chapter Lists and ISO/IEC 14496-12 sample tables/edit lists. */
2
+ import { isUnavailable } from "./unavailable.js";
3
+ import { ChapterBytes, ChapterUnsupported, CHAPTER_LIMITS, chapterResult, chapterSeconds, navigationSections } from "./chapters.js";
4
+
5
+ async function box(reader, at, end) {
6
+ reader.step();
7
+ const bytes = await reader.bytes(at, 8);
8
+ let size = bytes.readUInt32BE(0), header = 8;
9
+ if (size === 1) { size = Number((await reader.bytes(at + 8, 8)).readBigUInt64BE()); header = 16; }
10
+ if (size === 0) size = end - at;
11
+ if (!Number.isSafeInteger(size) || size < header || at + size > end) throw new Error("Chapter box exceeds its parent");
12
+ return { type: bytes.toString("latin1", 4, 8), at, dataOffset: at + header, end: at + size };
13
+ }
14
+
15
+ async function boxes(reader, start, end) {
16
+ const result = [];
17
+ for (let at = start; at < end;) {
18
+ if (end - at < 8) throw new Error("Truncated chapter box header");
19
+ const child = await box(reader, at, end);
20
+ result.push(child);
21
+ at = child.end;
22
+ }
23
+ return result;
24
+ }
25
+
26
+ const one = (list, type) => list.find((field) => field.type === type);
27
+ async function inside(reader, parent) { return parent ? boxes(reader, parent.dataOffset, parent.end) : []; }
28
+ async function payload(reader, field, count) {
29
+ if (!field || field.dataOffset + count > field.end) throw new Error("Truncated chapter field");
30
+ return reader.bytes(field.dataOffset, count);
31
+ }
32
+ async function table(reader, field, width, prefix = 8) {
33
+ const head = await payload(reader, field, prefix);
34
+ const count = head.readUInt32BE(prefix - 4);
35
+ if (count > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-sample-table-budget");
36
+ if (field.dataOffset + prefix + width * count !== field.end) throw new Error("Chapter sample table size mismatch");
37
+ return { head, count, bytes: await reader.bytes(field.dataOffset + prefix, count * width) };
38
+ }
39
+
40
+ function titleOf(bytes) {
41
+ if (bytes.length > CHAPTER_LIMITS.textBytes) throw new ChapterUnsupported("chapter-title-budget");
42
+ if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder("utf-16be", { fatal: true }).decode(bytes.subarray(2));
43
+ if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder("utf-16le", { fatal: true }).decode(bytes.subarray(2));
44
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/\0+$/, "");
45
+ }
46
+
47
+ const MAC_LANGUAGES = new Map([[0, "eng"], [1, "fra"], [2, "deu"], [3, "ita"],
48
+ [4, "nld"], [5, "swe"], [6, "spa"], [7, "dan"], [8, "por"], [9, "nor"], [13, "fin"], [32, "rus"]]);
49
+
50
+ function trackTitle(bytes, format, languageCode) {
51
+ if (bytes.length > CHAPTER_LIMITS.textBytes) throw new ChapterUnsupported("chapter-title-budget");
52
+ const bom = bytes.length >= 2 && ((bytes[0] === 0xfe && bytes[1] === 0xff) || (bytes[0] === 0xff && bytes[1] === 0xfe));
53
+ if (format === "tx3g" || bom || (languageCode >= 0x400 && languageCode !== 0x7fff)) return titleOf(bytes);
54
+ if (!MAC_LANGUAGES.has(languageCode)) throw new ChapterUnsupported(`chapter-text-encoding:${languageCode}`);
55
+ return new TextDecoder(languageCode === 32 ? "x-mac-cyrillic" : "macintosh", { fatal: true }).decode(bytes).replace(/\0+$/, "");
56
+ }
57
+
58
+ function chapter(id, start, end, title, language = "und", order = 0) {
59
+ return { id, parentId: null, order, startSeconds: start, endSeconds: end,
60
+ titles: [{ text: title, languages: [language], countries: [], languageSource: "mp4" }], hidden: false, enabled: true, children: [] };
61
+ }
62
+
63
+ async function nero(reader, field) {
64
+ const initial = await payload(reader, field, 4);
65
+ const version = initial[0];
66
+ if (version > 1) throw new ChapterUnsupported("chpl-version");
67
+ const prefix = version === 1 ? 9 : 5;
68
+ const count = (await payload(reader, field, prefix))[prefix - 1];
69
+ let at = field.dataOffset + prefix;
70
+ const chapters = [];
71
+ for (let index = 0; index < count; index++) {
72
+ if (at + 9 > field.end) throw new Error("Truncated chpl entry");
73
+ const head = await reader.bytes(at, 9);
74
+ const time = head.readBigUInt64BE(0), length = head[8];
75
+ at += 9;
76
+ if (at + length > field.end) throw new Error("Truncated chpl title");
77
+ chapters.push({ ...chapter(`mp4:chpl:${index}`, chapterSeconds(time, 10000000), null,
78
+ titleOf(await reader.bytes(at, length)), "und", index), timeTicks: String(time), timeScale: 10000000 });
79
+ at += length;
80
+ }
81
+ if (at !== field.end) throw new Error("Unexpected bytes after chpl entries");
82
+ return { id: "mp4:chpl", source: "chpl", ordered: false, chapters };
83
+ }
84
+
85
+ async function timedHeader(reader, field) {
86
+ const first = await payload(reader, field, 4);
87
+ if (first[0] > 1) throw new ChapterUnsupported("mp4-time-header-version");
88
+ const wide = first[0] === 1;
89
+ const data = await payload(reader, field, wide ? 32 : 20);
90
+ const scale = data.readUInt32BE(wide ? 20 : 12);
91
+ const duration = wide ? data.readBigUInt64BE(24) : BigInt(data.readUInt32BE(16));
92
+ if (scale === 0) throw new Error("Zero chapter time scale");
93
+ return { scale, duration, wide };
94
+ }
95
+
96
+ async function trackInfo(reader, field) {
97
+ const parts = await inside(reader, field);
98
+ const tkhd = one(parts, "tkhd");
99
+ const first = await payload(reader, tkhd, 4);
100
+ if (first[0] > 1) throw new ChapterUnsupported("tkhd-version");
101
+ const data = await payload(reader, tkhd, first[0] === 1 ? 24 : 16);
102
+ const id = data.readUInt32BE(first[0] === 1 ? 20 : 12);
103
+ const enabled = (first.readUIntBE(1, 3) & 1) !== 0;
104
+ const references = await inside(reader, one(parts, "tref"));
105
+ const refs = [];
106
+ for (const ref of references.filter((item) => item.type === "chap")) {
107
+ const size = ref.end - ref.dataOffset;
108
+ if (size % 4 || size / 4 > CHAPTER_LIMITS.entries) throw new Error("Invalid chap references");
109
+ const values = await payload(reader, ref, size);
110
+ for (let at = 0; at < size; at += 4) refs.push(values.readUInt32BE(at));
111
+ }
112
+ return { id, enabled, refs, parts };
113
+ }
114
+
115
+ async function sizesOf(reader, fields) {
116
+ const stsz = one(fields, "stsz");
117
+ if (stsz) {
118
+ const head = await payload(reader, stsz, 12);
119
+ const uniform = head.readUInt32BE(4), count = head.readUInt32BE(8);
120
+ if (count > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-sample-count");
121
+ if (stsz.dataOffset + 12 + (uniform ? 0 : 4 * count) !== stsz.end) throw new Error("Invalid chapter stsz");
122
+ const values = uniform ? null : await reader.bytes(stsz.dataOffset + 12, count * 4);
123
+ return Array.from({ length: count }, (_, index) => uniform || values.readUInt32BE(index * 4));
124
+ }
125
+ const compact = one(fields, "stz2");
126
+ const head = await payload(reader, compact, 12);
127
+ const width = head[7], count = head.readUInt32BE(8);
128
+ if (![4, 8, 16].includes(width)) throw new Error("Invalid stz2 field size");
129
+ if (count > CHAPTER_LIMITS.entries) throw new ChapterUnsupported("chapter-sample-count");
130
+ const length = Math.ceil(width * count / 8);
131
+ if (compact.dataOffset + 12 + length !== compact.end) throw new Error("Invalid chapter stz2");
132
+ const values = await reader.bytes(compact.dataOffset + 12, length);
133
+ return Array.from({ length: count }, (_, index) => width === 16 ? values.readUInt16BE(index * 2)
134
+ : width === 8 ? values[index] : index % 2 ? values[index >> 1] & 15 : values[index >> 1] >> 4);
135
+ }
136
+
137
+ async function editMap(reader, parts, movieScale, trackScale, trackDuration) {
138
+ const edts = await inside(reader, one(parts, "edts"));
139
+ const elst = one(edts, "elst");
140
+ if (!elst) return [{ media: 0, movie: 0, duration: chapterSeconds(trackDuration, trackScale), index: 0 }];
141
+ const version = (await payload(reader, elst, 4))[0];
142
+ if (version > 1) throw new ChapterUnsupported("chapter-edit-version");
143
+ const width = version === 1 ? 20 : 12;
144
+ const entries = await table(reader, elst, width);
145
+ let movie = 0;
146
+ const result = [];
147
+ for (let index = 0; index < entries.count; index++) {
148
+ const at = index * width;
149
+ const duration = chapterSeconds(version === 1 ? entries.bytes.readBigUInt64BE(at) : BigInt(entries.bytes.readUInt32BE(at)), movieScale);
150
+ const media = version === 1 ? entries.bytes.readBigInt64BE(at + 8) : BigInt(entries.bytes.readInt32BE(at + 4));
151
+ const rate = version === 1 ? at + 16 : at + 8;
152
+ if (entries.bytes.readInt16BE(rate) !== 1 || entries.bytes.readInt16BE(rate + 2) !== 0) throw new ChapterUnsupported("chapter-edit-rate");
153
+ if (media < -1n) throw new Error("Invalid chapter edit media time");
154
+ if (media !== -1n) result.push({ media: chapterSeconds(media, trackScale), movie, duration, index });
155
+ movie += duration;
156
+ }
157
+ return result;
158
+ }
159
+
160
+ async function quicktime(reader, track, movieScale) {
161
+ const mdia = await inside(reader, one(track.parts, "mdia"));
162
+ const timing = await timedHeader(reader, one(mdia, "mdhd"));
163
+ const mdhd = await payload(reader, one(mdia, "mdhd"), timing.wide ? 34 : 22);
164
+ const lang = mdhd.readUInt16BE(timing.wide ? 32 : 20);
165
+ let language = lang ? String.fromCharCode(((lang >> 10) & 31) + 96, ((lang >> 5) & 31) + 96, (lang & 31) + 96) : "und";
166
+ if (lang < 0x400) language = MAC_LANGUAGES.get(lang) ?? "und";
167
+ if (!/^[a-z]{3}$/.test(language)) language = "und";
168
+ const elng = one(mdia, "elng");
169
+ if (elng) {
170
+ await payload(reader, elng, 4);
171
+ language = titleOf(await reader.bytes(elng.dataOffset + 4, elng.end - elng.dataOffset - 4)) || language;
172
+ }
173
+ const minf = await inside(reader, one(mdia, "minf"));
174
+ const dinf = await inside(reader, one(minf, "dinf"));
175
+ const dref = one(dinf, "dref");
176
+ const dataRefs = dref ? await boxes(reader, dref.dataOffset + 8, dref.end) : [];
177
+ if (dref && (await payload(reader, dref, 8)).readUInt32BE(4) !== dataRefs.length) throw new Error("Chapter data reference count mismatch");
178
+ const fields = await inside(reader, one(minf, "stbl"));
179
+ const stsd = one(fields, "stsd");
180
+ if (!stsd) throw new Error("Chapter track has no sample descriptions");
181
+ const descriptions = await boxes(reader, stsd.dataOffset + 8, stsd.end);
182
+ if ((await payload(reader, stsd, 8)).readUInt32BE(4) !== descriptions.length) throw new Error("Chapter sample description count mismatch");
183
+ if (!descriptions.length) throw new Error("Chapter track has no sample description");
184
+ for (const description of descriptions) {
185
+ if (!["text", "tx3g"].includes(description.type)) throw new ChapterUnsupported(`chapter-sample-format:${description.type}`);
186
+ const reference = (await payload(reader, description, 8)).readUInt16BE(6);
187
+ const source = dataRefs[reference - 1];
188
+ if (!source || !["url ", "alis"].includes(source.type) || ((await payload(reader, source, 4)).readUIntBE(1, 3) & 1) === 0) {
189
+ throw new ChapterUnsupported("external-chapter-data-reference");
190
+ }
191
+ }
192
+ const sizes = await sizesOf(reader, fields);
193
+ const durations = await table(reader, one(fields, "stts"), 8);
194
+ const times = [];
195
+ let ticks = 0n;
196
+ for (let index = 0; index < durations.count; index++) {
197
+ const count = durations.bytes.readUInt32BE(index * 8), delta = BigInt(durations.bytes.readUInt32BE(index * 8 + 4));
198
+ if (times.length + count > sizes.length) throw new Error("stts exceeds chapter sample count");
199
+ for (let sample = 0; sample < count; sample++) { times.push({ start: ticks, end: ticks + delta }); ticks += delta; }
200
+ }
201
+ if (times.length !== sizes.length) throw new Error("stts does not cover chapter samples");
202
+ const ctts = one(fields, "ctts");
203
+ if (ctts) {
204
+ const offsets = await table(reader, ctts, 8);
205
+ if (offsets.head[0] > 1) throw new ChapterUnsupported("chapter-ctts-version");
206
+ let sample = 0;
207
+ for (let index = 0; index < offsets.count; index++) {
208
+ const count = offsets.bytes.readUInt32BE(index * 8);
209
+ const offset = BigInt(offsets.head[0] === 1 ? offsets.bytes.readInt32BE(index * 8 + 4) : offsets.bytes.readUInt32BE(index * 8 + 4));
210
+ if (sample + count > times.length) throw new Error("ctts exceeds chapter samples");
211
+ for (let n = 0; n < count; n++, sample++) { times[sample].start += offset; times[sample].end += offset; }
212
+ }
213
+ if (sample !== times.length) throw new Error("ctts does not cover chapter samples");
214
+ }
215
+ const chunksField = one(fields, "stco") ?? one(fields, "co64");
216
+ const wide = chunksField?.type === "co64";
217
+ const chunks = await table(reader, chunksField, wide ? 8 : 4);
218
+ const runs = await table(reader, one(fields, "stsc"), 12);
219
+ const locations = [];
220
+ const formats = [];
221
+ let run = 0, sample = 0;
222
+ const runAt = (index) => ({ first: runs.bytes.readUInt32BE(index * 12), count: runs.bytes.readUInt32BE(index * 12 + 4), description: runs.bytes.readUInt32BE(index * 12 + 8) });
223
+ if (chunks.count && (!runs.count || runAt(0).first !== 1)) throw new Error("Invalid first chapter chunk mapping");
224
+ for (let index = 0; index < runs.count; index++) {
225
+ const current = runAt(index);
226
+ if (!current.count || current.description < 1 || current.description > descriptions.length || current.first > chunks.count || (index && current.first <= runAt(index - 1).first)) throw new Error("Invalid chapter chunk mapping");
227
+ }
228
+ for (let index = 0; index < chunks.count; index++) {
229
+ if (run + 1 < runs.count && runAt(run + 1).first === index + 1) run++;
230
+ let at = wide ? Number(chunks.bytes.readBigUInt64BE(index * 8)) : chunks.bytes.readUInt32BE(index * 4);
231
+ for (let n = 0; n < runAt(run).count; n++, sample++) {
232
+ if (sample >= sizes.length || !Number.isSafeInteger(at) || at < 0 || at + sizes[sample] > reader.size) throw new Error("Chapter sample outside file or count");
233
+ locations.push(at); formats.push(descriptions[runAt(run).description - 1].type); at += sizes[sample];
234
+ }
235
+ }
236
+ if (locations.length !== sizes.length) throw new Error("Chunk table does not cover chapter samples");
237
+ const edits = await editMap(reader, track.parts, movieScale, timing.scale, timing.duration);
238
+ const chapters = [];
239
+ for (let index = 0; index < sizes.length; index++) {
240
+ if (sizes[index] < 2 || sizes[index] > CHAPTER_LIMITS.textBytes + 65536) throw new ChapterUnsupported("chapter-text-sample-size");
241
+ const head = await reader.bytes(locations[index], 2);
242
+ const length = head.readUInt16BE();
243
+ if (length + 2 > sizes[index]) throw new Error("Chapter text exceeds sample");
244
+ const title = trackTitle(await reader.bytes(locations[index] + 2, length), formats[index], lang);
245
+ const start = chapterSeconds(times[index].start, timing.scale), end = chapterSeconds(times[index].end, timing.scale);
246
+ for (const edit of edits) {
247
+ if (end <= edit.media || start >= edit.media + edit.duration) continue;
248
+ chapters.push(chapter(`mp4:track:${track.id}:sample:${index}:edit:${edit.index}`,
249
+ edit.movie + Math.max(start - edit.media, 0), edit.movie + Math.min(end - edit.media, edit.duration), title, language, chapters.length));
250
+ }
251
+ }
252
+ return { id: `mp4:track:${track.id}`, source: "quicktime", ordered: false, chapters };
253
+ }
254
+
255
+ export async function readMp4Chapters(container, held) {
256
+ const cached = container.moovHeld;
257
+ const originalRead = held.read;
258
+ const ranges = held.ranges ? [...held.ranges] : undefined;
259
+ if (cached?.offset !== undefined) ranges?.push([cached.offset, cached.offset + cached.moov.length - 1]);
260
+ const reader = new ChapterBytes({ ...held, ranges, read: (start, end) => {
261
+ if (cached?.offset !== undefined && start >= cached.offset && end < cached.offset + cached.moov.length) return Promise.resolve(cached.moov.subarray(start - cached.offset, end - cached.offset + 1));
262
+ return originalRead(start, end);
263
+ } }, container.fileSize);
264
+ let moov = null;
265
+ for (let at = 0; at < reader.size;) {
266
+ const field = await box(reader, at, reader.size);
267
+ if (field.type === "moov") { moov = field; break; }
268
+ at = field.end;
269
+ }
270
+ if (!moov) return chapterResult("absent");
271
+ const parts = await inside(reader, moov);
272
+ const udta = await inside(reader, one(parts, "udta"));
273
+ const chpl = one(udta, "chpl");
274
+ const editions = [];
275
+ if (chpl) editions.push(await nero(reader, chpl));
276
+ let pending = null;
277
+ try {
278
+ const tracks = [];
279
+ for (const field of parts.filter((part) => part.type === "trak")) tracks.push(await trackInfo(reader, field));
280
+ const referrer = tracks.find((track) => track.enabled && track.refs.length);
281
+ if (referrer) {
282
+ const track = tracks.find((item) => item.id === referrer.refs[0]);
283
+ if (!track) throw new Error("Referenced chapter track is missing");
284
+ const extensions = await inside(reader, one(parts, "mvex"));
285
+ for (const defaults of extensions.filter((item) => item.type === "trex")) {
286
+ if ((await payload(reader, defaults, 8)).readUInt32BE(4) === track.id) {
287
+ // Fragment samples are not described by the initial sample tables.
288
+ throw new ChapterUnsupported("chapter-fragmented-track");
289
+ }
290
+ }
291
+ const timing = await timedHeader(reader, one(parts, "mvhd"));
292
+ editions.unshift(await quicktime(reader, track, timing.scale));
293
+ }
294
+ } catch (error) {
295
+ if (!editions.length) throw error;
296
+ if (isUnavailable(error)) pending = { state: "pending", start: error.start, end: error.end };
297
+ else throw error;
298
+ }
299
+ const selected = editions[0];
300
+ if (!selected || !selected.chapters.length) return chapterResult(pending ? "pending" : "absent", { editions, pending,
301
+ selectedEditionId: selected?.id ?? null });
302
+ let duration = container.mediaInfo?.durationSeconds ?? null;
303
+ if (duration === null && one(parts, "mvhd")) {
304
+ const timing = await timedHeader(reader, one(parts, "mvhd"));
305
+ if (timing.duration !== (timing.wide ? 0xffffffffffffffffn : 0xffffffffn)) duration = chapterSeconds(timing.duration, timing.scale);
306
+ }
307
+ return chapterResult("available", { editions, selectedEditionId: selected.id, complete: pending === null,
308
+ pending, representationsDisagree: editions.length > 1 && JSON.stringify(editions[0].chapters.map((item) => [item.startSeconds, item.titles[0].text])) !== JSON.stringify(editions[1].chapters.map((item) => [item.startSeconds, item.titles[0].text])),
309
+ sections: navigationSections(selected, duration) });
310
+ }
@@ -0,0 +1,9 @@
1
+ /** HTTP validation without torrent startup or media parsing. */
2
+ export class ChapterController {
3
+ constructor({ sourceRegistry, chapters }) { this.sourceRegistry = sourceRegistry; this.chapters = chapters; }
4
+ get({ sourceKey, fileIndex }) {
5
+ if (!this.sourceRegistry.get(sourceKey)) return { status: 404, body: { code: "SOURCE_NOT_FOUND", error: "Source key was not found." } };
6
+ if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) return { status: 400, body: { error: "fileIndex must be a non-negative integer." } };
7
+ return { status: 200, body: { ...this.chapters.get(sourceKey, fileIndex), capability: "supported" } };
8
+ }
9
+ }
@@ -713,6 +713,17 @@ export function createDataChannelHandler({
713
713
  * @type {Map<string, Map<string, DataChannel | null>>}
714
714
  */
715
715
  const viewersOnConnection = new Map();
716
+ // One selected chapter file per channel; removed with the channel.
717
+ const chapterChannels = new Map();
718
+
719
+ function publishChaptersChanged(event) {
720
+ for (const [channel, selected] of chapterChannels) {
721
+ if (selected.sourceKey === event.sourceKey && selected.fileIndex === event.fileIndex) {
722
+ try { send(channel, { type: "chapters-changed", ...event }); }
723
+ catch (error) { log(`[dc] chapter notification failed: ${error.message}`); }
724
+ }
725
+ }
726
+ }
716
727
 
717
728
  /**
718
729
  * The connections a viewer is still named on, oldest first.
@@ -799,6 +810,7 @@ export function createDataChannelHandler({
799
810
  * @returns {void}
800
811
  */
801
812
  function forgetChannel(channel) {
813
+ chapterChannels.delete(channel);
802
814
  for (const viewers of viewersOnConnection.values()) {
803
815
  for (const [consumerId, on] of viewers) {
804
816
  if (on === channel) {
@@ -1162,6 +1174,16 @@ export function createDataChannelHandler({
1162
1174
  return;
1163
1175
  }
1164
1176
 
1177
+ const chapterPath = /^\/api\/sources\/([^/]+)\/chapters$/.exec(path);
1178
+ if (method === "GET" && chapterPath) {
1179
+ const rawIndex = new URLSearchParams(query ?? "").get("fileIndex");
1180
+ const fileIndex = rawIndex !== null && /^\d+$/.test(rawIndex) ? Number(rawIndex) : NaN;
1181
+ if (Number.isSafeInteger(fileIndex) && fileIndex >= 0) {
1182
+ try { chapterChannels.set(channel, { sourceKey: decodeURIComponent(chapterPath[1]), fileIndex }); }
1183
+ catch (error) { log(`[dc] chapter source key cannot be decoded: ${error.message}`); }
1184
+ }
1185
+ }
1186
+
1165
1187
 
1166
1188
  const queryInfo = query ? `?${query}` : "";
1167
1189
  const bodyInfo =
@@ -1464,7 +1486,8 @@ export function createDataChannelHandler({
1464
1486
  handleChannel,
1465
1487
  publishSubtitleCues,
1466
1488
  /** What the film costs viewers' links beyond itself, per byte of film, or null. */
1467
- serviceShare: () => deliveryShares.serviceShare()
1489
+ serviceShare: () => deliveryShares.serviceShare(),
1490
+ publishChaptersChanged
1468
1491
  };
1469
1492
  }
1470
1493