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.
@@ -1,357 +1,82 @@
1
-
2
1
  /**
3
- * The AI 音频 panel: a compact audio-generation studio.
4
- * TTS / music / SFX / voice design are separated; each mode only lists
5
- * compatible models and shows its own parameters.
2
+ * The AI 音频 panel shell: header with 生成 / 资源库 tabs, toast host, and
3
+ * cross-tab hooks (library refresh after saves, «use this voice» reuse).
6
4
  */
7
5
 
8
- import { useEffect, useMemo, useState } from 'react'
6
+ import { useRef, useState } from 'react'
9
7
  import type { AudiogenApi } from './api.ts'
10
8
  import type { AudiogenScope } from './settings-scope.ts'
11
- import { audioModelOptions } from './settings-scope.ts'
12
9
  import { tt } from './helpers.ts'
13
- import { HISTORY_API, type AudioMode, type GeneratedAudio, type HistoryEntry } from '../protocol.ts'
10
+ import { GridIcon, ListIcon } from './icons.tsx'
11
+ import { StudioView, type StudioReuse } from './studio-view.tsx'
12
+ import { LibraryView } from './library-view.tsx'
14
13
  import css from './audio-panel.module.css'
15
14
 
16
- function useConfig(scope: AudiogenScope) {
17
- const [value, setValue] = useState(scope.getSnapshot().value)
18
- useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
19
- return value
20
- }
21
-
22
- function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: () => void } {
23
- const [entries, setEntries] = useState<HistoryEntry[]>([])
24
- const reload = (): void => {
25
- void fetch(HISTORY_API.list, { method: 'POST' })
26
- .then(async response => {
27
- const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
28
- if (body.ok === true) setEntries(body.history ?? [])
29
- })
30
- .catch(() => { /* history is best-effort */ })
31
- }
32
- useEffect(() => { reload() }, [])
33
- const clear = (): void => {
34
- void fetch(HISTORY_API.clear, { method: 'POST' })
35
- .then(() => reload())
36
- .catch(() => { /* best-effort */ })
37
- }
38
- return { entries, reload, clear }
39
- }
40
-
41
- function dataUrlOf(audio: GeneratedAudio): string {
42
- return `data:${audio.mime};base64,${audio.b64}`
43
- }
15
+ type Tab = 'studio' | 'library'
44
16
 
45
17
  export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope }) {
46
18
  const { api, scope } = props
47
- const config = useConfig(scope)
48
- const enabled = config?.enabled ?? true
49
- const modelOptions = audioModelOptions(config)
50
- const channels = config?.channels ?? []
51
- const connected = enabled && channels.some(channel => {
52
- const keyHeld = scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`)
53
- return channel.apiUrl.trim() !== '' && keyHeld && (channel.models.length > 0 || channel.preset === 'minimax')
54
- })
55
-
56
- const [mode, setMode] = useState<AudioMode>('tts')
57
- const [prompt, setPrompt] = useState('')
58
- const [previewText, setPreviewText] = useState('')
59
- const [model, setModel] = useState('')
60
- const [voice, setVoice] = useState('')
61
- const [speed, setSpeed] = useState('')
62
- const [duration, setDuration] = useState('')
63
- const [lyrics, setLyrics] = useState('')
64
- const [instrumental, setInstrumental] = useState(false)
65
- const [format, setFormat] = useState('mp3')
66
- // MiniMax TTS 高级参数(其他厂商忽略)
67
- const [emotion, setEmotion] = useState('')
68
- const [vol, setVol] = useState('')
69
- const [pitch, setPitch] = useState('')
70
- const [toneText, setToneText] = useState('')
71
- const [sampleRate, setSampleRate] = useState('')
72
- const [bitrate, setBitrate] = useState('')
73
- const [audioChannel, setAudioChannel] = useState('')
74
- const [subtitle, setSubtitle] = useState(false)
75
- const [loading, setLoading] = useState(false)
76
- const [error, setError] = useState<string | null>(null)
77
- const [outputs, setOutputs] = useState<GeneratedAudio[]>([])
78
- const { entries, reload, clear } = useHistory()
79
-
80
- const isMiniMaxChannel = useMemo(() => {
81
- const target = channels.find(candidate => candidate.id === modelOptions.defaultChannelId) ?? channels[0]
82
- return target !== undefined && (target.preset === 'minimax' || /minimax/i.test(target.apiUrl))
83
- }, [channels, modelOptions.defaultChannelId])
84
-
85
- const visibleModels = useMemo(() => {
86
- if (mode === 'voice_design') return []
87
- return modelOptions.models
88
- .filter(entry => entry.category === undefined || entry.category === 'tts' && mode === 'tts' || entry.category === mode)
89
- .map(entry => entry.alias)
90
- }, [modelOptions.models, mode])
91
-
92
- useEffect(() => {
93
- if (visibleModels.length > 0 && !visibleModels.includes(model)) {
94
- setModel(visibleModels[0]!)
95
- }
96
- }, [visibleModels, model])
97
-
98
- const submit = async (): Promise<void> => {
99
- if (prompt.trim() === '') {
100
- setError(tt('prompt.required'))
101
- return
102
- }
103
- setLoading(true)
104
- setError(null)
105
- try {
106
- const response = await api.generate({
107
- mode,
108
- model: (model || visibleModels[0]) ?? '',
109
- prompt: prompt.trim(),
110
- ...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
111
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
112
- ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
113
- ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
114
- ...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
115
- ...(instrumental ? { isInstrumental: true } : {}),
116
- ...(format.trim() !== '' ? { format: format.trim() } : {}),
117
- ...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
118
- ...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
119
- ...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
120
- ...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
121
- ...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
122
- ...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
123
- ...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
124
- ...(subtitle ? { subtitleEnable: true } : {}),
125
- })
126
- if (!response.ok) {
127
- setError(response.message ?? '生成失败')
128
- return
129
- }
130
- setOutputs(response.outputs ?? [])
131
- reload()
132
- } catch (err) {
133
- setError(err instanceof Error ? err.message : String(err))
134
- } finally {
135
- setLoading(false)
136
- }
19
+ const [tab, setTab] = useState<Tab>('studio')
20
+ const [libraryRev, setLibraryRev] = useState(0)
21
+ const [reuse, setReuse] = useState<StudioReuse | null>(null)
22
+ const [toast, setToast] = useState<string | null>(null)
23
+ const toastTimer = useRef<number | undefined>(undefined)
24
+
25
+ const showToast = (text: string): void => {
26
+ setToast(text)
27
+ if (toastTimer.current !== undefined) window.clearTimeout(toastTimer.current)
28
+ toastTimer.current = window.setTimeout(() => setToast(null), 2400)
137
29
  }
138
30
 
139
- const modeLabel = useMemo(() => {
140
- if (mode === 'tts') return tt('mode.tts')
141
- if (mode === 'music') return tt('mode.music')
142
- if (mode === 'sfx') return tt('mode.sfx')
143
- return tt('mode.voiceDesign')
144
- }, [mode])
145
-
146
- const needModel = mode !== 'voice_design'
147
-
148
31
  return (
149
32
  <div className={css.panel}>
150
33
  <header className={css.header}>
151
34
  <h2 className={css.title}>{tt('panel.title')}</h2>
152
- <span className={css.hint}>{modeLabel}</span>
153
- </header>
154
- <div className={css.layout}>
155
- <div className={css.form}>
156
- <div className={css.modeRow}>
157
- {(['tts', 'music', 'sfx', 'voice_design'] as const).map(item => (
158
- <button
159
- key={item}
160
- type="button"
161
- className={css.modeButton}
162
- data-active={mode === item ? 'true' : 'false'}
163
- onClick={() => setMode(item)}
164
- >
165
- {item === 'tts' ? tt('mode.tts') : item === 'music' ? tt('mode.music') : item === 'sfx' ? tt('mode.sfx') : tt('mode.voiceDesign')}
166
- </button>
167
- ))}
168
- </div>
169
-
170
- <label className={css.label}>
171
- <span>{mode === 'voice_design' ? '音色描述' : mode === 'tts' ? '文本' : '提示词'}</span>
172
- <textarea className={css.textarea} value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('prompt.placeholder')} />
173
- </label>
174
-
175
- {mode === 'voice_design' ? (
176
- <label className={css.label}>
177
- <span>试听文本</span>
178
- <input className={css.input} value={previewText} onChange={event => setPreviewText(event.target.value)} placeholder="你好,这是新设计的音色试听。" />
179
- </label>
180
- ) : null}
181
-
182
- {needModel ? (
183
- <label className={css.label}>
184
- <span>{tt('model.label')}</span>
185
- <select className={css.select} value={model} onChange={event => setModel(event.target.value)}>
186
- {visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
187
- {visibleModels.map(item => <option key={item} value={item}>{item}</option>)}
188
- </select>
189
- </label>
190
- ) : null}
191
-
192
- {mode === 'tts' ? (
193
- <label className={css.label}>
194
- <span>{tt('voice.label')}</span>
195
- <input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder={isMiniMaxChannel ? 'male-qn-qingse / female-shaonv' : 'alloy / 自定义音色'} />
196
- </label>
197
- ) : null}
198
-
199
- {mode === 'tts' ? (
200
- <label className={css.label}>
201
- <span>{tt('speed.label')}</span>
202
- <input className={css.input} type="number" step="0.1" min="0.5" max="2" value={speed} onChange={event => setSpeed(event.target.value)} placeholder="1.0" />
203
- </label>
204
- ) : null}
205
-
206
- {mode === 'tts' && isMiniMaxChannel ? (
207
- <details className={css.advanced}>
208
- <summary>MiniMax 高级参数</summary>
209
- <label className={css.label}>
210
- <span>情绪 emotion</span>
211
- <input className={css.input} value={emotion} onChange={event => setEmotion(event.target.value)} placeholder="happy / sad / angry / nervous…" />
212
- </label>
213
- <div className={css.row}>
214
- <label className={css.label}>
215
- <span>音量 vol (0-10)</span>
216
- <input className={css.input} type="number" min="0" max="10" step="0.5" value={vol} onChange={event => setVol(event.target.value)} placeholder="1" />
217
- </label>
218
- <label className={css.label}>
219
- <span>音调 pitch (-12~12)</span>
220
- <input className={css.input} type="number" min="-12" max="12" value={pitch} onChange={event => setPitch(event.target.value)} placeholder="0" />
221
- </label>
222
- </div>
223
- <div className={css.row}>
224
- <label className={css.label}>
225
- <span>采样率</span>
226
- <input className={css.input} type="number" min="16000" max="48000" step="8000" value={sampleRate} onChange={event => setSampleRate(event.target.value)} placeholder="32000" />
227
- </label>
228
- <label className={css.label}>
229
- <span>码率 bps</span>
230
- <input className={css.input} type="number" min="64000" max="320000" step="8000" value={bitrate} onChange={event => setBitrate(event.target.value)} placeholder="128000" />
231
- </label>
232
- <label className={css.label}>
233
- <span>声道</span>
234
- <select className={css.select} value={audioChannel} onChange={event => setAudioChannel(event.target.value)}>
235
- <option value="">默认(1)</option>
236
- <option value="1">1</option>
237
- <option value="2">2</option>
238
- </select>
239
- </label>
240
- </div>
241
- <label className={css.label}>
242
- <span>发音词典(每行一条:"文字/读音")</span>
243
- <textarea className={css.textarea} value={toneText} onChange={event => setToneText(event.target.value)} placeholder={'处理/(chu3)(li3)\n危险/dangerous'} />
244
- </label>
245
- <label className={css.checkbox}>
246
- <input type="checkbox" checked={subtitle} onChange={event => setSubtitle(event.target.checked)} />
247
- <span>生成字幕 subtitle_enable</span>
248
- </label>
249
- </details>
250
- ) : null}
251
-
252
- {mode === 'music' || mode === 'sfx' ? (
253
- <label className={css.label}>
254
- <span>{tt('duration.label')}</span>
255
- <input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
256
- </label>
257
- ) : null}
258
-
259
- {mode === 'music' ? (
260
- <>
261
- <label className={css.label}>
262
- <span>歌词(纯音乐模式可留空;多段用空行分隔)</span>
263
- <textarea className={css.textarea} value={lyrics} onChange={event => setLyrics(event.target.value)} placeholder={'第一段歌词…\n\n第二段歌词…'} />
264
- </label>
265
- <label className={css.checkbox}>
266
- <input type="checkbox" checked={instrumental} onChange={event => setInstrumental(event.target.checked)} />
267
- <span>纯音乐(无歌词/人声)is_instrumental</span>
268
- </label>
269
- <div className={css.row}>
270
- <label className={css.label}>
271
- <span>采样率</span>
272
- <select className={css.select} value={sampleRate} onChange={event => setSampleRate(event.target.value)}>
273
- <option value="">默认(44100)</option>
274
- <option value="16000">16000</option>
275
- <option value="24000">24000</option>
276
- <option value="32000">32000</option>
277
- <option value="44100">44100</option>
278
- </select>
279
- </label>
280
- <label className={css.label}>
281
- <span>码率 bps</span>
282
- <select className={css.select} value={bitrate} onChange={event => setBitrate(event.target.value)}>
283
- <option value="">默认(256000)</option>
284
- <option value="32000">32000</option>
285
- <option value="64000">64000</option>
286
- <option value="128000">128000</option>
287
- <option value="256000">256000</option>
288
- </select>
289
- </label>
290
- </div>
291
- </>
292
- ) : null}
293
-
294
- {needModel ? (
295
- <label className={css.label}>
296
- <span>{tt('format.label')}</span>
297
- <select className={css.select} value={format} onChange={event => setFormat(event.target.value)}>
298
- <option value="mp3">mp3</option>
299
- <option value="wav">wav</option>
300
- {mode === 'tts' ? (
301
- <>
302
- <option value="flac">flac</option>
303
- <option value="ogg">ogg</option>
304
- </>
305
- ) : null}
306
- <option value="pcm">pcm</option>
307
- </select>
308
- </label>
309
- ) : null}
310
-
311
- {!connected && <p className={css.hint}>{tt('config.missing')}</p>}
312
- <button type="button" className={css.generate} disabled={loading || !connected || (needModel && visibleModels.length === 0)} onClick={() => void submit()}>
313
- {loading ? tt('generating') : tt('generate')}
35
+ <div className={css.tabs} role="tablist" aria-label="AI 音频">
36
+ <button
37
+ type="button"
38
+ role="tab"
39
+ aria-selected={tab === 'studio'}
40
+ className={css.tab}
41
+ data-active={tab === 'studio' ? 'true' : 'false'}
42
+ onClick={() => setTab('studio')}
43
+ >
44
+ <GridIcon /> {tt('tab.studio')}
45
+ </button>
46
+ <button
47
+ type="button"
48
+ role="tab"
49
+ aria-selected={tab === 'library'}
50
+ className={css.tab}
51
+ data-active={tab === 'library' ? 'true' : 'false'}
52
+ onClick={() => setTab('library')}
53
+ >
54
+ <ListIcon /> {tt('tab.library')}
314
55
  </button>
315
56
  </div>
57
+ </header>
316
58
 
317
- <div className={css.result}>
318
- {error !== null ? <p className={css.error}>{error}</p> : null}
319
- {outputs.length === 0 ? <p className={css.empty}>{tt('result.empty')}</p> : (
320
- <>
321
- <p className={css.hint}>{tt('result.done', { count: outputs.length })}</p>
322
- <div className={css.audioList}>
323
- {outputs.map((audio, index) => (
324
- <div className={css.audioCard} key={audio.id}>
325
- {audio.voiceId !== undefined ? <p className={css.hint}>新音色 ID:{audio.voiceId}</p> : null}
326
- <audio className={css.audio} controls preload="metadata" src={dataUrlOf(audio)} />
327
- <a className={css.download} href={dataUrlOf(audio)} download={`generated-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>下载</a>
328
- </div>
329
- ))}
330
- </div>
331
- </>
332
- )}
333
- </div>
334
-
335
- <aside className={css.history}>
336
- <div className={css.historyHeader}>
337
- <strong className={css.historyTitle}>{tt('history.title')}</strong>
338
- <button type="button" onClick={clear} style={{ border: 0, background: 'none', cursor: 'pointer', color: 'inherit', fontSize: 12 }}>清空</button>
339
- </div>
340
- {entries.length === 0 ? <p className={css.historyEmpty}>{tt('history.empty')}</p> : (
341
- <div>
342
- {entries.map(entry => (
343
- <div className={css.historyItem} key={entry.id}>
344
- <div className={css.historyPrompt}>{entry.prompt}</div>
345
- <div className={css.historyMeta}>{entry.mode} · {entry.model}{entry.channel ? ` · ${entry.channel}` : ''}</div>
346
- {entry.audio.map((audio, index) => (
347
- <audio key={index} className={css.historyAudio} controls preload="none" src={audio.url} />
348
- ))}
349
- </div>
350
- ))}
351
- </div>
352
- )}
353
- </aside>
354
- </div>
59
+ {tab === 'studio' ? (
60
+ <StudioView
61
+ api={api}
62
+ scope={scope}
63
+ reuse={reuse}
64
+ onLibraryChanged={() => setLibraryRev(revision => revision + 1)}
65
+ showToast={showToast}
66
+ />
67
+ ) : (
68
+ <LibraryView
69
+ api={api}
70
+ revision={libraryRev}
71
+ showToast={showToast}
72
+ onReuseVoice={payload => {
73
+ setReuse({ nonce: Date.now(), mode: payload.mode, ...(payload.voice === undefined ? {} : { voice: payload.voice }), ...(payload.voiceId === undefined ? {} : { voiceId: payload.voiceId }), ...(payload.model === undefined ? {} : { model: payload.model }) })
74
+ setTab('studio')
75
+ }}
76
+ />
77
+ )}
78
+
79
+ {toast !== null ? <div className={css.toast} role="status">{toast}</div> : null}
355
80
  </div>
356
81
  )
357
82
  }
@@ -27,6 +27,7 @@ export interface AudioGenSettings {
27
27
  announceToAgent?: boolean
28
28
  allowAgentAudioGeneration?: boolean
29
29
  defaultModel?: string
30
+ autoSaveToLibrary?: boolean
30
31
  }
31
32
 
32
33
  export interface AudioGenSettingsCardState extends CardShell {
@@ -35,6 +36,7 @@ export interface AudioGenSettingsCardState extends CardShell {
35
36
  announceToAgent: CardFieldState
36
37
  allowAgentAudioGeneration: CardFieldState
37
38
  defaultModel: CardFieldState
39
+ autoSaveToLibrary: CardFieldState
38
40
  }
39
41
 
40
42
  export interface AudioGenSettingsCardFace extends CardActions {
@@ -54,6 +56,7 @@ export class AudioGenSettingsCardController {
54
56
  booleanField('announceToAgent'),
55
57
  booleanField('allowAgentAudioGeneration'),
56
58
  textField('defaultModel'),
59
+ booleanField('autoSaveToLibrary'),
57
60
  ])
58
61
  this.channelsForm = new ChannelsForm(scope)
59
62
  }
@@ -68,6 +71,7 @@ export class AudioGenSettingsCardController {
68
71
  announceToAgent: this.form.field('announceToAgent'),
69
72
  allowAgentAudioGeneration: this.form.field('allowAgentAudioGeneration'),
70
73
  defaultModel: this.form.field('defaultModel'),
74
+ autoSaveToLibrary: this.form.field('autoSaveToLibrary'),
71
75
  }
72
76
  }
73
77
 
@@ -714,6 +718,11 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
714
718
  <input type="checkbox" checked={state.allowAgentAudioGeneration.text === 'true' || state.allowAgentAudioGeneration.text === ''} disabled={!state.writable} onChange={event => props.edit('allowAgentAudioGeneration', String(event.target.checked))} /> {t('settings.allowAgentAudio')}
715
719
  </label>
716
720
  </div>
721
+ <div className={css.field}>
722
+ <label className={css.label}>
723
+ <input type="checkbox" checked={state.autoSaveToLibrary.text === 'true'} disabled={!state.writable} onChange={event => props.edit('autoSaveToLibrary', String(event.target.checked))} /> {t('settings.autoSaveLibrary')}
724
+ </label>
725
+ </div>
717
726
  <div className={css.footer}>
718
727
  {state.failed ? <p className={css.failed}>保存失败</p> : null}
719
728
  <button type="button" className={css.discard} disabled={!state.dirty || state.saving} onClick={() => props.discard()}>{t('settings.discard')}</button>
package/src/client/api.ts CHANGED
@@ -1,14 +1,21 @@
1
1
  /**
2
- * Browser-side API client for the audio generation and history routes.
2
+ * Browser-side API client for the audio generation, history and
3
+ * resource-library routes.
3
4
  */
4
5
 
5
- import { GENERATE_API, HISTORY_API, type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry } from '../protocol.ts'
6
+ import {
7
+ GENERATE_API, HISTORY_API, LIBRARY_API,
8
+ type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry,
9
+ type LibraryEntry, type LibrarySaveRequest, type LibraryUpdateRequest,
10
+ } from '../protocol.ts'
6
11
 
7
12
  export interface GenerateResponse {
8
13
  ok: boolean
9
14
  outputs?: GeneratedAudio[]
10
15
  history?: HistoryEntry[]
11
16
  historyError?: string
17
+ /** Resource-library entries created by the generation (auto-save). */
18
+ resources?: Array<{ id: string; name: string; type: string }>
12
19
  code?: string
13
20
  message?: string
14
21
  }
@@ -33,4 +40,40 @@ export class AudiogenApi {
33
40
  async clearHistory(): Promise<void> {
34
41
  await fetch(HISTORY_API.clear, { method: 'POST' })
35
42
  }
43
+
44
+ async libraryList(): Promise<LibraryEntry[]> {
45
+ const response = await fetch(LIBRARY_API.list, { method: 'POST' })
46
+ const body = await response.json() as { ok?: boolean; entries?: LibraryEntry[] }
47
+ return body.ok === true ? (body.entries ?? []) : []
48
+ }
49
+
50
+ async librarySave(request: LibrarySaveRequest): Promise<{ ok: boolean; entry?: LibraryEntry; message?: string }> {
51
+ const response = await fetch(LIBRARY_API.save, {
52
+ method: 'POST',
53
+ headers: { 'content-type': 'application/json' },
54
+ body: JSON.stringify(request),
55
+ })
56
+ const body = await response.json() as { ok?: boolean; entry?: LibraryEntry; message?: string }
57
+ return { ok: body.ok === true, ...(body.entry === undefined ? {} : { entry: body.entry }), ...(body.message === undefined ? {} : { message: body.message }) }
58
+ }
59
+
60
+ async libraryUpdate(request: LibraryUpdateRequest): Promise<{ ok: boolean; entry?: LibraryEntry; message?: string }> {
61
+ const response = await fetch(LIBRARY_API.update, {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json' },
64
+ body: JSON.stringify(request),
65
+ })
66
+ const body = await response.json() as { ok?: boolean; entry?: LibraryEntry; message?: string }
67
+ return { ok: body.ok === true, ...(body.entry === undefined ? {} : { entry: body.entry }), ...(body.message === undefined ? {} : { message: body.message }) }
68
+ }
69
+
70
+ async libraryRemove(ids: string[]): Promise<{ ok: boolean }> {
71
+ const response = await fetch(LIBRARY_API.remove, {
72
+ method: 'POST',
73
+ headers: { 'content-type': 'application/json' },
74
+ body: JSON.stringify({ ids }),
75
+ })
76
+ const body = await response.json() as { ok?: boolean }
77
+ return { ok: body.ok === true }
78
+ }
36
79
  }