dsh-audiogen 0.1.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 +201 -0
- package/README.md +67 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +2457 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1345 -0
- package/package.json +93 -0
- package/skills/design/SKILL.md +13 -0
- package/skills/music/SKILL.md +16 -0
- package/skills/sfx/SKILL.md +16 -0
- package/skills/tts/SKILL.md +18 -0
- package/src/agent-audio-tools.ts +190 -0
- package/src/audio-engine.ts +377 -0
- package/src/audio-presets.ts +80 -0
- package/src/audio-store.ts +131 -0
- package/src/client/AudioGenPanel.tsx +206 -0
- package/src/client/SettingsCard.tsx +337 -0
- package/src/client/api.ts +36 -0
- package/src/client/audio-panel.module.css +198 -0
- package/src/client/audio-toolview.module.css +69 -0
- package/src/client/audio-toolview.tsx +119 -0
- package/src/client/channels-form.ts +263 -0
- package/src/client/controller.ts +44 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/helpers.ts +27 -0
- package/src/client/index.ts +103 -0
- package/src/client/locales.ts +133 -0
- package/src/client/mount.tsx +96 -0
- package/src/client/panel.module.css +1566 -0
- package/src/client/settings-card.module.css +1023 -0
- package/src/client/settings-form.ts +336 -0
- package/src/client/settings-scope.ts +289 -0
- package/src/client/sidebar-entry.ts +115 -0
- package/src/index.ts +201 -0
- package/src/protocol.ts +179 -0
- package/src/routes.ts +386 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /api/dsh-audiogen route family:
|
|
3
|
+
* - a loopback-only settings bridge for the plugin's own namespace,
|
|
4
|
+
* - a presets route for the settings card,
|
|
5
|
+
* - the audio-generation proxy that keeps API keys host-side,
|
|
6
|
+
* - same-origin audio file serving and history persistence.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
10
|
+
import { randomUUID } from 'node:crypto'
|
|
11
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
12
|
+
import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
|
|
13
|
+
import { generateAudio, AudioGenError, type AudioChannel } from './audio-engine.ts'
|
|
14
|
+
import { AUDIO_PRESETS } from './audio-presets.ts'
|
|
15
|
+
import { appendHistory, clearHistory, listHistory, readAudioFile, removeHistory, saveAudioFile } from './audio-store.ts'
|
|
16
|
+
import {
|
|
17
|
+
AUDIO_API, AUDIOGEN_SETTINGS_NAMESPACE, GENERATE_API, HISTORY_API, PRESETS_API, SETTINGS_API,
|
|
18
|
+
type GenerateAudioRequest, type GeneratedAudio, type HistoryEntryInput,
|
|
19
|
+
} from './protocol.ts'
|
|
20
|
+
|
|
21
|
+
const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
/** Settings seam face the bridge needs. */
|
|
24
|
+
export interface SettingsSeam {
|
|
25
|
+
describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[]
|
|
26
|
+
mutate(ns: unknown, ops: unknown, expectedRevision?: number): Promise<void>
|
|
27
|
+
readonly writable?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The channels view used by routes and the host plugin. */
|
|
31
|
+
export interface ChannelsView {
|
|
32
|
+
channels: AudioChannel[]
|
|
33
|
+
defaultChannelId: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Route dependencies. */
|
|
37
|
+
export interface AudiogenRoutesDeps {
|
|
38
|
+
settings: SettingsSeam
|
|
39
|
+
resolveChannels: () => ChannelsView
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
43
|
+
const address = request.socket.remoteAddress
|
|
44
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
45
|
+
const host = request.headers.host
|
|
46
|
+
if (typeof host !== 'string') return false
|
|
47
|
+
let hostUrl: URL
|
|
48
|
+
try {
|
|
49
|
+
hostUrl = new URL(`http://${host}`)
|
|
50
|
+
} catch {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
54
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
55
|
+
const origin = request.headers.origin
|
|
56
|
+
if (origin === undefined) return true
|
|
57
|
+
try {
|
|
58
|
+
return new URL(origin).host === hostUrl.host
|
|
59
|
+
} catch {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
65
|
+
const payload = JSON.stringify(body)
|
|
66
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
67
|
+
res.end(payload)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<Record<string, unknown> | undefined> {
|
|
71
|
+
const chunks: Buffer[] = []
|
|
72
|
+
let size = 0
|
|
73
|
+
for await (const chunk of req) {
|
|
74
|
+
const buffer = chunk as Buffer
|
|
75
|
+
size += buffer.length
|
|
76
|
+
if (size > maxBytes) return undefined
|
|
77
|
+
chunks.push(buffer)
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
81
|
+
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function messageOf(error: unknown): string {
|
|
88
|
+
return error instanceof Error ? error.message : String(error)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseGenerateRequest(body: Record<string, unknown>): GenerateAudioRequest | undefined {
|
|
92
|
+
const mode = body.mode === 'music' ? 'music' : body.mode === 'sfx' ? 'sfx' : 'tts'
|
|
93
|
+
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
|
|
94
|
+
if (prompt === '') return undefined
|
|
95
|
+
return {
|
|
96
|
+
mode,
|
|
97
|
+
model: typeof body.model === 'string' ? body.model.trim() : '',
|
|
98
|
+
prompt,
|
|
99
|
+
...(typeof body.voice === 'string' && body.voice.trim() !== '' ? { voice: body.voice.trim() } : {}),
|
|
100
|
+
...(typeof body.speed === 'number' ? { speed: body.speed } : {}),
|
|
101
|
+
...(typeof body.duration === 'number' ? { duration: body.duration } : {}),
|
|
102
|
+
...(typeof body.format === 'string' && body.format.trim() !== '' ? { format: body.format.trim() } : {}),
|
|
103
|
+
...(typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {}),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toView(descriptor: SettingsDescriptor): Record<string, unknown> {
|
|
108
|
+
return {
|
|
109
|
+
ns: String(descriptor.ns),
|
|
110
|
+
schema: descriptor.schema,
|
|
111
|
+
value: descriptor.value,
|
|
112
|
+
...(descriptor.base === undefined ? {} : { base: descriptor.base }),
|
|
113
|
+
...(descriptor.user === undefined ? {} : { user: descriptor.user }),
|
|
114
|
+
...(descriptor.secrets === undefined ? {} : {
|
|
115
|
+
secrets: descriptor.secrets.map(secret => ({ path: [...secret.path], set: secret.set })),
|
|
116
|
+
}),
|
|
117
|
+
revision: descriptor.revision,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function failureOf(error: unknown): { ok: false; code: string; message: string } {
|
|
122
|
+
if (error instanceof SettingsConflictError) {
|
|
123
|
+
return { ok: false, code: 'settings-conflict', message: error.message }
|
|
124
|
+
}
|
|
125
|
+
return { ok: false, code: 'settings-rejected', message: error instanceof Error ? error.message : String(error) }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Resolve a requested model alias onto a concrete channel/upstream id.
|
|
130
|
+
*/
|
|
131
|
+
function resolveChannelRequest(
|
|
132
|
+
request: GenerateAudioRequest,
|
|
133
|
+
view: ChannelsView,
|
|
134
|
+
): { ok: true; request: GenerateAudioRequest } | { ok: false; code: string; message: string } {
|
|
135
|
+
if (view.channels.length === 0) {
|
|
136
|
+
return { ok: false, code: 'no-channels', message: '尚未配置任何渠道:请先在「设置 → 插件 → AI 音频」添加渠道并填写 API 地址与密钥' }
|
|
137
|
+
}
|
|
138
|
+
const explicit = view.channels.find(candidate => candidate.id === request.channelId)
|
|
139
|
+
const defaults = view.channels.find(candidate => candidate.id === view.defaultChannelId) ?? view.channels[0]
|
|
140
|
+
const target = explicit ?? defaults
|
|
141
|
+
const asked = request.model.trim()
|
|
142
|
+
if (asked === '') {
|
|
143
|
+
const alias = target?.models[0]?.alias ?? ''
|
|
144
|
+
if (alias === '') {
|
|
145
|
+
return { ok: false, code: 'no-models', message: `渠道「${target?.name ?? ''}」尚未配置模型/音色,请先在设置中添加` }
|
|
146
|
+
}
|
|
147
|
+
const mapping = target!.models.find(model => model.alias === alias)!
|
|
148
|
+
return { ok: true, request: { ...request, model: alias, upstream: mapping.id, channelId: target!.id, channel: target!.name } }
|
|
149
|
+
}
|
|
150
|
+
const hosting = view.channels.filter(channel => channel.models.some(model => model.alias === asked))
|
|
151
|
+
if (hosting.length === 0) {
|
|
152
|
+
const available = [...new Set(view.channels.flatMap(channel => channel.models.map(model => model.alias)))]
|
|
153
|
+
return { ok: false, code: 'audio-model-not-configured', message: `模型/音色「${asked}」未在任一渠道配置;可用:${available.join('、') || '(无)'}` }
|
|
154
|
+
}
|
|
155
|
+
const picked = target !== undefined && target.models.some(model => model.alias === asked) ? target : hosting[0]!
|
|
156
|
+
const mapping = picked.models.find(model => model.alias === asked)!
|
|
157
|
+
return { ok: true, request: { ...request, model: asked, upstream: mapping.id, channelId: picked.id, channel: picked.name } }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Build every /api/dsh-audiogen route. */
|
|
161
|
+
export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
|
|
162
|
+
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
163
|
+
if (!isLoopbackRequest(req)) {
|
|
164
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
if (req.method !== method) {
|
|
168
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const audioFileFrom = (rawUrl: string | undefined, basePath: string): string | undefined => {
|
|
175
|
+
if (rawUrl === undefined) return undefined
|
|
176
|
+
let pathname: string
|
|
177
|
+
try {
|
|
178
|
+
pathname = new URL(rawUrl, 'http://localhost').pathname
|
|
179
|
+
} catch {
|
|
180
|
+
return undefined
|
|
181
|
+
}
|
|
182
|
+
if (!pathname.startsWith(`${basePath}/`)) return undefined
|
|
183
|
+
return decodeURIComponent(pathname.slice(basePath.length + 1))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return [
|
|
187
|
+
// ---------------------------------------------------------- presets
|
|
188
|
+
{
|
|
189
|
+
kind: 'exact',
|
|
190
|
+
path: PRESETS_API,
|
|
191
|
+
handler: async (req, res) => {
|
|
192
|
+
if (!guard(req, res, 'POST')) return
|
|
193
|
+
writeJson(res, 200, { ok: true, presets: AUDIO_PRESETS })
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
// -------------------------------------------------- settings describe
|
|
197
|
+
{
|
|
198
|
+
kind: 'exact',
|
|
199
|
+
path: SETTINGS_API.describe,
|
|
200
|
+
handler: async (req, res) => {
|
|
201
|
+
if (!guard(req, res, 'POST')) return
|
|
202
|
+
const descriptor = deps.settings.describe({ redactSecrets: true })
|
|
203
|
+
.find(candidate => String(candidate.ns) === AUDIOGEN_SETTINGS_NAMESPACE)
|
|
204
|
+
writeJson(res, 200, {
|
|
205
|
+
ok: true,
|
|
206
|
+
value: {
|
|
207
|
+
namespaces: descriptor === undefined ? [] : [toView(descriptor)],
|
|
208
|
+
writable: deps.settings.writable !== false,
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
// ----------------------------------------------------- settings mutate
|
|
214
|
+
{
|
|
215
|
+
kind: 'exact',
|
|
216
|
+
path: SETTINGS_API.mutate,
|
|
217
|
+
handler: async (req, res) => {
|
|
218
|
+
if (!guard(req, res, 'POST')) return
|
|
219
|
+
const body = await readJsonBody(req)
|
|
220
|
+
if (body === undefined) {
|
|
221
|
+
writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'unreadable JSON body' })
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
const ns = typeof body.ns === 'string' ? body.ns : ''
|
|
225
|
+
if (ns !== AUDIOGEN_SETTINGS_NAMESPACE || !Array.isArray(body.ops)) {
|
|
226
|
+
writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'malformed bridge settings request' })
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
|
|
230
|
+
try {
|
|
231
|
+
await deps.settings.mutate(settingsNamespace(ns), body.ops, expectedRevision)
|
|
232
|
+
} catch (error) {
|
|
233
|
+
writeJson(res, 200, failureOf(error))
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
const descriptor = deps.settings.describe({ redactSecrets: true })
|
|
237
|
+
.find(candidate => String(candidate.ns) === ns)
|
|
238
|
+
if (descriptor === undefined) {
|
|
239
|
+
writeJson(res, 200, { ok: false, code: 'internal', message: `settings namespace "${ns}" was disposed after the mutate` })
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
writeJson(res, 200, { ok: true, value: toView(descriptor) })
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
// ----------------------------------------------------------- generate
|
|
246
|
+
{
|
|
247
|
+
kind: 'exact',
|
|
248
|
+
path: GENERATE_API,
|
|
249
|
+
handler: async (req, res) => {
|
|
250
|
+
if (!guard(req, res, 'POST')) return
|
|
251
|
+
const body = await readJsonBody(req)
|
|
252
|
+
const parsed = body === undefined ? undefined : parseGenerateRequest(body)
|
|
253
|
+
if (parsed === undefined) {
|
|
254
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt/text is required' })
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
const view = deps.resolveChannels()
|
|
258
|
+
const resolved = resolveChannelRequest(parsed, view)
|
|
259
|
+
if (!resolved.ok) {
|
|
260
|
+
writeJson(res, 200, { ok: false, code: resolved.code, message: resolved.message })
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
const request = resolved.request
|
|
264
|
+
const channel = view.channels.find(candidate => candidate.id === request.channelId)!
|
|
265
|
+
try {
|
|
266
|
+
const outputs = await generateAudio(channel, request)
|
|
267
|
+
const generated: GeneratedAudio[] = []
|
|
268
|
+
for (const [index, output] of outputs.entries()) {
|
|
269
|
+
const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`)
|
|
270
|
+
generated.push({
|
|
271
|
+
id: saved.id,
|
|
272
|
+
b64: Buffer.from(output.data).toString('base64'),
|
|
273
|
+
mime: saved.mime,
|
|
274
|
+
bytes: saved.bytes,
|
|
275
|
+
url: `${AUDIO_API.file}/${encodeURIComponent(saved.file)}`,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
let history
|
|
279
|
+
try {
|
|
280
|
+
history = await appendHistory({
|
|
281
|
+
id: randomUUID(),
|
|
282
|
+
createdAt: Date.now(),
|
|
283
|
+
mode: request.mode,
|
|
284
|
+
model: request.model,
|
|
285
|
+
prompt: request.prompt,
|
|
286
|
+
...(request.voice === undefined ? {} : { voice: request.voice }),
|
|
287
|
+
...(request.speed === undefined ? {} : { speed: request.speed }),
|
|
288
|
+
...(request.duration === undefined ? {} : { duration: request.duration }),
|
|
289
|
+
...(request.format === undefined ? {} : { format: request.format }),
|
|
290
|
+
audio: generated,
|
|
291
|
+
...(request.channelId === undefined ? {} : { channelId: request.channelId }),
|
|
292
|
+
...(request.channel === undefined ? {} : { channel: request.channel }),
|
|
293
|
+
})
|
|
294
|
+
} catch (error) {
|
|
295
|
+
writeJson(res, 200, { ok: true, outputs: generated, historyError: messageOf(error) })
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
writeJson(res, 200, { ok: true, outputs: generated, history })
|
|
299
|
+
} catch (error) {
|
|
300
|
+
const code = error instanceof AudioGenError ? error.code : 'generate-failed'
|
|
301
|
+
writeJson(res, 200, { ok: false, code, message: messageOf(error) })
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
// ----------------------------------------------------------- audio file
|
|
306
|
+
{
|
|
307
|
+
kind: 'prefix',
|
|
308
|
+
path: AUDIO_API.file,
|
|
309
|
+
handler: async (req, res) => {
|
|
310
|
+
if (!isLoopbackRequest(req)) {
|
|
311
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
if (req.method !== 'GET') {
|
|
315
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
const file = audioFileFrom(req.url, AUDIO_API.file)
|
|
319
|
+
if (file === undefined) {
|
|
320
|
+
writeJson(res, 400, { error: 'invalid audio file' })
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
const stored = await readAudioFile(file)
|
|
324
|
+
if (stored === undefined) {
|
|
325
|
+
writeJson(res, 404, { error: 'audio not found' })
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
res.writeHead(200, {
|
|
329
|
+
'content-type': stored.mime,
|
|
330
|
+
'content-length': stored.bytes,
|
|
331
|
+
'cache-control': 'private, max-age=3600',
|
|
332
|
+
})
|
|
333
|
+
res.end(stored.data)
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
// ------------------------------------------------------- history
|
|
337
|
+
{
|
|
338
|
+
kind: 'exact', path: HISTORY_API.list,
|
|
339
|
+
handler: async (req, res) => {
|
|
340
|
+
if (!guard(req, res, 'POST')) return
|
|
341
|
+
writeJson(res, 200, { ok: true, history: await listHistory() })
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
kind: 'exact', path: HISTORY_API.clear,
|
|
346
|
+
handler: async (req, res) => {
|
|
347
|
+
if (!guard(req, res, 'POST')) return
|
|
348
|
+
writeJson(res, 200, { ok: true, history: await clearHistory() })
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
kind: 'exact', path: HISTORY_API.remove,
|
|
353
|
+
handler: async (req, res) => {
|
|
354
|
+
if (!guard(req, res, 'POST')) return
|
|
355
|
+
const body = await readJsonBody(req)
|
|
356
|
+
const id = typeof body?.id === 'string' ? body.id : ''
|
|
357
|
+
writeJson(res, 200, { ok: true, history: await removeHistory(id) })
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
kind: 'prefix', path: HISTORY_API.audio,
|
|
362
|
+
handler: async (req, res) => {
|
|
363
|
+
if (!isLoopbackRequest(req)) {
|
|
364
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
if (req.method !== 'GET') {
|
|
368
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
const file = audioFileFrom(req.url, HISTORY_API.audio)
|
|
372
|
+
if (file === undefined) {
|
|
373
|
+
writeJson(res, 400, { error: 'invalid audio file' })
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
const stored = await readAudioFile(file)
|
|
377
|
+
if (stored === undefined) {
|
|
378
|
+
writeJson(res, 404, { error: 'audio not found' })
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
res.writeHead(200, { 'content-type': stored.mime, 'content-length': stored.bytes, 'cache-control': 'private, max-age=3600' })
|
|
382
|
+
res.end(stored.data)
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
]
|
|
386
|
+
}
|