@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/src/helpers.ts ADDED
@@ -0,0 +1,369 @@
1
+ import type {
2
+ FileType,
3
+ ImageHash,
4
+ ImageHashesResult,
5
+ ImageHashKind,
6
+ ResourceAPI,
7
+ } from "@hoardodile/sdk-types"
8
+ import {
9
+ PLUGIN_IMAGE_PROBE_CONCURRENCY,
10
+ PLUGIN_VIDEO_PROBE_CONCURRENCY,
11
+ } from "@hoardodile/sdk-types/plugin"
12
+ import {
13
+ RESOURCE_PREVIEW_MAX_AREA,
14
+ RESOURCE_PREVIEW_SIZE_THRESHOLD,
15
+ } from "@hoardodile/sdk-types/resource"
16
+
17
+ /**
18
+ * True when an image exceeds the preview thresholds — the pixel-area
19
+ * cap **or** the byte-size threshold — and should be served through the
20
+ * preview pipeline instead of the original. Format-driven transcode
21
+ * needs (formats browsers cannot render natively) are decided by the
22
+ * consuming plugin separately.
23
+ */
24
+ function exceedsPreviewThresholds(check: {
25
+ readonly width: number | undefined
26
+ readonly height: number | undefined
27
+ readonly sizeBytes: number | undefined
28
+ }): boolean {
29
+ const { width, height, sizeBytes } = check
30
+ const exceedsArea =
31
+ width !== undefined &&
32
+ height !== undefined &&
33
+ width * height > RESOURCE_PREVIEW_MAX_AREA
34
+ const exceedsSize =
35
+ sizeBytes !== undefined && sizeBytes > RESOURCE_PREVIEW_SIZE_THRESHOLD
36
+ return exceedsArea || exceedsSize
37
+ }
38
+
39
+ /** Return the lower-cased extension from the last dot, or `""` when there is none. */
40
+ export function extname(filename: string): string {
41
+ const dot = filename.lastIndexOf(".")
42
+ if (dot === -1) return ""
43
+ return filename.slice(dot).toLowerCase()
44
+ }
45
+
46
+ /** Natural-sort filenames (case-insensitive, numeric). Mutates and returns. */
47
+ export function naturalSort(files: readonly string[]): string[] {
48
+ return [...files].sort((a, b) =>
49
+ a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }),
50
+ )
51
+ }
52
+
53
+ /**
54
+ * Map items with at most `limit` promises in flight. Results keep input
55
+ * order; the first rejection aborts the map (in-flight calls settle).
56
+ * Probe loops use this to fan out across the host's concurrent API
57
+ * dispatch instead of trickling one RPC at a time.
58
+ */
59
+ export async function mapConcurrent<T, R>(
60
+ items: readonly T[],
61
+ limit: number,
62
+ fn: (item: T, index: number) => Promise<R>,
63
+ ): Promise<R[]> {
64
+ const results: R[] = new Array(items.length)
65
+ let next = 0
66
+ async function lane(): Promise<void> {
67
+ for (;;) {
68
+ const index = next++
69
+ if (index >= items.length) return
70
+ const item = items[index]
71
+ if (item === undefined) continue
72
+ results[index] = await fn(item, index)
73
+ }
74
+ }
75
+ const lanes = Math.max(1, Math.min(limit, items.length))
76
+ const runners: Promise<void>[] = []
77
+ for (let i = 0; i < lanes; i++) runners.push(lane())
78
+ await Promise.all(runners)
79
+ return results
80
+ }
81
+
82
+ /** File-list item shapes produced by the probe helpers. */
83
+ type ProbedImageFile = {
84
+ readonly type: "image"
85
+ readonly width?: number
86
+ readonly height?: number
87
+ readonly preview: boolean
88
+ }
89
+ type ProbedVideoFile = {
90
+ readonly type: "video"
91
+ readonly width?: number
92
+ readonly height?: number
93
+ readonly durationMs?: number
94
+ }
95
+ type ProbedAudioFile = {
96
+ readonly type: "audio"
97
+ readonly durationMs?: number
98
+ readonly hasCoverArt?: boolean
99
+ }
100
+
101
+ /**
102
+ * Probe a file and return the file-item shaped object matching what the
103
+ * content actually is, or `undefined` for non-media files. This is the
104
+ * primitive: plugins that accept mixed media route on the result
105
+ * instead of pre-sorting by extension, and the three per-kind helpers
106
+ * below are thin narrowings of it.
107
+ *
108
+ * When identification succeeds but decoding does not (no ffprobe on the
109
+ * host, a damaged container), the file keeps its media type and simply
110
+ * carries no dimensions — losing the entry entirely would be worse than
111
+ * showing it undecorated.
112
+ */
113
+ export async function probeMediaFile(
114
+ api: ResourceAPI,
115
+ filename: string,
116
+ ): Promise<ProbedImageFile | ProbedVideoFile | ProbedAudioFile | undefined> {
117
+ const probed = await api.probe(filename)
118
+ switch (probed.kind) {
119
+ case "image": {
120
+ const { width, height } = probed
121
+ const sizeBytes = (await api.statFile(filename))?.sizeBytes
122
+ return {
123
+ type: "image",
124
+ width,
125
+ height,
126
+ preview: exceedsPreviewThresholds({ width, height, sizeBytes }),
127
+ }
128
+ }
129
+ case "video":
130
+ return {
131
+ type: "video",
132
+ width: probed.width,
133
+ height: probed.height,
134
+ durationMs: probed.durationMs,
135
+ }
136
+ case "audio":
137
+ return {
138
+ type: "audio",
139
+ durationMs: probed.durationMs,
140
+ hasCoverArt: probed.coverArt === undefined ? undefined : true,
141
+ }
142
+ case "other":
143
+ return undefined
144
+ default:
145
+ return undecodedItem(await api.sniff(filename))
146
+ }
147
+ }
148
+
149
+ /** Bare file item for media the host identified but could not decode. */
150
+ function undecodedItem(
151
+ type: FileType | undefined,
152
+ ): ProbedImageFile | ProbedVideoFile | ProbedAudioFile | undefined {
153
+ switch (type?.kind) {
154
+ case "image":
155
+ return { type: "image", preview: false }
156
+ case "video":
157
+ return { type: "video" }
158
+ case "audio":
159
+ return { type: "audio" }
160
+ default:
161
+ return undefined
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Probe a file expected to be an image. A file that turns out not to be
167
+ * one still yields an image-shaped item (with no dimensions), so a file
168
+ * list keeps its declared type.
169
+ */
170
+ export async function probeImageFile(
171
+ api: ResourceAPI,
172
+ filename: string,
173
+ ): Promise<ProbedImageFile> {
174
+ const item = await probeMediaFile(api, filename)
175
+ return item?.type === "image" ? item : { type: "image", preview: false }
176
+ }
177
+
178
+ /** Probe a file expected to be a video. See {@link probeImageFile}. */
179
+ export async function probeVideoFile(
180
+ api: ResourceAPI,
181
+ filename: string,
182
+ ): Promise<ProbedVideoFile> {
183
+ const item = await probeMediaFile(api, filename)
184
+ return item?.type === "video" ? item : { type: "video" }
185
+ }
186
+
187
+ /** Probe a file expected to be audio. See {@link probeImageFile}. */
188
+ export async function probeAudioFile(
189
+ api: ResourceAPI,
190
+ filename: string,
191
+ ): Promise<ProbedAudioFile> {
192
+ const item = await probeMediaFile(api, filename)
193
+ return item?.type === "audio" ? item : { type: "audio" }
194
+ }
195
+
196
+ /**
197
+ * One entry of {@link mediaFileList}: the filename plus everything the
198
+ * probe pass learned about the file (`sniffed` is the sniffed type that
199
+ * decided the probe lane — the same data the entry's dimensions were
200
+ * routed on).
201
+ */
202
+ export type MediaFileListEntry = {
203
+ readonly filename: string
204
+ readonly sniffed?: FileType
205
+ } & (ProbedImageFile | ProbedVideoFile | ProbedAudioFile)
206
+
207
+ export type MediaFileListOptions = {
208
+ /**
209
+ * Candidate names to probe. When absent the helper lists the
210
+ * resource itself (natural-sorted). Pass a pre-filtered list (e.g.
211
+ * top-level files only) to skip names the plugin never wants.
212
+ */
213
+ readonly names?: readonly string[]
214
+ }
215
+
216
+ /**
217
+ * Build a typed media file list in one pass: sniff every candidate,
218
+ * then probe images and timed media (video/audio) in separate bounded
219
+ * lanes — sharp header reads fan out wider than ffprobe spawns. The
220
+ * result keeps input order and drops files that are not decodable
221
+ * media. The one-call implementation for a `listFiles` hook over a
222
+ * flat media resource.
223
+ */
224
+ export async function mediaFileList(
225
+ api: ResourceAPI,
226
+ opts: MediaFileListOptions = {},
227
+ ): Promise<readonly MediaFileListEntry[]> {
228
+ // `listFileNames` already returns the host's canonical order (the
229
+ // `.order` upload order, natural name sort otherwise) — re-sorting
230
+ // here would scramble it. Plugins wanting a different order pass
231
+ // explicit `names`.
232
+ const files = opts.names ?? (await api.listFileNames())
233
+ const types = await mapConcurrent(
234
+ files,
235
+ PLUGIN_IMAGE_PROBE_CONCURRENCY,
236
+ (name) => api.sniff(name),
237
+ )
238
+ const entries = new Map<string, MediaFileListEntry>()
239
+ const imageIndexes: number[] = []
240
+ const timedIndexes: number[] = []
241
+ for (const index of files.keys()) {
242
+ const kind = types[index]?.kind
243
+ if (kind === "image") imageIndexes.push(index)
244
+ else if (kind === "video" || kind === "audio") timedIndexes.push(index)
245
+ }
246
+
247
+ async function probeInto(index: number): Promise<void> {
248
+ const filename = files[index]
249
+ if (filename === undefined) return
250
+ const probed = await probeMediaFile(api, filename)
251
+ if (probed === undefined) return
252
+ entries.set(filename, {
253
+ filename,
254
+ sniffed: types[index],
255
+ ...probed,
256
+ })
257
+ }
258
+
259
+ await Promise.all([
260
+ mapConcurrent(imageIndexes, PLUGIN_IMAGE_PROBE_CONCURRENCY, probeInto),
261
+ mapConcurrent(timedIndexes, PLUGIN_VIDEO_PROBE_CONCURRENCY, probeInto),
262
+ ])
263
+
264
+ const result: MediaFileListEntry[] = []
265
+ for (const filename of files) {
266
+ const entry = entries.get(filename)
267
+ if (entry !== undefined) result.push(entry)
268
+ }
269
+ return result
270
+ }
271
+
272
+ /**
273
+ * The `sourceMeta` for a bare file count — the one-liner for plugins
274
+ * whose card needs only `fileCount`. Resolves to `undefined` for empty
275
+ * resources, matching the host's "nothing to report" contract.
276
+ */
277
+ export async function countSourceMeta(
278
+ api: ResourceAPI,
279
+ ): Promise<{ readonly fileCount: number } | undefined> {
280
+ const files = await api.listFileNames()
281
+ return files.length === 0 ? undefined : { fileCount: files.length }
282
+ }
283
+
284
+ export type ReadFileChunksOptions = {
285
+ /** Chunk size in bytes. Defaults to 1 MiB. */
286
+ readonly chunkSize?: number
287
+ }
288
+
289
+ /**
290
+ * Stream a file as a sequence of chunks via ranged `readFile` calls.
291
+ * Memory stays bounded by the chunk size on both sides of the plugin
292
+ * boundary — the host never buffers the whole file.
293
+ */
294
+ export async function* readFileChunks(
295
+ api: ResourceAPI,
296
+ path: string,
297
+ opts: ReadFileChunksOptions = {},
298
+ ): AsyncGenerator<Uint8Array, void, undefined> {
299
+ const chunkSize = opts.chunkSize ?? 1024 * 1024
300
+ let offset = 0
301
+ for (;;) {
302
+ const chunk = await api.readFile(path, {
303
+ start: offset,
304
+ end: offset + chunkSize,
305
+ })
306
+ if (chunk.byteLength === 0) return
307
+ yield chunk
308
+ if (chunk.byteLength < chunkSize) return
309
+ offset += chunk.byteLength
310
+ }
311
+ }
312
+
313
+ /** Per-file default hash kinds for {@link imageHashesFor}. */
314
+ export const DEFAULT_IMAGE_HASH_KINDS: readonly ImageHashKind[] = [
315
+ "sha256",
316
+ "dhash",
317
+ ]
318
+
319
+ export type ImageHashesForOptions = {
320
+ /** Hash kinds per image. Defaults to sha256 + dhash. */
321
+ readonly kinds?: readonly ImageHashKind[]
322
+ }
323
+
324
+ /**
325
+ * Compute the requested hash kinds of one image file as `ImageHash`
326
+ * entries (`scope` = the file path). Resolves to `[]` for non-image or
327
+ * undecodable files.
328
+ */
329
+ export async function imageHashesForFile(
330
+ api: ResourceAPI,
331
+ scope: string,
332
+ kinds: readonly ImageHashKind[] = DEFAULT_IMAGE_HASH_KINDS,
333
+ ): Promise<readonly ImageHash[]> {
334
+ const computed = await api.computeImageHashes(scope, kinds)
335
+ if (computed === undefined) return []
336
+ const entries: ImageHash[] = []
337
+ for (const kind of kinds) {
338
+ const value = computed[kind]
339
+ if (value !== undefined) entries.push({ scope, type: kind, value })
340
+ }
341
+ return entries
342
+ }
343
+
344
+ /**
345
+ * One-line `imageHashes` hook implementation for image plugins: hash
346
+ * every image file of the resource (animated sources hash their first
347
+ * frame). Image files are selected by content, so a mislabelled photo
348
+ * is still deduplicated. Plugins facing image-less resources omit the
349
+ * hook entirely.
350
+ */
351
+ export async function imageHashesFor(
352
+ api: ResourceAPI,
353
+ opts: ImageHashesForOptions = {},
354
+ ): Promise<ImageHashesResult> {
355
+ const kinds = opts.kinds ?? DEFAULT_IMAGE_HASH_KINDS
356
+ const names = await api.listFileNames()
357
+ const types = await mapConcurrent(
358
+ names,
359
+ PLUGIN_IMAGE_PROBE_CONCURRENCY,
360
+ (name) => api.sniff(name),
361
+ )
362
+ const images = names.filter((_, i) => types[i]?.kind === "image")
363
+ const hashes = (
364
+ await mapConcurrent(images, PLUGIN_IMAGE_PROBE_CONCURRENCY, (filename) =>
365
+ imageHashesForFile(api, filename, kinds),
366
+ )
367
+ ).flat()
368
+ return { hashes }
369
+ }
package/src/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @hoardodile/sdk-server — the authoring surface for plugin `main.js`
3
+ * files, and the only package plugin authors need on the server side.
4
+ * The plugin contract (`definePlugin`, `ResourceAPI`, fixtures, hook
5
+ * names) lives in @hoardodile/sdk-types and is re-exported here so
6
+ * authors have a single import root.
7
+ *
8
+ * This package is fully MIT and dependency-closed within the SDK — it
9
+ * never imports `@hoardodile/host` (the app-side runtime). Dev-time
10
+ * test tooling (`runPluginHook`, `createDirectoryResourceAPI`) lives in
11
+ * `@hoardodile/host` and is consumed from plugin tests as a
12
+ * devDependency.
13
+ */
14
+
15
+ export type {
16
+ ArchiveExtraction,
17
+ ArchiveExtractionEntry,
18
+ AudioCoverArt,
19
+ AudioInfo,
20
+ AudioTags,
21
+ ContainerListing,
22
+ Detection,
23
+ FileType,
24
+ ImageHash,
25
+ ImageHashesResult,
26
+ ImageHashKind,
27
+ ImageInfo,
28
+ Logger,
29
+ MediaKind,
30
+ PluginAssetDeleteResult,
31
+ PluginAssetError,
32
+ PluginAssetErrorName,
33
+ PluginDefinition,
34
+ PluginDownloadRequest,
35
+ PluginDownloadResult,
36
+ ProbeResult,
37
+ ReadFileRange,
38
+ ResourceAPI,
39
+ ResourceAPIFixtureConfig,
40
+ VideoInfo,
41
+ } from "@hoardodile/sdk-types"
42
+ export {
43
+ assertPluginShape,
44
+ createFailingPlugin,
45
+ createResourceAPIFixture,
46
+ definePlugin,
47
+ err,
48
+ fileTypeFromName,
49
+ isDetected,
50
+ isErr,
51
+ isMissed,
52
+ isOk,
53
+ isPluginAssetError,
54
+ matchResult,
55
+ ok,
56
+ pluginAssetError,
57
+ stubLogger,
58
+ } from "@hoardodile/sdk-types"
59
+ export type { Detector } from "./detectors.ts"
60
+ export {
61
+ all,
62
+ any,
63
+ files,
64
+ hasExt,
65
+ hasKind,
66
+ hasMime,
67
+ hasName,
68
+ minFiles,
69
+ not,
70
+ } from "./detectors.ts"
@@ -0,0 +1,52 @@
1
+ import type { Result } from "@hoardodile/sdk-types"
2
+ import { describe, expect, test } from "vitest"
3
+ import {
4
+ err,
5
+ isDetected,
6
+ isErr,
7
+ isMissed,
8
+ isOk,
9
+ matchResult,
10
+ ok,
11
+ } from "./index.ts"
12
+
13
+ describe("result helper re-exports", () => {
14
+ test("ok/err build the same literal shapes as the contract", () => {
15
+ expect(ok()).toEqual({ ok: true })
16
+ expect(ok({ files: [] })).toEqual({ ok: true, files: [] })
17
+ expect(err({ reasons: ["required-file"] })).toEqual({
18
+ ok: false,
19
+ reasons: ["required-file"],
20
+ })
21
+ })
22
+
23
+ test("guards narrow both branches", () => {
24
+ const hit = ok({ page: 1 })
25
+ const miss = err({ reasons: ["page-image"] })
26
+ expect(isOk(hit)).toBe(true)
27
+ expect(isOk(miss)).toBe(false)
28
+ expect(isErr(miss)).toBe(true)
29
+ expect(isErr(hit)).toBe(false)
30
+ expect(isDetected(hit)).toBe(true)
31
+ expect(isMissed(miss)).toBe(true)
32
+ })
33
+
34
+ test("matchResult dispatches to the branch handler", () => {
35
+ type Hit = { readonly count: number }
36
+ type Miss = { readonly reasons: readonly string[] }
37
+ const hit: Result<Hit, Miss> = ok({ count: 2 })
38
+ const miss: Result<Hit, Miss> = err({ reasons: ["required-file"] })
39
+ expect(
40
+ matchResult(hit, {
41
+ ok: (r) => r.count,
42
+ err: () => 0,
43
+ }),
44
+ ).toBe(2)
45
+ expect(
46
+ matchResult(miss, {
47
+ ok: () => 0,
48
+ err: (r) => r.reasons.length,
49
+ }),
50
+ ).toBe(1)
51
+ })
52
+ })