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 +20 -6
- package/package.json +1 -1
- package/src/host/routes.js +29 -3
- package/test/settings.mjs +65 -0
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
|
-
'
|
|
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
package/src/host/routes.js
CHANGED
|
@@ -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 } }
|