@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.
- package/LICENSE +18 -0
- package/README.md +67 -0
- package/dist/helpers.d.ts +121 -0
- package/dist/helpers.js +183 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/detectors.test.ts +115 -0
- package/src/detectors.ts +177 -0
- package/src/helpers.test.ts +250 -0
- package/src/helpers.ts +369 -0
- package/src/index.ts +70 -0
- package/src/result-reexports.test.ts +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Wooloo <ayan0312000@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @hoardodile/sdk-server
|
|
2
|
+
|
|
3
|
+
The authoring surface for hoardodile content plugin `main.js` files — the
|
|
4
|
+
**only** package you need on the server side of a plugin.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm add @hoardodile/sdk-server
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What's in it
|
|
13
|
+
|
|
14
|
+
- **`definePlugin()`** — declarative plugin definition with runtime shape
|
|
15
|
+
validation (unknown hooks and synchronous hooks are rejected at load
|
|
16
|
+
time, not at hook time)
|
|
17
|
+
- **Composable detectors** — `hasKind`, `hasMime`, `hasExt`, `hasName`,
|
|
18
|
+
`minFiles`, `all`, `any`, `not`, `files`
|
|
19
|
+
- **`ResourceAPI`** — the typed, resource-scoped API every hook receives
|
|
20
|
+
(`listFiles`, `readFile`, `statFile`, `statFiles`, `sniff`, `probe`,
|
|
21
|
+
`hashBytes`, `computeImageHashes`, scoped logging)
|
|
22
|
+
- **Fixtures** — `createResourceAPIFixture`, `stubLogger` for unit tests
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
// src/main.ts
|
|
28
|
+
import { any, definePlugin, hasKind } from "@hoardodile/sdk-server"
|
|
29
|
+
import { probeMediaFile } from "@hoardodile/sdk-server/helpers"
|
|
30
|
+
|
|
31
|
+
export default definePlugin({
|
|
32
|
+
// Content decides, so mislabelled and extension-less files still match.
|
|
33
|
+
detect: any(hasKind("image"), hasKind("video")),
|
|
34
|
+
sourceMeta: async (api) => {
|
|
35
|
+
const files = await api.listFileNames()
|
|
36
|
+
return {
|
|
37
|
+
files,
|
|
38
|
+
previews: await Promise.all(files.map((f) => probeMediaFile(api, f))),
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Subpaths
|
|
45
|
+
|
|
46
|
+
| Entry | Contents |
|
|
47
|
+
| ----- | -------- |
|
|
48
|
+
| `@hoardodile/sdk-server` | The authoring surface (root) |
|
|
49
|
+
| `@hoardodile/sdk-server/helpers` | Probe/file helpers: `extname`, `mapConcurrent`, `naturalSort`, `probeMediaFile`, `probeImageFile`, `probeVideoFile`, `probeAudioFile`, `readFileChunks` |
|
|
50
|
+
|
|
51
|
+
## Where does the code live?
|
|
52
|
+
|
|
53
|
+
`definePlugin`, `ResourceAPI`, the fixtures and the hook-name contract
|
|
54
|
+
are implemented in `@hoardodile/sdk-types` and re-exported here so
|
|
55
|
+
plugin authors have a single import root. This package is
|
|
56
|
+
dependency-closed within the SDK — it never imports `@hoardodile/host`.
|
|
57
|
+
Dev-time test tooling (`runPluginHook`, `createDirectoryResourceAPI`) is
|
|
58
|
+
available from `@hoardodile/host` as a **devDependency** (never bundled
|
|
59
|
+
into the shipped plugin): it runs hooks against a real directory for
|
|
60
|
+
Layer-2 tests.
|
|
61
|
+
|
|
62
|
+
## Licensing
|
|
63
|
+
|
|
64
|
+
MIT. The plugin contract, the SDK packages and the plugin code you write
|
|
65
|
+
are all permissive — a plugin built with this SDK is an independent
|
|
66
|
+
work and can be released under any license (MIT, GPL, proprietary, or
|
|
67
|
+
none at all).
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { ImageHashKind, FileType, ResourceAPI, ImageHashesResult, ImageHash } from '@hoardodile/sdk-types';
|
|
2
|
+
|
|
3
|
+
/** Return the lower-cased extension from the last dot, or `""` when there is none. */
|
|
4
|
+
declare function extname(filename: string): string;
|
|
5
|
+
/** Natural-sort filenames (case-insensitive, numeric). Mutates and returns. */
|
|
6
|
+
declare function naturalSort(files: readonly string[]): string[];
|
|
7
|
+
/**
|
|
8
|
+
* Map items with at most `limit` promises in flight. Results keep input
|
|
9
|
+
* order; the first rejection aborts the map (in-flight calls settle).
|
|
10
|
+
* Probe loops use this to fan out across the host's concurrent API
|
|
11
|
+
* dispatch instead of trickling one RPC at a time.
|
|
12
|
+
*/
|
|
13
|
+
declare function mapConcurrent<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
14
|
+
/** File-list item shapes produced by the probe helpers. */
|
|
15
|
+
type ProbedImageFile = {
|
|
16
|
+
readonly type: "image";
|
|
17
|
+
readonly width?: number;
|
|
18
|
+
readonly height?: number;
|
|
19
|
+
readonly preview: boolean;
|
|
20
|
+
};
|
|
21
|
+
type ProbedVideoFile = {
|
|
22
|
+
readonly type: "video";
|
|
23
|
+
readonly width?: number;
|
|
24
|
+
readonly height?: number;
|
|
25
|
+
readonly durationMs?: number;
|
|
26
|
+
};
|
|
27
|
+
type ProbedAudioFile = {
|
|
28
|
+
readonly type: "audio";
|
|
29
|
+
readonly durationMs?: number;
|
|
30
|
+
readonly hasCoverArt?: boolean;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Probe a file and return the file-item shaped object matching what the
|
|
34
|
+
* content actually is, or `undefined` for non-media files. This is the
|
|
35
|
+
* primitive: plugins that accept mixed media route on the result
|
|
36
|
+
* instead of pre-sorting by extension, and the three per-kind helpers
|
|
37
|
+
* below are thin narrowings of it.
|
|
38
|
+
*
|
|
39
|
+
* When identification succeeds but decoding does not (no ffprobe on the
|
|
40
|
+
* host, a damaged container), the file keeps its media type and simply
|
|
41
|
+
* carries no dimensions — losing the entry entirely would be worse than
|
|
42
|
+
* showing it undecorated.
|
|
43
|
+
*/
|
|
44
|
+
declare function probeMediaFile(api: ResourceAPI, filename: string): Promise<ProbedImageFile | ProbedVideoFile | ProbedAudioFile | undefined>;
|
|
45
|
+
/**
|
|
46
|
+
* Probe a file expected to be an image. A file that turns out not to be
|
|
47
|
+
* one still yields an image-shaped item (with no dimensions), so a file
|
|
48
|
+
* list keeps its declared type.
|
|
49
|
+
*/
|
|
50
|
+
declare function probeImageFile(api: ResourceAPI, filename: string): Promise<ProbedImageFile>;
|
|
51
|
+
/** Probe a file expected to be a video. See {@link probeImageFile}. */
|
|
52
|
+
declare function probeVideoFile(api: ResourceAPI, filename: string): Promise<ProbedVideoFile>;
|
|
53
|
+
/** Probe a file expected to be audio. See {@link probeImageFile}. */
|
|
54
|
+
declare function probeAudioFile(api: ResourceAPI, filename: string): Promise<ProbedAudioFile>;
|
|
55
|
+
/**
|
|
56
|
+
* One entry of {@link mediaFileList}: the filename plus everything the
|
|
57
|
+
* probe pass learned about the file (`sniffed` is the sniffed type that
|
|
58
|
+
* decided the probe lane — the same data the entry's dimensions were
|
|
59
|
+
* routed on).
|
|
60
|
+
*/
|
|
61
|
+
type MediaFileListEntry = {
|
|
62
|
+
readonly filename: string;
|
|
63
|
+
readonly sniffed?: FileType;
|
|
64
|
+
} & (ProbedImageFile | ProbedVideoFile | ProbedAudioFile);
|
|
65
|
+
type MediaFileListOptions = {
|
|
66
|
+
/**
|
|
67
|
+
* Candidate names to probe. When absent the helper lists the
|
|
68
|
+
* resource itself (natural-sorted). Pass a pre-filtered list (e.g.
|
|
69
|
+
* top-level files only) to skip names the plugin never wants.
|
|
70
|
+
*/
|
|
71
|
+
readonly names?: readonly string[];
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Build a typed media file list in one pass: sniff every candidate,
|
|
75
|
+
* then probe images and timed media (video/audio) in separate bounded
|
|
76
|
+
* lanes — sharp header reads fan out wider than ffprobe spawns. The
|
|
77
|
+
* result keeps input order and drops files that are not decodable
|
|
78
|
+
* media. The one-call implementation for a `listFiles` hook over a
|
|
79
|
+
* flat media resource.
|
|
80
|
+
*/
|
|
81
|
+
declare function mediaFileList(api: ResourceAPI, opts?: MediaFileListOptions): Promise<readonly MediaFileListEntry[]>;
|
|
82
|
+
/**
|
|
83
|
+
* The `sourceMeta` for a bare file count — the one-liner for plugins
|
|
84
|
+
* whose card needs only `fileCount`. Resolves to `undefined` for empty
|
|
85
|
+
* resources, matching the host's "nothing to report" contract.
|
|
86
|
+
*/
|
|
87
|
+
declare function countSourceMeta(api: ResourceAPI): Promise<{
|
|
88
|
+
readonly fileCount: number;
|
|
89
|
+
} | undefined>;
|
|
90
|
+
type ReadFileChunksOptions = {
|
|
91
|
+
/** Chunk size in bytes. Defaults to 1 MiB. */
|
|
92
|
+
readonly chunkSize?: number;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Stream a file as a sequence of chunks via ranged `readFile` calls.
|
|
96
|
+
* Memory stays bounded by the chunk size on both sides of the plugin
|
|
97
|
+
* boundary — the host never buffers the whole file.
|
|
98
|
+
*/
|
|
99
|
+
declare function readFileChunks(api: ResourceAPI, path: string, opts?: ReadFileChunksOptions): AsyncGenerator<Uint8Array, void, undefined>;
|
|
100
|
+
/** Per-file default hash kinds for {@link imageHashesFor}. */
|
|
101
|
+
declare const DEFAULT_IMAGE_HASH_KINDS: readonly ImageHashKind[];
|
|
102
|
+
type ImageHashesForOptions = {
|
|
103
|
+
/** Hash kinds per image. Defaults to sha256 + dhash. */
|
|
104
|
+
readonly kinds?: readonly ImageHashKind[];
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Compute the requested hash kinds of one image file as `ImageHash`
|
|
108
|
+
* entries (`scope` = the file path). Resolves to `[]` for non-image or
|
|
109
|
+
* undecodable files.
|
|
110
|
+
*/
|
|
111
|
+
declare function imageHashesForFile(api: ResourceAPI, scope: string, kinds?: readonly ImageHashKind[]): Promise<readonly ImageHash[]>;
|
|
112
|
+
/**
|
|
113
|
+
* One-line `imageHashes` hook implementation for image plugins: hash
|
|
114
|
+
* every image file of the resource (animated sources hash their first
|
|
115
|
+
* frame). Image files are selected by content, so a mislabelled photo
|
|
116
|
+
* is still deduplicated. Plugins facing image-less resources omit the
|
|
117
|
+
* hook entirely.
|
|
118
|
+
*/
|
|
119
|
+
declare function imageHashesFor(api: ResourceAPI, opts?: ImageHashesForOptions): Promise<ImageHashesResult>;
|
|
120
|
+
|
|
121
|
+
export { DEFAULT_IMAGE_HASH_KINDS, type ImageHashesForOptions, type MediaFileListEntry, type MediaFileListOptions, type ReadFileChunksOptions, countSourceMeta, extname, imageHashesFor, imageHashesForFile, mapConcurrent, mediaFileList, naturalSort, probeAudioFile, probeImageFile, probeMediaFile, probeVideoFile, readFileChunks };
|
package/dist/helpers.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { PLUGIN_IMAGE_PROBE_CONCURRENCY, PLUGIN_VIDEO_PROBE_CONCURRENCY } from '@hoardodile/sdk-types/plugin';
|
|
2
|
+
import { RESOURCE_PREVIEW_MAX_AREA, RESOURCE_PREVIEW_SIZE_THRESHOLD } from '@hoardodile/sdk-types/resource';
|
|
3
|
+
|
|
4
|
+
// src/helpers.ts
|
|
5
|
+
function exceedsPreviewThresholds(check) {
|
|
6
|
+
const { width, height, sizeBytes } = check;
|
|
7
|
+
const exceedsArea = width !== void 0 && height !== void 0 && width * height > RESOURCE_PREVIEW_MAX_AREA;
|
|
8
|
+
const exceedsSize = sizeBytes !== void 0 && sizeBytes > RESOURCE_PREVIEW_SIZE_THRESHOLD;
|
|
9
|
+
return exceedsArea || exceedsSize;
|
|
10
|
+
}
|
|
11
|
+
function extname(filename) {
|
|
12
|
+
const dot = filename.lastIndexOf(".");
|
|
13
|
+
if (dot === -1) return "";
|
|
14
|
+
return filename.slice(dot).toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
function naturalSort(files) {
|
|
17
|
+
return [...files].sort(
|
|
18
|
+
(a, b) => a.localeCompare(b, void 0, { sensitivity: "base", numeric: true })
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
async function mapConcurrent(items, limit, fn) {
|
|
22
|
+
const results = new Array(items.length);
|
|
23
|
+
let next = 0;
|
|
24
|
+
async function lane() {
|
|
25
|
+
for (; ; ) {
|
|
26
|
+
const index = next++;
|
|
27
|
+
if (index >= items.length) return;
|
|
28
|
+
const item = items[index];
|
|
29
|
+
if (item === void 0) continue;
|
|
30
|
+
results[index] = await fn(item, index);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const lanes = Math.max(1, Math.min(limit, items.length));
|
|
34
|
+
const runners = [];
|
|
35
|
+
for (let i = 0; i < lanes; i++) runners.push(lane());
|
|
36
|
+
await Promise.all(runners);
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
39
|
+
async function probeMediaFile(api, filename) {
|
|
40
|
+
const probed = await api.probe(filename);
|
|
41
|
+
switch (probed.kind) {
|
|
42
|
+
case "image": {
|
|
43
|
+
const { width, height } = probed;
|
|
44
|
+
const sizeBytes = (await api.statFile(filename))?.sizeBytes;
|
|
45
|
+
return {
|
|
46
|
+
type: "image",
|
|
47
|
+
width,
|
|
48
|
+
height,
|
|
49
|
+
preview: exceedsPreviewThresholds({ width, height, sizeBytes })
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
case "video":
|
|
53
|
+
return {
|
|
54
|
+
type: "video",
|
|
55
|
+
width: probed.width,
|
|
56
|
+
height: probed.height,
|
|
57
|
+
durationMs: probed.durationMs
|
|
58
|
+
};
|
|
59
|
+
case "audio":
|
|
60
|
+
return {
|
|
61
|
+
type: "audio",
|
|
62
|
+
durationMs: probed.durationMs,
|
|
63
|
+
hasCoverArt: probed.coverArt === void 0 ? void 0 : true
|
|
64
|
+
};
|
|
65
|
+
case "other":
|
|
66
|
+
return void 0;
|
|
67
|
+
default:
|
|
68
|
+
return undecodedItem(await api.sniff(filename));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function undecodedItem(type) {
|
|
72
|
+
switch (type?.kind) {
|
|
73
|
+
case "image":
|
|
74
|
+
return { type: "image", preview: false };
|
|
75
|
+
case "video":
|
|
76
|
+
return { type: "video" };
|
|
77
|
+
case "audio":
|
|
78
|
+
return { type: "audio" };
|
|
79
|
+
default:
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function probeImageFile(api, filename) {
|
|
84
|
+
const item = await probeMediaFile(api, filename);
|
|
85
|
+
return item?.type === "image" ? item : { type: "image", preview: false };
|
|
86
|
+
}
|
|
87
|
+
async function probeVideoFile(api, filename) {
|
|
88
|
+
const item = await probeMediaFile(api, filename);
|
|
89
|
+
return item?.type === "video" ? item : { type: "video" };
|
|
90
|
+
}
|
|
91
|
+
async function probeAudioFile(api, filename) {
|
|
92
|
+
const item = await probeMediaFile(api, filename);
|
|
93
|
+
return item?.type === "audio" ? item : { type: "audio" };
|
|
94
|
+
}
|
|
95
|
+
async function mediaFileList(api, opts = {}) {
|
|
96
|
+
const files = opts.names ?? await api.listFileNames();
|
|
97
|
+
const types = await mapConcurrent(
|
|
98
|
+
files,
|
|
99
|
+
PLUGIN_IMAGE_PROBE_CONCURRENCY,
|
|
100
|
+
(name) => api.sniff(name)
|
|
101
|
+
);
|
|
102
|
+
const entries = /* @__PURE__ */ new Map();
|
|
103
|
+
const imageIndexes = [];
|
|
104
|
+
const timedIndexes = [];
|
|
105
|
+
for (const index of files.keys()) {
|
|
106
|
+
const kind = types[index]?.kind;
|
|
107
|
+
if (kind === "image") imageIndexes.push(index);
|
|
108
|
+
else if (kind === "video" || kind === "audio") timedIndexes.push(index);
|
|
109
|
+
}
|
|
110
|
+
async function probeInto(index) {
|
|
111
|
+
const filename = files[index];
|
|
112
|
+
if (filename === void 0) return;
|
|
113
|
+
const probed = await probeMediaFile(api, filename);
|
|
114
|
+
if (probed === void 0) return;
|
|
115
|
+
entries.set(filename, {
|
|
116
|
+
filename,
|
|
117
|
+
sniffed: types[index],
|
|
118
|
+
...probed
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
await Promise.all([
|
|
122
|
+
mapConcurrent(imageIndexes, PLUGIN_IMAGE_PROBE_CONCURRENCY, probeInto),
|
|
123
|
+
mapConcurrent(timedIndexes, PLUGIN_VIDEO_PROBE_CONCURRENCY, probeInto)
|
|
124
|
+
]);
|
|
125
|
+
const result = [];
|
|
126
|
+
for (const filename of files) {
|
|
127
|
+
const entry = entries.get(filename);
|
|
128
|
+
if (entry !== void 0) result.push(entry);
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
async function countSourceMeta(api) {
|
|
133
|
+
const files = await api.listFileNames();
|
|
134
|
+
return files.length === 0 ? void 0 : { fileCount: files.length };
|
|
135
|
+
}
|
|
136
|
+
async function* readFileChunks(api, path, opts = {}) {
|
|
137
|
+
const chunkSize = opts.chunkSize ?? 1024 * 1024;
|
|
138
|
+
let offset = 0;
|
|
139
|
+
for (; ; ) {
|
|
140
|
+
const chunk = await api.readFile(path, {
|
|
141
|
+
start: offset,
|
|
142
|
+
end: offset + chunkSize
|
|
143
|
+
});
|
|
144
|
+
if (chunk.byteLength === 0) return;
|
|
145
|
+
yield chunk;
|
|
146
|
+
if (chunk.byteLength < chunkSize) return;
|
|
147
|
+
offset += chunk.byteLength;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
var DEFAULT_IMAGE_HASH_KINDS = [
|
|
151
|
+
"sha256",
|
|
152
|
+
"dhash"
|
|
153
|
+
];
|
|
154
|
+
async function imageHashesForFile(api, scope, kinds = DEFAULT_IMAGE_HASH_KINDS) {
|
|
155
|
+
const computed = await api.computeImageHashes(scope, kinds);
|
|
156
|
+
if (computed === void 0) return [];
|
|
157
|
+
const entries = [];
|
|
158
|
+
for (const kind of kinds) {
|
|
159
|
+
const value = computed[kind];
|
|
160
|
+
if (value !== void 0) entries.push({ scope, type: kind, value });
|
|
161
|
+
}
|
|
162
|
+
return entries;
|
|
163
|
+
}
|
|
164
|
+
async function imageHashesFor(api, opts = {}) {
|
|
165
|
+
const kinds = opts.kinds ?? DEFAULT_IMAGE_HASH_KINDS;
|
|
166
|
+
const names = await api.listFileNames();
|
|
167
|
+
const types = await mapConcurrent(
|
|
168
|
+
names,
|
|
169
|
+
PLUGIN_IMAGE_PROBE_CONCURRENCY,
|
|
170
|
+
(name) => api.sniff(name)
|
|
171
|
+
);
|
|
172
|
+
const images = names.filter((_, i) => types[i]?.kind === "image");
|
|
173
|
+
const hashes = (await mapConcurrent(
|
|
174
|
+
images,
|
|
175
|
+
PLUGIN_IMAGE_PROBE_CONCURRENCY,
|
|
176
|
+
(filename) => imageHashesForFile(api, filename, kinds)
|
|
177
|
+
)).flat();
|
|
178
|
+
return { hashes };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export { DEFAULT_IMAGE_HASH_KINDS, countSourceMeta, extname, imageHashesFor, imageHashesForFile, mapConcurrent, mediaFileList, naturalSort, probeAudioFile, probeImageFile, probeMediaFile, probeVideoFile, readFileChunks };
|
|
182
|
+
//# sourceMappingURL=helpers.js.map
|
|
183
|
+
//# sourceMappingURL=helpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/helpers.ts"],"names":[],"mappings":";;;;AAuBA,SAAS,yBAAyB,KAAA,EAItB;AACX,EAAA,MAAM,EAAE,KAAA,EAAO,MAAA,EAAQ,SAAA,EAAU,GAAI,KAAA;AACrC,EAAA,MAAM,cACL,KAAA,KAAU,MAAA,IACV,MAAA,KAAW,MAAA,IACX,QAAQ,MAAA,GAAS,yBAAA;AAClB,EAAA,MAAM,WAAA,GACL,SAAA,KAAc,MAAA,IAAa,SAAA,GAAY,+BAAA;AACxC,EAAA,OAAO,WAAA,IAAe,WAAA;AACvB;AAGO,SAAS,QAAQ,QAAA,EAA0B;AACjD,EAAA,MAAM,GAAA,GAAM,QAAA,CAAS,WAAA,CAAY,GAAG,CAAA;AACpC,EAAA,IAAI,GAAA,KAAQ,IAAI,OAAO,EAAA;AACvB,EAAA,OAAO,QAAA,CAAS,KAAA,CAAM,GAAG,CAAA,CAAE,WAAA,EAAY;AACxC;AAGO,SAAS,YAAY,KAAA,EAAoC;AAC/D,EAAA,OAAO,CAAC,GAAG,KAAK,CAAA,CAAE,IAAA;AAAA,IAAK,CAAC,CAAA,EAAG,CAAA,KAC1B,CAAA,CAAE,aAAA,CAAc,CAAA,EAAG,MAAA,EAAW,EAAE,WAAA,EAAa,MAAA,EAAQ,OAAA,EAAS,IAAA,EAAM;AAAA,GACrE;AACD;AAQA,eAAsB,aAAA,CACrB,KAAA,EACA,KAAA,EACA,EAAA,EACe;AACf,EAAA,MAAM,OAAA,GAAe,IAAI,KAAA,CAAM,KAAA,CAAM,MAAM,CAAA;AAC3C,EAAA,IAAI,IAAA,GAAO,CAAA;AACX,EAAA,eAAe,IAAA,GAAsB;AACpC,IAAA,WAAS;AACR,MAAA,MAAM,KAAA,GAAQ,IAAA,EAAA;AACd,MAAA,IAAI,KAAA,IAAS,MAAM,MAAA,EAAQ;AAC3B,MAAA,MAAM,IAAA,GAAO,MAAM,KAAK,CAAA;AACxB,MAAA,IAAI,SAAS,MAAA,EAAW;AACxB,MAAA,OAAA,CAAQ,KAAK,CAAA,GAAI,MAAM,EAAA,CAAG,MAAM,KAAK,CAAA;AAAA,IACtC;AAAA,EACD;AACA,EAAA,MAAM,KAAA,GAAQ,KAAK,GAAA,CAAI,CAAA,EAAG,KAAK,GAAA,CAAI,KAAA,EAAO,KAAA,CAAM,MAAM,CAAC,CAAA;AACvD,EAAA,MAAM,UAA2B,EAAC;AAClC,EAAA,KAAA,IAAS,CAAA,GAAI,GAAG,CAAA,GAAI,KAAA,EAAO,KAAK,OAAA,CAAQ,IAAA,CAAK,MAAM,CAAA;AACnD,EAAA,MAAM,OAAA,CAAQ,IAAI,OAAO,CAAA;AACzB,EAAA,OAAO,OAAA;AACR;AAiCA,eAAsB,cAAA,CACrB,KACA,QAAA,EAC2E;AAC3E,EAAA,MAAM,MAAA,GAAS,MAAM,GAAA,CAAI,KAAA,CAAM,QAAQ,CAAA;AACvC,EAAA,QAAQ,OAAO,IAAA;AAAM,IACpB,KAAK,OAAA,EAAS;AACb,MAAA,MAAM,EAAE,KAAA,EAAO,MAAA,EAAO,GAAI,MAAA;AAC1B,MAAA,MAAM,SAAA,GAAA,CAAa,MAAM,GAAA,CAAI,QAAA,CAAS,QAAQ,CAAA,GAAI,SAAA;AAClD,MAAA,OAAO;AAAA,QACN,IAAA,EAAM,OAAA;AAAA,QACN,KAAA;AAAA,QACA,MAAA;AAAA,QACA,SAAS,wBAAA,CAAyB,EAAE,KAAA,EAAO,MAAA,EAAQ,WAAW;AAAA,OAC/D;AAAA,IACD;AAAA,IACA,KAAK,OAAA;AACJ,MAAA,OAAO;AAAA,QACN,IAAA,EAAM,OAAA;AAAA,QACN,OAAO,MAAA,CAAO,KAAA;AAAA,QACd,QAAQ,MAAA,CAAO,MAAA;AAAA,QACf,YAAY,MAAA,CAAO;AAAA,OACpB;AAAA,IACD,KAAK,OAAA;AACJ,MAAA,OAAO;AAAA,QACN,IAAA,EAAM,OAAA;AAAA,QACN,YAAY,MAAA,CAAO,UAAA;AAAA,QACnB,WAAA,EAAa,MAAA,CAAO,QAAA,KAAa,MAAA,GAAY,MAAA,GAAY;AAAA,OAC1D;AAAA,IACD,KAAK,OAAA;AACJ,MAAA,OAAO,MAAA;AAAA,IACR;AACC,MAAA,OAAO,aAAA,CAAc,MAAM,GAAA,CAAI,KAAA,CAAM,QAAQ,CAAC,CAAA;AAAA;AAEjD;AAGA,SAAS,cACR,IAAA,EACkE;AAClE,EAAA,QAAQ,MAAM,IAAA;AAAM,IACnB,KAAK,OAAA;AACJ,MAAA,OAAO,EAAE,IAAA,EAAM,OAAA,EAAS,OAAA,EAAS,KAAA,EAAM;AAAA,IACxC,KAAK,OAAA;AACJ,MAAA,OAAO,EAAE,MAAM,OAAA,EAAQ;AAAA,IACxB,KAAK,OAAA;AACJ,MAAA,OAAO,EAAE,MAAM,OAAA,EAAQ;AAAA,IACxB;AACC,MAAA,OAAO,MAAA;AAAA;AAEV;AAOA,eAAsB,cAAA,CACrB,KACA,QAAA,EAC2B;AAC3B,EAAA,MAAM,IAAA,GAAO,MAAM,cAAA,CAAe,GAAA,EAAK,QAAQ,CAAA;AAC/C,EAAA,OAAO,IAAA,EAAM,SAAS,OAAA,GAAU,IAAA,GAAO,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,KAAA,EAAM;AACxE;AAGA,eAAsB,cAAA,CACrB,KACA,QAAA,EAC2B;AAC3B,EAAA,MAAM,IAAA,GAAO,MAAM,cAAA,CAAe,GAAA,EAAK,QAAQ,CAAA;AAC/C,EAAA,OAAO,MAAM,IAAA,KAAS,OAAA,GAAU,IAAA,GAAO,EAAE,MAAM,OAAA,EAAQ;AACxD;AAGA,eAAsB,cAAA,CACrB,KACA,QAAA,EAC2B;AAC3B,EAAA,MAAM,IAAA,GAAO,MAAM,cAAA,CAAe,GAAA,EAAK,QAAQ,CAAA;AAC/C,EAAA,OAAO,MAAM,IAAA,KAAS,OAAA,GAAU,IAAA,GAAO,EAAE,MAAM,OAAA,EAAQ;AACxD;AA8BA,eAAsB,aAAA,CACrB,GAAA,EACA,IAAA,GAA6B,EAAC,EACW;AAKzC,EAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,KAAA,IAAU,MAAM,IAAI,aAAA,EAAc;AACrD,EAAA,MAAM,QAAQ,MAAM,aAAA;AAAA,IACnB,KAAA;AAAA,IACA,8BAAA;AAAA,IACA,CAAC,IAAA,KAAS,GAAA,CAAI,KAAA,CAAM,IAAI;AAAA,GACzB;AACA,EAAA,MAAM,OAAA,uBAAc,GAAA,EAAgC;AACpD,EAAA,MAAM,eAAyB,EAAC;AAChC,EAAA,MAAM,eAAyB,EAAC;AAChC,EAAA,KAAA,MAAW,KAAA,IAAS,KAAA,CAAM,IAAA,EAAK,EAAG;AACjC,IAAA,MAAM,IAAA,GAAO,KAAA,CAAM,KAAK,CAAA,EAAG,IAAA;AAC3B,IAAA,IAAI,IAAA,KAAS,OAAA,EAAS,YAAA,CAAa,IAAA,CAAK,KAAK,CAAA;AAAA,SAAA,IACpC,SAAS,OAAA,IAAW,IAAA,KAAS,OAAA,EAAS,YAAA,CAAa,KAAK,KAAK,CAAA;AAAA,EACvE;AAEA,EAAA,eAAe,UAAU,KAAA,EAA8B;AACtD,IAAA,MAAM,QAAA,GAAW,MAAM,KAAK,CAAA;AAC5B,IAAA,IAAI,aAAa,MAAA,EAAW;AAC5B,IAAA,MAAM,MAAA,GAAS,MAAM,cAAA,CAAe,GAAA,EAAK,QAAQ,CAAA;AACjD,IAAA,IAAI,WAAW,MAAA,EAAW;AAC1B,IAAA,OAAA,CAAQ,IAAI,QAAA,EAAU;AAAA,MACrB,QAAA;AAAA,MACA,OAAA,EAAS,MAAM,KAAK,CAAA;AAAA,MACpB,GAAG;AAAA,KACH,CAAA;AAAA,EACF;AAEA,EAAA,MAAM,QAAQ,GAAA,CAAI;AAAA,IACjB,aAAA,CAAc,YAAA,EAAc,8BAAA,EAAgC,SAAS,CAAA;AAAA,IACrE,aAAA,CAAc,YAAA,EAAc,8BAAA,EAAgC,SAAS;AAAA,GACrE,CAAA;AAED,EAAA,MAAM,SAA+B,EAAC;AACtC,EAAA,KAAA,MAAW,YAAY,KAAA,EAAO;AAC7B,IAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,GAAA,CAAI,QAAQ,CAAA;AAClC,IAAA,IAAI,KAAA,KAAU,MAAA,EAAW,MAAA,CAAO,IAAA,CAAK,KAAK,CAAA;AAAA,EAC3C;AACA,EAAA,OAAO,MAAA;AACR;AAOA,eAAsB,gBACrB,GAAA,EACsD;AACtD,EAAA,MAAM,KAAA,GAAQ,MAAM,GAAA,CAAI,aAAA,EAAc;AACtC,EAAA,OAAO,MAAM,MAAA,KAAW,CAAA,GAAI,SAAY,EAAE,SAAA,EAAW,MAAM,MAAA,EAAO;AACnE;AAYA,gBAAuB,cAAA,CACtB,GAAA,EACA,IAAA,EACA,IAAA,GAA8B,EAAC,EACe;AAC9C,EAAA,MAAM,SAAA,GAAY,IAAA,CAAK,SAAA,IAAa,IAAA,GAAO,IAAA;AAC3C,EAAA,IAAI,MAAA,GAAS,CAAA;AACb,EAAA,WAAS;AACR,IAAA,MAAM,KAAA,GAAQ,MAAM,GAAA,CAAI,QAAA,CAAS,IAAA,EAAM;AAAA,MACtC,KAAA,EAAO,MAAA;AAAA,MACP,KAAK,MAAA,GAAS;AAAA,KACd,CAAA;AACD,IAAA,IAAI,KAAA,CAAM,eAAe,CAAA,EAAG;AAC5B,IAAA,MAAM,KAAA;AACN,IAAA,IAAI,KAAA,CAAM,aAAa,SAAA,EAAW;AAClC,IAAA,MAAA,IAAU,KAAA,CAAM,UAAA;AAAA,EACjB;AACD;AAGO,IAAM,wBAAA,GAAqD;AAAA,EACjE,QAAA;AAAA,EACA;AACD;AAYA,eAAsB,kBAAA,CACrB,GAAA,EACA,KAAA,EACA,KAAA,GAAkC,wBAAA,EACF;AAChC,EAAA,MAAM,QAAA,GAAW,MAAM,GAAA,CAAI,kBAAA,CAAmB,OAAO,KAAK,CAAA;AAC1D,EAAA,IAAI,QAAA,KAAa,MAAA,EAAW,OAAO,EAAC;AACpC,EAAA,MAAM,UAAuB,EAAC;AAC9B,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACzB,IAAA,MAAM,KAAA,GAAQ,SAAS,IAAI,CAAA;AAC3B,IAAA,IAAI,KAAA,KAAU,QAAW,OAAA,CAAQ,IAAA,CAAK,EAAE,KAAA,EAAO,IAAA,EAAM,IAAA,EAAM,KAAA,EAAO,CAAA;AAAA,EACnE;AACA,EAAA,OAAO,OAAA;AACR;AASA,eAAsB,cAAA,CACrB,GAAA,EACA,IAAA,GAA8B,EAAC,EACF;AAC7B,EAAA,MAAM,KAAA,GAAQ,KAAK,KAAA,IAAS,wBAAA;AAC5B,EAAA,MAAM,KAAA,GAAQ,MAAM,GAAA,CAAI,aAAA,EAAc;AACtC,EAAA,MAAM,QAAQ,MAAM,aAAA;AAAA,IACnB,KAAA;AAAA,IACA,8BAAA;AAAA,IACA,CAAC,IAAA,KAAS,GAAA,CAAI,KAAA,CAAM,IAAI;AAAA,GACzB;AACA,EAAA,MAAM,MAAA,GAAS,KAAA,CAAM,MAAA,CAAO,CAAC,CAAA,EAAG,MAAM,KAAA,CAAM,CAAC,CAAA,EAAG,IAAA,KAAS,OAAO,CAAA;AAChE,EAAA,MAAM,UACL,MAAM,aAAA;AAAA,IAAc,MAAA;AAAA,IAAQ,8BAAA;AAAA,IAAgC,CAAC,QAAA,KAC5D,kBAAA,CAAmB,GAAA,EAAK,UAAU,KAAK;AAAA,KAEvC,IAAA,EAAK;AACP,EAAA,OAAO,EAAE,MAAA,EAAO;AACjB","file":"helpers.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"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ResourceAPI, Detection } from '@hoardodile/sdk-types';
|
|
2
|
+
export { ArchiveExtraction, ArchiveExtractionEntry, AudioCoverArt, AudioInfo, AudioTags, ContainerListing, Detection, FileType, ImageHash, ImageHashKind, ImageHashesResult, ImageInfo, Logger, MediaKind, PluginAssetDeleteResult, PluginAssetError, PluginAssetErrorName, PluginDefinition, PluginDownloadRequest, PluginDownloadResult, ProbeResult, ReadFileRange, ResourceAPI, ResourceAPIFixtureConfig, VideoInfo, assertPluginShape, createFailingPlugin, createResourceAPIFixture, definePlugin, err, fileTypeFromName, isDetected, isErr, isMissed, isOk, isPluginAssetError, matchResult, ok, pluginAssetError, stubLogger } from '@hoardodile/sdk-types';
|
|
3
|
+
import { MediaKind } from '@hoardodile/sdk-types/media-exts';
|
|
4
|
+
|
|
5
|
+
/** A detector evaluates a resource and returns a {@link Detection}. */
|
|
6
|
+
type Detector = (api: ResourceAPI) => Promise<Detection>;
|
|
7
|
+
/** Detect when all given detectors pass. */
|
|
8
|
+
declare function all(...detectors: readonly Detector[]): Detector;
|
|
9
|
+
/** Detect when at least one given detector passes. */
|
|
10
|
+
declare function any(...detectors: readonly Detector[]): Detector;
|
|
11
|
+
/** Negate a detector: passes when the wrapped detector fails. */
|
|
12
|
+
declare function not(detector: Detector, reasons: readonly string[]): Detector;
|
|
13
|
+
/**
|
|
14
|
+
* Detect when the resource has at least one file with any of the given
|
|
15
|
+
* extensions.
|
|
16
|
+
*
|
|
17
|
+
* Extension matching is the fast path: it costs one filename comparison
|
|
18
|
+
* and no reads. It is also only as good as the names — reach for
|
|
19
|
+
* {@link hasKind} or {@link hasMime} when the archive may carry
|
|
20
|
+
* mislabelled or extension-less files.
|
|
21
|
+
*/
|
|
22
|
+
declare function hasExt(extensions: ReadonlySet<string>): Detector;
|
|
23
|
+
/**
|
|
24
|
+
* Detect when at least one file's **content** belongs to `kind`. Reads a
|
|
25
|
+
* small header per file (bounded fan-out, short-circuits on the first
|
|
26
|
+
* match), so a resource of images detects as such no matter what the
|
|
27
|
+
* files are called.
|
|
28
|
+
*/
|
|
29
|
+
declare function hasKind(kind: MediaKind): Detector;
|
|
30
|
+
/**
|
|
31
|
+
* Detect when at least one file's sniffed MIME type matches `pattern` —
|
|
32
|
+
* the content-based counterpart of {@link hasName}, for formats a media
|
|
33
|
+
* kind cannot express (`application/epub+zip`, `application/pdf`).
|
|
34
|
+
*
|
|
35
|
+
* A string matches by exact equality (use a `RegExp` for prefix or
|
|
36
|
+
* wildcard matching).
|
|
37
|
+
*/
|
|
38
|
+
declare function hasMime(pattern: RegExp | string): Detector;
|
|
39
|
+
/** Detect when the resource has a file whose name matches the given regex. */
|
|
40
|
+
declare function hasName(pattern: RegExp): Detector;
|
|
41
|
+
/** Detect when the resource has at least `count` files. */
|
|
42
|
+
declare function minFiles(count: number): Detector;
|
|
43
|
+
/** File-selection helpers that operate on a {@link ResourceAPI}. */
|
|
44
|
+
declare const files: {
|
|
45
|
+
/**
|
|
46
|
+
* Return the first file matching any of the given extension sets,
|
|
47
|
+
* or `undefined` when none match.
|
|
48
|
+
*/
|
|
49
|
+
readonly firstMatching: (api: ResourceAPI, ...extensions: readonly ReadonlySet<string>[]) => Promise<string | undefined>;
|
|
50
|
+
/**
|
|
51
|
+
* Return the first file whose **content** belongs to one of the given
|
|
52
|
+
* media kinds, or `undefined` when none does. The content-based
|
|
53
|
+
* counterpart of {@link files.firstMatching} — pick it when the
|
|
54
|
+
* chosen file is going to be decoded anyway (a cover, a first page),
|
|
55
|
+
* where a mislabelled name would otherwise cost a failed render.
|
|
56
|
+
*/
|
|
57
|
+
readonly firstOfKind: (api: ResourceAPI, ...kinds: readonly MediaKind[]) => Promise<string | undefined>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export { type Detector, all, any, files, hasExt, hasKind, hasMime, hasName, minFiles, not };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ok, err } from '@hoardodile/sdk-types';
|
|
2
|
+
export { assertPluginShape, createFailingPlugin, createResourceAPIFixture, definePlugin, err, fileTypeFromName, isDetected, isErr, isMissed, isOk, isPluginAssetError, matchResult, ok, pluginAssetError, stubLogger } from '@hoardodile/sdk-types';
|
|
3
|
+
import '@hoardodile/sdk-types/plugin';
|
|
4
|
+
import '@hoardodile/sdk-types/resource';
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
function extname(filename) {
|
|
8
|
+
const dot = filename.lastIndexOf(".");
|
|
9
|
+
if (dot === -1) return "";
|
|
10
|
+
return filename.slice(dot).toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
async function mapConcurrent(items, limit, fn) {
|
|
13
|
+
const results = new Array(items.length);
|
|
14
|
+
let next = 0;
|
|
15
|
+
async function lane() {
|
|
16
|
+
for (; ; ) {
|
|
17
|
+
const index = next++;
|
|
18
|
+
if (index >= items.length) return;
|
|
19
|
+
const item = items[index];
|
|
20
|
+
if (item === void 0) continue;
|
|
21
|
+
results[index] = await fn(item, index);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const lanes = Math.max(1, Math.min(limit, items.length));
|
|
25
|
+
const runners = [];
|
|
26
|
+
for (let i = 0; i < lanes; i++) runners.push(lane());
|
|
27
|
+
await Promise.all(runners);
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/detectors.ts
|
|
32
|
+
var SNIFF_CONCURRENCY = 8;
|
|
33
|
+
function all(...detectors) {
|
|
34
|
+
return async function detectAll(api) {
|
|
35
|
+
for (const detector of detectors) {
|
|
36
|
+
const result = await detector(api);
|
|
37
|
+
if (!result.ok) return result;
|
|
38
|
+
}
|
|
39
|
+
return ok();
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function any(...detectors) {
|
|
43
|
+
return async function detectAny(api) {
|
|
44
|
+
for (const detector of detectors) {
|
|
45
|
+
const result = await detector(api);
|
|
46
|
+
if (result.ok) return ok();
|
|
47
|
+
}
|
|
48
|
+
return err({ reasons: ["no-detector-matched"] });
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function not(detector, reasons) {
|
|
52
|
+
return async function detectNot(api) {
|
|
53
|
+
const result = await detector(api);
|
|
54
|
+
return result.ok ? err({ reasons }) : ok();
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function hasExt(extensions) {
|
|
58
|
+
return async function detectHasExt(api) {
|
|
59
|
+
const files2 = await api.listFileNames();
|
|
60
|
+
const has = files2.some((name) => extensions.has(extname(name)));
|
|
61
|
+
return has ? ok() : err({ reasons: ["required-extension"] });
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function hasKind(kind) {
|
|
65
|
+
return async function detectHasKind(api) {
|
|
66
|
+
const matched = await someFileType(api, (type) => type.kind === kind);
|
|
67
|
+
return matched ? ok() : err({ reasons: [`required-kind:${kind}`] });
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function hasMime(pattern) {
|
|
71
|
+
return async function detectHasMime(api) {
|
|
72
|
+
const matched = await someFileType(
|
|
73
|
+
api,
|
|
74
|
+
(type) => typeof pattern === "string" ? type.mime === pattern : pattern.test(type.mime)
|
|
75
|
+
);
|
|
76
|
+
return matched ? ok() : err({ reasons: ["required-mime"] });
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function someFileType(api, predicate) {
|
|
80
|
+
const files2 = await api.listFileNames();
|
|
81
|
+
for (let i = 0; i < files2.length; i += SNIFF_CONCURRENCY) {
|
|
82
|
+
const batch = files2.slice(i, i + SNIFF_CONCURRENCY);
|
|
83
|
+
const types = await mapConcurrent(
|
|
84
|
+
batch,
|
|
85
|
+
SNIFF_CONCURRENCY,
|
|
86
|
+
(name) => api.sniff(name)
|
|
87
|
+
);
|
|
88
|
+
if (types.some((type) => type !== void 0 && predicate(type))) return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
function hasName(pattern) {
|
|
93
|
+
return async function detectHasName(api) {
|
|
94
|
+
const files2 = await api.listFileNames();
|
|
95
|
+
const has = files2.some((name) => pattern.test(name));
|
|
96
|
+
return has ? ok() : err({ reasons: ["required-file"] });
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function minFiles(count) {
|
|
100
|
+
return async function detectMinFiles(api) {
|
|
101
|
+
const files2 = await api.listFileNames();
|
|
102
|
+
return files2.length >= count ? ok() : err({ reasons: ["insufficient-files"] });
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
var files = {
|
|
106
|
+
/**
|
|
107
|
+
* Return the first file matching any of the given extension sets,
|
|
108
|
+
* or `undefined` when none match.
|
|
109
|
+
*/
|
|
110
|
+
async firstMatching(api, ...extensions) {
|
|
111
|
+
const allFiles = await api.listFileNames();
|
|
112
|
+
for (const filename of allFiles) {
|
|
113
|
+
const ext = extname(filename);
|
|
114
|
+
for (const set of extensions) {
|
|
115
|
+
if (set.has(ext)) return filename;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return void 0;
|
|
119
|
+
},
|
|
120
|
+
/**
|
|
121
|
+
* Return the first file whose **content** belongs to one of the given
|
|
122
|
+
* media kinds, or `undefined` when none does. The content-based
|
|
123
|
+
* counterpart of {@link files.firstMatching} — pick it when the
|
|
124
|
+
* chosen file is going to be decoded anyway (a cover, a first page),
|
|
125
|
+
* where a mislabelled name would otherwise cost a failed render.
|
|
126
|
+
*/
|
|
127
|
+
async firstOfKind(api, ...kinds) {
|
|
128
|
+
const wanted = new Set(kinds);
|
|
129
|
+
const allFiles = await api.listFileNames();
|
|
130
|
+
for (let i = 0; i < allFiles.length; i += SNIFF_CONCURRENCY) {
|
|
131
|
+
const batch = allFiles.slice(i, i + SNIFF_CONCURRENCY);
|
|
132
|
+
const types = await mapConcurrent(
|
|
133
|
+
batch,
|
|
134
|
+
SNIFF_CONCURRENCY,
|
|
135
|
+
(name) => api.sniff(name)
|
|
136
|
+
);
|
|
137
|
+
const hit = types.findIndex(
|
|
138
|
+
(type) => type !== void 0 && wanted.has(type.kind)
|
|
139
|
+
);
|
|
140
|
+
if (hit !== -1) return batch[hit];
|
|
141
|
+
}
|
|
142
|
+
return void 0;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export { all, any, files, hasExt, hasKind, hasMime, hasName, minFiles, not };
|
|
147
|
+
//# sourceMappingURL=index.js.map
|
|
148
|
+
//# sourceMappingURL=index.js.map
|