dsh-any-audio 0.1.0-beta.0 → 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/lib/client.js CHANGED
@@ -79,7 +79,8 @@ window.__ModuleLoader__.load({
79
79
  '默认音源:{0}': 'Default bank: {0}',
80
80
  '未安装音源': 'No bank installed',
81
81
  '音源已安装': 'Sound source installed',
82
- '开始下载,请保持页面打开': 'Download started — keep this page open',
82
+ '开始下载,关掉设置页后仍会继续': 'Download started — it keeps running after you close this page',
83
+ '已有一个下载任务在进行': 'A download is already running',
83
84
  '校验通过': 'Verification passed',
84
85
  '校验失败:文件与记录不一致': 'Verification failed: file does not match its record',
85
86
  '已处理': 'Done',
@@ -322,6 +323,18 @@ window.__ModuleLoader__.load({
322
323
  const [customPath, setCustomPath] = useState('')
323
324
  const [customUrl, setCustomUrl] = useState('')
324
325
 
326
+ // The job lives on the host, not in this component: reopening the settings
327
+ // page must pick up an install that is still running.
328
+ useEffect(() => {
329
+ let cancelled = false
330
+ api('/jobs/active?kind=install')
331
+ .then((data) => {
332
+ if (!cancelled && data.job) setJob(data.job)
333
+ })
334
+ .catch(() => {})
335
+ return () => { cancelled = true }
336
+ }, [])
337
+
325
338
  useEffect(() => {
326
339
  if (!job || job.status !== 'running') return undefined
327
340
  const timer = setInterval(async () => {
@@ -346,7 +359,7 @@ window.__ModuleLoader__.load({
346
359
  try {
347
360
  const data = await api('/sources/install', { method: 'POST', body })
348
361
  setJob(data.job)
349
- notify(t('开始下载,请保持页面打开'))
362
+ notify(data.reused ? t('已有一个下载任务在进行') : t('开始下载,关掉设置页后仍会继续'))
350
363
  } catch (error) {
351
364
  notify(error.message, 'error')
352
365
  } finally {
@@ -408,6 +421,7 @@ window.__ModuleLoader__.load({
408
421
 
409
422
  const fonts = state.fonts
410
423
  const percent = job && job.total ? (job.received / job.total) * 100 : 0
424
+ const installing = Boolean(job && job.status === 'running')
411
425
  return h(Fragment, null,
412
426
  h(Card, {
413
427
  title: t('音源(SoundFont)'),
@@ -416,7 +430,7 @@ window.__ModuleLoader__.load({
416
430
  },
417
431
  job ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4 } },
418
432
  h('div', { style: { ...S.row, justifyContent: 'space-between' } },
419
- h('span', { style: S.label }, job.status === 'running' ? `${t('正在安装')}${job.total ? ` ${Math.round(percent)}%` : ''}` : job.status === 'done' ? t('完成:{0}', job.message || t('已安装')) : t('失败:{0}', job.error || '')),
433
+ h('span', { style: S.label }, job.status === 'running' ? `${t('正在安装')}${job.label ? ` ${job.label}` : ''}${job.total ? ` ${Math.round(percent)}%` : ''}` : job.status === 'done' ? t('完成:{0}', job.message || t('已安装')) : t('失败:{0}', job.error || '')),
420
434
  job.total ? h('span', { style: S.mono }, `${bytes(job.received)} / ${bytes(job.total)}`) : null,
421
435
  ),
422
436
  h('div', { style: S.bar }, h('div', { style: S.barFill(percent) })),
@@ -445,7 +459,7 @@ window.__ModuleLoader__.load({
445
459
  font.suggested && !font.default ? h('span', { style: S.chip }, t('建议安装')) : null,
446
460
  h('span', { style: S.mono }, bytes(font.bytes)),
447
461
  h('span', { style: S.chip }, installed ? (installed.status === 'ok' ? t('已安装') : installed.status === 'modified' ? t('文件被修改') : t('已安装(未登记)')) : t('未安装')),
448
- installed ? null : h(Btn, { kind: 'primary', disabled: busy === `install:${font.id}`, onClick: () => install({ id: font.id }) }, t('下载安装')),
462
+ installed ? null : h(Btn, { kind: 'primary', disabled: installing || busy === `install:${font.id}`, onClick: () => install({ id: font.id }) }, t('下载安装')),
449
463
  installed ? h(Btn, { onClick: () => openPreview(installed.path) }, t('试听')) : null,
450
464
  installed ? h(Btn, { onClick: () => act('verify', { path: installed.path }) }, t('校验')) : null,
451
465
  installed ? h(Btn, { onClick: () => act('remove', { path: installed.path, removeFile: false }) }, t('移除记录')) : null,
@@ -462,14 +476,14 @@ window.__ModuleLoader__.load({
462
476
  h('div', { style: S.label }, t('从本机文件安装(7z/zip 请先解压出 .sf2/.sf3/.dls)')),
463
477
  h('div', { style: S.row },
464
478
  h(TextInput, { value: customPath, onChange: setCustomPath, placeholder: 'C:\\Downloads\\Piano.sf2' }),
465
- h(Btn, { disabled: !customPath.trim() || busy.startsWith('install:'), onClick: () => install({ fromPath: customPath.trim() }) }, t('安装文件')),
479
+ h(Btn, { disabled: !customPath.trim() || installing || busy.startsWith('install:'), onClick: () => install({ fromPath: customPath.trim() }) }, t('安装文件')),
466
480
  ),
467
481
  ),
468
482
  h('div', { style: S.item },
469
483
  h('div', { style: S.label }, t('从网址安装(直链 .sf2/.sf3/.dls)')),
470
484
  h('div', { style: S.row },
471
485
  h(TextInput, { value: customUrl, onChange: setCustomUrl, placeholder: 'https://example.com/bank.sf2' }),
472
- h(Btn, { disabled: !customUrl.trim() || busy.startsWith('install:'), onClick: () => install({ url: customUrl.trim() }) }, t('下载安装')),
486
+ h(Btn, { disabled: !customUrl.trim() || installing || busy.startsWith('install:'), onClick: () => install({ url: customUrl.trim() }) }, t('下载安装')),
473
487
  ),
474
488
  ),
475
489
  ),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-any-audio",
3
- "version": "0.1.0-beta.0",
3
+ "version": "0.1.0",
4
4
  "description": "AnyAudio — an agent-driven audio production toolkit for DeepSeek Harness: music, sound effects, narration and mixing.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -61,6 +61,7 @@ function publicJob(job) {
61
61
  return {
62
62
  id: job.id,
63
63
  kind: job.kind,
64
+ label: job.label ?? null,
64
65
  status: job.status,
65
66
  received: job.received,
66
67
  total: job.total,
@@ -72,9 +73,9 @@ function publicJob(job) {
72
73
  }
73
74
  }
74
75
 
75
- function startJob(kind, runner) {
76
+ function startJob(kind, runner, label = '') {
76
77
  const id = randomUUID()
77
- const job = { id, kind, status: 'running', received: 0, total: 0, message: '', startedAt: Date.now(), abort: new AbortController() }
78
+ const job = { id, kind, label, status: 'running', received: 0, total: 0, message: '', startedAt: Date.now(), abort: new AbortController() }
78
79
  jobs.set(id, job)
79
80
  runner(job)
80
81
  .then((result) => {
@@ -91,6 +92,22 @@ function startJob(kind, runner) {
91
92
  return job
92
93
  }
93
94
 
95
+ function runningJob(kind) {
96
+ for (const job of jobs.values()) {
97
+ if (job.kind === kind && job.status === 'running') return job
98
+ }
99
+ return null
100
+ }
101
+
102
+ /** A short name for the progress line; a malformed URL must not break the call. */
103
+ function urlLabel(url) {
104
+ try {
105
+ return basename(new URL(url).pathname) || url
106
+ } catch {
107
+ return url
108
+ }
109
+ }
110
+
94
111
  /** Recommended fonts plus whatever is installed, merged for the UI. */
95
112
  async function fontState(workspaceRoot) {
96
113
  const installed = await listFonts()
@@ -211,6 +228,11 @@ function makeRoutes({ ctx, log, workspaceRoot }) {
211
228
  const id = typeof body.id === 'string' && body.id.trim() ? body.id.trim() : 'generaluser'
212
229
  const url = typeof body.url === 'string' && body.url.trim() ? body.url.trim() : undefined
213
230
  const fromPath = typeof body.fromPath === 'string' && body.fromPath.trim() ? body.fromPath.trim() : undefined
231
+ // One install at a time: a second request must join the running job instead
232
+ // of starting a parallel download that would write the same ".part" file.
233
+ const running = runningJob('install')
234
+ if (running) return { ok: true, job: publicJob(running), reused: true }
235
+ const label = url ? urlLabel(url) : fromPath ? basename(fromPath) : findRecommended(id)?.name ?? id
214
236
  const job = startJob('install', async (current) => {
215
237
  const result = await installFont({
216
238
  id,
@@ -234,7 +256,7 @@ function makeRoutes({ ctx, log, workspaceRoot }) {
234
256
  })
235
257
  current.message = result.alreadyInstalled ? 'already installed' : 'installed'
236
258
  return { ...result, name: basename(result.path), id: recommended?.id ?? null }
237
- })
259
+ }, label)
238
260
  return { ok: true, job: publicJob(job) }
239
261
  }
240
262
 
@@ -494,6 +516,10 @@ function makeRoutes({ ctx, log, workspaceRoot }) {
494
516
  const job = jobs.get(url.searchParams.get('id') ?? '')
495
517
  return json(res, job ? 200 : 404, job ? { ok: true, job: publicJob(job) } : { ok: false, error: 'job not found' })
496
518
  }
519
+ if (method === 'GET' && path === `${PREFIX}/jobs/active`) {
520
+ const job = runningJob(url.searchParams.get('kind') ?? 'install')
521
+ return json(res, 200, { ok: true, job: job ? publicJob(job) : null })
522
+ }
497
523
  if (method !== 'POST') return json(res, 405, { ok: false, error: 'method not allowed' })
498
524
 
499
525
  let body
package/test/settings.mjs CHANGED
@@ -73,6 +73,25 @@ const server = http.createServer((req, res) => {
73
73
  })
74
74
  })
75
75
 
76
+ // Serves bytes slowly enough that an install is still running while the next
77
+ // request arrives; that is what lets the queueing rules be observed.
78
+ const slowServer = http.createServer((req, res) => {
79
+ const chunk = Buffer.alloc(64 * 1024, 3)
80
+ const chunks = 20
81
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(chunk.length * chunks) })
82
+ let sent = 0
83
+ const timer = setInterval(() => {
84
+ if (sent >= chunks) {
85
+ clearInterval(timer)
86
+ res.end()
87
+ return
88
+ }
89
+ sent++
90
+ res.write(chunk)
91
+ }, 40)
92
+ req.on('close', () => clearInterval(timer))
93
+ })
94
+
76
95
  function assert(condition, message) {
77
96
  if (!condition) throw new Error(message)
78
97
  }
@@ -251,6 +270,52 @@ server.listen(0, '127.0.0.1', async () => {
251
270
  assert((await readFile(outsider, 'utf8')) === 'keep me', 'the file must still exist')
252
271
  })
253
272
 
273
+ // One install at a time, and the job must outlive the settings page that
274
+ // started it: the host owns the download, the page only watches it.
275
+ await step('a second install joins the running job instead of starting a parallel one', async () => {
276
+ await new Promise((resolve) => slowServer.listen(0, '127.0.0.1', resolve))
277
+ const slowPort = slowServer.address().port
278
+ try {
279
+ const first = await post('/sources/install', { url: `http://127.0.0.1:${slowPort}/slow-bank.sf2` })
280
+ assert(first.ok && first.job?.id, first.error ?? 'no job id')
281
+ assert(first.job.label === 'slow-bank.sf2', `job label should name the file, got ${first.job.label}`)
282
+ const second = await post('/sources/install', { fromPath: join(testRoot, 'custom-bank.sf2') })
283
+ assert(second.ok, second.error)
284
+ assert(second.reused === true, 'a second install must join the running job')
285
+ assert(second.job.id === first.job.id, `a parallel install started: ${second.job.id} != ${first.job.id}`)
286
+
287
+ // No polling happens in between: this is the "settings page was closed" case.
288
+ const active = await get('/jobs/active?kind=install')
289
+ assert(active.job?.id === first.job.id, 'a reopened settings page must find the running install')
290
+ await new Promise((resolve) => setTimeout(resolve, 300))
291
+ const seen = (await get(`/jobs?id=${encodeURIComponent(first.job.id)}`)).job
292
+ assert(seen && (seen.status === 'running' || seen.status === 'done'), 'the job must stay queryable without polling')
293
+ if (seen.status === 'running') {
294
+ assert(seen.received > 0, 'a restored page must get the progress that already happened')
295
+ }
296
+
297
+ let job = seen
298
+ for (let i = 0; i < 200 && job.status === 'running'; i++) {
299
+ await new Promise((resolve) => setTimeout(resolve, 100))
300
+ job = (await get(`/jobs?id=${encodeURIComponent(first.job.id)}`)).job
301
+ }
302
+ assert(job.status === 'done', `job ended as ${job.status}: ${job.error ?? ''}`)
303
+ assert(job.result.path === join(testFontDir, 'slow-bank.sf2'), `installed to ${job.result.path}`)
304
+ assert((await get('/jobs/active?kind=install')).job === null, 'no install may still be reported as running')
305
+ const refreshed = await get('/state')
306
+ assert(
307
+ refreshed.fonts.rows.some((row) => row.name === 'slow-bank.sf2'),
308
+ 'the page must see the finished install on its next refresh',
309
+ )
310
+ const leftovers = (await readdir(testFontDir)).filter((name) => name.endsWith('.part'))
311
+ assert(leftovers.length === 0, `partial downloads left behind: ${leftovers.join(', ')}`)
312
+ const removed = await post('/sources/remove', { path: job.result.path, removeFile: true })
313
+ assert(removed.ok && removed.removed, removed.error)
314
+ } finally {
315
+ slowServer.close()
316
+ }
317
+ })
318
+
254
319
  // The four steps below cover the "saved default points at a bank that no longer
255
320
  // exists" case: the page and the renderer must always agree.
256
321
  const testExec = { agent: { workspaceRoot: root } }