@hoardodile/host 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 +73 -0
- package/dist/chunks/worker-entry.mjs +422 -0
- package/dist/container-fKxefO7p.d.ts +58 -0
- package/dist/contract/index.d.ts +49 -0
- package/dist/contract/index.js +187 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/hoard/index.d.ts +754 -0
- package/dist/hoard/index.js +1939 -0
- package/dist/hoard/index.js.map +1 -0
- package/dist/image-Dx2Be3Bj.d.ts +168 -0
- package/dist/index.d.ts +788 -0
- package/dist/index.js +3311 -0
- package/dist/index.js.map +1 -0
- package/dist/media/index.d.ts +96 -0
- package/dist/media/index.js +152 -0
- package/dist/media/index.js.map +1 -0
- package/dist/probe/index.d.ts +113 -0
- package/dist/probe/index.js +454 -0
- package/dist/probe/index.js.map +1 -0
- package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
- package/dist/render/index.d.ts +125 -0
- package/dist/render/index.js +430 -0
- package/dist/render/index.js.map +1 -0
- package/package.json +103 -0
- package/src/__testutils__/zip-fixtures.ts +139 -0
- package/src/activation.ts +116 -0
- package/src/api-types.ts +60 -0
- package/src/api.test.ts +564 -0
- package/src/api.ts +501 -0
- package/src/archive/7z.test.ts +222 -0
- package/src/archive/7z.ts +261 -0
- package/src/archive/archive-ops.test.ts +654 -0
- package/src/archive/extract-archive.test.ts +237 -0
- package/src/archive/extract-archive.ts +534 -0
- package/src/archive/extract.ts +693 -0
- package/src/archive/format.ts +49 -0
- package/src/archive/index.ts +116 -0
- package/src/archive/listing.ts +121 -0
- package/src/archive/materialize.ts +88 -0
- package/src/archive/name-decode.test.ts +34 -0
- package/src/archive/name-decode.ts +287 -0
- package/src/archive/nested-cd-cache.ts +55 -0
- package/src/archive/nested-entry.test.ts +194 -0
- package/src/archive/nested-entry.ts +254 -0
- package/src/archive/pack.ts +40 -0
- package/src/archive/zip-entries.test.ts +56 -0
- package/src/archive/zip-entries.ts +336 -0
- package/src/capability-guard.ts +73 -0
- package/src/container.ts +62 -0
- package/src/contract/container-fixture.ts +74 -0
- package/src/contract/index.ts +10 -0
- package/src/contract/suite.test.ts +101 -0
- package/src/contract/suite.ts +205 -0
- package/src/define-plugin.test.ts +53 -0
- package/src/define-plugin.ts +12 -0
- package/src/dev-runner.test.ts +98 -0
- package/src/dev-runner.ts +73 -0
- package/src/directory-api.test.ts +229 -0
- package/src/directory-api.ts +42 -0
- package/src/directory-container.ts +141 -0
- package/src/discovery.ts +295 -0
- package/src/errors.ts +74 -0
- package/src/fixtures.ts +8 -0
- package/src/hash.test.ts +301 -0
- package/src/hash.ts +167 -0
- package/src/hoard/dir-size.ts +58 -0
- package/src/hoard/index.ts +84 -0
- package/src/hoard/migrate-script.test.ts +539 -0
- package/src/hoard/order-manifest.test.ts +115 -0
- package/src/hoard/order-manifest.ts +131 -0
- package/src/hoard/paths.test.ts +226 -0
- package/src/hoard/paths.ts +569 -0
- package/src/hoard/plugin-vault.test.ts +160 -0
- package/src/hoard/plugin-vault.ts +233 -0
- package/src/hoard/sanitize.test.ts +149 -0
- package/src/hoard/sanitize.ts +230 -0
- package/src/hoard/staging-dir.ts +133 -0
- package/src/hoard/version.test.ts +285 -0
- package/src/hoard/version.ts +216 -0
- package/src/hoard/versioned-folder-ops.ts +152 -0
- package/src/hoard/write-versioned.test.ts +67 -0
- package/src/hoard/write-versioned.ts +39 -0
- package/src/hooks.test.ts +528 -0
- package/src/hooks.ts +428 -0
- package/src/index.ts +135 -0
- package/src/loader.test.ts +442 -0
- package/src/loader.ts +213 -0
- package/src/media/index.ts +16 -0
- package/src/media/render-cache.ts +27 -0
- package/src/media/seekable.ts +35 -0
- package/src/media/thumb-input.test.ts +109 -0
- package/src/media/thumb-input.ts +194 -0
- package/src/nested-view.test.ts +223 -0
- package/src/nested-view.ts +239 -0
- package/src/probe/audio.test.ts +135 -0
- package/src/probe/audio.ts +140 -0
- package/src/probe/av.ts +84 -0
- package/src/probe/ffprobe.ts +197 -0
- package/src/probe/image.test.ts +251 -0
- package/src/probe/image.ts +336 -0
- package/src/probe/index.ts +54 -0
- package/src/probe/sniff.test.ts +72 -0
- package/src/probe/sniff.ts +67 -0
- package/src/probe/video.test.ts +41 -0
- package/src/probe/video.ts +116 -0
- package/src/probe-cache.test.ts +89 -0
- package/src/probe-cache.ts +65 -0
- package/src/render/ffmpeg.ts +78 -0
- package/src/render/index.ts +34 -0
- package/src/render/pipeline.test.ts +187 -0
- package/src/render/pipeline.ts +479 -0
- package/src/sandbox/call-timers.ts +68 -0
- package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
- package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
- package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
- package/src/sandbox/fixtures/container-plugin.mjs +8 -0
- package/src/sandbox/fixtures/context-plugin.mjs +19 -0
- package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
- package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
- package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
- package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
- package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
- package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
- package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
- package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
- package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
- package/src/sandbox/fixtures/range-plugin.mjs +9 -0
- package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
- package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
- package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
- package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
- package/src/sandbox/host.test.ts +619 -0
- package/src/sandbox/host.ts +933 -0
- package/src/sandbox/host.unit.test.ts +330 -0
- package/src/sandbox/protocol.test.ts +85 -0
- package/src/sandbox/protocol.ts +147 -0
- package/src/sandbox/sandboxed-plugin.ts +30 -0
- package/src/sandbox/worker-entry.mjs +422 -0
- package/src/seed.test.ts +102 -0
- package/src/seed.ts +160 -0
- package/src/settings-store.ts +26 -0
- package/src/types.ts +21 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs"
|
|
2
|
+
import { readFile } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import type { Readable } from "node:stream"
|
|
5
|
+
import { buffer } from "node:stream/consumers"
|
|
6
|
+
import {
|
|
7
|
+
type ExtractedEntry,
|
|
8
|
+
readExistingManifest,
|
|
9
|
+
} from "./archive/extract-archive.ts"
|
|
10
|
+
import type { NestedCdCache, NestedResolver } from "./archive/index.ts"
|
|
11
|
+
import { createNestedResolver, splitVirtualPath } from "./archive/index.ts"
|
|
12
|
+
import { readFileRange } from "./archive/zip-entries.ts"
|
|
13
|
+
import type { ResourceContainer } from "./container.ts"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Wrap a {@link ResourceContainer} so every read operation understands
|
|
17
|
+
* virtual paths (`outer!inner`, see `./archive`). The plugin API,
|
|
18
|
+
* the server artifact view and the CLI directory container all funnel
|
|
19
|
+
* entry reads through this wrapper, so nested-container support lands
|
|
20
|
+
* everywhere (hooks, cover pipeline, thumbnails, the HTTP file route)
|
|
21
|
+
* without each consumer knowing about containers.
|
|
22
|
+
*
|
|
23
|
+
* A virtual path resolves in two steps:
|
|
24
|
+
* 1. nested zip addressing — the resolver reads the outer entry's
|
|
25
|
+
* central directory and streams the *decompressed* inner bytes;
|
|
26
|
+
* 2. materialized cache addressing — when `extractCacheDir` is wired
|
|
27
|
+
* and the outer archive was extracted by the plugin API (its
|
|
28
|
+
* manifest exists in `<extractCacheDir>/<outer>/`), the inner file
|
|
29
|
+
* is served straight from disk. This is how non-zip containers
|
|
30
|
+
* (rar/7z/tar — not expressible as random-access sources) get
|
|
31
|
+
* `outer!inner` addressing after extraction, and it is strictly
|
|
32
|
+
* better than streaming: entries are seekable.
|
|
33
|
+
*
|
|
34
|
+
* Literal entries pass through untouched; virtual entries are only
|
|
35
|
+
* addressable by name, never enumerated.
|
|
36
|
+
*
|
|
37
|
+
* `scope` prefixes nested-cache keys when the cache is shared across
|
|
38
|
+
* containers (see {@link createNestedResolver}); `resolveSeekablePath`
|
|
39
|
+
* forwards the base container's capability for literal entries and the
|
|
40
|
+
* extracted file path for materialized ones.
|
|
41
|
+
*/
|
|
42
|
+
export function createNestedAwareContainer(
|
|
43
|
+
base: ResourceContainer,
|
|
44
|
+
nestedCdCache?: NestedCdCache,
|
|
45
|
+
scope?: string,
|
|
46
|
+
extractCacheDir?: string,
|
|
47
|
+
): ResourceContainer {
|
|
48
|
+
const resolver = createNestedResolver(
|
|
49
|
+
{
|
|
50
|
+
sizeOf: (rel) => base.resolveByteRange(rel).then((r) => r?.size),
|
|
51
|
+
readSlice: (rel, start, end) => base.readEntrySlice(rel, start, end),
|
|
52
|
+
},
|
|
53
|
+
{ cdCache: nestedCdCache, scope },
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Manifest reads are memoized per (cacheDir, outer): archives are
|
|
57
|
+
// immutable per version, so a parsed manifest never needs invalidation.
|
|
58
|
+
const manifestMemo = new Map<
|
|
59
|
+
string,
|
|
60
|
+
Promise<readonly ExtractedEntry[] | undefined>
|
|
61
|
+
>()
|
|
62
|
+
|
|
63
|
+
function readManifest(
|
|
64
|
+
outer: string,
|
|
65
|
+
): Promise<readonly ExtractedEntry[] | undefined> {
|
|
66
|
+
const key = `${extractCacheDir}:${outer}`
|
|
67
|
+
const pending = manifestMemo.get(key)
|
|
68
|
+
if (pending !== undefined) return pending
|
|
69
|
+
const work = readExistingManifest(
|
|
70
|
+
join(extractCacheDir!, outer, "index.json"),
|
|
71
|
+
outer,
|
|
72
|
+
).catch(() => undefined)
|
|
73
|
+
manifestMemo.set(key, work)
|
|
74
|
+
return work
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type MaterializedEntry = {
|
|
78
|
+
readonly kind: "materialized"
|
|
79
|
+
readonly absPath: string
|
|
80
|
+
readonly sizeBytes: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve `outer!inner` against the extraction cache: the inner path
|
|
85
|
+
* must appear in the outer's manifest (the manifest paths were
|
|
86
|
+
* sanitized at extraction time, so this is a whitelist — no
|
|
87
|
+
* traversal can reach outside the cache dir).
|
|
88
|
+
*/
|
|
89
|
+
async function resolveMaterialized(
|
|
90
|
+
rel: string,
|
|
91
|
+
): Promise<MaterializedEntry | undefined> {
|
|
92
|
+
if (extractCacheDir === undefined) return undefined
|
|
93
|
+
const parts = splitVirtualPath(rel)
|
|
94
|
+
if (parts === undefined) return undefined
|
|
95
|
+
const { outer, inner } = parts
|
|
96
|
+
const manifest = await readManifest(outer)
|
|
97
|
+
const entry = manifest?.find((e) => e.path === inner)
|
|
98
|
+
if (entry === undefined) return undefined
|
|
99
|
+
return {
|
|
100
|
+
kind: "materialized",
|
|
101
|
+
absPath: join(extractCacheDir, outer, inner),
|
|
102
|
+
sizeBytes: entry.sizeBytes,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function resolve(rel: string) {
|
|
107
|
+
const nested = await resolver.resolve(rel)
|
|
108
|
+
if (nested.kind === "container") return nested
|
|
109
|
+
const materialized = await resolveMaterialized(rel)
|
|
110
|
+
return materialized ?? nested
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function readEntrySlice(
|
|
114
|
+
rel: string,
|
|
115
|
+
start: number,
|
|
116
|
+
end: number,
|
|
117
|
+
): Promise<Buffer> {
|
|
118
|
+
const resolved = await resolve(rel)
|
|
119
|
+
if (resolved.kind === "literal") {
|
|
120
|
+
return base.readEntrySlice(rel, start, end)
|
|
121
|
+
}
|
|
122
|
+
if (resolved.kind === "materialized") {
|
|
123
|
+
// readEntrySlice's `end` is exclusive; readFileRange's is
|
|
124
|
+
// inclusive.
|
|
125
|
+
return readFileRange(resolved.absPath, start, end - 1)
|
|
126
|
+
}
|
|
127
|
+
// Slice the decompressed bytes: consume the stream only up to
|
|
128
|
+
// `end` so head reads (sniff/probe windows) stay bounded.
|
|
129
|
+
return readVirtualSlice(() => resolved.openStream(), start, end)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function openEntryStream(rel: string): Promise<{
|
|
133
|
+
readonly stream: Readable
|
|
134
|
+
readonly size: number
|
|
135
|
+
readonly mtimeMs?: number
|
|
136
|
+
readonly path?: string
|
|
137
|
+
}> {
|
|
138
|
+
const resolved = await resolve(rel)
|
|
139
|
+
if (resolved.kind === "literal") {
|
|
140
|
+
return base.openEntryStream(rel)
|
|
141
|
+
}
|
|
142
|
+
if (resolved.kind === "materialized") {
|
|
143
|
+
return {
|
|
144
|
+
stream: createReadStream(resolved.absPath),
|
|
145
|
+
size: resolved.sizeBytes,
|
|
146
|
+
path: resolved.absPath,
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { stream: resolved.openStream(), size: resolved.entry.sizeBytes }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function readEntry(rel: string): Promise<Buffer> {
|
|
153
|
+
const resolved = await resolve(rel)
|
|
154
|
+
if (resolved.kind === "literal") {
|
|
155
|
+
return base.readEntry(rel)
|
|
156
|
+
}
|
|
157
|
+
if (resolved.kind === "materialized") {
|
|
158
|
+
return readFile(resolved.absPath)
|
|
159
|
+
}
|
|
160
|
+
return buffer(resolved.openStream())
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function resolveByteRange(
|
|
164
|
+
rel: string,
|
|
165
|
+
): Promise<{ readonly size: number } | undefined> {
|
|
166
|
+
const resolved = await resolve(rel)
|
|
167
|
+
if (resolved.kind === "literal") {
|
|
168
|
+
return base.resolveByteRange(rel)
|
|
169
|
+
}
|
|
170
|
+
if (resolved.kind === "materialized") {
|
|
171
|
+
return { size: resolved.sizeBytes }
|
|
172
|
+
}
|
|
173
|
+
return { size: resolved.entry.sizeBytes }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function resolveSeekablePath(rel: string): Promise<string | undefined> {
|
|
177
|
+
const resolved = await resolve(rel)
|
|
178
|
+
if (resolved.kind === "literal") {
|
|
179
|
+
return base.resolveSeekablePath?.(rel)
|
|
180
|
+
}
|
|
181
|
+
if (resolved.kind === "materialized") {
|
|
182
|
+
// Extracted files are real on-disk entries — libvips/ffmpeg
|
|
183
|
+
// can mmap/open them directly instead of streaming.
|
|
184
|
+
return resolved.absPath
|
|
185
|
+
}
|
|
186
|
+
return undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
listEntries: base.listEntries,
|
|
191
|
+
readEntry,
|
|
192
|
+
readEntrySlice,
|
|
193
|
+
openEntryStream,
|
|
194
|
+
resolveByteRange,
|
|
195
|
+
resolveSeekablePath,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Read `[start, end)` of an entry stream without buffering more than
|
|
201
|
+
* `end` bytes. STORED and DEFLATE entries alike stream through this
|
|
202
|
+
* path, so a truncated header slice never inflates the whole entry.
|
|
203
|
+
*/
|
|
204
|
+
async function readVirtualSlice(
|
|
205
|
+
openStream: () => Readable,
|
|
206
|
+
start: number,
|
|
207
|
+
end: number,
|
|
208
|
+
): Promise<Buffer> {
|
|
209
|
+
if (end <= start) return Buffer.alloc(0)
|
|
210
|
+
const chunks: Buffer[] = []
|
|
211
|
+
let collected = 0
|
|
212
|
+
await new Promise<void>((resolveDone, rejectDone) => {
|
|
213
|
+
const stream = openStream()
|
|
214
|
+
stream.on("data", (chunk: Buffer | string) => {
|
|
215
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : chunk
|
|
216
|
+
const need = end - collected
|
|
217
|
+
if (need <= 0) {
|
|
218
|
+
stream.destroy()
|
|
219
|
+
resolveDone()
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
const take = bytes.subarray(0, Math.min(need, bytes.length))
|
|
223
|
+
chunks.push(take)
|
|
224
|
+
collected += take.length
|
|
225
|
+
if (collected >= end) {
|
|
226
|
+
stream.destroy()
|
|
227
|
+
resolveDone()
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
stream.on("end", () => resolveDone())
|
|
231
|
+
stream.on("error", (err) => rejectDone(err))
|
|
232
|
+
})
|
|
233
|
+
const full = Buffer.concat(chunks, collected)
|
|
234
|
+
const sliceStart = Math.min(start, full.length)
|
|
235
|
+
return full.subarray(sliceStart)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Resolver instance type re-export for callers that build their own views. */
|
|
239
|
+
export type { NestedResolver }
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "vitest"
|
|
4
|
+
import { extToAudioInputFormat, parseFfprobeAudioJson } from "./audio.ts"
|
|
5
|
+
|
|
6
|
+
function payload(value: unknown): string {
|
|
7
|
+
return JSON.stringify(value)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("parseFfprobeAudioJson", () => {
|
|
11
|
+
test("reads stream parameters, duration and bit rate", () => {
|
|
12
|
+
const info = parseFfprobeAudioJson(
|
|
13
|
+
payload({
|
|
14
|
+
streams: [
|
|
15
|
+
{
|
|
16
|
+
codec_type: "audio",
|
|
17
|
+
codec_name: "flac",
|
|
18
|
+
sample_rate: "44100",
|
|
19
|
+
channels: 2,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
format: { duration: "183.456", bit_rate: "938211" },
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
expect(info).toEqual({
|
|
26
|
+
codec: "flac",
|
|
27
|
+
sampleRate: 44100,
|
|
28
|
+
channels: 2,
|
|
29
|
+
durationMs: 183456,
|
|
30
|
+
bitRate: 938211,
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("surfaces embedded artwork with its dimensions", () => {
|
|
35
|
+
const info = parseFfprobeAudioJson(
|
|
36
|
+
payload({
|
|
37
|
+
streams: [
|
|
38
|
+
{ codec_type: "audio", codec_name: "mp3" },
|
|
39
|
+
{
|
|
40
|
+
codec_type: "video",
|
|
41
|
+
codec_name: "mjpeg",
|
|
42
|
+
width: 600,
|
|
43
|
+
height: 600,
|
|
44
|
+
disposition: { attached_pic: 1 },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
format: {},
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
expect(info?.coverArt).toEqual({ width: 600, height: 600 })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("a real video stream is not artwork", () => {
|
|
54
|
+
const info = parseFfprobeAudioJson(
|
|
55
|
+
payload({
|
|
56
|
+
streams: [
|
|
57
|
+
{ codec_type: "audio", codec_name: "aac" },
|
|
58
|
+
{
|
|
59
|
+
codec_type: "video",
|
|
60
|
+
codec_name: "h264",
|
|
61
|
+
width: 1920,
|
|
62
|
+
height: 1080,
|
|
63
|
+
disposition: { attached_pic: 0 },
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
format: {},
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
expect(info?.coverArt).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("returns undefined without an audio stream", () => {
|
|
73
|
+
const info = parseFfprobeAudioJson(
|
|
74
|
+
payload({
|
|
75
|
+
streams: [{ codec_type: "video", codec_name: "h264" }],
|
|
76
|
+
format: { duration: "10.0" },
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
expect(info).toBeUndefined()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("omits fields the container does not report", () => {
|
|
83
|
+
const info = parseFfprobeAudioJson(
|
|
84
|
+
payload({ streams: [{ codec_type: "audio" }], format: {} }),
|
|
85
|
+
)
|
|
86
|
+
expect(info).toEqual({})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("reads tags case-insensitively, container first", () => {
|
|
90
|
+
const info = parseFfprobeAudioJson(
|
|
91
|
+
payload({
|
|
92
|
+
streams: [
|
|
93
|
+
{
|
|
94
|
+
codec_type: "audio",
|
|
95
|
+
tags: { TITLE: "stream title", ALBUM: "stream album" },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
format: { tags: { Title: "format title", ARTIST: "someone" } },
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
expect(info?.tags).toEqual({
|
|
102
|
+
title: "format title",
|
|
103
|
+
artist: "someone",
|
|
104
|
+
album: "stream album",
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test("falls back to album_artist and drops empty tag values", () => {
|
|
109
|
+
const info = parseFfprobeAudioJson(
|
|
110
|
+
payload({
|
|
111
|
+
streams: [{ codec_type: "audio" }],
|
|
112
|
+
format: { tags: { album_artist: "band", title: "" } },
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
expect(info?.tags).toEqual({ artist: "band" })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("rejects a negative duration instead of persisting it", () => {
|
|
119
|
+
const info = parseFfprobeAudioJson(
|
|
120
|
+
payload({
|
|
121
|
+
streams: [{ codec_type: "audio" }],
|
|
122
|
+
format: { duration: "-1.0" },
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
expect(info?.durationMs).toBeUndefined()
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe("extToAudioInputFormat", () => {
|
|
130
|
+
test("maps opus onto the ogg demuxer and m4a onto mp4", () => {
|
|
131
|
+
expect(extToAudioInputFormat(".opus")).toBe("ogg")
|
|
132
|
+
expect(extToAudioInputFormat(".M4A")).toBe("mp4")
|
|
133
|
+
expect(extToAudioInputFormat(".mp4")).toBeUndefined()
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Readable } from "node:stream"
|
|
2
|
+
import { AUDIO_FFMPEG_INPUT_FORMAT } from "@hoardodile/sdk-types/media-exts"
|
|
3
|
+
import type { AudioCoverArt, AudioInfo, AudioTags } from "../types.ts"
|
|
4
|
+
import {
|
|
5
|
+
type FfprobePayload,
|
|
6
|
+
type FfprobeStream,
|
|
7
|
+
ffprobeDurationMs,
|
|
8
|
+
ffprobeInt,
|
|
9
|
+
getFfprobePath,
|
|
10
|
+
parseFfprobePayload,
|
|
11
|
+
runFfprobeJson,
|
|
12
|
+
} from "./ffprobe.ts"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Audio probing via ffprobe (see `ffprobe.ts` for path resolution and the
|
|
16
|
+
* process plumbing). One probe answers everything the host needs about an
|
|
17
|
+
* audio file: playable duration, stream parameters, whether the container
|
|
18
|
+
* carries embedded artwork, and the human-facing tags.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Resolve an extension to its ffmpeg `-f` container name for piped input. */
|
|
22
|
+
export function extToAudioInputFormat(ext: string): string | undefined {
|
|
23
|
+
return AUDIO_FFMPEG_INPUT_FORMAT[ext.toLowerCase()]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pure parser; broken out so callers and tests can hit it without
|
|
28
|
+
* spawning ffprobe. Returns `undefined` when the payload carries no
|
|
29
|
+
* audio stream at all — a video file probed as audio must not produce a
|
|
30
|
+
* half-filled result.
|
|
31
|
+
*/
|
|
32
|
+
export function parseFfprobeAudioJson(json: string): AudioInfo | undefined {
|
|
33
|
+
return audioInfoFromPayload(parseFfprobePayload(json))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function audioInfoFromPayload(
|
|
37
|
+
payload: FfprobePayload,
|
|
38
|
+
): AudioInfo | undefined {
|
|
39
|
+
const streams = payload.streams ?? []
|
|
40
|
+
const audioStream = streams.find((s) => s.codec_type === "audio")
|
|
41
|
+
if (audioStream === undefined) return undefined
|
|
42
|
+
|
|
43
|
+
const result: {
|
|
44
|
+
durationMs?: number
|
|
45
|
+
codec?: string
|
|
46
|
+
bitRate?: number
|
|
47
|
+
sampleRate?: number
|
|
48
|
+
channels?: number
|
|
49
|
+
coverArt?: AudioCoverArt
|
|
50
|
+
tags?: AudioTags
|
|
51
|
+
} = {}
|
|
52
|
+
|
|
53
|
+
const durationMs = ffprobeDurationMs(payload.format)
|
|
54
|
+
if (durationMs !== undefined) result.durationMs = durationMs
|
|
55
|
+
if (
|
|
56
|
+
typeof audioStream.codec_name === "string" &&
|
|
57
|
+
audioStream.codec_name.length > 0
|
|
58
|
+
) {
|
|
59
|
+
result.codec = audioStream.codec_name
|
|
60
|
+
}
|
|
61
|
+
const bitRate = ffprobeInt(payload.format?.bit_rate)
|
|
62
|
+
if (bitRate !== undefined) result.bitRate = bitRate
|
|
63
|
+
const sampleRate = ffprobeInt(audioStream.sample_rate)
|
|
64
|
+
if (sampleRate !== undefined) result.sampleRate = sampleRate
|
|
65
|
+
const channels = ffprobeInt(audioStream.channels)
|
|
66
|
+
if (channels !== undefined) result.channels = channels
|
|
67
|
+
const picture = streams.find(isAttachedPicture)
|
|
68
|
+
if (picture !== undefined) {
|
|
69
|
+
result.coverArt = {
|
|
70
|
+
width: ffprobeInt(picture.width),
|
|
71
|
+
height: ffprobeInt(picture.height),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const tags = readAudioTags(payload)
|
|
75
|
+
if (tags !== undefined) result.tags = tags
|
|
76
|
+
|
|
77
|
+
return result
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* True for the still image ffmpeg exposes as a video stream: ID3 APIC
|
|
82
|
+
* frames, FLAC PICTURE blocks and MP4 `covr` atoms all surface this way.
|
|
83
|
+
*/
|
|
84
|
+
function isAttachedPicture(stream: FfprobeStream): boolean {
|
|
85
|
+
if (stream.codec_type !== "video") return false
|
|
86
|
+
const disposition = stream.disposition
|
|
87
|
+
if (typeof disposition !== "object" || disposition === null) return false
|
|
88
|
+
return (disposition as Record<string, unknown>).attached_pic === 1
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Read the well-known tags off the container, falling back to the audio
|
|
93
|
+
* stream's own tag table (Vorbis comments in Matroska/Ogg land there).
|
|
94
|
+
* Tag keys vary in case between muxers, so lookups are case-insensitive.
|
|
95
|
+
*/
|
|
96
|
+
function readAudioTags(payload: FfprobePayload): AudioTags | undefined {
|
|
97
|
+
const tables = [
|
|
98
|
+
payload.format?.tags,
|
|
99
|
+
payload.streams?.find((s) => s.codec_type === "audio")?.tags,
|
|
100
|
+
]
|
|
101
|
+
const lookup = new Map<string, string>()
|
|
102
|
+
for (const table of tables) {
|
|
103
|
+
if (typeof table !== "object" || table === null) continue
|
|
104
|
+
for (const [key, value] of Object.entries(table)) {
|
|
105
|
+
if (typeof value !== "string" || value.length === 0) continue
|
|
106
|
+
const normalized = key.toLowerCase()
|
|
107
|
+
if (!lookup.has(normalized)) lookup.set(normalized, value)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const tags: { title?: string; artist?: string; album?: string } = {}
|
|
111
|
+
const title = lookup.get("title")
|
|
112
|
+
if (title !== undefined) tags.title = title
|
|
113
|
+
const artist = lookup.get("artist") ?? lookup.get("album_artist")
|
|
114
|
+
if (artist !== undefined) tags.artist = artist
|
|
115
|
+
const album = lookup.get("album")
|
|
116
|
+
if (album !== undefined) tags.album = album
|
|
117
|
+
return Object.keys(tags).length === 0 ? undefined : tags
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Probe an audio stream or path with ffprobe. Probe failures — a missing
|
|
122
|
+
* binary, an unreadable container, or a source with no audio stream —
|
|
123
|
+
* return `undefined` so callers can treat "not yet probed" and "probe
|
|
124
|
+
* failed" the same way.
|
|
125
|
+
*/
|
|
126
|
+
export async function probeAudio(
|
|
127
|
+
source: string | Readable,
|
|
128
|
+
extHint?: string,
|
|
129
|
+
): Promise<AudioInfo | undefined> {
|
|
130
|
+
try {
|
|
131
|
+
const inputFormat =
|
|
132
|
+
typeof source === "string" || extHint === undefined
|
|
133
|
+
? undefined
|
|
134
|
+
: extToAudioInputFormat(extHint)
|
|
135
|
+
const payload = await runFfprobeJson(source, getFfprobePath(), inputFormat)
|
|
136
|
+
return audioInfoFromPayload(payload)
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/probe/av.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Readable } from "node:stream"
|
|
2
|
+
import type { ProbeResult } from "@hoardodile/sdk-types"
|
|
3
|
+
|
|
4
|
+
import { audioInfoFromPayload } from "./audio.ts"
|
|
5
|
+
import {
|
|
6
|
+
type FfprobePayload,
|
|
7
|
+
type FfprobeStream,
|
|
8
|
+
getFfprobePath,
|
|
9
|
+
runFfprobeJson,
|
|
10
|
+
} from "./ffprobe.ts"
|
|
11
|
+
import { videoMetaFromPayload } from "./video.ts"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The unified audio/video probe behind `ResourceAPI.probe`: one ffprobe
|
|
15
|
+
* pass answers both "which family is this" and "what are its
|
|
16
|
+
* parameters", the way ffprobe itself reports media (`format` plus a
|
|
17
|
+
* `streams` array) instead of asking the caller to guess the family
|
|
18
|
+
* first.
|
|
19
|
+
*
|
|
20
|
+
* Container formats that carry either kind (Ogg, Matroska, ISO-BMFF)
|
|
21
|
+
* are settled here: the stream layout decides, not the extension and
|
|
22
|
+
* not the container MIME.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type AvProbeOptions = {
|
|
26
|
+
/** MIME type from content sniffing; carried into the result. */
|
|
27
|
+
readonly mime: string
|
|
28
|
+
/**
|
|
29
|
+
* ffmpeg `-f` container name. Required for stream sources (a pipe
|
|
30
|
+
* has no filename for ffprobe to key off); ignored for paths.
|
|
31
|
+
*/
|
|
32
|
+
readonly inputFormat?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* True for the still image ffmpeg exposes as a video stream: ID3 APIC
|
|
37
|
+
* frames, FLAC PICTURE blocks and MP4 `covr` atoms all surface this
|
|
38
|
+
* way, and none of them make the file a video.
|
|
39
|
+
*/
|
|
40
|
+
function isAttachedPicture(stream: FfprobeStream): boolean {
|
|
41
|
+
const disposition = stream.disposition
|
|
42
|
+
if (typeof disposition !== "object" || disposition === null) return false
|
|
43
|
+
return (disposition as Record<string, unknown>).attached_pic === 1
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Classify an ffprobe payload into a {@link ProbeResult}. A real video
|
|
48
|
+
* stream wins; otherwise an audio stream decides; a payload with
|
|
49
|
+
* neither is a decode failure.
|
|
50
|
+
*/
|
|
51
|
+
export function avResultFromPayload(
|
|
52
|
+
payload: FfprobePayload,
|
|
53
|
+
mime: string,
|
|
54
|
+
): ProbeResult {
|
|
55
|
+
const streams = payload.streams ?? []
|
|
56
|
+
const hasVideo = streams.some(
|
|
57
|
+
(stream) => stream.codec_type === "video" && !isAttachedPicture(stream),
|
|
58
|
+
)
|
|
59
|
+
if (hasVideo) {
|
|
60
|
+
return { kind: "video", mime, ...videoMetaFromPayload(payload) }
|
|
61
|
+
}
|
|
62
|
+
const audio = audioInfoFromPayload(payload)
|
|
63
|
+
if (audio !== undefined) return { kind: "audio", mime, ...audio }
|
|
64
|
+
return { kind: "unknown", reason: "failed" }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Probe an audio/video source with ffprobe. Never rejects: a missing
|
|
69
|
+
* binary, an unreadable container or a source ffprobe cannot demux all
|
|
70
|
+
* resolve to `{ kind: "unknown", reason: "failed" }`.
|
|
71
|
+
*/
|
|
72
|
+
export async function probeAvMedia(
|
|
73
|
+
source: string | Readable,
|
|
74
|
+
opts: AvProbeOptions,
|
|
75
|
+
): Promise<ProbeResult> {
|
|
76
|
+
try {
|
|
77
|
+
const inputFormat =
|
|
78
|
+
typeof source === "string" ? undefined : opts.inputFormat
|
|
79
|
+
const payload = await runFfprobeJson(source, getFfprobePath(), inputFormat)
|
|
80
|
+
return avResultFromPayload(payload, opts.mime)
|
|
81
|
+
} catch {
|
|
82
|
+
return { kind: "unknown", reason: "failed" }
|
|
83
|
+
}
|
|
84
|
+
}
|