@torrent-tv/proxy 2.101.43 → 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 +2 -1
- package/package.json +1 -1
- package/routes/api/sources/chapters/get.js +8 -0
- package/server.js +29 -2
- package/services/media/ChapterOrchestrator.js +104 -0
- package/services/media/ContainerOrchestrator.js +13 -2
- package/services/media/container/Container.js +5 -0
- package/services/media/container/MatroskaContainer.js +4 -0
- package/services/media/container/Mp4Container.js +4 -0
- package/services/media/container/chapters.js +104 -0
- package/services/media/container/matroska-chapters.js +213 -0
- package/services/media/container/mp4-chapters.js +310 -0
- package/services/server/controllers/ChapterController.js +9 -0
- package/services/transport/data-channel-handler.js +24 -1
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
|
@@ -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.
|
|
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) =>
|
|
@@ -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
|
|