@torrent-tv/proxy 2.93.4 → 2.94.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/routes/api/sources/container-metadata/get.js +32 -0
- package/routes/api/sources/cover/get.js +30 -0
- package/server.js +31 -1
- package/services/media/ContainerOrchestrator.js +49 -1
- package/services/media/container/AviContainer.js +46 -0
- package/services/media/container/Container.js +49 -1
- package/services/media/container/MatroskaContainer.js +138 -0
- package/services/media/container/Mp4Container.js +40 -0
- package/services/media/container/matroska-work-tags.js +217 -0
- package/services/media/container/mp4-work-tags.js +220 -0
- package/services/media/container/work-tags.js +205 -0
- package/services/torrent/worker/worker.js +5 -2
package/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one file of a registered source states about the work it carries.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/sources/:sourceKey/files/:fileIndex/container-metadata
|
|
5
|
+
*
|
|
6
|
+
* Answers `200` with the file's statements (`services/media/container/work-tags.js`)
|
|
7
|
+
* once they are read, `202 { status: "pending" }` while the bytes at the edges of
|
|
8
|
+
* the file have not arrived (the page asks again), and `404` where the format
|
|
9
|
+
* states nothing this proxy reads or the file cannot be read. The reading never
|
|
10
|
+
* asks the swarm for anything beyond the first and the last piece of the file,
|
|
11
|
+
* which opening a file fetches anyway.
|
|
12
|
+
*
|
|
13
|
+
* @param {import("fastify").FastifyRequest} req
|
|
14
|
+
* @param {import("fastify").FastifyReply} reply
|
|
15
|
+
* @param {{
|
|
16
|
+
* sourceRegistry: { get: (key: string) => unknown },
|
|
17
|
+
* inspectWorkTags: (address: { sourceKey: string, fileIndex: number, requestId?: string }) => Promise<{ kind: string, value?: object, reason?: string }>
|
|
18
|
+
* }} deps
|
|
19
|
+
* @returns {Promise<void>}
|
|
20
|
+
*/
|
|
21
|
+
export async function handleApiSourceContainerMetadataGet(req, reply, { sourceRegistry, inspectWorkTags }) {
|
|
22
|
+
const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
23
|
+
const fileIndex = Number(req.params?.fileIndex);
|
|
24
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
25
|
+
return reply.code(400).send({ error: "sourceKey and a file index are required." });
|
|
26
|
+
}
|
|
27
|
+
if (!sourceRegistry.get(sourceKey)) return reply.code(404).send({ error: "Source key was not found." });
|
|
28
|
+
const result = await inspectWorkTags({ sourceKey, fileIndex, requestId: req.id });
|
|
29
|
+
if (result.kind === "result" && result.value) return reply.send(result.value);
|
|
30
|
+
if (result.kind === "terminal") return reply.code(404).send({ error: `This file states nothing about the work (${result.reason ?? "unavailable"}).` });
|
|
31
|
+
return reply.code(202).send({ status: "pending" });
|
|
32
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cover image one file of a registered source carries inside it.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/sources/:sourceKey/files/:fileIndex/cover
|
|
5
|
+
*
|
|
6
|
+
* Answers `200` with the image's bytes and its type, `202 { status: "pending" }`
|
|
7
|
+
* while they have not arrived, and `404` where the file carries none. Nothing
|
|
8
|
+
* keeps the image but the file itself: the page turns the bytes into a `blob:`
|
|
9
|
+
* address for as long as it shows them.
|
|
10
|
+
*
|
|
11
|
+
* @param {import("fastify").FastifyRequest} req
|
|
12
|
+
* @param {import("fastify").FastifyReply} reply
|
|
13
|
+
* @param {{
|
|
14
|
+
* sourceRegistry: { get: (key: string) => unknown },
|
|
15
|
+
* inspectCover: (address: { sourceKey: string, fileIndex: number, requestId?: string }) => Promise<{ kind: string, value?: { type: string, bytes: Buffer }, reason?: string }>
|
|
16
|
+
* }} deps
|
|
17
|
+
* @returns {Promise<void>}
|
|
18
|
+
*/
|
|
19
|
+
export async function handleApiSourceCoverGet(req, reply, { sourceRegistry, inspectCover }) {
|
|
20
|
+
const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
21
|
+
const fileIndex = Number(req.params?.fileIndex);
|
|
22
|
+
if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
23
|
+
return reply.code(400).send({ error: "sourceKey and a file index are required." });
|
|
24
|
+
}
|
|
25
|
+
if (!sourceRegistry.get(sourceKey)) return reply.code(404).send({ error: "Source key was not found." });
|
|
26
|
+
const result = await inspectCover({ sourceKey, fileIndex, requestId: req.id });
|
|
27
|
+
if (result.kind === "result" && result.value) return reply.type(result.value.type).send(result.value.bytes);
|
|
28
|
+
if (result.kind === "terminal") return reply.code(404).send({ error: `This file carries no cover (${result.reason ?? "unavailable"}).` });
|
|
29
|
+
return reply.code(202).send({ status: "pending" });
|
|
30
|
+
}
|
package/server.js
CHANGED
|
@@ -25,6 +25,8 @@ import { handleApiSourcesPost } from "./routes/api/sources/post.js";
|
|
|
25
25
|
import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
|
|
26
26
|
import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
|
|
27
27
|
import { handleApiSourceFingerprintGet } from "./routes/api/sources/fingerprint/get.js";
|
|
28
|
+
import { handleApiSourceContainerMetadataGet } from "./routes/api/sources/container-metadata/get.js";
|
|
29
|
+
import { handleApiSourceCoverGet } from "./routes/api/sources/cover/get.js";
|
|
28
30
|
import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
|
|
29
31
|
import { handleApiSourceViewerPost } from "./routes/api/sources/viewer/post.js";
|
|
30
32
|
import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
|
|
@@ -52,7 +54,7 @@ import { createPlaybackPlanner } from "./services/media/playback-planner.js";
|
|
|
52
54
|
import { KeyframeTables } from "./services/media/KeyframeTables.js";
|
|
53
55
|
import { contentsOf } from "./services/torrent/Contents.js";
|
|
54
56
|
import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
|
|
55
|
-
import { containerOrchestrator, CONTAINER_HEAD_BYTES } from "./services/media/ContainerOrchestrator.js";
|
|
57
|
+
import { containerOrchestrator, CONTAINER_HEAD_BYTES, describeWorkTags } from "./services/media/ContainerOrchestrator.js";
|
|
56
58
|
import { readPlaybackDeclarations } from "./services/media/read-playback-declarations.js";
|
|
57
59
|
import { DownloadMaps } from "./services/viewer/DownloadMaps.js";
|
|
58
60
|
import { nativeSourceMap } from "./services/viewer/NativeSourceMap.js";
|
|
@@ -516,6 +518,7 @@ export async function startProxyServer({
|
|
|
516
518
|
return probePackets({ url: url.toString(), statement, signal, onRecord });
|
|
517
519
|
} }),
|
|
518
520
|
label: String(file.name ?? ""),
|
|
521
|
+
fileOffset: file.offset,
|
|
519
522
|
onTracks: (tracks) => contentsOf(torrent).noteVideo(fileIndex, tracks.some((track) => track.type === "video")),
|
|
520
523
|
onReadStart: () => ({ storage: mediaReads.revision(sourceKey, fileIndex), memory: mediaReads.memoryRevision(), demand: downloadMaps.epoch(sourceKey, fileIndex) }),
|
|
521
524
|
onReadResult: async (statement, result, revision) => {
|
|
@@ -981,6 +984,33 @@ export async function startProxyServer({
|
|
|
981
984
|
}
|
|
982
985
|
})
|
|
983
986
|
);
|
|
987
|
+
// What the opened file states about the work, and its cover: read only from
|
|
988
|
+
// the edges of the file, which opening it fetches anyway (meta#139).
|
|
989
|
+
const workTagsLogged = new Set();
|
|
990
|
+
app.get("/api/sources/:sourceKey/files/:fileIndex/container-metadata", async (req, reply) =>
|
|
991
|
+
handleApiSourceContainerMetadataGet(req, reply, {
|
|
992
|
+
sourceRegistry,
|
|
993
|
+
inspectWorkTags: async (address) => {
|
|
994
|
+
const params = await containerOver(address);
|
|
995
|
+
const result = params ? await containerOrchestrator.inspect(params, "work-tags") : { kind: "pending" };
|
|
996
|
+
const key = `${address.sourceKey}:${address.fileIndex}`;
|
|
997
|
+
if (result.kind !== "pending" && result.kind !== "needs-ranges" && !workTagsLogged.has(key)) {
|
|
998
|
+
workTagsLogged.add(key);
|
|
999
|
+
logger.info(`container metadata "${params?.label ?? ""}": ${describeWorkTags(result)}`);
|
|
1000
|
+
}
|
|
1001
|
+
return result;
|
|
1002
|
+
}
|
|
1003
|
+
})
|
|
1004
|
+
);
|
|
1005
|
+
app.get("/api/sources/:sourceKey/files/:fileIndex/cover", async (req, reply) =>
|
|
1006
|
+
handleApiSourceCoverGet(req, reply, {
|
|
1007
|
+
sourceRegistry,
|
|
1008
|
+
inspectCover: async (address) => {
|
|
1009
|
+
const params = await containerOver(address);
|
|
1010
|
+
return params ? containerOrchestrator.inspect(params, "cover") : { kind: "pending" };
|
|
1011
|
+
}
|
|
1012
|
+
})
|
|
1013
|
+
);
|
|
984
1014
|
app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
|
|
985
1015
|
handleApiSourceWarmPost(req, reply, {
|
|
986
1016
|
sourceRegistry,
|
|
@@ -19,6 +19,7 @@ import { ContainerFactory } from "./container/ContainerFactory.js";
|
|
|
19
19
|
import { BytesUnavailable, isUnavailable, strictReader } from "./container/unavailable.js";
|
|
20
20
|
import { OSHASH_EDGE_BYTES, hasOshash, oshash } from "./oshash.js";
|
|
21
21
|
import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
|
|
22
|
+
import { OutsideReadableEdges } from "./container/work-tags.js";
|
|
22
23
|
import { logger } from "../../utils/logger.js";
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -45,6 +46,45 @@ async function readFingerprint({ readRange, fileSize }) {
|
|
|
45
46
|
return { hash: oshash(fileSize, head, tail), size: fileSize };
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Which bytes of a file a reading of what it states about the work may ask the
|
|
51
|
+
* swarm for: the pieces that hold its first and last bytes. Those are fetched
|
|
52
|
+
* when a file is opened anyway — its OpenSubtitles hash is read from both
|
|
53
|
+
* edges (`readFingerprint`) and its container head from the first — so a
|
|
54
|
+
* reading that stays inside them costs no download (torrent-tv/meta#139).
|
|
55
|
+
*
|
|
56
|
+
* Where the file lies in the torrent is what decides which pieces those are;
|
|
57
|
+
* without it, the first and the last piece length of the file are taken, which
|
|
58
|
+
* is never more than one piece beyond them at each end.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ fileSize: number, fileOffset?: number, portionBytes?: number }} params
|
|
61
|
+
* @returns {(start: number, end: number) => boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function edgesOf({ fileSize, fileOffset, portionBytes }) {
|
|
64
|
+
const piece = Number.isFinite(portionBytes) && portionBytes > 0 ? portionBytes : CONTAINER_HEAD_BYTES;
|
|
65
|
+
const offset = Number.isInteger(fileOffset) && fileOffset >= 0 ? fileOffset : null;
|
|
66
|
+
const headEnd = offset === null ? piece : (Math.floor(offset / piece) + 1) * piece - offset;
|
|
67
|
+
const tailStart = offset === null ? fileSize - piece : Math.floor((offset + fileSize - 1) / piece) * piece - offset;
|
|
68
|
+
return (start, end) => end < headEnd || start >= tailStart;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One log line's worth of what a reading of work tags found: which fields the
|
|
73
|
+
* file states, not their values, and whether something was left outside the
|
|
74
|
+
* edges — the two things a field check counts.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ kind: string, value?: object, reason?: string }} result
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function describeWorkTags(result) {
|
|
80
|
+
if (result.kind !== "result" || !result.value) return `none (${result.reason ?? result.kind})`;
|
|
81
|
+
const tags = result.value;
|
|
82
|
+
const stated = Object.entries(tags)
|
|
83
|
+
.filter(([key, value]) => key !== "outsideEdges" && (Array.isArray(value) ? value.length > 0 : value && typeof value === "object" ? Object.keys(value).length > 0 : value !== null))
|
|
84
|
+
.map(([key, value]) => (key === "externalIds" ? `ids:${Object.keys(value).join("+")}` : key));
|
|
85
|
+
return `${stated.length ? stated.join(" ") : "nothing stated"}${tags.outsideEdges ? " (some elements lie outside the edges and are not held)" : ""}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
48
88
|
export class ContainerOrchestrator {
|
|
49
89
|
#reads = new Map();
|
|
50
90
|
#lifetimes = new Map();
|
|
@@ -89,7 +129,7 @@ export class ContainerOrchestrator {
|
|
|
89
129
|
|
|
90
130
|
/** Read one statement without converting missing bytes into an empty answer. */
|
|
91
131
|
async inspect(params, statement = "tracks") {
|
|
92
|
-
if (!["container", "tracks", "media-info", "keyframes", "packets", "fingerprint"].includes(statement)) {
|
|
132
|
+
if (!["container", "tracks", "media-info", "keyframes", "packets", "fingerprint", "work-tags", "cover"].includes(statement)) {
|
|
93
133
|
throw new TypeError(`Unknown media statement: ${statement}`);
|
|
94
134
|
}
|
|
95
135
|
const key = `${params.sourceKey}:${params.fileIndex}`;
|
|
@@ -148,6 +188,9 @@ export class ContainerOrchestrator {
|
|
|
148
188
|
value = await container.readMediaInfo();
|
|
149
189
|
} else if (statement === "keyframes") {
|
|
150
190
|
value = await container.readKeyframeIndex();
|
|
191
|
+
} else if (statement === "work-tags" || statement === "cover") {
|
|
192
|
+
value = await (statement === "cover" ? container.readCover(edgesOf(params)) : container.readWorkTags(edgesOf(params)));
|
|
193
|
+
if (value === null) return { kind: "terminal", reason: statement === "cover" ? "no-cover" : "format-states-nothing", requestId };
|
|
151
194
|
} else if (statement === "packets") {
|
|
152
195
|
value = await container.readPacketIndex(params.packetInterval);
|
|
153
196
|
await value?.prepareAudioDependencies?.(params.packetInterval, container.readRange);
|
|
@@ -161,6 +204,11 @@ export class ContainerOrchestrator {
|
|
|
161
204
|
logger.info(`media request=${requestId} needs memory=${error.bytes}`);
|
|
162
205
|
return result;
|
|
163
206
|
}
|
|
207
|
+
if (error instanceof OutsideReadableEdges) {
|
|
208
|
+
// Not a fault and not "not here yet": the bytes lie where this reading
|
|
209
|
+
// may not ask for them. Asked again, it reads them once they are held.
|
|
210
|
+
return { kind: "terminal", reason: "outside-edges", requestId };
|
|
211
|
+
}
|
|
164
212
|
if (isUnavailable(error)) {
|
|
165
213
|
const result = { kind: "needs-ranges", ranges: [[error.start, error.end]], requestId };
|
|
166
214
|
logger.info(`media request=${requestId} needs bytes=${error.start}-${error.end}`);
|
|
@@ -14,6 +14,7 @@ import { isUnavailable } from "./unavailable.js";
|
|
|
14
14
|
import { Mpeg4PictureTiming } from "./mpeg4-picture-timing.js";
|
|
15
15
|
import { MpegElementaryIndex } from "./mpeg-elementary-index.js";
|
|
16
16
|
import { RetainedReads } from "./RetainedReads.js";
|
|
17
|
+
import { OutsideReadableEdges, edgeReader, emptyWorkTags, text, textList, yearOf } from "./work-tags.js";
|
|
17
18
|
|
|
18
19
|
const MPEG4_CODECS = new Set(["FMP4", "XVID", "DIVX", "DX50", "MP4V", "M4S2", "MP4S"]);
|
|
19
20
|
const MPEG_AUDIO_CODECS = new Set(["mp1", "mp2", "mp3"]);
|
|
@@ -25,6 +26,7 @@ export class AviContainer extends Container {
|
|
|
25
26
|
#packets = null;
|
|
26
27
|
#scan = null;
|
|
27
28
|
#declarations = null;
|
|
29
|
+
#workTags = null;
|
|
28
30
|
|
|
29
31
|
async #readHeaders() {
|
|
30
32
|
if (this.#headers) return this.#headers;
|
|
@@ -61,6 +63,50 @@ export class AviContainer extends Container {
|
|
|
61
63
|
get formatName() {
|
|
62
64
|
return "avi";
|
|
63
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* What the file states about the work, from the `LIST INFO` chunk of the
|
|
68
|
+
* RIFF (Microsoft, "Multimedia Programming Interface and Data
|
|
69
|
+
* Specifications 1.0", INFO list): `INAM` the title, `IGNR` the genre,
|
|
70
|
+
* `ICRD` the creation date, `ISBJ` the subject, `ICMT` comments.
|
|
71
|
+
*
|
|
72
|
+
* Looked for among the top-level chunks before `movi`, where writers put it;
|
|
73
|
+
* walking past `movi` would read the header of what follows it, which lies
|
|
74
|
+
* wherever the film ends. The specification states no character set, so a
|
|
75
|
+
* value that is not valid UTF-8 is left out rather than shown garbled.
|
|
76
|
+
*
|
|
77
|
+
* @param {(start: number, end: number) => boolean} mayFetch
|
|
78
|
+
* @returns {Promise<import("./work-tags.js").WorkTags>}
|
|
79
|
+
*/
|
|
80
|
+
async readWorkTags(mayFetch) {
|
|
81
|
+
if (this.#workTags) return this.#workTags;
|
|
82
|
+
const tags = emptyWorkTags();
|
|
83
|
+
const head = await this.readRange(0, Math.min(11, this.fileSize - 1));
|
|
84
|
+
if (!isAvi(head)) throw new Error("AVI RIFF header is absent.");
|
|
85
|
+
const end = Math.min(this.fileSize, 8 + head.readUInt32LE(4));
|
|
86
|
+
const read = edgeReader(this.readRange, mayFetch, isUnavailable);
|
|
87
|
+
try {
|
|
88
|
+
const top = await riffChunks(read, 12, end, "movi");
|
|
89
|
+
const info = top.find((chunk) => chunk.id === "LIST" && chunk.type === "INFO");
|
|
90
|
+
const fields = new Map();
|
|
91
|
+
for (const field of info ? await riffChunks(read, info.start + 4, info.end) : []) {
|
|
92
|
+
if (field.end <= field.start || fields.has(field.id)) continue;
|
|
93
|
+
const value = (await read(field.start, field.end - 1)).toString("utf8");
|
|
94
|
+
// U+FFFD is what the decoder puts where the bytes were not UTF-8.
|
|
95
|
+
if (!value.includes(String.fromCodePoint(0xfffd))) fields.set(field.id, text(value));
|
|
96
|
+
}
|
|
97
|
+
tags.title = fields.get("INAM") ?? null;
|
|
98
|
+
tags.genres = textList([fields.get("IGNR")]);
|
|
99
|
+
tags.year = yearOf(fields.get("ICRD"));
|
|
100
|
+
tags.description = fields.get("ISBJ") ?? fields.get("ICMT") ?? null;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (!(error instanceof OutsideReadableEdges)) throw error;
|
|
103
|
+
tags.outsideEdges = true;
|
|
104
|
+
}
|
|
105
|
+
tags.trackTitles = await this.trackTitlesForWork();
|
|
106
|
+
if (!tags.outsideEdges) this.#workTags = tags;
|
|
107
|
+
return tags;
|
|
108
|
+
}
|
|
109
|
+
|
|
64
110
|
|
|
65
111
|
packetIndexBytes() { return (this.#packets ?? this.#scan?.index)?.allocatedBytes() ?? 0; }
|
|
66
112
|
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* - AVI RIFF §: LIST hdrl, idx1
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { strictReader } from "./unavailable.js";
|
|
15
|
+
import { isUnavailable, strictReader } from "./unavailable.js";
|
|
16
|
+
import { textList } from "./work-tags.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* What one file declares about itself. Every field is either a value the
|
|
@@ -495,6 +496,53 @@ export class Container {
|
|
|
495
496
|
return this.mediaInfo;
|
|
496
497
|
}
|
|
497
498
|
|
|
499
|
+
/**
|
|
500
|
+
* What the file states about the WORK it carries — title, series, season,
|
|
501
|
+
* episode, year, genre, description, ids, track and chapter titles, and
|
|
502
|
+
* whether it has a cover — in the one shape of `work-tags.js`.
|
|
503
|
+
*
|
|
504
|
+
* `mayFetch` says which bytes this reading may ask the swarm for: the edges
|
|
505
|
+
* of the file, which playback fetches anyway. Anything elsewhere is read only
|
|
506
|
+
* if already held, and left out otherwise (`outsideEdges`).
|
|
507
|
+
*
|
|
508
|
+
* `null` means this format states nothing about the work that this proxy
|
|
509
|
+
* reads — a final answer about the format, not "nobody has looked yet".
|
|
510
|
+
*
|
|
511
|
+
* @param {(start: number, end: number) => boolean} _mayFetch
|
|
512
|
+
* @returns {Promise<import("./work-tags.js").WorkTags | null>}
|
|
513
|
+
*/
|
|
514
|
+
async readWorkTags(_mayFetch) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The titles of the file's tracks, for what it states about its work. A
|
|
520
|
+
* track table this program refuses to read costs only the titles: the
|
|
521
|
+
* statements about the work are read from other elements. Bytes that have
|
|
522
|
+
* not arrived are still "not here yet".
|
|
523
|
+
*
|
|
524
|
+
* @returns {Promise<string[]>}
|
|
525
|
+
*/
|
|
526
|
+
async trackTitlesForWork() {
|
|
527
|
+
try {
|
|
528
|
+
return textList((await this.readTracks()).map((track) => track.name));
|
|
529
|
+
} catch (error) {
|
|
530
|
+
if (isUnavailable(error)) throw error;
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The cover image the file carries, checked to be an image by its bytes.
|
|
537
|
+
* `null` where it carries none.
|
|
538
|
+
*
|
|
539
|
+
* @param {(start: number, end: number) => boolean} _mayFetch
|
|
540
|
+
* @returns {Promise<{ type: string, bytes: Buffer } | null>}
|
|
541
|
+
*/
|
|
542
|
+
async readCover(_mayFetch) {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
|
|
498
546
|
/**
|
|
499
547
|
* Subtitle-specific: where cues live (Matroska cluster positions or MP4 sample ranges).
|
|
500
548
|
* Returned via track objects' clusterPositions/samples, so base has no extra method — tracks carry it.
|
|
@@ -45,6 +45,14 @@ import { ElementReader } from "./ebml-stream.js";
|
|
|
45
45
|
import { firstFrameOffset, readBlockHeader, walkHeldClusters } from "./matroska-clusters.js";
|
|
46
46
|
import { readMatroskaPackets } from "./matroska-packets.js";
|
|
47
47
|
import { RetainedReads } from "./RetainedReads.js";
|
|
48
|
+
import { isUnavailable } from "./unavailable.js";
|
|
49
|
+
import {
|
|
50
|
+
ID_ATTACHED_FILE, ID_ATTACHMENTS, ID_CHAPTERS, ID_FILE_DATA, ID_FILE_MEDIA_TYPE, ID_FILE_NAME, ID_INFO_TITLE, ID_TAGS,
|
|
51
|
+
chapterTitlesOf, coverIndexOf, workFromTags
|
|
52
|
+
} from "./matroska-work-tags.js";
|
|
53
|
+
import {
|
|
54
|
+
COVER_TYPES, MAX_COVER_BYTES, OutsideReadableEdges, edgeReader, emptyWorkTags, imageTypeOf, isNumberingOnly, text, textList
|
|
55
|
+
} from "./work-tags.js";
|
|
48
56
|
|
|
49
57
|
const ID_EBML = 0x1a45dfa3;
|
|
50
58
|
const ID_SEGMENT = 0x18538067;
|
|
@@ -124,6 +132,10 @@ function readString(buf, el) {
|
|
|
124
132
|
* the walk to the first cluster or from the SeekHead.
|
|
125
133
|
* @property {number | null} tracksAt
|
|
126
134
|
* @property {number | null} cuesAt
|
|
135
|
+
* @property {number | null} tagsAt
|
|
136
|
+
* @property {number | null} chaptersAt
|
|
137
|
+
* @property {number | null} attachmentsAt
|
|
138
|
+
* @property {string | null} segmentTitle - `Info/Title`, RFC 9559 §5.1.2.12.
|
|
127
139
|
* @property {number | null} firstClusterAt
|
|
128
140
|
*/
|
|
129
141
|
|
|
@@ -145,6 +157,15 @@ export class MatroskaContainer extends Container {
|
|
|
145
157
|
*/
|
|
146
158
|
#cues = undefined;
|
|
147
159
|
|
|
160
|
+
/** @type {import("./work-tags.js").WorkTags | null} */
|
|
161
|
+
#workTags = null;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Where the cover's data lies: undefined until looked for, null for none.
|
|
165
|
+
* @type {{ at: number, size: number, mediaType: string } | null | undefined}
|
|
166
|
+
*/
|
|
167
|
+
#coverAt = undefined;
|
|
168
|
+
|
|
148
169
|
get formatName() {
|
|
149
170
|
return "matroska";
|
|
150
171
|
}
|
|
@@ -293,6 +314,7 @@ export class MatroskaContainer extends Container {
|
|
|
293
314
|
const positionOf = (id) => found.get(id) ?? seeks.get(id) ?? null;
|
|
294
315
|
|
|
295
316
|
let secondsPerTick = DEFAULT_TIMESTAMP_SCALE / 1e9;
|
|
317
|
+
let segmentTitle = null;
|
|
296
318
|
const infoAt = positionOf(ID_INFO);
|
|
297
319
|
if (infoAt !== null) {
|
|
298
320
|
const info = await reader.header(infoAt, segmentEnd);
|
|
@@ -304,6 +326,8 @@ export class MatroskaContainer extends Container {
|
|
|
304
326
|
if (scale > 0) {
|
|
305
327
|
secondsPerTick = scale / 1e9;
|
|
306
328
|
}
|
|
329
|
+
} else if (field.id === ID_INFO_TITLE) {
|
|
330
|
+
segmentTitle = readString(data, field);
|
|
307
331
|
}
|
|
308
332
|
}
|
|
309
333
|
}
|
|
@@ -315,6 +339,10 @@ export class MatroskaContainer extends Container {
|
|
|
315
339
|
infoAt,
|
|
316
340
|
tracksAt: positionOf(ID_TRACKS),
|
|
317
341
|
cuesAt: positionOf(ID_CUES),
|
|
342
|
+
tagsAt: positionOf(ID_TAGS),
|
|
343
|
+
chaptersAt: positionOf(ID_CHAPTERS),
|
|
344
|
+
attachmentsAt: positionOf(ID_ATTACHMENTS),
|
|
345
|
+
segmentTitle,
|
|
318
346
|
firstClusterAt: firstClusterAt ?? seeks.get(ID_CLUSTER) ?? null
|
|
319
347
|
};
|
|
320
348
|
return this.#layout;
|
|
@@ -366,6 +394,116 @@ export class MatroskaContainer extends Container {
|
|
|
366
394
|
return info;
|
|
367
395
|
}
|
|
368
396
|
|
|
397
|
+
/**
|
|
398
|
+
* What the file states about the work: `Info/Title`, the `Tags` that apply
|
|
399
|
+
* to the whole file, the titles of its chapters and tracks, and whether it
|
|
400
|
+
* carries a cover (see `matroska-work-tags.js`).
|
|
401
|
+
*
|
|
402
|
+
* Each element is read on its own, so one that lies outside the bytes this
|
|
403
|
+
* reading may fetch costs only itself. A reading that left something out is
|
|
404
|
+
* not kept: the bytes may be held by the next ask.
|
|
405
|
+
*
|
|
406
|
+
* @param {(start: number, end: number) => boolean} mayFetch
|
|
407
|
+
* @returns {Promise<import("./work-tags.js").WorkTags>}
|
|
408
|
+
*/
|
|
409
|
+
async readWorkTags(mayFetch) {
|
|
410
|
+
if (this.#workTags) return this.#workTags;
|
|
411
|
+
const tags = emptyWorkTags();
|
|
412
|
+
const layout = await this.#segmentLayout();
|
|
413
|
+
if (!layout) {
|
|
414
|
+
this.#workTags = tags;
|
|
415
|
+
return tags;
|
|
416
|
+
}
|
|
417
|
+
tags.segmentTitle = text(layout.segmentTitle);
|
|
418
|
+
const reader = new ElementReader({ read: edgeReader(this.readRange, mayFetch, isUnavailable), fileSize: this.fileSize, portionBytes: this.portionBytes });
|
|
419
|
+
const leftOut = (error) => {
|
|
420
|
+
if (!(error instanceof OutsideReadableEdges)) throw error;
|
|
421
|
+
tags.outsideEdges = true;
|
|
422
|
+
};
|
|
423
|
+
const dataOf = async (at, id) => {
|
|
424
|
+
if (at === null) return null;
|
|
425
|
+
const header = await reader.header(at, layout.segmentEnd);
|
|
426
|
+
return header && header.id === id ? reader.data(header) : null;
|
|
427
|
+
};
|
|
428
|
+
try {
|
|
429
|
+
const data = await dataOf(layout.tagsAt, ID_TAGS);
|
|
430
|
+
if (data) Object.assign(tags, workFromTags(data));
|
|
431
|
+
} catch (error) { leftOut(error); }
|
|
432
|
+
try {
|
|
433
|
+
const data = await dataOf(layout.chaptersAt, ID_CHAPTERS);
|
|
434
|
+
if (data) tags.chapterTitles = textList(chapterTitlesOf(data).filter((title) => !isNumberingOnly(title)));
|
|
435
|
+
} catch (error) { leftOut(error); }
|
|
436
|
+
try {
|
|
437
|
+
const cover = await this.#coverAddress(reader, layout);
|
|
438
|
+
if (cover && cover.size > 0 && cover.size <= MAX_COVER_BYTES) tags.cover = { type: cover.mediaType, size: cover.size };
|
|
439
|
+
} catch (error) { leftOut(error); }
|
|
440
|
+
tags.trackTitles = await this.trackTitlesForWork();
|
|
441
|
+
if (!tags.outsideEdges) this.#workTags = tags;
|
|
442
|
+
return tags;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The cover the file carries as an attachment, its bytes checked to be the
|
|
447
|
+
* image its type says. `null` where there is none, it is larger than
|
|
448
|
+
* {@link MAX_COVER_BYTES}, or its bytes are not an image a browser shows.
|
|
449
|
+
*
|
|
450
|
+
* @param {(start: number, end: number) => boolean} mayFetch
|
|
451
|
+
* @returns {Promise<{ type: string, bytes: Buffer } | null>}
|
|
452
|
+
*/
|
|
453
|
+
async readCover(mayFetch) {
|
|
454
|
+
const layout = await this.#segmentLayout();
|
|
455
|
+
if (!layout) return null;
|
|
456
|
+
const read = edgeReader(this.readRange, mayFetch, isUnavailable);
|
|
457
|
+
const reader = new ElementReader({ read, fileSize: this.fileSize, portionBytes: this.portionBytes });
|
|
458
|
+
const cover = await this.#coverAddress(reader, layout);
|
|
459
|
+
if (!cover || cover.size === 0 || cover.size > MAX_COVER_BYTES) return null;
|
|
460
|
+
const bytes = Buffer.from(await read(cover.at, cover.at + cover.size - 1));
|
|
461
|
+
const type = imageTypeOf(bytes);
|
|
462
|
+
return type && COVER_TYPES.has(type) ? { type, bytes } : null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Where the cover's data lies, from the headers of the attachments: each
|
|
467
|
+
* `AttachedFile`'s name and media type are read, its data never — fonts of
|
|
468
|
+
* an anime release are megabytes each.
|
|
469
|
+
*
|
|
470
|
+
* @param {ElementReader} reader
|
|
471
|
+
* @param {SegmentLayout} layout
|
|
472
|
+
* @returns {Promise<{ at: number, size: number, mediaType: string } | null>}
|
|
473
|
+
*/
|
|
474
|
+
async #coverAddress(reader, layout) {
|
|
475
|
+
if (this.#coverAt !== undefined) return this.#coverAt;
|
|
476
|
+
if (layout.attachmentsAt === null) return (this.#coverAt = null);
|
|
477
|
+
const attachments = await reader.header(layout.attachmentsAt, layout.segmentEnd);
|
|
478
|
+
if (!attachments || attachments.id !== ID_ATTACHMENTS || attachments.end === null) return (this.#coverAt = null);
|
|
479
|
+
const files = [];
|
|
480
|
+
for (let at = attachments.dataOffset; at < attachments.end;) {
|
|
481
|
+
const file = await reader.header(at, attachments.end);
|
|
482
|
+
if (!file || file.end === null) break;
|
|
483
|
+
if (file.id === ID_ATTACHED_FILE) {
|
|
484
|
+
const found = { name: "", mediaType: "", at: null, size: 0 };
|
|
485
|
+
for (let inner = file.dataOffset; inner < file.end;) {
|
|
486
|
+
const field = await reader.header(inner, file.end);
|
|
487
|
+
if (!field || field.end === null) break;
|
|
488
|
+
if (field.id === ID_FILE_NAME || field.id === ID_FILE_MEDIA_TYPE) {
|
|
489
|
+
const value = (await reader.data(field))?.toString("utf8").replace(/\0+$/u, "") ?? "";
|
|
490
|
+
if (field.id === ID_FILE_NAME) found.name = value;
|
|
491
|
+
else found.mediaType = value.toLowerCase();
|
|
492
|
+
} else if (field.id === ID_FILE_DATA) {
|
|
493
|
+
found.at = field.dataOffset;
|
|
494
|
+
found.size = field.size ?? 0;
|
|
495
|
+
}
|
|
496
|
+
inner = field.end;
|
|
497
|
+
}
|
|
498
|
+
if (found.at !== null) files.push(found);
|
|
499
|
+
}
|
|
500
|
+
at = file.end;
|
|
501
|
+
}
|
|
502
|
+
const index = coverIndexOf(files);
|
|
503
|
+
this.#coverAt = index < 0 ? null : { at: files[index].at, size: files[index].size, mediaType: files[index].mediaType || "image/jpeg" };
|
|
504
|
+
return this.#coverAt;
|
|
505
|
+
}
|
|
506
|
+
|
|
369
507
|
/**
|
|
370
508
|
* Every track the file declares, in its order, read once.
|
|
371
509
|
*
|
|
@@ -27,6 +27,8 @@ import { h264Configuration } from "./h264-configuration.js";
|
|
|
27
27
|
import { hevcConfiguration } from "./hevc-configuration.js";
|
|
28
28
|
import { mpegAudioFrame } from "./mpeg-audio-frame.js";
|
|
29
29
|
import { readMp4Fragments } from "./mp4-fragments.js";
|
|
30
|
+
import { workFromMoov } from "./mp4-work-tags.js";
|
|
31
|
+
import { COVER_TYPES, MAX_COVER_BYTES, emptyWorkTags, imageTypeOf } from "./work-tags.js";
|
|
30
32
|
|
|
31
33
|
/** A box header is eight bytes, or sixteen when the size field says 1 (§4.2). */
|
|
32
34
|
const HEADER_BYTES = 8;
|
|
@@ -513,6 +515,44 @@ export class Mp4Container extends Container {
|
|
|
513
515
|
return this.moovHeld;
|
|
514
516
|
}
|
|
515
517
|
|
|
518
|
+
/**
|
|
519
|
+
* What the file states about the work, from the `moov` box this class
|
|
520
|
+
* already holds for its tracks (see `mp4-work-tags.js`). The box is a read
|
|
521
|
+
* playback makes anyway, so `mayFetch` has nothing to decide here.
|
|
522
|
+
*
|
|
523
|
+
* @param {(start: number, end: number) => boolean} _mayFetch
|
|
524
|
+
* @returns {Promise<import("./work-tags.js").WorkTags>}
|
|
525
|
+
*/
|
|
526
|
+
async readWorkTags(_mayFetch) {
|
|
527
|
+
if (this.workTags) return this.workTags;
|
|
528
|
+
const tags = emptyWorkTags();
|
|
529
|
+
const held = await this.#moovBuffer();
|
|
530
|
+
if (held) {
|
|
531
|
+
const { work, cover } = workFromMoov(held.moov, held.header);
|
|
532
|
+
Object.assign(tags, work);
|
|
533
|
+
if (cover && cover.size > 0 && cover.size <= MAX_COVER_BYTES) tags.cover = { type: cover.type, size: cover.size };
|
|
534
|
+
}
|
|
535
|
+
tags.trackTitles = await this.trackTitlesForWork();
|
|
536
|
+
this.workTags = tags;
|
|
537
|
+
return tags;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The cover the item list carries, its bytes checked to be the image its
|
|
542
|
+
* type says. `null` where there is none or it is larger than {@link MAX_COVER_BYTES}.
|
|
543
|
+
*
|
|
544
|
+
* @param {(start: number, end: number) => boolean} _mayFetch
|
|
545
|
+
* @returns {Promise<{ type: string, bytes: Buffer } | null>}
|
|
546
|
+
*/
|
|
547
|
+
async readCover(_mayFetch) {
|
|
548
|
+
const held = await this.#moovBuffer();
|
|
549
|
+
const cover = held ? workFromMoov(held.moov, held.header).cover : null;
|
|
550
|
+
if (!cover || cover.size === 0 || cover.size > MAX_COVER_BYTES) return null;
|
|
551
|
+
const bytes = Buffer.from(held.moov.subarray(cover.at, cover.at + cover.size));
|
|
552
|
+
const type = imageTypeOf(bytes);
|
|
553
|
+
return type && COVER_TYPES.has(type) ? { type, bytes } : null;
|
|
554
|
+
}
|
|
555
|
+
|
|
516
556
|
/** Exact decode-order samples from the same moov used for all metadata. */
|
|
517
557
|
async readPacketIndex() {
|
|
518
558
|
if (this.packetIndex) return this.packetIndex;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a Matroska file states about the work, per the Matroska tagging
|
|
3
|
+
* specification (matroska.org, "Tags" and "Tagging"), RFC 9559 §5.1.7
|
|
4
|
+
* (Chapters), §5.1.6 (Attachments) and §5.1.8 (Tags).
|
|
5
|
+
*
|
|
6
|
+
* A `Tag` applies to what its `Targets` name. `TargetTypeValue` states the
|
|
7
|
+
* level (70 a collection, 60 a season or volume, 50 an episode or a film, 30 a
|
|
8
|
+
* chapter or a scene) and is 50 when absent. A `Tag` whose `Targets` name a
|
|
9
|
+
* particular track, edition, chapter or attachment by a non-zero UID describes
|
|
10
|
+
* that one only — mkvmerge's per-track statistics are of this kind — and says
|
|
11
|
+
* nothing about the work.
|
|
12
|
+
*
|
|
13
|
+
* Parsing over buffers already read; where the bytes come from is the
|
|
14
|
+
* container's business.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { iterateElements, readUint } from "./ebml-reader.js";
|
|
18
|
+
import { externalIdsOf, text, textList, wholeNumber, yearOf } from "./work-tags.js";
|
|
19
|
+
|
|
20
|
+
export const ID_TAGS = 0x1254c367;
|
|
21
|
+
export const ID_CHAPTERS = 0x1043a770;
|
|
22
|
+
export const ID_ATTACHMENTS = 0x1941a469;
|
|
23
|
+
export const ID_INFO_TITLE = 0x7ba9;
|
|
24
|
+
|
|
25
|
+
const ID_TAG = 0x7373;
|
|
26
|
+
const ID_TARGETS = 0x63c0;
|
|
27
|
+
const ID_TARGET_TYPE_VALUE = 0x68ca;
|
|
28
|
+
const TARGET_UIDS = new Set([0x63c5, 0x63c9, 0x63c4, 0x63c6]);
|
|
29
|
+
const ID_SIMPLE_TAG = 0x67c8;
|
|
30
|
+
const ID_TAG_NAME = 0x45a3;
|
|
31
|
+
const ID_TAG_STRING = 0x4487;
|
|
32
|
+
const ID_TAG_DEFAULT = 0x4484;
|
|
33
|
+
|
|
34
|
+
const ID_EDITION_ENTRY = 0x45b9;
|
|
35
|
+
const ID_CHAPTER_ATOM = 0xb6;
|
|
36
|
+
const ID_CHAPTER_DISPLAY = 0x80;
|
|
37
|
+
const ID_CHAP_STRING = 0x85;
|
|
38
|
+
const ID_CHAPTER_FLAG_HIDDEN = 0x98;
|
|
39
|
+
|
|
40
|
+
export const ID_ATTACHED_FILE = 0x61a7;
|
|
41
|
+
export const ID_FILE_NAME = 0x466e;
|
|
42
|
+
export const ID_FILE_MEDIA_TYPE = 0x4660;
|
|
43
|
+
export const ID_FILE_DATA = 0x465c;
|
|
44
|
+
|
|
45
|
+
/** The level a `Tag` applies to when its `Targets` say nothing (Matroska tagging). */
|
|
46
|
+
const DEFAULT_TARGET_TYPE_VALUE = 50;
|
|
47
|
+
|
|
48
|
+
const string = (buffer, element) => buffer.toString("utf8", element.dataOffset, element.dataOffset + element.size).replace(/\0+$/u, "");
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every `SimpleTag` of one `Tag`, nested ones included, as name and value.
|
|
52
|
+
*
|
|
53
|
+
* @param {Buffer} buffer
|
|
54
|
+
* @param {number} start
|
|
55
|
+
* @param {number} end
|
|
56
|
+
* @param {Array<{ name: string, value: string, isDefault: boolean }>} out
|
|
57
|
+
*/
|
|
58
|
+
function simpleTags(buffer, start, end, out) {
|
|
59
|
+
for (const element of iterateElements(buffer, start, end)) {
|
|
60
|
+
if (element.id !== ID_SIMPLE_TAG) continue;
|
|
61
|
+
const limit = Math.min(end, element.dataOffset + element.size);
|
|
62
|
+
let name = null;
|
|
63
|
+
let value = null;
|
|
64
|
+
let isDefault = true;
|
|
65
|
+
for (const field of iterateElements(buffer, element.dataOffset, limit)) {
|
|
66
|
+
if (field.id === ID_TAG_NAME) name = string(buffer, field).toUpperCase();
|
|
67
|
+
else if (field.id === ID_TAG_STRING) value = string(buffer, field);
|
|
68
|
+
else if (field.id === ID_TAG_DEFAULT) isDefault = readUint(buffer, field.dataOffset, field.size) !== 0;
|
|
69
|
+
}
|
|
70
|
+
if (name && value !== null) out.push({ name, value, isDefault });
|
|
71
|
+
simpleTags(buffer, element.dataOffset, limit, out);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The `Tag`s of a `Tags` element that describe the work, grouped by level.
|
|
77
|
+
*
|
|
78
|
+
* @param {Buffer} data - The data of a `Tags` element.
|
|
79
|
+
* @returns {Map<number, Array<{ name: string, value: string, isDefault: boolean }>>}
|
|
80
|
+
*/
|
|
81
|
+
export function tagsByLevel(data) {
|
|
82
|
+
/** @type {Map<number, Array<{ name: string, value: string, isDefault: boolean }>>} */
|
|
83
|
+
const levels = new Map();
|
|
84
|
+
for (const tag of iterateElements(data)) {
|
|
85
|
+
if (tag.id !== ID_TAG) continue;
|
|
86
|
+
const end = Math.min(data.length, tag.dataOffset + tag.size);
|
|
87
|
+
let level = DEFAULT_TARGET_TYPE_VALUE;
|
|
88
|
+
let particular = false;
|
|
89
|
+
for (const element of iterateElements(data, tag.dataOffset, end)) {
|
|
90
|
+
if (element.id !== ID_TARGETS) continue;
|
|
91
|
+
for (const field of iterateElements(data, element.dataOffset, Math.min(end, element.dataOffset + element.size))) {
|
|
92
|
+
if (field.id === ID_TARGET_TYPE_VALUE) level = readUint(data, field.dataOffset, field.size);
|
|
93
|
+
else if (TARGET_UIDS.has(field.id) && readUint(data, field.dataOffset, field.size) !== 0) particular = true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (particular) continue;
|
|
97
|
+
const found = [];
|
|
98
|
+
simpleTags(data, tag.dataOffset, end, found);
|
|
99
|
+
if (!levels.has(level)) levels.set(level, []);
|
|
100
|
+
levels.get(level).push(...found);
|
|
101
|
+
}
|
|
102
|
+
return levels;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The values one level states for one name, the default one first.
|
|
107
|
+
*
|
|
108
|
+
* @param {Map<number, Array<{ name: string, value: string, isDefault: boolean }>>} levels
|
|
109
|
+
* @param {number} level
|
|
110
|
+
* @param {string[]} names
|
|
111
|
+
* @returns {string[]}
|
|
112
|
+
*/
|
|
113
|
+
function valuesOf(levels, level, names) {
|
|
114
|
+
const at = (levels.get(level) ?? []).filter((tag) => names.includes(tag.name));
|
|
115
|
+
return [...at.filter((tag) => tag.isDefault), ...at.filter((tag) => !tag.isDefault)].map((tag) => tag.value);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What the `Tags` element states about the work.
|
|
120
|
+
*
|
|
121
|
+
* An episode is recognised by what the file states, never by a name: a season
|
|
122
|
+
* number (`PART_NUMBER` at level 60) or an episode number (`PART_NUMBER` at
|
|
123
|
+
* level 50). For an episode the level-50 `TITLE` is the episode's and the
|
|
124
|
+
* level-70 one is the series'; otherwise the level-50 `TITLE` is the film's.
|
|
125
|
+
* The year of a series is its level-70 date; a level-50 date of an episode is
|
|
126
|
+
* the episode's and is kept apart.
|
|
127
|
+
*
|
|
128
|
+
* @param {Buffer} data
|
|
129
|
+
* @returns {Partial<import("./work-tags.js").WorkTags>}
|
|
130
|
+
*/
|
|
131
|
+
export function workFromTags(data) {
|
|
132
|
+
const levels = tagsByLevel(data);
|
|
133
|
+
const first = (level, ...names) => valuesOf(levels, level, names)[0];
|
|
134
|
+
const season = wholeNumber(first(60, "PART_NUMBER"), 0, 999);
|
|
135
|
+
const episode = wholeNumber(first(50, "PART_NUMBER"), 0, 9999);
|
|
136
|
+
const isEpisode = season !== null || episode !== null;
|
|
137
|
+
const itemTitles = valuesOf(levels, 50, ["TITLE"]);
|
|
138
|
+
const collectionTitles = valuesOf(levels, 70, ["TITLE"]);
|
|
139
|
+
const itemDate = first(50, "DATE_RELEASED", "DATE_RECORDED", "DATE_ENCODED");
|
|
140
|
+
const collectionDate = first(70, "DATE_RELEASED", "DATE_RECORDED");
|
|
141
|
+
const statedAt = (...names) => [70, 60, 50, 30].flatMap((level) => valuesOf(levels, level, names));
|
|
142
|
+
return {
|
|
143
|
+
title: text(isEpisode ? collectionTitles[0] : itemTitles[0]),
|
|
144
|
+
otherTitles: textList(isEpisode ? collectionTitles.slice(1) : itemTitles.slice(1)),
|
|
145
|
+
seriesTitle: isEpisode ? text(collectionTitles[0]) : null,
|
|
146
|
+
season,
|
|
147
|
+
episode,
|
|
148
|
+
episodeTitle: isEpisode ? text(itemTitles[0]) : null,
|
|
149
|
+
year: isEpisode ? yearOf(collectionDate) : yearOf(itemDate),
|
|
150
|
+
itemYear: isEpisode ? yearOf(itemDate) : null,
|
|
151
|
+
genres: textList(statedAt("GENRE")),
|
|
152
|
+
description: text([50, 70].flatMap((level) => valuesOf(levels, level, ["DESCRIPTION", "SYNOPSIS", "SUMMARY"]))[0]),
|
|
153
|
+
externalIds: externalIdsOf({
|
|
154
|
+
imdb: [50, 70].flatMap((level) => valuesOf(levels, level, ["IMDB"]))[0],
|
|
155
|
+
tmdb: [70, 50].flatMap((level) => valuesOf(levels, level, ["TMDB"]))[0],
|
|
156
|
+
tvdb: [70, 50].flatMap((level) => valuesOf(levels, level, ["TVDB"]))[0]
|
|
157
|
+
})
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The titles of the chapters a file states, hidden ones left out, nested ones
|
|
163
|
+
* included, in their order.
|
|
164
|
+
*
|
|
165
|
+
* @param {Buffer} data - The data of a `Chapters` element.
|
|
166
|
+
* @returns {string[]}
|
|
167
|
+
*/
|
|
168
|
+
export function chapterTitlesOf(data) {
|
|
169
|
+
const titles = [];
|
|
170
|
+
const atoms = (start, end) => {
|
|
171
|
+
for (const element of iterateElements(data, start, end)) {
|
|
172
|
+
const limit = Math.min(end, element.dataOffset + element.size);
|
|
173
|
+
if (element.id === ID_EDITION_ENTRY) atoms(element.dataOffset, limit);
|
|
174
|
+
if (element.id !== ID_CHAPTER_ATOM) continue;
|
|
175
|
+
let hidden = false;
|
|
176
|
+
let title = null;
|
|
177
|
+
for (const field of iterateElements(data, element.dataOffset, limit)) {
|
|
178
|
+
if (field.id === ID_CHAPTER_FLAG_HIDDEN) hidden = readUint(data, field.dataOffset, field.size) !== 0;
|
|
179
|
+
if (field.id === ID_CHAPTER_DISPLAY && title === null) {
|
|
180
|
+
for (const display of iterateElements(data, field.dataOffset, Math.min(limit, field.dataOffset + field.size))) {
|
|
181
|
+
if (display.id === ID_CHAP_STRING) {
|
|
182
|
+
title = string(data, display);
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!hidden && title) titles.push(title);
|
|
189
|
+
atoms(element.dataOffset, limit);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
atoms(0, data.length);
|
|
193
|
+
return titles;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Which attachment is the cover, by the names the Matroska attachments
|
|
198
|
+
* specification gives covers (`cover`, `cover_land`, `small_cover`,
|
|
199
|
+
* `small_cover_land`, any image extension), the full-size portrait one first.
|
|
200
|
+
*
|
|
201
|
+
* @param {Array<{ name: string, mediaType: string }>} files
|
|
202
|
+
* @returns {number} The index of the cover, or -1.
|
|
203
|
+
*/
|
|
204
|
+
export function coverIndexOf(files) {
|
|
205
|
+
const ranks = ["cover", "cover_land", "small_cover", "small_cover_land"];
|
|
206
|
+
let best = -1;
|
|
207
|
+
let bestRank = ranks.length;
|
|
208
|
+
files.forEach((file, index) => {
|
|
209
|
+
const match = /^(.+)\.(?:jpe?g|png|webp|gif|bmp)$/iu.exec(file.name.trim());
|
|
210
|
+
const rank = match ? ranks.indexOf(match[1].toLowerCase()) : -1;
|
|
211
|
+
if (rank >= 0 && rank < bestRank && /^image\//iu.test(file.mediaType || "image/")) {
|
|
212
|
+
best = index;
|
|
213
|
+
bestRank = rank;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
return best;
|
|
217
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What an MP4 or QuickTime file states about the work, from the `moov`
|
|
3
|
+
* box the container already holds for its tracks.
|
|
4
|
+
*
|
|
5
|
+
* Three places carry it, and a file may use any of them:
|
|
6
|
+
*
|
|
7
|
+
* 1. the iTunes item list — `moov/udta/meta` with handler `mdir`, then `ilst`,
|
|
8
|
+
* each item a box named by its four characters (`©nam`, `tvsh`, `tvsn`,
|
|
9
|
+
* `tves`, `tven`, `©gen`, `©day`, `desc`, `ldes`, `©cmt`, `stik`, `covr`)
|
|
10
|
+
* holding a `data` box: a type indicator, a locale, then the value
|
|
11
|
+
* (Apple, "QuickTime File Format", Metadata, "Data atom"; the well-known
|
|
12
|
+
* types 1 UTF-8, 13 JPEG, 14 PNG, 21 big-endian signed integer, 27 BMP);
|
|
13
|
+
* 2. QuickTime metadata keys — `meta` with handler `mdta`, a `keys` box naming
|
|
14
|
+
* each item, and `ilst` items addressed by the 1-based index of their key
|
|
15
|
+
* (`com.apple.quicktime.title`, `.genre`, `.description`, `.year`,
|
|
16
|
+
* `.creationdate`, `.artwork`);
|
|
17
|
+
* 3. QuickTime user data text — `moov/udta/©nam` and its kind, each a 16-bit
|
|
18
|
+
* length, a 16-bit language and the text.
|
|
19
|
+
*
|
|
20
|
+
* The LostFilm files measured in #136 use the first: `©nam` is the episode,
|
|
21
|
+
* `tvsh` the series, `tvsn`/`tves` the season and episode. An item that is a TV
|
|
22
|
+
* episode is recognised by what the file states (`tvsh`, `tvsn`, `tves`, or
|
|
23
|
+
* `stik` 10, "TV Show"), never by a name.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { text, textList, wholeNumber, yearOf } from "./work-tags.js";
|
|
27
|
+
|
|
28
|
+
const TYPE_UTF8 = 1;
|
|
29
|
+
const TYPE_UTF16 = 2;
|
|
30
|
+
const TYPE_JPEG = 13;
|
|
31
|
+
const TYPE_PNG = 14;
|
|
32
|
+
const TYPE_INTEGER = 21;
|
|
33
|
+
const TYPE_UNSIGNED = 22;
|
|
34
|
+
const TYPE_BMP = 27;
|
|
35
|
+
|
|
36
|
+
/** `stik` 10: the item is an episode of a TV show (Apple). */
|
|
37
|
+
const MEDIA_KIND_TV_SHOW = 10;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The boxes directly inside a range.
|
|
41
|
+
*
|
|
42
|
+
* @param {Buffer} buffer
|
|
43
|
+
* @param {number} start
|
|
44
|
+
* @param {number} end
|
|
45
|
+
* @returns {Array<{ type: string, rawType: number, dataOffset: number, end: number }>}
|
|
46
|
+
*/
|
|
47
|
+
function boxes(buffer, start, end) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (let at = start; at + 8 <= end;) {
|
|
50
|
+
let size = buffer.readUInt32BE(at);
|
|
51
|
+
let header = 8;
|
|
52
|
+
if (size === 1) {
|
|
53
|
+
if (at + 16 > end) break;
|
|
54
|
+
size = Number(buffer.readBigUInt64BE(at + 8));
|
|
55
|
+
header = 16;
|
|
56
|
+
} else if (size === 0) {
|
|
57
|
+
size = end - at;
|
|
58
|
+
}
|
|
59
|
+
if (size < header || at + size > end) break;
|
|
60
|
+
out.push({ type: buffer.toString("latin1", at + 4, at + 8), rawType: buffer.readUInt32BE(at + 4), dataOffset: at + header, end: at + size });
|
|
61
|
+
at += size;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const child = (buffer, box, type) => boxes(buffer, box.dataOffset, box.end).find((one) => one.type === type) ?? null;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The children of a `meta` box. ISO/IEC 14496-12 makes it a full box (four
|
|
70
|
+
* bytes of version and flags before its children); QuickTime writes it without
|
|
71
|
+
* them. Which one a file wrote is read from where the handler box is.
|
|
72
|
+
*
|
|
73
|
+
* @param {Buffer} buffer
|
|
74
|
+
* @param {{ dataOffset: number, end: number }} meta
|
|
75
|
+
*/
|
|
76
|
+
function metaChildren(buffer, meta) {
|
|
77
|
+
const plain = meta.dataOffset + 8 <= meta.end && buffer.toString("latin1", meta.dataOffset + 4, meta.dataOffset + 8) === "hdlr";
|
|
78
|
+
return boxes(buffer, meta.dataOffset + (plain ? 0 : 4), meta.end);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The value of one item's first `data` box.
|
|
83
|
+
*
|
|
84
|
+
* @param {Buffer} buffer
|
|
85
|
+
* @param {{ dataOffset: number, end: number }} item
|
|
86
|
+
* @returns {{ type: number, value: Buffer, at: number } | null}
|
|
87
|
+
*/
|
|
88
|
+
function dataOf(buffer, item) {
|
|
89
|
+
const data = boxes(buffer, item.dataOffset, item.end).find((one) => one.type === "data");
|
|
90
|
+
if (!data || data.end - data.dataOffset < 8) return null;
|
|
91
|
+
const type = buffer.readUInt32BE(data.dataOffset) & 0xffffff;
|
|
92
|
+
return { type, value: buffer.subarray(data.dataOffset + 8, data.end), at: data.dataOffset + 8 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {{ type: number, value: Buffer }} data */
|
|
96
|
+
function stringOf(data) {
|
|
97
|
+
if (!data) return null;
|
|
98
|
+
// Type 2 is UTF-16 big-endian; Node reads only little-endian, so the bytes are swapped on a copy.
|
|
99
|
+
if (data.type === TYPE_UTF16) return data.value.length % 2 === 0 ? text(Buffer.from(data.value).swap16().toString("utf16le")) : null;
|
|
100
|
+
// Type 0 is "implicit"; writers use it for text as often as for anything else.
|
|
101
|
+
return data.type === TYPE_UTF8 || data.type === 0 ? text(data.value.toString("utf8")) : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @param {{ type: number, value: Buffer }} data */
|
|
105
|
+
function integerOf(data) {
|
|
106
|
+
if (!data || ![0, TYPE_INTEGER, TYPE_UNSIGNED].includes(data.type)) return null;
|
|
107
|
+
const { value } = data;
|
|
108
|
+
if (value.length === 1) return value.readUInt8(0);
|
|
109
|
+
if (value.length === 2) return value.readUInt16BE(0);
|
|
110
|
+
if (value.length === 4) return data.type === TYPE_INTEGER ? value.readInt32BE(0) : value.readUInt32BE(0);
|
|
111
|
+
if (value.length === 8) return Number(value.readBigUInt64BE(0));
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The image type an item's data type states. */
|
|
116
|
+
function imageType(data) {
|
|
117
|
+
if (!data) return null;
|
|
118
|
+
if (data.type === TYPE_JPEG) return "image/jpeg";
|
|
119
|
+
if (data.type === TYPE_PNG) return "image/png";
|
|
120
|
+
if (data.type === TYPE_BMP) return "image/bmp";
|
|
121
|
+
return data.type === 0 ? "image/jpeg" : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every item one `meta` box states, by name: an iTunes four-character name, or
|
|
126
|
+
* for an `mdta` list the key the item's index points at.
|
|
127
|
+
*
|
|
128
|
+
* @param {Buffer} buffer
|
|
129
|
+
* @param {{ dataOffset: number, end: number }} meta
|
|
130
|
+
* @returns {Map<string, { type: number, value: Buffer, at: number }>}
|
|
131
|
+
*/
|
|
132
|
+
function itemsOf(buffer, meta) {
|
|
133
|
+
const items = new Map();
|
|
134
|
+
const children = metaChildren(buffer, meta);
|
|
135
|
+
const hdlr = children.find((one) => one.type === "hdlr");
|
|
136
|
+
const handler = hdlr && hdlr.end - hdlr.dataOffset >= 12 ? buffer.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
|
|
137
|
+
const ilst = children.find((one) => one.type === "ilst");
|
|
138
|
+
if (!ilst) return items;
|
|
139
|
+
/** @type {string[]} */
|
|
140
|
+
const keys = [];
|
|
141
|
+
if (handler === "mdta") {
|
|
142
|
+
const keysBox = children.find((one) => one.type === "keys");
|
|
143
|
+
if (keysBox) {
|
|
144
|
+
for (const key of boxes(buffer, keysBox.dataOffset + 8, keysBox.end)) keys.push(buffer.toString("utf8", key.dataOffset, key.end));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const item of boxes(buffer, ilst.dataOffset, ilst.end)) {
|
|
148
|
+
const name = handler === "mdta" ? keys[item.rawType - 1] : item.type;
|
|
149
|
+
const data = name ? dataOf(buffer, item) : null;
|
|
150
|
+
if (data && !items.has(name)) items.set(name, data);
|
|
151
|
+
}
|
|
152
|
+
return items;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* QuickTime user data text atoms directly inside `udta` (`©nam` and its kind).
|
|
157
|
+
*
|
|
158
|
+
* @param {Buffer} buffer
|
|
159
|
+
* @param {{ dataOffset: number, end: number }} udta
|
|
160
|
+
* @returns {Map<string, string>}
|
|
161
|
+
*/
|
|
162
|
+
function userDataTexts(buffer, udta) {
|
|
163
|
+
const texts = new Map();
|
|
164
|
+
for (const box of boxes(buffer, udta.dataOffset, udta.end)) {
|
|
165
|
+
if (box.type.charCodeAt(0) !== 0xa9 || box.end - box.dataOffset < 4) continue;
|
|
166
|
+
const length = buffer.readUInt16BE(box.dataOffset);
|
|
167
|
+
const value = text(buffer.toString("utf8", box.dataOffset + 4, Math.min(box.end, box.dataOffset + 4 + length)));
|
|
168
|
+
if (value && !texts.has(box.type)) texts.set(box.type, value);
|
|
169
|
+
}
|
|
170
|
+
return texts;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* What a `moov` box states about the work, and where its cover lies inside it.
|
|
175
|
+
*
|
|
176
|
+
* @param {Buffer} moov - The whole box, its header included.
|
|
177
|
+
* @param {number} header - The size of its header.
|
|
178
|
+
* @returns {{ work: Partial<import("./work-tags.js").WorkTags>, cover: { type: string, at: number, size: number } | null }}
|
|
179
|
+
*/
|
|
180
|
+
export function workFromMoov(moov, header) {
|
|
181
|
+
const root = { dataOffset: header, end: moov.length };
|
|
182
|
+
const udta = child(moov, root, "udta");
|
|
183
|
+
/** @type {Map<string, { type: number, value: Buffer, at: number }>} */
|
|
184
|
+
const items = new Map();
|
|
185
|
+
for (const meta of [udta && child(moov, udta, "meta"), child(moov, root, "meta")]) {
|
|
186
|
+
if (!meta) continue;
|
|
187
|
+
for (const [name, data] of itemsOf(moov, meta)) if (!items.has(name)) items.set(name, data);
|
|
188
|
+
}
|
|
189
|
+
const texts = udta ? userDataTexts(moov, udta) : new Map();
|
|
190
|
+
const say = (...names) => {
|
|
191
|
+
for (const name of names) {
|
|
192
|
+
const value = items.has(name) ? stringOf(items.get(name)) : texts.get(name) ?? null;
|
|
193
|
+
if (value) return value;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
};
|
|
197
|
+
const show = say("tvsh");
|
|
198
|
+
const season = wholeNumber(integerOf(items.get("tvsn")), 0, 999);
|
|
199
|
+
const episode = wholeNumber(integerOf(items.get("tves")), 0, 9999);
|
|
200
|
+
const isEpisode = Boolean(show) || season !== null || episode !== null || integerOf(items.get("stik")) === MEDIA_KIND_TV_SHOW;
|
|
201
|
+
const name = say("©nam", "com.apple.quicktime.title", "com.apple.quicktime.displayname");
|
|
202
|
+
const date = say("©day", "com.apple.quicktime.year", "com.apple.quicktime.creationdate");
|
|
203
|
+
const artwork = items.get("covr") ?? items.get("com.apple.quicktime.artwork") ?? null;
|
|
204
|
+
const coverType = imageType(artwork);
|
|
205
|
+
return {
|
|
206
|
+
work: {
|
|
207
|
+
title: isEpisode ? show : name,
|
|
208
|
+
seriesTitle: show,
|
|
209
|
+
season,
|
|
210
|
+
episode,
|
|
211
|
+
episodeId: say("tven"),
|
|
212
|
+
episodeTitle: isEpisode ? name : null,
|
|
213
|
+
year: isEpisode ? null : yearOf(date),
|
|
214
|
+
itemYear: isEpisode ? yearOf(date) : null,
|
|
215
|
+
genres: textList([say("©gen", "com.apple.quicktime.genre")]),
|
|
216
|
+
description: say("ldes", "desc", "com.apple.quicktime.description", "©des", "©cmt")
|
|
217
|
+
},
|
|
218
|
+
cover: artwork && coverType ? { type: coverType, at: artwork.at, size: artwork.value.length } : null
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What a file states about the WORK it carries — title, series, season,
|
|
3
|
+
* episode, year, genre, description, external ids, track and chapter titles,
|
|
4
|
+
* a cover — in one shape for every container.
|
|
5
|
+
*
|
|
6
|
+
* Each container reads its own format's fields (Matroska `Tags`, `Chapters` and
|
|
7
|
+
* `Attachments`; MP4's iTunes item list and QuickTime metadata keys; AVI's
|
|
8
|
+
* `LIST INFO`) and states them here by meaning, so the reader of the answer
|
|
9
|
+
* never needs to know which format said it. Nothing here interprets a value
|
|
10
|
+
* beyond the format's own definition: whether a title is a release name, and
|
|
11
|
+
* how much a field is trusted, is decided by whoever identifies the work.
|
|
12
|
+
*
|
|
13
|
+
* These fields are read only from bytes the proxy already fetches for playback
|
|
14
|
+
* — the first and the last piece of the file, and whatever is already held —
|
|
15
|
+
* so reading them never asks the swarm for anything (torrent-tv/meta#139). A
|
|
16
|
+
* field whose bytes lie elsewhere and have not arrived is left out, and the
|
|
17
|
+
* answer says so in `outsideEdges`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Longest text taken from one field, in characters. A stated limit. */
|
|
21
|
+
export const MAX_TEXT = 2000;
|
|
22
|
+
|
|
23
|
+
/** Most genres, track titles or chapter titles kept. A stated limit. */
|
|
24
|
+
export const MAX_LIST = 32;
|
|
25
|
+
|
|
26
|
+
/** Largest cover image read, in bytes. A stated limit: covers measured in #136 are tens of kilobytes. */
|
|
27
|
+
export const MAX_COVER_BYTES = 4 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Image types a cover may have; anything else is not shown by a browser as a poster. */
|
|
30
|
+
export const COVER_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/bmp"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} WorkTags
|
|
34
|
+
* @property {string | null} title - The title of what the file holds: the film,
|
|
35
|
+
* or for an episode the series.
|
|
36
|
+
* @property {string[]} otherTitles - Further titles the file states for the same thing.
|
|
37
|
+
* @property {string | null} segmentTitle - A title the container states for the
|
|
38
|
+
* file as a whole without saying what it names (Matroska `Info/Title`); often
|
|
39
|
+
* a release name.
|
|
40
|
+
* @property {string | null} seriesTitle
|
|
41
|
+
* @property {number | null} season
|
|
42
|
+
* @property {number | null} episode
|
|
43
|
+
* @property {string | null} episodeId - An episode code as written (`101`).
|
|
44
|
+
* @property {string | null} episodeTitle
|
|
45
|
+
* @property {number | null} year - The year of the work: of the film, or of the series.
|
|
46
|
+
* @property {number | null} itemYear - The year of one episode, where the file states it apart.
|
|
47
|
+
* @property {string[]} genres
|
|
48
|
+
* @property {string | null} description
|
|
49
|
+
* @property {{ imdb?: string, tmdb?: { kind: "movie" | "tv", id: number }, tvdb?: number }} externalIds
|
|
50
|
+
* @property {string[]} trackTitles
|
|
51
|
+
* @property {string[]} chapterTitles
|
|
52
|
+
* @property {{ type: string, size: number } | null} cover - What the cover is; its
|
|
53
|
+
* bytes are read by `readCover`.
|
|
54
|
+
* @property {boolean} outsideEdges - Some element lies outside the bytes this
|
|
55
|
+
* reading may fetch and was not held; it was left out.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/** @returns {WorkTags} */
|
|
59
|
+
export function emptyWorkTags() {
|
|
60
|
+
return {
|
|
61
|
+
title: null, otherTitles: [], segmentTitle: null, seriesTitle: null, season: null, episode: null, episodeId: null,
|
|
62
|
+
episodeTitle: null, year: null, itemYear: null, genres: [], description: null, externalIds: {},
|
|
63
|
+
trackTitles: [], chapterTitles: [], cover: null, outsideEdges: false
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A text field, trimmed and bounded; `null` for nothing.
|
|
69
|
+
*
|
|
70
|
+
* @param {unknown} value
|
|
71
|
+
* @returns {string | null}
|
|
72
|
+
*/
|
|
73
|
+
export function text(value) {
|
|
74
|
+
if (typeof value !== "string") return null;
|
|
75
|
+
// Control characters other than tab and line breaks are padding or damage, not text.
|
|
76
|
+
const clean = [...value].filter((char) => {
|
|
77
|
+
const code = char.codePointAt(0) ?? 0;
|
|
78
|
+
return code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d;
|
|
79
|
+
}).join("").trim();
|
|
80
|
+
return clean.length > 0 ? clean.slice(0, MAX_TEXT) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A whole number written as text or stored as one, within bounds.
|
|
85
|
+
*
|
|
86
|
+
* @param {unknown} value
|
|
87
|
+
* @param {number} min
|
|
88
|
+
* @param {number} max
|
|
89
|
+
* @returns {number | null}
|
|
90
|
+
*/
|
|
91
|
+
export function wholeNumber(value, min, max) {
|
|
92
|
+
const number = typeof value === "number" ? value : typeof value === "string" && /^\s*\d{1,9}\s*$/u.test(value) ? Number(value) : NaN;
|
|
93
|
+
return Number.isInteger(number) && number >= min && number <= max ? number : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The year a date field states: its leading four digits (`2026`, `2026-03-01`,
|
|
98
|
+
* `2026-03-01T00:00:00Z`).
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} value
|
|
101
|
+
* @returns {number | null}
|
|
102
|
+
*/
|
|
103
|
+
export function yearOf(value) {
|
|
104
|
+
const match = typeof value === "string" ? /^\s*(\d{4})(?:\D|$)/u.exec(value) : null;
|
|
105
|
+
return match ? wholeNumber(Number(match[1]), 1888, 2100) : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Ids in the forms the Matroska tagging specification states: `IMDB` is `tt`
|
|
110
|
+
* followed by digits, `TMDB` is `movie/<id>` or `tv/<id>`, `TVDB` is a number.
|
|
111
|
+
*
|
|
112
|
+
* @param {{ imdb?: unknown, tmdb?: unknown, tvdb?: unknown }} raw
|
|
113
|
+
* @returns {WorkTags["externalIds"]}
|
|
114
|
+
*/
|
|
115
|
+
export function externalIdsOf({ imdb, tmdb, tvdb }) {
|
|
116
|
+
const ids = {};
|
|
117
|
+
const imdbMatch = typeof imdb === "string" ? /^\s*(tt\d{7,12})\s*$/u.exec(imdb) : null;
|
|
118
|
+
if (imdbMatch) ids.imdb = imdbMatch[1];
|
|
119
|
+
const tmdbMatch = typeof tmdb === "string" ? /^\s*(movie|tv)\/(\d{1,10})\s*$/u.exec(tmdb) : null;
|
|
120
|
+
if (tmdbMatch) ids.tmdb = { kind: /** @type {"movie" | "tv"} */ (tmdbMatch[1]), id: Number(tmdbMatch[2]) };
|
|
121
|
+
const tvdbNumber = wholeNumber(tvdb, 1, 2_147_483_647);
|
|
122
|
+
if (tvdbNumber !== null) ids.tvdb = tvdbNumber;
|
|
123
|
+
return ids;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A chapter title that only numbers the chapter (`Chapter 01`, `Глава 3`,
|
|
128
|
+
* `01`, `00:12:00.000`) says nothing about the work.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} title
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
export function isNumberingOnly(title) {
|
|
134
|
+
return /^\s*(?:chapter|chap|глава|часть|kapitel|chapitre|capitulo|capítulo)?\s*[#№]?\s*[\d:.]+\s*$/iu.test(title);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Distinct non-empty texts, bounded.
|
|
139
|
+
*
|
|
140
|
+
* @param {Iterable<unknown>} values
|
|
141
|
+
* @returns {string[]}
|
|
142
|
+
*/
|
|
143
|
+
export function textList(values) {
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const value of values) {
|
|
146
|
+
const one = text(value);
|
|
147
|
+
if (one && !out.includes(one)) out.push(one);
|
|
148
|
+
if (out.length >= MAX_LIST) break;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The image type a cover's bytes have, from their signature — a container's own
|
|
155
|
+
* statement of the type is checked against the bytes rather than trusted alone.
|
|
156
|
+
*
|
|
157
|
+
* @param {Buffer} head - At least the first twelve bytes.
|
|
158
|
+
* @returns {string | null}
|
|
159
|
+
*/
|
|
160
|
+
export function imageTypeOf(head) {
|
|
161
|
+
if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return "image/jpeg";
|
|
162
|
+
if (head.length >= 8 && head.readUInt32BE(0) === 0x89504e47 && head.readUInt32BE(4) === 0x0d0a1a0a) return "image/png";
|
|
163
|
+
if (head.length >= 12 && head.toString("latin1", 0, 4) === "RIFF" && head.toString("latin1", 8, 12) === "WEBP") return "image/webp";
|
|
164
|
+
if (head.length >= 6 && /^GIF8[79]a$/u.test(head.toString("latin1", 0, 6))) return "image/gif";
|
|
165
|
+
if (head.length >= 2 && head.toString("latin1", 0, 2) === "BM") return "image/bmp";
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Thrown by a reading of work tags where the bytes lie outside what it may
|
|
171
|
+
* fetch and have not arrived. The reading leaves that element out; it is not a
|
|
172
|
+
* fault of the file and not "not here yet".
|
|
173
|
+
*/
|
|
174
|
+
export class OutsideReadableEdges extends Error {
|
|
175
|
+
/**
|
|
176
|
+
* @param {number} start
|
|
177
|
+
* @param {number} end - Inclusive.
|
|
178
|
+
*/
|
|
179
|
+
constructor(start, end) {
|
|
180
|
+
super(`bytes ${start}-${end} lie outside the edges this reading may fetch and are not held`);
|
|
181
|
+
this.name = "OutsideReadableEdges";
|
|
182
|
+
this.start = start;
|
|
183
|
+
this.end = end;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A reader that fetches only inside the edges, and reads elsewhere only what is
|
|
189
|
+
* already held.
|
|
190
|
+
*
|
|
191
|
+
* @param {(start: number, end: number) => Promise<Buffer>} read - Strict: every byte or `BytesUnavailable`.
|
|
192
|
+
* @param {(start: number, end: number) => boolean} mayFetch
|
|
193
|
+
* @param {(error: unknown) => boolean} isUnavailable
|
|
194
|
+
* @returns {(start: number, end: number) => Promise<Buffer>}
|
|
195
|
+
*/
|
|
196
|
+
export function edgeReader(read, mayFetch, isUnavailable) {
|
|
197
|
+
return async (start, end) => {
|
|
198
|
+
try {
|
|
199
|
+
return await read(start, end);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (isUnavailable(error) && !mayFetch(start, end)) throw new OutsideReadableEdges(start, end);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -353,7 +353,10 @@ async function runCommand(command, params, id) {
|
|
|
353
353
|
index,
|
|
354
354
|
name: file.name,
|
|
355
355
|
path: file.path,
|
|
356
|
-
length: file.length
|
|
356
|
+
length: file.length,
|
|
357
|
+
// Where the file begins in the torrent's bytes: which pieces hold its
|
|
358
|
+
// edges follows from it.
|
|
359
|
+
offset: file.offset
|
|
357
360
|
}))
|
|
358
361
|
};
|
|
359
362
|
}
|
|
@@ -1046,7 +1049,7 @@ async function keepWholeFiles() {
|
|
|
1046
1049
|
infoHash: torrent.infoHash,
|
|
1047
1050
|
name: torrent.name,
|
|
1048
1051
|
pieceLength: Number(torrent.pieceLength) || 0,
|
|
1049
|
-
files: torrent.files.map((file, index) => ({ index, name: file.name, path: file.path, length: file.length }))
|
|
1052
|
+
files: torrent.files.map((file, index) => ({ index, name: file.name, path: file.path, length: file.length, offset: file.offset }))
|
|
1050
1053
|
});
|
|
1051
1054
|
pool.remove(torrent, "downloaded-whole");
|
|
1052
1055
|
pool.addWholeSource(sourceKey);
|