@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.
Files changed (144) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +73 -0
  3. package/dist/chunks/worker-entry.mjs +422 -0
  4. package/dist/container-fKxefO7p.d.ts +58 -0
  5. package/dist/contract/index.d.ts +49 -0
  6. package/dist/contract/index.js +187 -0
  7. package/dist/contract/index.js.map +1 -0
  8. package/dist/hoard/index.d.ts +754 -0
  9. package/dist/hoard/index.js +1939 -0
  10. package/dist/hoard/index.js.map +1 -0
  11. package/dist/image-Dx2Be3Bj.d.ts +168 -0
  12. package/dist/index.d.ts +788 -0
  13. package/dist/index.js +3311 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/media/index.d.ts +96 -0
  16. package/dist/media/index.js +152 -0
  17. package/dist/media/index.js.map +1 -0
  18. package/dist/probe/index.d.ts +113 -0
  19. package/dist/probe/index.js +454 -0
  20. package/dist/probe/index.js.map +1 -0
  21. package/dist/probe-cache-BkRI7HEK.d.ts +25 -0
  22. package/dist/render/index.d.ts +125 -0
  23. package/dist/render/index.js +430 -0
  24. package/dist/render/index.js.map +1 -0
  25. package/package.json +103 -0
  26. package/src/__testutils__/zip-fixtures.ts +139 -0
  27. package/src/activation.ts +116 -0
  28. package/src/api-types.ts +60 -0
  29. package/src/api.test.ts +564 -0
  30. package/src/api.ts +501 -0
  31. package/src/archive/7z.test.ts +222 -0
  32. package/src/archive/7z.ts +261 -0
  33. package/src/archive/archive-ops.test.ts +654 -0
  34. package/src/archive/extract-archive.test.ts +237 -0
  35. package/src/archive/extract-archive.ts +534 -0
  36. package/src/archive/extract.ts +693 -0
  37. package/src/archive/format.ts +49 -0
  38. package/src/archive/index.ts +116 -0
  39. package/src/archive/listing.ts +121 -0
  40. package/src/archive/materialize.ts +88 -0
  41. package/src/archive/name-decode.test.ts +34 -0
  42. package/src/archive/name-decode.ts +287 -0
  43. package/src/archive/nested-cd-cache.ts +55 -0
  44. package/src/archive/nested-entry.test.ts +194 -0
  45. package/src/archive/nested-entry.ts +254 -0
  46. package/src/archive/pack.ts +40 -0
  47. package/src/archive/zip-entries.test.ts +56 -0
  48. package/src/archive/zip-entries.ts +336 -0
  49. package/src/capability-guard.ts +73 -0
  50. package/src/container.ts +62 -0
  51. package/src/contract/container-fixture.ts +74 -0
  52. package/src/contract/index.ts +10 -0
  53. package/src/contract/suite.test.ts +101 -0
  54. package/src/contract/suite.ts +205 -0
  55. package/src/define-plugin.test.ts +53 -0
  56. package/src/define-plugin.ts +12 -0
  57. package/src/dev-runner.test.ts +98 -0
  58. package/src/dev-runner.ts +73 -0
  59. package/src/directory-api.test.ts +229 -0
  60. package/src/directory-api.ts +42 -0
  61. package/src/directory-container.ts +141 -0
  62. package/src/discovery.ts +295 -0
  63. package/src/errors.ts +74 -0
  64. package/src/fixtures.ts +8 -0
  65. package/src/hash.test.ts +301 -0
  66. package/src/hash.ts +167 -0
  67. package/src/hoard/dir-size.ts +58 -0
  68. package/src/hoard/index.ts +84 -0
  69. package/src/hoard/migrate-script.test.ts +539 -0
  70. package/src/hoard/order-manifest.test.ts +115 -0
  71. package/src/hoard/order-manifest.ts +131 -0
  72. package/src/hoard/paths.test.ts +226 -0
  73. package/src/hoard/paths.ts +569 -0
  74. package/src/hoard/plugin-vault.test.ts +160 -0
  75. package/src/hoard/plugin-vault.ts +233 -0
  76. package/src/hoard/sanitize.test.ts +149 -0
  77. package/src/hoard/sanitize.ts +230 -0
  78. package/src/hoard/staging-dir.ts +133 -0
  79. package/src/hoard/version.test.ts +285 -0
  80. package/src/hoard/version.ts +216 -0
  81. package/src/hoard/versioned-folder-ops.ts +152 -0
  82. package/src/hoard/write-versioned.test.ts +67 -0
  83. package/src/hoard/write-versioned.ts +39 -0
  84. package/src/hooks.test.ts +528 -0
  85. package/src/hooks.ts +428 -0
  86. package/src/index.ts +135 -0
  87. package/src/loader.test.ts +442 -0
  88. package/src/loader.ts +213 -0
  89. package/src/media/index.ts +16 -0
  90. package/src/media/render-cache.ts +27 -0
  91. package/src/media/seekable.ts +35 -0
  92. package/src/media/thumb-input.test.ts +109 -0
  93. package/src/media/thumb-input.ts +194 -0
  94. package/src/nested-view.test.ts +223 -0
  95. package/src/nested-view.ts +239 -0
  96. package/src/probe/audio.test.ts +135 -0
  97. package/src/probe/audio.ts +140 -0
  98. package/src/probe/av.ts +84 -0
  99. package/src/probe/ffprobe.ts +197 -0
  100. package/src/probe/image.test.ts +251 -0
  101. package/src/probe/image.ts +336 -0
  102. package/src/probe/index.ts +54 -0
  103. package/src/probe/sniff.test.ts +72 -0
  104. package/src/probe/sniff.ts +67 -0
  105. package/src/probe/video.test.ts +41 -0
  106. package/src/probe/video.ts +116 -0
  107. package/src/probe-cache.test.ts +89 -0
  108. package/src/probe-cache.ts +65 -0
  109. package/src/render/ffmpeg.ts +78 -0
  110. package/src/render/index.ts +34 -0
  111. package/src/render/pipeline.test.ts +187 -0
  112. package/src/render/pipeline.ts +479 -0
  113. package/src/sandbox/call-timers.ts +68 -0
  114. package/src/sandbox/fixtures/api-error-plugin.mjs +11 -0
  115. package/src/sandbox/fixtures/asset-plugin.mjs +13 -0
  116. package/src/sandbox/fixtures/chatty-plugin.mjs +14 -0
  117. package/src/sandbox/fixtures/container-plugin.mjs +8 -0
  118. package/src/sandbox/fixtures/context-plugin.mjs +19 -0
  119. package/src/sandbox/fixtures/crash-plugin.mjs +6 -0
  120. package/src/sandbox/fixtures/echo-plugin.mjs +17 -0
  121. package/src/sandbox/fixtures/exit-plugin.mjs +6 -0
  122. package/src/sandbox/fixtures/fetch-plugin.mjs +8 -0
  123. package/src/sandbox/fixtures/flood-api-plugin.mjs +8 -0
  124. package/src/sandbox/fixtures/flood-log-plugin.mjs +8 -0
  125. package/src/sandbox/fixtures/hostile-dynamic-plugin.mjs +9 -0
  126. package/src/sandbox/fixtures/hostile-plugin.mjs +13 -0
  127. package/src/sandbox/fixtures/huge-result-plugin.mjs +8 -0
  128. package/src/sandbox/fixtures/logging-plugin.mjs +9 -0
  129. package/src/sandbox/fixtures/range-plugin.mjs +9 -0
  130. package/src/sandbox/fixtures/slow-api-plugin.mjs +11 -0
  131. package/src/sandbox/fixtures/spin-plugin.mjs +9 -0
  132. package/src/sandbox/fixtures/stuck-plugin.mjs +12 -0
  133. package/src/sandbox/fixtures/thrower-plugin.mjs +7 -0
  134. package/src/sandbox/host.test.ts +619 -0
  135. package/src/sandbox/host.ts +933 -0
  136. package/src/sandbox/host.unit.test.ts +330 -0
  137. package/src/sandbox/protocol.test.ts +85 -0
  138. package/src/sandbox/protocol.ts +147 -0
  139. package/src/sandbox/sandboxed-plugin.ts +30 -0
  140. package/src/sandbox/worker-entry.mjs +422 -0
  141. package/src/seed.test.ts +102 -0
  142. package/src/seed.ts +160 -0
  143. package/src/settings-store.ts +26 -0
  144. 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
+ }
@@ -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
+ }