dsh-audiogen 0.3.5 → 0.4.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/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const AUDIOGEN_SETTINGS_NAMESPACE = 'dsh-audiogen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '0.3.5'
11
+ export const PLUGIN_VERSION = '0.4.0'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring dsh-imagegen). */
14
14
  export const SETTINGS_API = {
@@ -41,12 +41,30 @@ export const HISTORY_API = {
41
41
  audio: '/api/dsh-audiogen/history/audio',
42
42
  } as const
43
43
 
44
+ /** Host-persisted resource-library routes. */
45
+ export const LIBRARY_API = {
46
+ list: '/api/dsh-audiogen/library/list',
47
+ save: '/api/dsh-audiogen/library/save',
48
+ update: '/api/dsh-audiogen/library/update',
49
+ remove: '/api/dsh-audiogen/library/remove',
50
+ audio: '/api/dsh-audiogen/library/audio',
51
+ } as const
52
+
44
53
  /** Maximum number of history entries retained host-side (oldest evicted). */
45
54
  export const HISTORY_MAX = 50
46
55
 
47
56
  /** Audio generation modes. */
48
57
  export type AudioMode = 'tts' | 'music' | 'sfx' | 'voice_design'
49
58
 
59
+ /** Resource-library entry kinds (map to directories under library/). */
60
+ export type LibraryType = 'voice' | 'music' | 'sfx' | 'tts'
61
+
62
+ /** Voice resources: gender bucket (male / female / custom). */
63
+ export type VoiceCategory = 'male' | 'female' | 'custom'
64
+
65
+ /** All library types, for iteration and validation. */
66
+ export const LIBRARY_TYPES: readonly LibraryType[] = ['voice', 'music', 'sfx', 'tts'] as const
67
+
50
68
  /** The capability category of an audio model/voice. */
51
69
  export type AudioModelCategory =
52
70
  | 'tts'
@@ -122,6 +140,12 @@ export interface GenerateAudioRequest {
122
140
  loop?: boolean
123
141
  /** ElevenLabs 音效:提示词影响度 0-1(prompt_influence,默认 0.3)。 */
124
142
  promptInfluence?: number
143
+ /** Stable Audio 随机种子(seed,0-4294967294,默认 0=随机)。 */
144
+ seed?: number
145
+ /** Stable Audio 采样步数(steps,按模型收敛:stable-audio-2: 30-100;2.5/3: 4-8)。 */
146
+ steps?: number
147
+ /** Stable Audio 提示词遵循度(cfg_scale 1-25;stable-audio-2 默认 7,2.5/3 默认 1)。 */
148
+ cfgScale?: number
125
149
  /** Output format, e.g. mp3, wav, pcm. */
126
150
  format?: string
127
151
  /** Channel this request targets (host falls back to default). */
@@ -162,6 +186,8 @@ export interface GenerateAudioRequest {
162
186
  voiceModify?: { pitch?: number; intensity?: number; timbre?: number; soundEffects?: string }
163
187
  /** MiniMax 双音色混合权重(timbre_weights)。 */
164
188
  timbreWeights?: Array<{ voiceId: string; weight: number }>
189
+ /** 生成完成后自动保存到资源库(面板勾选;宿主还会并入 autoSaveToLibrary 设置)。 */
190
+ saveToLibrary?: boolean
165
191
  }
166
192
 
167
193
  /** One generated audio, normalized host-side to base64. */
@@ -178,6 +204,8 @@ export interface GeneratedAudio {
178
204
  url: string
179
205
  /** Stable audio id / file name. */
180
206
  id: string
207
+ /** File name inside the audio/ store dir. */
208
+ file: string
181
209
  /** Optional voice id returned by a voice-design API. */
182
210
  voiceId?: string
183
211
  }
@@ -218,6 +246,8 @@ export interface HistoryEntry {
218
246
  audio: HistoryAudioRef[]
219
247
  channelId?: string
220
248
  channel?: string
249
+ /** Full resolved generation-request snapshot (provenance; no secrets). */
250
+ params?: Record<string, unknown>
221
251
  }
222
252
 
223
253
  /** A history entry the client submits for persistence (audio still carries base64). */
@@ -235,8 +265,101 @@ export interface HistoryEntryInput {
235
265
  audio: GeneratedAudio[]
236
266
  channelId?: string
237
267
  channel?: string
268
+ /** Full resolved generation-request snapshot (provenance; no secrets). */
269
+ params?: Record<string, unknown>
270
+ }
271
+
272
+ // --------------------------------------------------------------- resource library
273
+
274
+ /** File name / id pair the client hands the host so it can copy a stored audio file. */
275
+ export interface LibraryAudioInput {
276
+ /** Audio id (UUID) as stored in the audio/ dir. */
277
+ id: string
278
+ /** File name inside the audio/ dir (e.g. <uuid>.mp3). */
279
+ file: string
280
+ /** MIME type. */
281
+ mime: string
282
+ /** Optional generated voice id (voice_design outputs). */
283
+ voiceId?: string
284
+ /** Optional duration in seconds. */
285
+ duration?: number
286
+ }
287
+
288
+ /** Full provenance of one library resource. */
289
+ export interface LibraryProvenance {
290
+ mode: AudioMode
291
+ prompt: string
292
+ /** Channel display name snapshot. */
293
+ channel?: string
294
+ channelId?: string
295
+ /** Provider API base URL (non-secret). */
296
+ apiUrl?: string
297
+ /** Model/voice alias as configured. */
298
+ model?: string
299
+ /** Upstream model/voice id actually sent to the provider. */
300
+ upstream?: string
301
+ /** Voice alias used for TTS. */
302
+ voice?: string
303
+ /** voiceId returned by the provider (voice_design). */
304
+ voiceId?: string
305
+ /** Full resolved generation-request snapshot (no secrets). */
306
+ params?: Record<string, unknown>
238
307
  }
239
308
 
309
+ /** One audio file inside a library entry. */
310
+ export interface LibraryFileRef {
311
+ /** Same-origin URL (LIBRARY_API.audio/<rel>). */
312
+ url: string
313
+ /** Relative path under library/ (e.g. voice/male/<id>.mp3). */
314
+ rel: string
315
+ /** MIME type. */
316
+ mime: string
317
+ /** Exact encoded byte length. */
318
+ bytes: number
319
+ /** Duration in seconds when known. */
320
+ duration?: number
321
+ /** Optional generated voice id (voice_design outputs). */
322
+ voiceId?: string
323
+ }
324
+
325
+ /** One curated resource in the library. */
326
+ export interface LibraryEntry {
327
+ id: string
328
+ createdAt: number
329
+ type: LibraryType
330
+ /** voice: male/female/custom; tts: the speaking voice key; others: ''. */
331
+ category?: string
332
+ name: string
333
+ tags: string[]
334
+ note?: string
335
+ files: LibraryFileRef[]
336
+ provenance: LibraryProvenance
337
+ }
338
+
339
+ /** Client → host save request (audioFiles reference files in the audio/ dir). */
340
+ export interface LibrarySaveRequest {
341
+ audioFiles: LibraryAudioInput[]
342
+ type: LibraryType
343
+ category?: string
344
+ name?: string
345
+ tags?: string[]
346
+ note?: string
347
+ provenance: LibraryProvenance
348
+ }
349
+
350
+ /** Client → host update request (moving type/category relocates files). */
351
+ export interface LibraryUpdateRequest {
352
+ id: string
353
+ name?: string
354
+ tags?: string[]
355
+ note?: string
356
+ category?: string
357
+ type?: LibraryType
358
+ }
359
+
360
+ /** Default name length when no name was given. */
361
+ export const LIBRARY_NAME_MAX = 40
362
+
240
363
  /** The plugin settings fields edited by the settings card and panel. */
241
364
  export interface AudiogenConfig {
242
365
  enabled?: boolean
@@ -247,4 +370,6 @@ export interface AudiogenConfig {
247
370
  defaultChannelId?: string
248
371
  /** Optional default voice/model alias for quick generation. */
249
372
  defaultModel?: string
373
+ /** 生成完成后自动加入资源库(面板与 Agent 生成均生效;单次可取消勾选)。 */
374
+ autoSaveToLibrary?: boolean
250
375
  }
package/src/routes.ts CHANGED
@@ -13,10 +13,11 @@ import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } fro
13
13
  import { generateAudio, AudioGenError, type AudioChannel } from './audio-engine.ts'
14
14
  import { discoverAudioModels } from './audio-models.ts'
15
15
  import { AUDIO_PRESETS } from './audio-presets.ts'
16
- import { appendHistory, clearHistory, listHistory, readAudioFile, removeHistory, saveAudioFile } from './audio-store.ts'
16
+ import { appendHistory, clearHistory, listHistory, readAudioFile, removeHistory, saveAudioFile, listLibrary, saveToLibrary, updateLibraryEntry, removeLibraryEntries, readLibraryFile } from './audio-store.ts'
17
17
  import {
18
- AUDIO_API, AUDIOGEN_SETTINGS_NAMESPACE, GENERATE_API, HISTORY_API, MODEL_API, PRESETS_API, SETTINGS_API,
19
- type GenerateAudioRequest, type GeneratedAudio, type HistoryEntryInput,
18
+ AUDIO_API, AUDIOGEN_SETTINGS_NAMESPACE, GENERATE_API, HISTORY_API, LIBRARY_API, MODEL_API, PRESETS_API, SETTINGS_API,
19
+ LIBRARY_TYPES,
20
+ type GenerateAudioRequest, type GeneratedAudio, type HistoryEntryInput, type LibraryAudioInput, type LibraryProvenance, type LibraryType,
20
21
  } from './protocol.ts'
21
22
 
22
23
  const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024
@@ -38,6 +39,8 @@ export interface ChannelsView {
38
39
  export interface AudiogenRoutesDeps {
39
40
  settings: SettingsSeam
40
41
  resolveChannels: () => ChannelsView
42
+ /** Whether the auto-save-to-library setting is on. */
43
+ autoSave: () => boolean
41
44
  }
42
45
 
43
46
  function isLoopbackRequest(request: IncomingMessage): boolean {
@@ -126,6 +129,9 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateAudioReque
126
129
  ...(typeof body.isInstrumental === 'boolean' ? { isInstrumental: body.isInstrumental } : {}),
127
130
  ...(typeof body.loop === 'boolean' ? { loop: body.loop } : {}),
128
131
  ...(num(body.promptInfluence) !== undefined ? { promptInfluence: num(body.promptInfluence)! } : {}),
132
+ ...(num(body.seed) !== undefined ? { seed: num(body.seed)! } : {}),
133
+ ...(num(body.steps) !== undefined ? { steps: num(body.steps)! } : {}),
134
+ ...(num(body.cfgScale) !== undefined ? { cfgScale: num(body.cfgScale)! } : {}),
129
135
  ...(typeof body.format === 'string' && body.format.trim() !== '' ? { format: body.format.trim() } : {}),
130
136
  ...(typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {}),
131
137
  // ---- MiniMax TTS 专属字段(其他厂商忽略) ----
@@ -144,6 +150,7 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateAudioReque
144
150
  ...(str(body.languageBoost) !== undefined ? { languageBoost: str(body.languageBoost)! } : {}),
145
151
  ...(voiceModify !== undefined ? { voiceModify } : {}),
146
152
  ...(timbreWeights !== undefined && timbreWeights.length > 0 ? { timbreWeights } : {}),
153
+ ...(flag(body.saveToLibrary) !== undefined ? { saveToLibrary: flag(body.saveToLibrary)! } : {}),
147
154
  }
148
155
  }
149
156
 
@@ -208,6 +215,74 @@ function resolveChannelRequest(
208
215
  return { ok: true, request: { ...request, model: asked, upstream: mapping.id, channelId: picked.id, channel: picked.name } }
209
216
  }
210
217
 
218
+ /** Build the library type from a generation mode (voice_design → voice). */
219
+ function libraryTypeOf(mode: GenerateAudioRequest['mode']): LibraryType {
220
+ if (mode === 'voice_design') return 'voice'
221
+ return mode
222
+ }
223
+
224
+ /** Provenance snapshot straight from a resolved generate request. */
225
+ function provenanceOf(request: GenerateAudioRequest, apiUrl: string): LibraryProvenance {
226
+ return {
227
+ mode: request.mode,
228
+ prompt: request.prompt,
229
+ ...(request.channel === undefined ? {} : { channel: request.channel }),
230
+ ...(request.channelId === undefined ? {} : { channelId: request.channelId }),
231
+ ...(apiUrl === '' ? {} : { apiUrl }),
232
+ ...(request.model === undefined || request.model === '' ? {} : { model: request.model }),
233
+ ...(request.upstream === undefined || request.upstream === '' ? {} : { upstream: request.upstream }),
234
+ ...(request.voice === undefined ? {} : { voice: request.voice }),
235
+ params: { ...request },
236
+ }
237
+ }
238
+
239
+ const strOf = (value: unknown): string | undefined => typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
240
+ const strListOf = (value: unknown): string[] | undefined =>
241
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map(item => item.trim()) : undefined
242
+ const parseModeOf = (value: unknown): GenerateAudioRequest['mode'] =>
243
+ value === 'music' ? 'music' : value === 'sfx' ? 'sfx' : value === 'voice_design' ? 'voice_design' : 'tts'
244
+ const parseLibraryTypeOf = (value: unknown): LibraryType | undefined =>
245
+ (LIBRARY_TYPES as readonly unknown[]).includes(value) ? value as LibraryType : undefined
246
+
247
+ /** File name (audio/ id.ext) from a same-origin audio url. */
248
+ function historyFileIdOf(url: string): string {
249
+ try {
250
+ return decodeURIComponent(new URL(url, 'http://localhost').pathname.split('/').pop() ?? '')
251
+ } catch {
252
+ return ''
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Fill missing provenance fields from host-persisted history (which carries
258
+ * the resolved request snapshot) and the channel catalog. Client-supplied
259
+ * values win when present.
260
+ */
261
+ async function mergeLibraryProvenance(
262
+ given: LibraryProvenance,
263
+ files: LibraryAudioInput[],
264
+ channels: AudioChannel[],
265
+ ): Promise<LibraryProvenance> {
266
+ const wanted = new Set(files.map(file => file.file))
267
+ const history = await listHistory()
268
+ const entry = history.find(candidate => candidate.audio.some(audio => wanted.has(historyFileIdOf(audio.url))))
269
+ const params = entry?.params !== undefined && typeof entry.params === 'object' ? entry.params : undefined
270
+ const channel = channels.find(candidate => candidate.id === (entry?.channelId ?? ''))
271
+ return {
272
+ mode: entry?.mode ?? given.mode,
273
+ prompt: given.prompt !== '' ? given.prompt : (entry?.prompt ?? ''),
274
+ ...(given.channel !== undefined || entry?.channel !== undefined ? { channel: given.channel ?? entry?.channel } : {}),
275
+ ...(given.channelId !== undefined || entry?.channelId !== undefined ? { channelId: given.channelId ?? entry?.channelId } : {}),
276
+ ...((given.apiUrl ?? channel?.apiUrl ?? '') === '' ? {} : { apiUrl: given.apiUrl ?? channel?.apiUrl }),
277
+ ...(given.model !== undefined || entry?.model !== undefined ? { model: given.model ?? entry?.model } : {}),
278
+ ...((given.upstream ?? (typeof params?.upstream === 'string' ? params.upstream : undefined)) !== undefined
279
+ ? { upstream: given.upstream ?? (typeof params?.upstream === 'string' ? params.upstream : undefined) } : {}),
280
+ ...(given.voice !== undefined || entry?.voice !== undefined ? { voice: given.voice ?? entry?.voice } : {}),
281
+ ...(given.voiceId !== undefined || entry?.voiceId !== undefined ? { voiceId: given.voiceId ?? entry?.voiceId } : {}),
282
+ ...(given.params !== undefined || params !== undefined ? { params: given.params ?? params } : {}),
283
+ }
284
+ }
285
+
211
286
  /** Build every /api/dsh-audiogen route. */
212
287
  export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
213
288
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
@@ -348,6 +423,7 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
348
423
  const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`)
349
424
  generated.push({
350
425
  id: saved.id,
426
+ file: saved.file,
351
427
  b64: Buffer.from(output.data).toString('base64'),
352
428
  mime: saved.mime,
353
429
  bytes: saved.bytes,
@@ -355,6 +431,7 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
355
431
  ...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
356
432
  })
357
433
  }
434
+ const paramsSnapshot: Record<string, unknown> = { ...request }
358
435
  let history
359
436
  try {
360
437
  history = await appendHistory({
@@ -370,12 +447,33 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
370
447
  audio: generated,
371
448
  ...(request.channelId === undefined ? {} : { channelId: request.channelId }),
372
449
  ...(request.channel === undefined ? {} : { channel: request.channel }),
450
+ params: paramsSnapshot,
373
451
  })
374
452
  } catch (error) {
375
453
  writeJson(res, 200, { ok: true, outputs: generated, historyError: messageOf(error) })
376
454
  return
377
455
  }
378
- writeJson(res, 200, { ok: true, outputs: generated, history })
456
+ // ---- 资源库:单次勾选或设置自动入库(saveToLibrary === false 显式跳过) ----
457
+ const wantSave = request.saveToLibrary === true || (deps.autoSave() && request.saveToLibrary !== false)
458
+ let resources: Array<{ id: string; name: string; type: LibraryType }> | undefined
459
+ if (wantSave) {
460
+ try {
461
+ const entry = await saveToLibrary({
462
+ audioFiles: generated.map(audio => ({
463
+ id: audio.id,
464
+ file: audio.file,
465
+ mime: audio.mime,
466
+ ...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
467
+ })),
468
+ type: libraryTypeOf(request.mode),
469
+ provenance: provenanceOf(request, channel.apiUrl),
470
+ })
471
+ resources = [{ id: entry.id, name: entry.name, type: entry.type }]
472
+ } catch {
473
+ // library-save is best-effort: generation and history already succeeded
474
+ }
475
+ }
476
+ writeJson(res, 200, { ok: true, outputs: generated, history, ...(resources === undefined ? {} : { resources }) })
379
477
  } catch (error) {
380
478
  const code = error instanceof AudioGenError ? error.code : 'generate-failed'
381
479
  writeJson(res, 200, { ok: false, code, message: messageOf(error) })
@@ -413,6 +511,141 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
413
511
  res.end(stored.data)
414
512
  },
415
513
  },
514
+ // ------------------------------------------------------- resource library
515
+ {
516
+ kind: 'exact', path: LIBRARY_API.list,
517
+ handler: async (req, res) => {
518
+ if (!guard(req, res, 'POST')) return
519
+ writeJson(res, 200, { ok: true, entries: await listLibrary() })
520
+ },
521
+ },
522
+ {
523
+ kind: 'exact', path: LIBRARY_API.save,
524
+ handler: async (req, res) => {
525
+ if (!guard(req, res, 'POST')) return
526
+ const body = await readJsonBody(req)
527
+ const audioFiles: LibraryAudioInput[] = Array.isArray(body?.audioFiles)
528
+ ? body.audioFiles
529
+ .filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
530
+ .map(item => ({
531
+ id: strOf(item.id) ?? '',
532
+ file: strOf(item.file) ?? '',
533
+ mime: strOf(item.mime) ?? 'audio/mpeg',
534
+ ...(strOf(item.voiceId) !== undefined ? { voiceId: strOf(item.voiceId)! } : {}),
535
+ ...(typeof item.duration === 'number' && Number.isFinite(item.duration) ? { duration: item.duration } : {}),
536
+ }))
537
+ .filter(item => item.id !== '' && item.file !== '')
538
+ : []
539
+ if (audioFiles.length === 0) {
540
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: '没有可入库的音频文件' })
541
+ return
542
+ }
543
+ const type = parseLibraryTypeOf(body?.type)
544
+ if (type === undefined) {
545
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: '资源类型无效(voice/music/sfx/tts)' })
546
+ return
547
+ }
548
+ const rawProvenance = typeof body?.provenance === 'object' && body.provenance !== null ? body.provenance as Record<string, unknown> : {}
549
+ const provenance = await mergeLibraryProvenance({
550
+ mode: parseModeOf(rawProvenance.mode),
551
+ prompt: typeof rawProvenance.prompt === 'string' ? rawProvenance.prompt.trim() : '',
552
+ ...(strOf(rawProvenance.channel) !== undefined ? { channel: strOf(rawProvenance.channel)! } : {}),
553
+ ...(strOf(rawProvenance.channelId) !== undefined ? { channelId: strOf(rawProvenance.channelId)! } : {}),
554
+ ...(strOf(rawProvenance.apiUrl) !== undefined ? { apiUrl: strOf(rawProvenance.apiUrl)! } : {}),
555
+ ...(strOf(rawProvenance.model) !== undefined ? { model: strOf(rawProvenance.model)! } : {}),
556
+ ...(strOf(rawProvenance.upstream) !== undefined ? { upstream: strOf(rawProvenance.upstream)! } : {}),
557
+ ...(strOf(rawProvenance.voice) !== undefined ? { voice: strOf(rawProvenance.voice)! } : {}),
558
+ ...(strOf(rawProvenance.voiceId) !== undefined ? { voiceId: strOf(rawProvenance.voiceId)! } : {}),
559
+ ...(typeof rawProvenance.params === 'object' && rawProvenance.params !== null
560
+ ? { params: rawProvenance.params as Record<string, unknown> } : {}),
561
+ }, audioFiles, deps.resolveChannels().channels)
562
+ try {
563
+ const entry = await saveToLibrary({
564
+ audioFiles,
565
+ type,
566
+ ...(strOf(body?.category) !== undefined ? { category: strOf(body?.category)! } : {}),
567
+ ...(strOf(body?.name) !== undefined ? { name: strOf(body?.name)! } : {}),
568
+ ...(strListOf(body?.tags) !== undefined ? { tags: strListOf(body?.tags)! } : {}),
569
+ ...(strOf(body?.note) !== undefined ? { note: strOf(body?.note)! } : {}),
570
+ provenance,
571
+ })
572
+ writeJson(res, 200, { ok: true, entry })
573
+ } catch (error) {
574
+ writeJson(res, 200, { ok: false, code: 'library-save-failed', message: messageOf(error) })
575
+ }
576
+ },
577
+ },
578
+ {
579
+ kind: 'exact', path: LIBRARY_API.update,
580
+ handler: async (req, res) => {
581
+ if (!guard(req, res, 'POST')) return
582
+ const body = await readJsonBody(req)
583
+ const id = strOf(body?.id)
584
+ if (id === undefined) {
585
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: '缺少资源 id' })
586
+ return
587
+ }
588
+ try {
589
+ const entry = await updateLibraryEntry(id, {
590
+ ...(strOf(body?.name) !== undefined ? { name: strOf(body?.name)! } : {}),
591
+ ...(strListOf(body?.tags) !== undefined ? { tags: strListOf(body?.tags)! } : {}),
592
+ ...(typeof body?.note === 'string' ? { note: body.note } : {}),
593
+ ...(strOf(body?.category) !== undefined ? { category: strOf(body?.category)! } : {}),
594
+ ...(parseLibraryTypeOf(body?.type) !== undefined ? { type: parseLibraryTypeOf(body?.type)! } : {}),
595
+ })
596
+ if (entry === undefined) {
597
+ writeJson(res, 200, { ok: false, code: 'not-found', message: '资源不存在' })
598
+ return
599
+ }
600
+ writeJson(res, 200, { ok: true, entry })
601
+ } catch (error) {
602
+ writeJson(res, 200, { ok: false, code: 'library-update-failed', message: messageOf(error) })
603
+ }
604
+ },
605
+ },
606
+ {
607
+ kind: 'exact', path: LIBRARY_API.remove,
608
+ handler: async (req, res) => {
609
+ if (!guard(req, res, 'POST')) return
610
+ const body = await readJsonBody(req)
611
+ const ids = strListOf(body?.ids) ?? []
612
+ if (ids.length === 0) {
613
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: '缺少资源 id' })
614
+ return
615
+ }
616
+ try {
617
+ const entries = await removeLibraryEntries(ids)
618
+ writeJson(res, 200, { ok: true, entries })
619
+ } catch (error) {
620
+ writeJson(res, 200, { ok: false, code: 'library-remove-failed', message: messageOf(error) })
621
+ }
622
+ },
623
+ },
624
+ {
625
+ kind: 'prefix', path: LIBRARY_API.audio,
626
+ handler: async (req, res) => {
627
+ if (!isLoopbackRequest(req)) {
628
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
629
+ return
630
+ }
631
+ if (req.method !== 'GET') {
632
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
633
+ return
634
+ }
635
+ const rel = audioFileFrom(req.url, LIBRARY_API.audio)
636
+ if (rel === undefined) {
637
+ writeJson(res, 400, { error: 'invalid library audio' })
638
+ return
639
+ }
640
+ const stored = await readLibraryFile(rel)
641
+ if (stored === undefined) {
642
+ writeJson(res, 404, { error: 'library audio not found' })
643
+ return
644
+ }
645
+ res.writeHead(200, { 'content-type': stored.mime, 'content-length': stored.bytes, 'cache-control': 'private, max-age=3600' })
646
+ res.end(stored.data)
647
+ },
648
+ },
416
649
  // ------------------------------------------------------- history
417
650
  {
418
651
  kind: 'exact', path: HISTORY_API.list,