@hoardodile/sdk-server 0.0.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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/helpers.ts","../src/detectors.ts"],"names":["files"],"mappings":"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","file":"index.js","sourcesContent":["import type {\n\tFileType,\n\tImageHash,\n\tImageHashesResult,\n\tImageHashKind,\n\tResourceAPI,\n} from \"@hoardodile/sdk-types\"\nimport {\n\tPLUGIN_IMAGE_PROBE_CONCURRENCY,\n\tPLUGIN_VIDEO_PROBE_CONCURRENCY,\n} from \"@hoardodile/sdk-types/plugin\"\nimport {\n\tRESOURCE_PREVIEW_MAX_AREA,\n\tRESOURCE_PREVIEW_SIZE_THRESHOLD,\n} from \"@hoardodile/sdk-types/resource\"\n\n/**\n * True when an image exceeds the preview thresholds — the pixel-area\n * cap **or** the byte-size threshold — and should be served through the\n * preview pipeline instead of the original. Format-driven transcode\n * needs (formats browsers cannot render natively) are decided by the\n * consuming plugin separately.\n */\nfunction exceedsPreviewThresholds(check: {\n\treadonly width: number | undefined\n\treadonly height: number | undefined\n\treadonly sizeBytes: number | undefined\n}): boolean {\n\tconst { width, height, sizeBytes } = check\n\tconst exceedsArea =\n\t\twidth !== undefined &&\n\t\theight !== undefined &&\n\t\twidth * height > RESOURCE_PREVIEW_MAX_AREA\n\tconst exceedsSize =\n\t\tsizeBytes !== undefined && sizeBytes > RESOURCE_PREVIEW_SIZE_THRESHOLD\n\treturn exceedsArea || exceedsSize\n}\n\n/** Return the lower-cased extension from the last dot, or `\"\"` when there is none. */\nexport function extname(filename: string): string {\n\tconst dot = filename.lastIndexOf(\".\")\n\tif (dot === -1) return \"\"\n\treturn filename.slice(dot).toLowerCase()\n}\n\n/** Natural-sort filenames (case-insensitive, numeric). Mutates and returns. */\nexport function naturalSort(files: readonly string[]): string[] {\n\treturn [...files].sort((a, b) =>\n\t\ta.localeCompare(b, undefined, { sensitivity: \"base\", numeric: true }),\n\t)\n}\n\n/**\n * Map items with at most `limit` promises in flight. Results keep input\n * order; the first rejection aborts the map (in-flight calls settle).\n * Probe loops use this to fan out across the host's concurrent API\n * dispatch instead of trickling one RPC at a time.\n */\nexport async function mapConcurrent<T, R>(\n\titems: readonly T[],\n\tlimit: number,\n\tfn: (item: T, index: number) => Promise<R>,\n): Promise<R[]> {\n\tconst results: R[] = new Array(items.length)\n\tlet next = 0\n\tasync function lane(): Promise<void> {\n\t\tfor (;;) {\n\t\t\tconst index = next++\n\t\t\tif (index >= items.length) return\n\t\t\tconst item = items[index]\n\t\t\tif (item === undefined) continue\n\t\t\tresults[index] = await fn(item, index)\n\t\t}\n\t}\n\tconst lanes = Math.max(1, Math.min(limit, items.length))\n\tconst runners: Promise<void>[] = []\n\tfor (let i = 0; i < lanes; i++) runners.push(lane())\n\tawait Promise.all(runners)\n\treturn results\n}\n\n/** File-list item shapes produced by the probe helpers. */\ntype ProbedImageFile = {\n\treadonly type: \"image\"\n\treadonly width?: number\n\treadonly height?: number\n\treadonly preview: boolean\n}\ntype ProbedVideoFile = {\n\treadonly type: \"video\"\n\treadonly width?: number\n\treadonly height?: number\n\treadonly durationMs?: number\n}\ntype ProbedAudioFile = {\n\treadonly type: \"audio\"\n\treadonly durationMs?: number\n\treadonly hasCoverArt?: boolean\n}\n\n/**\n * Probe a file and return the file-item shaped object matching what the\n * content actually is, or `undefined` for non-media files. This is the\n * primitive: plugins that accept mixed media route on the result\n * instead of pre-sorting by extension, and the three per-kind helpers\n * below are thin narrowings of it.\n *\n * When identification succeeds but decoding does not (no ffprobe on the\n * host, a damaged container), the file keeps its media type and simply\n * carries no dimensions — losing the entry entirely would be worse than\n * showing it undecorated.\n */\nexport async function probeMediaFile(\n\tapi: ResourceAPI,\n\tfilename: string,\n): Promise<ProbedImageFile | ProbedVideoFile | ProbedAudioFile | undefined> {\n\tconst probed = await api.probe(filename)\n\tswitch (probed.kind) {\n\t\tcase \"image\": {\n\t\t\tconst { width, height } = probed\n\t\t\tconst sizeBytes = (await api.statFile(filename))?.sizeBytes\n\t\t\treturn {\n\t\t\t\ttype: \"image\",\n\t\t\t\twidth,\n\t\t\t\theight,\n\t\t\t\tpreview: exceedsPreviewThresholds({ width, height, sizeBytes }),\n\t\t\t}\n\t\t}\n\t\tcase \"video\":\n\t\t\treturn {\n\t\t\t\ttype: \"video\",\n\t\t\t\twidth: probed.width,\n\t\t\t\theight: probed.height,\n\t\t\t\tdurationMs: probed.durationMs,\n\t\t\t}\n\t\tcase \"audio\":\n\t\t\treturn {\n\t\t\t\ttype: \"audio\",\n\t\t\t\tdurationMs: probed.durationMs,\n\t\t\t\thasCoverArt: probed.coverArt === undefined ? undefined : true,\n\t\t\t}\n\t\tcase \"other\":\n\t\t\treturn undefined\n\t\tdefault:\n\t\t\treturn undecodedItem(await api.sniff(filename))\n\t}\n}\n\n/** Bare file item for media the host identified but could not decode. */\nfunction undecodedItem(\n\ttype: FileType | undefined,\n): ProbedImageFile | ProbedVideoFile | ProbedAudioFile | undefined {\n\tswitch (type?.kind) {\n\t\tcase \"image\":\n\t\t\treturn { type: \"image\", preview: false }\n\t\tcase \"video\":\n\t\t\treturn { type: \"video\" }\n\t\tcase \"audio\":\n\t\t\treturn { type: \"audio\" }\n\t\tdefault:\n\t\t\treturn undefined\n\t}\n}\n\n/**\n * Probe a file expected to be an image. A file that turns out not to be\n * one still yields an image-shaped item (with no dimensions), so a file\n * list keeps its declared type.\n */\nexport async function probeImageFile(\n\tapi: ResourceAPI,\n\tfilename: string,\n): Promise<ProbedImageFile> {\n\tconst item = await probeMediaFile(api, filename)\n\treturn item?.type === \"image\" ? item : { type: \"image\", preview: false }\n}\n\n/** Probe a file expected to be a video. See {@link probeImageFile}. */\nexport async function probeVideoFile(\n\tapi: ResourceAPI,\n\tfilename: string,\n): Promise<ProbedVideoFile> {\n\tconst item = await probeMediaFile(api, filename)\n\treturn item?.type === \"video\" ? item : { type: \"video\" }\n}\n\n/** Probe a file expected to be audio. See {@link probeImageFile}. */\nexport async function probeAudioFile(\n\tapi: ResourceAPI,\n\tfilename: string,\n): Promise<ProbedAudioFile> {\n\tconst item = await probeMediaFile(api, filename)\n\treturn item?.type === \"audio\" ? item : { type: \"audio\" }\n}\n\n/**\n * One entry of {@link mediaFileList}: the filename plus everything the\n * probe pass learned about the file (`sniffed` is the sniffed type that\n * decided the probe lane — the same data the entry's dimensions were\n * routed on).\n */\nexport type MediaFileListEntry = {\n\treadonly filename: string\n\treadonly sniffed?: FileType\n} & (ProbedImageFile | ProbedVideoFile | ProbedAudioFile)\n\nexport type MediaFileListOptions = {\n\t/**\n\t * Candidate names to probe. When absent the helper lists the\n\t * resource itself (natural-sorted). Pass a pre-filtered list (e.g.\n\t * top-level files only) to skip names the plugin never wants.\n\t */\n\treadonly names?: readonly string[]\n}\n\n/**\n * Build a typed media file list in one pass: sniff every candidate,\n * then probe images and timed media (video/audio) in separate bounded\n * lanes — sharp header reads fan out wider than ffprobe spawns. The\n * result keeps input order and drops files that are not decodable\n * media. The one-call implementation for a `listFiles` hook over a\n * flat media resource.\n */\nexport async function mediaFileList(\n\tapi: ResourceAPI,\n\topts: MediaFileListOptions = {},\n): Promise<readonly MediaFileListEntry[]> {\n\t// `listFileNames` already returns the host's canonical order (the\n\t// `.order` upload order, natural name sort otherwise) — re-sorting\n\t// here would scramble it. Plugins wanting a different order pass\n\t// explicit `names`.\n\tconst files = opts.names ?? (await api.listFileNames())\n\tconst types = await mapConcurrent(\n\t\tfiles,\n\t\tPLUGIN_IMAGE_PROBE_CONCURRENCY,\n\t\t(name) => api.sniff(name),\n\t)\n\tconst entries = new Map<string, MediaFileListEntry>()\n\tconst imageIndexes: number[] = []\n\tconst timedIndexes: number[] = []\n\tfor (const index of files.keys()) {\n\t\tconst kind = types[index]?.kind\n\t\tif (kind === \"image\") imageIndexes.push(index)\n\t\telse if (kind === \"video\" || kind === \"audio\") timedIndexes.push(index)\n\t}\n\n\tasync function probeInto(index: number): Promise<void> {\n\t\tconst filename = files[index]\n\t\tif (filename === undefined) return\n\t\tconst probed = await probeMediaFile(api, filename)\n\t\tif (probed === undefined) return\n\t\tentries.set(filename, {\n\t\t\tfilename,\n\t\t\tsniffed: types[index],\n\t\t\t...probed,\n\t\t})\n\t}\n\n\tawait Promise.all([\n\t\tmapConcurrent(imageIndexes, PLUGIN_IMAGE_PROBE_CONCURRENCY, probeInto),\n\t\tmapConcurrent(timedIndexes, PLUGIN_VIDEO_PROBE_CONCURRENCY, probeInto),\n\t])\n\n\tconst result: MediaFileListEntry[] = []\n\tfor (const filename of files) {\n\t\tconst entry = entries.get(filename)\n\t\tif (entry !== undefined) result.push(entry)\n\t}\n\treturn result\n}\n\n/**\n * The `sourceMeta` for a bare file count — the one-liner for plugins\n * whose card needs only `fileCount`. Resolves to `undefined` for empty\n * resources, matching the host's \"nothing to report\" contract.\n */\nexport async function countSourceMeta(\n\tapi: ResourceAPI,\n): Promise<{ readonly fileCount: number } | undefined> {\n\tconst files = await api.listFileNames()\n\treturn files.length === 0 ? undefined : { fileCount: files.length }\n}\n\nexport type ReadFileChunksOptions = {\n\t/** Chunk size in bytes. Defaults to 1 MiB. */\n\treadonly chunkSize?: number\n}\n\n/**\n * Stream a file as a sequence of chunks via ranged `readFile` calls.\n * Memory stays bounded by the chunk size on both sides of the plugin\n * boundary — the host never buffers the whole file.\n */\nexport async function* readFileChunks(\n\tapi: ResourceAPI,\n\tpath: string,\n\topts: ReadFileChunksOptions = {},\n): AsyncGenerator<Uint8Array, void, undefined> {\n\tconst chunkSize = opts.chunkSize ?? 1024 * 1024\n\tlet offset = 0\n\tfor (;;) {\n\t\tconst chunk = await api.readFile(path, {\n\t\t\tstart: offset,\n\t\t\tend: offset + chunkSize,\n\t\t})\n\t\tif (chunk.byteLength === 0) return\n\t\tyield chunk\n\t\tif (chunk.byteLength < chunkSize) return\n\t\toffset += chunk.byteLength\n\t}\n}\n\n/** Per-file default hash kinds for {@link imageHashesFor}. */\nexport const DEFAULT_IMAGE_HASH_KINDS: readonly ImageHashKind[] = [\n\t\"sha256\",\n\t\"dhash\",\n]\n\nexport type ImageHashesForOptions = {\n\t/** Hash kinds per image. Defaults to sha256 + dhash. */\n\treadonly kinds?: readonly ImageHashKind[]\n}\n\n/**\n * Compute the requested hash kinds of one image file as `ImageHash`\n * entries (`scope` = the file path). Resolves to `[]` for non-image or\n * undecodable files.\n */\nexport async function imageHashesForFile(\n\tapi: ResourceAPI,\n\tscope: string,\n\tkinds: readonly ImageHashKind[] = DEFAULT_IMAGE_HASH_KINDS,\n): Promise<readonly ImageHash[]> {\n\tconst computed = await api.computeImageHashes(scope, kinds)\n\tif (computed === undefined) return []\n\tconst entries: ImageHash[] = []\n\tfor (const kind of kinds) {\n\t\tconst value = computed[kind]\n\t\tif (value !== undefined) entries.push({ scope, type: kind, value })\n\t}\n\treturn entries\n}\n\n/**\n * One-line `imageHashes` hook implementation for image plugins: hash\n * every image file of the resource (animated sources hash their first\n * frame). Image files are selected by content, so a mislabelled photo\n * is still deduplicated. Plugins facing image-less resources omit the\n * hook entirely.\n */\nexport async function imageHashesFor(\n\tapi: ResourceAPI,\n\topts: ImageHashesForOptions = {},\n): Promise<ImageHashesResult> {\n\tconst kinds = opts.kinds ?? DEFAULT_IMAGE_HASH_KINDS\n\tconst names = await api.listFileNames()\n\tconst types = await mapConcurrent(\n\t\tnames,\n\t\tPLUGIN_IMAGE_PROBE_CONCURRENCY,\n\t\t(name) => api.sniff(name),\n\t)\n\tconst images = names.filter((_, i) => types[i]?.kind === \"image\")\n\tconst hashes = (\n\t\tawait mapConcurrent(images, PLUGIN_IMAGE_PROBE_CONCURRENCY, (filename) =>\n\t\t\timageHashesForFile(api, filename, kinds),\n\t\t)\n\t).flat()\n\treturn { hashes }\n}\n","import type { Detection, ResourceAPI } from \"@hoardodile/sdk-types\"\nimport { err, ok } from \"@hoardodile/sdk-types\"\nimport type { MediaKind } from \"@hoardodile/sdk-types/media-exts\"\nimport { extname, mapConcurrent } from \"./helpers.ts\"\n\n/** A detector evaluates a resource and returns a {@link Detection}. */\nexport type Detector = (api: ResourceAPI) => Promise<Detection>\n\n/** Sniff fan-out bound: detection reads a small header per candidate. */\nconst SNIFF_CONCURRENCY = 8\n\n/** Detect when all given detectors pass. */\nexport function all(...detectors: readonly Detector[]): Detector {\n\treturn async function detectAll(api) {\n\t\tfor (const detector of detectors) {\n\t\t\tconst result = await detector(api)\n\t\t\tif (!result.ok) return result\n\t\t}\n\t\treturn ok()\n\t}\n}\n\n/** Detect when at least one given detector passes. */\nexport function any(...detectors: readonly Detector[]): Detector {\n\treturn async function detectAny(api) {\n\t\tfor (const detector of detectors) {\n\t\t\tconst result = await detector(api)\n\t\t\tif (result.ok) return ok()\n\t\t}\n\t\treturn err({ reasons: [\"no-detector-matched\"] })\n\t}\n}\n\n/** Negate a detector: passes when the wrapped detector fails. */\nexport function not(detector: Detector, reasons: readonly string[]): Detector {\n\treturn async function detectNot(api) {\n\t\tconst result = await detector(api)\n\t\treturn result.ok ? err({ reasons }) : ok()\n\t}\n}\n\n/**\n * Detect when the resource has at least one file with any of the given\n * extensions.\n *\n * Extension matching is the fast path: it costs one filename comparison\n * and no reads. It is also only as good as the names — reach for\n * {@link hasKind} or {@link hasMime} when the archive may carry\n * mislabelled or extension-less files.\n */\nexport function hasExt(extensions: ReadonlySet<string>): Detector {\n\treturn async function detectHasExt(api) {\n\t\tconst files = await api.listFileNames()\n\t\tconst has = files.some((name) => extensions.has(extname(name)))\n\t\treturn has ? ok() : err({ reasons: [\"required-extension\"] })\n\t}\n}\n\n/**\n * Detect when at least one file's **content** belongs to `kind`. Reads a\n * small header per file (bounded fan-out, short-circuits on the first\n * match), so a resource of images detects as such no matter what the\n * files are called.\n */\nexport function hasKind(kind: MediaKind): Detector {\n\treturn async function detectHasKind(api) {\n\t\tconst matched = await someFileType(api, (type) => type.kind === kind)\n\t\treturn matched ? ok() : err({ reasons: [`required-kind:${kind}`] })\n\t}\n}\n\n/**\n * Detect when at least one file's sniffed MIME type matches `pattern` —\n * the content-based counterpart of {@link hasName}, for formats a media\n * kind cannot express (`application/epub+zip`, `application/pdf`).\n *\n * A string matches by exact equality (use a `RegExp` for prefix or\n * wildcard matching).\n */\nexport function hasMime(pattern: RegExp | string): Detector {\n\treturn async function detectHasMime(api) {\n\t\tconst matched = await someFileType(api, (type) =>\n\t\t\ttypeof pattern === \"string\"\n\t\t\t\t? type.mime === pattern\n\t\t\t\t: pattern.test(type.mime),\n\t\t)\n\t\treturn matched ? ok() : err({ reasons: [\"required-mime\"] })\n\t}\n}\n\n/**\n * True when any file's sniffed type satisfies `predicate`. Files are\n * sniffed in bounded parallel batches and the walk stops at the first\n * match, so a 10k-entry archive does not pay for a full scan.\n */\nasync function someFileType(\n\tapi: ResourceAPI,\n\tpredicate: (\n\t\ttype: NonNullable<Awaited<ReturnType<ResourceAPI[\"sniff\"]>>>,\n\t) => boolean,\n): Promise<boolean> {\n\tconst files = await api.listFileNames()\n\tfor (let i = 0; i < files.length; i += SNIFF_CONCURRENCY) {\n\t\tconst batch = files.slice(i, i + SNIFF_CONCURRENCY)\n\t\tconst types = await mapConcurrent(batch, SNIFF_CONCURRENCY, (name) =>\n\t\t\tapi.sniff(name),\n\t\t)\n\t\tif (types.some((type) => type !== undefined && predicate(type))) return true\n\t}\n\treturn false\n}\n\n/** Detect when the resource has a file whose name matches the given regex. */\nexport function hasName(pattern: RegExp): Detector {\n\treturn async function detectHasName(api) {\n\t\tconst files = await api.listFileNames()\n\t\tconst has = files.some((name) => pattern.test(name))\n\t\treturn has ? ok() : err({ reasons: [\"required-file\"] })\n\t}\n}\n\n/** Detect when the resource has at least `count` files. */\nexport function minFiles(count: number): Detector {\n\treturn async function detectMinFiles(api) {\n\t\tconst files = await api.listFileNames()\n\t\treturn files.length >= count\n\t\t\t? ok()\n\t\t\t: err({ reasons: [\"insufficient-files\"] })\n\t}\n}\n\n/** File-selection helpers that operate on a {@link ResourceAPI}. */\nexport const files = {\n\t/**\n\t * Return the first file matching any of the given extension sets,\n\t * or `undefined` when none match.\n\t */\n\tasync firstMatching(\n\t\tapi: ResourceAPI,\n\t\t...extensions: readonly ReadonlySet<string>[]\n\t): Promise<string | undefined> {\n\t\tconst allFiles = await api.listFileNames()\n\t\tfor (const filename of allFiles) {\n\t\t\tconst ext = extname(filename)\n\t\t\tfor (const set of extensions) {\n\t\t\t\tif (set.has(ext)) return filename\n\t\t\t}\n\t\t}\n\t\treturn undefined\n\t},\n\n\t/**\n\t * Return the first file whose **content** belongs to one of the given\n\t * media kinds, or `undefined` when none does. The content-based\n\t * counterpart of {@link files.firstMatching} — pick it when the\n\t * chosen file is going to be decoded anyway (a cover, a first page),\n\t * where a mislabelled name would otherwise cost a failed render.\n\t */\n\tasync firstOfKind(\n\t\tapi: ResourceAPI,\n\t\t...kinds: readonly MediaKind[]\n\t): Promise<string | undefined> {\n\t\tconst wanted = new Set<MediaKind>(kinds)\n\t\tconst allFiles = await api.listFileNames()\n\t\tfor (let i = 0; i < allFiles.length; i += SNIFF_CONCURRENCY) {\n\t\t\tconst batch = allFiles.slice(i, i + SNIFF_CONCURRENCY)\n\t\t\tconst types = await mapConcurrent(batch, SNIFF_CONCURRENCY, (name) =>\n\t\t\t\tapi.sniff(name),\n\t\t\t)\n\t\t\tconst hit = types.findIndex(\n\t\t\t\t(type) => type !== undefined && wanted.has(type.kind),\n\t\t\t)\n\t\t\tif (hit !== -1) return batch[hit]\n\t\t}\n\t\treturn undefined\n\t},\n} as const\n"]}
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@hoardodile/sdk-server",
3
+ "version": "0.0.0",
4
+ "license": "MIT",
5
+ "description": "Authoring SDK for hoardodile content plugins: definePlugin, detectors and helpers.",
6
+ "keywords": [
7
+ "hoardodile",
8
+ "plugin",
9
+ "sdk"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/hoardodile/hoardodile.git",
14
+ "directory": "plugins/sdk-server"
15
+ },
16
+ "type": "module",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "development": "./src/index.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./helpers": {
24
+ "types": "./dist/helpers.d.ts",
25
+ "development": "./src/helpers.ts",
26
+ "default": "./dist/helpers.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "sideEffects": false,
34
+ "engines": {
35
+ "node": ">=24"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "dependencies": {
41
+ "@hoardodile/sdk-types": "0.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^26.2.0",
45
+ "tsup": "^8.5.1",
46
+ "typescript": "5.9.3",
47
+ "vitest": "^4.1.11"
48
+ },
49
+ "scripts": {
50
+ "build": "tsup",
51
+ "lint": "tsc --noEmit",
52
+ "test": "vitest run --maxWorkers=2"
53
+ }
54
+ }
@@ -0,0 +1,115 @@
1
+ import { createResourceAPIFixture } from "@hoardodile/sdk-types"
2
+ import { describe, expect, test } from "vitest"
3
+ import { files, hasExt, hasKind, hasMime } from "./detectors.ts"
4
+
5
+ /**
6
+ * The content-aware detectors are what make a plugin robust against
7
+ * mislabelled archives, so every case here pits the filename against
8
+ * the sniffed type.
9
+ */
10
+
11
+ describe("hasKind", () => {
12
+ test("matches on content even when the extension lies", async () => {
13
+ const { api } = createResourceAPIFixture({
14
+ files: ["scan.dat"],
15
+ types: {
16
+ "scan.dat": {
17
+ mime: "image/jpeg",
18
+ ext: ".jpg",
19
+ kind: "image",
20
+ source: "magic",
21
+ },
22
+ },
23
+ })
24
+ await expect(hasKind("image")(api)).resolves.toEqual({ ok: true })
25
+ // The extension detector, by contrast, sees nothing.
26
+ await expect(hasExt(new Set([".jpg"]))(api)).resolves.toEqual({
27
+ ok: false,
28
+ reasons: ["required-extension"],
29
+ })
30
+ })
31
+
32
+ test("misses when no file carries the kind", async () => {
33
+ const { api } = createResourceAPIFixture({ files: ["notes.txt"] })
34
+ await expect(hasKind("video")(api)).resolves.toEqual({
35
+ ok: false,
36
+ reasons: ["required-kind:video"],
37
+ })
38
+ })
39
+
40
+ test("scans past the first batch of files", async () => {
41
+ const names = Array.from({ length: 30 }, (_, i) => `${i}.txt`)
42
+ const { api } = createResourceAPIFixture({
43
+ files: [...names, "late.png"],
44
+ })
45
+ await expect(hasKind("image")(api)).resolves.toEqual({ ok: true })
46
+ })
47
+ })
48
+
49
+ describe("hasMime", () => {
50
+ test("matches formats a media kind cannot express", async () => {
51
+ const { api } = createResourceAPIFixture({
52
+ files: ["book.bin"],
53
+ types: {
54
+ "book.bin": {
55
+ mime: "application/epub+zip",
56
+ ext: ".epub",
57
+ kind: "other",
58
+ source: "magic",
59
+ },
60
+ },
61
+ })
62
+ await expect(hasMime(/^application\/epub/)(api)).resolves.toEqual({
63
+ ok: true,
64
+ })
65
+ await expect(hasMime(/^application\/pdf/)(api)).resolves.toEqual({
66
+ ok: false,
67
+ reasons: ["required-mime"],
68
+ })
69
+ })
70
+
71
+ test("a string matches by exact equality", async () => {
72
+ const { api } = createResourceAPIFixture({
73
+ files: ["book.bin"],
74
+ types: {
75
+ "book.bin": {
76
+ mime: "application/epub+zip",
77
+ ext: ".epub",
78
+ kind: "other",
79
+ source: "magic",
80
+ },
81
+ },
82
+ })
83
+ await expect(hasMime("application/epub+zip")(api)).resolves.toEqual({
84
+ ok: true,
85
+ })
86
+ await expect(hasMime("application/pdf")(api)).resolves.toEqual({
87
+ ok: false,
88
+ reasons: ["required-mime"],
89
+ })
90
+ })
91
+ })
92
+
93
+ describe("files.firstOfKind", () => {
94
+ test("returns the first file whose content matches", async () => {
95
+ const { api } = createResourceAPIFixture({
96
+ files: ["readme.txt", "cover.bin", "page.png"],
97
+ types: {
98
+ "cover.bin": {
99
+ mime: "image/webp",
100
+ ext: ".webp",
101
+ kind: "image",
102
+ source: "magic",
103
+ },
104
+ },
105
+ })
106
+ await expect(files.firstOfKind(api, "image")).resolves.toBe("cover.bin")
107
+ })
108
+
109
+ test("resolves to undefined when nothing matches", async () => {
110
+ const { api } = createResourceAPIFixture({ files: ["readme.txt"] })
111
+ await expect(
112
+ files.firstOfKind(api, "image", "video"),
113
+ ).resolves.toBeUndefined()
114
+ })
115
+ })
@@ -0,0 +1,177 @@
1
+ import type { Detection, ResourceAPI } from "@hoardodile/sdk-types"
2
+ import { err, ok } from "@hoardodile/sdk-types"
3
+ import type { MediaKind } from "@hoardodile/sdk-types/media-exts"
4
+ import { extname, mapConcurrent } from "./helpers.ts"
5
+
6
+ /** A detector evaluates a resource and returns a {@link Detection}. */
7
+ export type Detector = (api: ResourceAPI) => Promise<Detection>
8
+
9
+ /** Sniff fan-out bound: detection reads a small header per candidate. */
10
+ const SNIFF_CONCURRENCY = 8
11
+
12
+ /** Detect when all given detectors pass. */
13
+ export function all(...detectors: readonly Detector[]): Detector {
14
+ return async function detectAll(api) {
15
+ for (const detector of detectors) {
16
+ const result = await detector(api)
17
+ if (!result.ok) return result
18
+ }
19
+ return ok()
20
+ }
21
+ }
22
+
23
+ /** Detect when at least one given detector passes. */
24
+ export function any(...detectors: readonly Detector[]): Detector {
25
+ return async function detectAny(api) {
26
+ for (const detector of detectors) {
27
+ const result = await detector(api)
28
+ if (result.ok) return ok()
29
+ }
30
+ return err({ reasons: ["no-detector-matched"] })
31
+ }
32
+ }
33
+
34
+ /** Negate a detector: passes when the wrapped detector fails. */
35
+ export function not(detector: Detector, reasons: readonly string[]): Detector {
36
+ return async function detectNot(api) {
37
+ const result = await detector(api)
38
+ return result.ok ? err({ reasons }) : ok()
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Detect when the resource has at least one file with any of the given
44
+ * extensions.
45
+ *
46
+ * Extension matching is the fast path: it costs one filename comparison
47
+ * and no reads. It is also only as good as the names — reach for
48
+ * {@link hasKind} or {@link hasMime} when the archive may carry
49
+ * mislabelled or extension-less files.
50
+ */
51
+ export function hasExt(extensions: ReadonlySet<string>): Detector {
52
+ return async function detectHasExt(api) {
53
+ const files = await api.listFileNames()
54
+ const has = files.some((name) => extensions.has(extname(name)))
55
+ return has ? ok() : err({ reasons: ["required-extension"] })
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Detect when at least one file's **content** belongs to `kind`. Reads a
61
+ * small header per file (bounded fan-out, short-circuits on the first
62
+ * match), so a resource of images detects as such no matter what the
63
+ * files are called.
64
+ */
65
+ export function hasKind(kind: MediaKind): Detector {
66
+ return async function detectHasKind(api) {
67
+ const matched = await someFileType(api, (type) => type.kind === kind)
68
+ return matched ? ok() : err({ reasons: [`required-kind:${kind}`] })
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Detect when at least one file's sniffed MIME type matches `pattern` —
74
+ * the content-based counterpart of {@link hasName}, for formats a media
75
+ * kind cannot express (`application/epub+zip`, `application/pdf`).
76
+ *
77
+ * A string matches by exact equality (use a `RegExp` for prefix or
78
+ * wildcard matching).
79
+ */
80
+ export function hasMime(pattern: RegExp | string): Detector {
81
+ return async function detectHasMime(api) {
82
+ const matched = await someFileType(api, (type) =>
83
+ typeof pattern === "string"
84
+ ? type.mime === pattern
85
+ : pattern.test(type.mime),
86
+ )
87
+ return matched ? ok() : err({ reasons: ["required-mime"] })
88
+ }
89
+ }
90
+
91
+ /**
92
+ * True when any file's sniffed type satisfies `predicate`. Files are
93
+ * sniffed in bounded parallel batches and the walk stops at the first
94
+ * match, so a 10k-entry archive does not pay for a full scan.
95
+ */
96
+ async function someFileType(
97
+ api: ResourceAPI,
98
+ predicate: (
99
+ type: NonNullable<Awaited<ReturnType<ResourceAPI["sniff"]>>>,
100
+ ) => boolean,
101
+ ): Promise<boolean> {
102
+ const files = await api.listFileNames()
103
+ for (let i = 0; i < files.length; i += SNIFF_CONCURRENCY) {
104
+ const batch = files.slice(i, i + SNIFF_CONCURRENCY)
105
+ const types = await mapConcurrent(batch, SNIFF_CONCURRENCY, (name) =>
106
+ api.sniff(name),
107
+ )
108
+ if (types.some((type) => type !== undefined && predicate(type))) return true
109
+ }
110
+ return false
111
+ }
112
+
113
+ /** Detect when the resource has a file whose name matches the given regex. */
114
+ export function hasName(pattern: RegExp): Detector {
115
+ return async function detectHasName(api) {
116
+ const files = await api.listFileNames()
117
+ const has = files.some((name) => pattern.test(name))
118
+ return has ? ok() : err({ reasons: ["required-file"] })
119
+ }
120
+ }
121
+
122
+ /** Detect when the resource has at least `count` files. */
123
+ export function minFiles(count: number): Detector {
124
+ return async function detectMinFiles(api) {
125
+ const files = await api.listFileNames()
126
+ return files.length >= count
127
+ ? ok()
128
+ : err({ reasons: ["insufficient-files"] })
129
+ }
130
+ }
131
+
132
+ /** File-selection helpers that operate on a {@link ResourceAPI}. */
133
+ export const files = {
134
+ /**
135
+ * Return the first file matching any of the given extension sets,
136
+ * or `undefined` when none match.
137
+ */
138
+ async firstMatching(
139
+ api: ResourceAPI,
140
+ ...extensions: readonly ReadonlySet<string>[]
141
+ ): Promise<string | undefined> {
142
+ const allFiles = await api.listFileNames()
143
+ for (const filename of allFiles) {
144
+ const ext = extname(filename)
145
+ for (const set of extensions) {
146
+ if (set.has(ext)) return filename
147
+ }
148
+ }
149
+ return undefined
150
+ },
151
+
152
+ /**
153
+ * Return the first file whose **content** belongs to one of the given
154
+ * media kinds, or `undefined` when none does. The content-based
155
+ * counterpart of {@link files.firstMatching} — pick it when the
156
+ * chosen file is going to be decoded anyway (a cover, a first page),
157
+ * where a mislabelled name would otherwise cost a failed render.
158
+ */
159
+ async firstOfKind(
160
+ api: ResourceAPI,
161
+ ...kinds: readonly MediaKind[]
162
+ ): Promise<string | undefined> {
163
+ const wanted = new Set<MediaKind>(kinds)
164
+ const allFiles = await api.listFileNames()
165
+ for (let i = 0; i < allFiles.length; i += SNIFF_CONCURRENCY) {
166
+ const batch = allFiles.slice(i, i + SNIFF_CONCURRENCY)
167
+ const types = await mapConcurrent(batch, SNIFF_CONCURRENCY, (name) =>
168
+ api.sniff(name),
169
+ )
170
+ const hit = types.findIndex(
171
+ (type) => type !== undefined && wanted.has(type.kind),
172
+ )
173
+ if (hit !== -1) return batch[hit]
174
+ }
175
+ return undefined
176
+ },
177
+ } as const
@@ -0,0 +1,250 @@
1
+ import { createResourceAPIFixture } from "@hoardodile/sdk-types"
2
+ import { describe, expect, test } from "vitest"
3
+ import {
4
+ countSourceMeta,
5
+ imageHashesFor,
6
+ imageHashesForFile,
7
+ mapConcurrent,
8
+ mediaFileList,
9
+ } from "./helpers.ts"
10
+
11
+ describe("mapConcurrent", () => {
12
+ test("maps every item and preserves input order", async () => {
13
+ const items = Array.from({ length: 20 }, (_, i) => i)
14
+ const result = await mapConcurrent(items, 4, async (n) => {
15
+ // Later items resolve faster — order must still follow the input.
16
+ await new Promise((resolve) => setTimeout(resolve, 20 - n))
17
+ return n * 10
18
+ })
19
+ expect(result).toEqual(items.map((n) => n * 10))
20
+ })
21
+
22
+ test("never exceeds the concurrency limit", async () => {
23
+ let inFlight = 0
24
+ let maxInFlight = 0
25
+ const items = Array.from({ length: 30 }, (_, i) => i)
26
+ await mapConcurrent(items, 5, async () => {
27
+ inFlight++
28
+ maxInFlight = Math.max(maxInFlight, inFlight)
29
+ await new Promise((resolve) => setTimeout(resolve, 2))
30
+ inFlight--
31
+ })
32
+ expect(maxInFlight).toBe(5)
33
+ })
34
+
35
+ test("runs fully in parallel when the limit covers all items", async () => {
36
+ let inFlight = 0
37
+ let maxInFlight = 0
38
+ await mapConcurrent([1, 2, 3], 10, async () => {
39
+ inFlight++
40
+ maxInFlight = Math.max(maxInFlight, inFlight)
41
+ await new Promise((resolve) => setTimeout(resolve, 2))
42
+ inFlight--
43
+ })
44
+ expect(maxInFlight).toBe(3)
45
+ })
46
+
47
+ test("propagates the first rejection", async () => {
48
+ await expect(
49
+ mapConcurrent([1, 2, 3], 2, async (n) => {
50
+ if (n === 2) throw new Error("boom")
51
+ await new Promise((resolve) => setTimeout(resolve, 5))
52
+ return n
53
+ }),
54
+ ).rejects.toThrow("boom")
55
+ })
56
+
57
+ test("handles an empty input", async () => {
58
+ await expect(mapConcurrent([], 4, async () => 1)).resolves.toEqual([])
59
+ })
60
+ })
61
+
62
+ describe("imageHashesForFile", () => {
63
+ test("maps the requested kinds to ImageHash entries", async () => {
64
+ const fixture = createResourceAPIFixture({
65
+ imageHashes: {
66
+ "1.jpg": {
67
+ hashes: [
68
+ { scope: "1.jpg", type: "sha256", value: "aa" },
69
+ { scope: "1.jpg", type: "dhash", value: "bb" },
70
+ { scope: "1.jpg", type: "phash", value: "cc" },
71
+ ],
72
+ },
73
+ },
74
+ })
75
+ await expect(
76
+ imageHashesForFile(fixture.api, "1.jpg", ["sha256", "dhash", "phash"]),
77
+ ).resolves.toEqual([
78
+ { scope: "1.jpg", type: "sha256", value: "aa" },
79
+ { scope: "1.jpg", type: "dhash", value: "bb" },
80
+ { scope: "1.jpg", type: "phash", value: "cc" },
81
+ ])
82
+ })
83
+
84
+ test("only returns the requested kinds", async () => {
85
+ const fixture = createResourceAPIFixture({
86
+ imageHashes: {
87
+ "1.jpg": {
88
+ hashes: [
89
+ { scope: "1.jpg", type: "sha256", value: "aa" },
90
+ { scope: "1.jpg", type: "dhash", value: "bb" },
91
+ ],
92
+ },
93
+ },
94
+ })
95
+ await expect(
96
+ imageHashesForFile(fixture.api, "1.jpg", ["dhash"]),
97
+ ).resolves.toEqual([{ scope: "1.jpg", type: "dhash", value: "bb" }])
98
+ })
99
+
100
+ test("resolves to [] for undecodable files", async () => {
101
+ const fixture = createResourceAPIFixture()
102
+ await expect(
103
+ imageHashesForFile(fixture.api, "broken.png"),
104
+ ).resolves.toEqual([])
105
+ })
106
+ })
107
+
108
+ describe("imageHashesFor", () => {
109
+ test("hashes every image file and skips non-images", async () => {
110
+ const fixture = createResourceAPIFixture({
111
+ files: ["1.jpg", "2.png", "readme.txt"],
112
+ imageHashes: {
113
+ "1.jpg": {
114
+ hashes: [
115
+ { scope: "1.jpg", type: "sha256", value: "aa" },
116
+ { scope: "1.jpg", type: "dhash", value: "bb" },
117
+ ],
118
+ },
119
+ "2.png": {
120
+ hashes: [
121
+ { scope: "2.png", type: "sha256", value: "cc" },
122
+ { scope: "2.png", type: "dhash", value: "dd" },
123
+ ],
124
+ },
125
+ },
126
+ })
127
+ await expect(imageHashesFor(fixture.api)).resolves.toEqual({
128
+ hashes: [
129
+ { scope: "1.jpg", type: "sha256", value: "aa" },
130
+ { scope: "1.jpg", type: "dhash", value: "bb" },
131
+ { scope: "2.png", type: "sha256", value: "cc" },
132
+ { scope: "2.png", type: "dhash", value: "dd" },
133
+ ],
134
+ })
135
+ })
136
+
137
+ test("defaults to sha256 + dhash and tolerates partial results", async () => {
138
+ const fixture = createResourceAPIFixture({
139
+ files: ["1.jpg"],
140
+ imageHashes: {
141
+ "1.jpg": {
142
+ hashes: [{ scope: "1.jpg", type: "dhash", value: "bb" }],
143
+ },
144
+ },
145
+ })
146
+ await expect(imageHashesFor(fixture.api)).resolves.toEqual({
147
+ hashes: [{ scope: "1.jpg", type: "dhash", value: "bb" }],
148
+ })
149
+ })
150
+
151
+ test("returns an empty list for image-less resources", async () => {
152
+ const fixture = createResourceAPIFixture({ files: ["notes.txt"] })
153
+ await expect(imageHashesFor(fixture.api)).resolves.toEqual({ hashes: [] })
154
+ })
155
+ })
156
+
157
+ describe("mediaFileList", () => {
158
+ test("lists every decodable media file in canonical order", async () => {
159
+ const fixture = createResourceAPIFixture({
160
+ files: ["10.jpg", "2.jpg", "01.mp4", "track.mp3", "readme.txt"],
161
+ probes: {
162
+ ".mp4": {
163
+ kind: "video",
164
+ mime: "video/mp4",
165
+ width: 640,
166
+ height: 480,
167
+ durationMs: 1000,
168
+ },
169
+ ".mp3": { kind: "audio", mime: "audio/mpeg", durationMs: 3000 },
170
+ "": {
171
+ kind: "image",
172
+ mime: "image/jpeg",
173
+ width: 800,
174
+ height: 1200,
175
+ animated: false,
176
+ },
177
+ },
178
+ })
179
+ const result = await mediaFileList(fixture.api)
180
+ expect(result.map((entry) => entry.filename)).toEqual([
181
+ "10.jpg",
182
+ "2.jpg",
183
+ "01.mp4",
184
+ "track.mp3",
185
+ ])
186
+ expect(result[0]).toMatchObject({ type: "image", width: 800 })
187
+ expect(result[1]).toMatchObject({ type: "image", width: 800 })
188
+ expect(result[2]).toMatchObject({
189
+ type: "video",
190
+ width: 640,
191
+ durationMs: 1000,
192
+ sniffed: { mime: "video/mp4" },
193
+ })
194
+ expect(result[3]).toMatchObject({ type: "audio", durationMs: 3000 })
195
+ })
196
+
197
+ test("accepts a pre-filtered name list without re-listing", async () => {
198
+ const fixture = createResourceAPIFixture({
199
+ files: ["a.jpg", "sub/b.jpg"],
200
+ probes: {
201
+ "": {
202
+ kind: "image",
203
+ mime: "image/jpeg",
204
+ width: 10,
205
+ height: 10,
206
+ animated: false,
207
+ },
208
+ },
209
+ })
210
+ const result = await mediaFileList(fixture.api, {
211
+ names: ["a.jpg"],
212
+ })
213
+ expect(result.map((entry) => entry.filename)).toEqual(["a.jpg"])
214
+ })
215
+
216
+ test("drops files that are not decodable media", async () => {
217
+ const fixture = createResourceAPIFixture({
218
+ files: ["a.jpg", "notes.txt"],
219
+ probes: {
220
+ "": {
221
+ kind: "other",
222
+ mime: "text/plain",
223
+ },
224
+ },
225
+ types: {
226
+ "a.jpg": {
227
+ mime: "image/jpeg",
228
+ ext: ".jpg",
229
+ kind: "image",
230
+ source: "magic",
231
+ },
232
+ },
233
+ })
234
+ await expect(mediaFileList(fixture.api)).resolves.toEqual([])
235
+ })
236
+ })
237
+
238
+ describe("countSourceMeta", () => {
239
+ test("reports the file count", async () => {
240
+ const fixture = createResourceAPIFixture({ files: ["a", "b", "c"] })
241
+ await expect(countSourceMeta(fixture.api)).resolves.toEqual({
242
+ fileCount: 3,
243
+ })
244
+ })
245
+
246
+ test("resolves to undefined for empty resources", async () => {
247
+ const fixture = createResourceAPIFixture()
248
+ await expect(countSourceMeta(fixture.api)).resolves.toBeUndefined()
249
+ })
250
+ })