dsh-audiogen 0.3.4 → 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.4'
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'
@@ -118,6 +136,16 @@ export interface GenerateAudioRequest {
118
136
  lyrics?: string
119
137
  /** MiniMax 是否生成纯音乐(无歌词/人声);true 时 lyrics 可为空。 */
120
138
  isInstrumental?: boolean
139
+ /** ElevenLabs 音效:生成无缝循环音效(loop,仅 eleven_text_to_sound_v2)。 */
140
+ loop?: boolean
141
+ /** ElevenLabs 音效:提示词影响度 0-1(prompt_influence,默认 0.3)。 */
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
121
149
  /** Output format, e.g. mp3, wav, pcm. */
122
150
  format?: string
123
151
  /** Channel this request targets (host falls back to default). */
@@ -158,6 +186,8 @@ export interface GenerateAudioRequest {
158
186
  voiceModify?: { pitch?: number; intensity?: number; timbre?: number; soundEffects?: string }
159
187
  /** MiniMax 双音色混合权重(timbre_weights)。 */
160
188
  timbreWeights?: Array<{ voiceId: string; weight: number }>
189
+ /** 生成完成后自动保存到资源库(面板勾选;宿主还会并入 autoSaveToLibrary 设置)。 */
190
+ saveToLibrary?: boolean
161
191
  }
162
192
 
163
193
  /** One generated audio, normalized host-side to base64. */
@@ -174,6 +204,8 @@ export interface GeneratedAudio {
174
204
  url: string
175
205
  /** Stable audio id / file name. */
176
206
  id: string
207
+ /** File name inside the audio/ store dir. */
208
+ file: string
177
209
  /** Optional voice id returned by a voice-design API. */
178
210
  voiceId?: string
179
211
  }
@@ -214,6 +246,8 @@ export interface HistoryEntry {
214
246
  audio: HistoryAudioRef[]
215
247
  channelId?: string
216
248
  channel?: string
249
+ /** Full resolved generation-request snapshot (provenance; no secrets). */
250
+ params?: Record<string, unknown>
217
251
  }
218
252
 
219
253
  /** A history entry the client submits for persistence (audio still carries base64). */
@@ -231,8 +265,101 @@ export interface HistoryEntryInput {
231
265
  audio: GeneratedAudio[]
232
266
  channelId?: string
233
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>
234
307
  }
235
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
+
236
363
  /** The plugin settings fields edited by the settings card and panel. */
237
364
  export interface AudiogenConfig {
238
365
  enabled?: boolean
@@ -243,4 +370,6 @@ export interface AudiogenConfig {
243
370
  defaultChannelId?: string
244
371
  /** Optional default voice/model alias for quick generation. */
245
372
  defaultModel?: string
373
+ /** 生成完成后自动加入资源库(面板与 Agent 生成均生效;单次可取消勾选)。 */
374
+ autoSaveToLibrary?: boolean
246
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 {
@@ -124,6 +127,11 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateAudioReque
124
127
  ...(num(body.duration) !== undefined ? { duration: num(body.duration)! } : {}),
125
128
  ...(typeof body.lyrics === 'string' && body.lyrics.trim() !== '' ? { lyrics: body.lyrics.trim() } : {}),
126
129
  ...(typeof body.isInstrumental === 'boolean' ? { isInstrumental: body.isInstrumental } : {}),
130
+ ...(typeof body.loop === 'boolean' ? { loop: body.loop } : {}),
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)! } : {}),
127
135
  ...(typeof body.format === 'string' && body.format.trim() !== '' ? { format: body.format.trim() } : {}),
128
136
  ...(typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {}),
129
137
  // ---- MiniMax TTS 专属字段(其他厂商忽略) ----
@@ -142,6 +150,7 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateAudioReque
142
150
  ...(str(body.languageBoost) !== undefined ? { languageBoost: str(body.languageBoost)! } : {}),
143
151
  ...(voiceModify !== undefined ? { voiceModify } : {}),
144
152
  ...(timbreWeights !== undefined && timbreWeights.length > 0 ? { timbreWeights } : {}),
153
+ ...(flag(body.saveToLibrary) !== undefined ? { saveToLibrary: flag(body.saveToLibrary)! } : {}),
145
154
  }
146
155
  }
147
156
 
@@ -180,11 +189,15 @@ function resolveChannelRequest(
180
189
  const defaults = view.channels.find(candidate => candidate.id === view.defaultChannelId) ?? view.channels[0]
181
190
  const target = explicit ?? defaults
182
191
  const asked = request.model.trim()
192
+
193
+ // 音色设计不按模型解析渠道:总是走 channelId(面板选择器)或默认渠道,
194
+ // 并丢弃可能残留的模型值,避免上一个模式的模型把渠道带偏。
195
+ if (request.mode === 'voice_design') {
196
+ if (target === undefined) return { ok: false, code: 'no-channels', message: '尚未配置任何渠道' }
197
+ return { ok: true, request: { ...request, model: '', upstream: undefined, channelId: target.id, channel: target.name } }
198
+ }
199
+
183
200
  if (asked === '') {
184
- if (request.mode === 'voice_design') {
185
- if (target === undefined) return { ok: false, code: 'no-channels', message: '尚未配置任何渠道' }
186
- return { ok: true, request: { ...request, channelId: target.id, channel: target.name } }
187
- }
188
201
  const alias = target?.models[0]?.alias ?? ''
189
202
  if (alias === '') {
190
203
  return { ok: false, code: 'no-models', message: `渠道「${target?.name ?? ''}」尚未配置模型/音色,请先在设置中添加` }
@@ -202,6 +215,74 @@ function resolveChannelRequest(
202
215
  return { ok: true, request: { ...request, model: asked, upstream: mapping.id, channelId: picked.id, channel: picked.name } }
203
216
  }
204
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
+
205
286
  /** Build every /api/dsh-audiogen route. */
206
287
  export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
207
288
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
@@ -342,6 +423,7 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
342
423
  const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`)
343
424
  generated.push({
344
425
  id: saved.id,
426
+ file: saved.file,
345
427
  b64: Buffer.from(output.data).toString('base64'),
346
428
  mime: saved.mime,
347
429
  bytes: saved.bytes,
@@ -349,6 +431,7 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
349
431
  ...(output.voiceId === undefined ? {} : { voiceId: output.voiceId }),
350
432
  })
351
433
  }
434
+ const paramsSnapshot: Record<string, unknown> = { ...request }
352
435
  let history
353
436
  try {
354
437
  history = await appendHistory({
@@ -364,12 +447,33 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
364
447
  audio: generated,
365
448
  ...(request.channelId === undefined ? {} : { channelId: request.channelId }),
366
449
  ...(request.channel === undefined ? {} : { channel: request.channel }),
450
+ params: paramsSnapshot,
367
451
  })
368
452
  } catch (error) {
369
453
  writeJson(res, 200, { ok: true, outputs: generated, historyError: messageOf(error) })
370
454
  return
371
455
  }
372
- 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 }) })
373
477
  } catch (error) {
374
478
  const code = error instanceof AudioGenError ? error.code : 'generate-failed'
375
479
  writeJson(res, 200, { ok: false, code, message: messageOf(error) })
@@ -407,6 +511,141 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
407
511
  res.end(stored.data)
408
512
  },
409
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
+ },
410
649
  // ------------------------------------------------------- history
411
650
  {
412
651
  kind: 'exact', path: HISTORY_API.list,