dsh-music-player 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -1,14 +1,17 @@
1
1
  # dsh-music-player
2
2
 
3
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
4
+
3
5
  DeepSeek Harness 本地音乐库播放器插件(bundle)。
4
6
 
5
- 在 Host 进程里扫描本地音乐目录(默认 `~/Music`,可在面板里改),以 HTTP 流式(支持 Range/拖进度)给浏览器提供音频;浏览器侧给聊天输入区注入**正在播放条**,并提供一个浮动的**播放面板**(曲目列表 / 循环模式 / 音量 / 实时频谱 / 音乐目录设置)。同时注册 `music_play` 模型工具,让 agent 可以直接按关键词播放本地音乐。
7
+ 在 Host 进程里扫描本地音乐目录(默认 `~/Music`,可在面板里改),以 HTTP Range 流式(仅显示播放时间,暂无拖动跳转)给浏览器提供音频;浏览器侧给聊天输入区注入**正在播放条**(曲目信息、上一首/播放暂停/下一首/停止、顺序/单曲/乱序循环模式、音量、实时频谱),并提供一个浮动的**播放面板**(曲目列表 / 音乐目录选择与格式提示)。同时注册 `music_play` 模型工具,让 agent 可以直接按关键词播放本地音乐。
6
8
 
7
9
  ## 特性
8
10
 
9
- - 本地音频流式播放,支持 seek / 断点续播
11
+ - 本地音频流式播放(HTTP Range),刷新后断点续播
10
12
  - 顺序播放、单曲循环、乱序播放三种模式
11
13
  - 实时 7 段频谱可视化(解码音频包络驱动)
14
+ - 播放时申请屏幕唤醒锁,防止听歌时熄屏/休眠(支持 Wake Lock 的浏览器,如 Chrome/Edge)
12
15
  - 播放列表面板可自由拖动,位置跨刷新记忆
13
16
  - `music_play` 模型工具:agent 可按关键词让浏览器播放
14
17
  - 支持的格式:`mp3 / m4a / m4b / aac / flac / wav / ogg / opus / webm / aiff`(自动递归扫描子目录,上限 500 首)
@@ -17,10 +20,10 @@ DeepSeek Harness 本地音乐库播放器插件(bundle)。
17
20
 
18
21
  ![播放条](assets/screenshot-bar.png)
19
22
 
20
- ![播放面板](assets/screenshot-panel.png)
21
-
22
23
  ![实时频谱](assets/screenshot-spectrum.png)
23
24
 
25
+ ![播放面板](assets/screenshot-panel.png)
26
+
24
27
  ## 安装
25
28
 
26
29
  需要已安装 `dsh` CLI。
package/lib/client.js CHANGED
@@ -379,13 +379,40 @@ window.__ModuleLoader__.load({
379
379
  audio.load();
380
380
  set({ currentId: null, currentName: null, playing: false, position: 0, duration: 0, pendingId: null, pendingName: null, vizState: 'ok' });
381
381
  clearPref(PREF_PLAYBACK);
382
+ releaseWakeLock();
383
+ }
384
+
385
+ // ---- Screen Wake Lock: keep the screen awake while music is playing ----
386
+ // While audio plays we request a screen wake lock so the display (and, for
387
+ // most power policies, the system) doesn't blank or sleep mid-song. Tabs
388
+ // can't stop OS-level deep sleep, but holding a wake lock while visible
389
+ // covers the common "screen blanked while I listened" case. Unsupported
390
+ // browsers (e.g. Safari) silently skip it — never fatal.
391
+ let wakeLock = null;
392
+ const wakeLockSupported = (typeof navigator !== 'undefined') && ('wakeLock' in navigator);
393
+ async function acquireWakeLock() {
394
+ if (!wakeLockSupported || !store.playing) return;
395
+ if (wakeLock !== null) return; // already held
396
+ try {
397
+ const sentinel = await navigator.wakeLock.request('screen');
398
+ sentinel.addEventListener('release', () => { if (wakeLock === sentinel) wakeLock = null; });
399
+ wakeLock = sentinel;
400
+ } catch (e) {
401
+ wakeLock = null; // denied or transient — non-fatal
402
+ }
403
+ }
404
+ function releaseWakeLock() {
405
+ if (wakeLock !== null) {
406
+ try { wakeLock.release(); } catch (e) {}
407
+ wakeLock = null;
408
+ }
382
409
  }
383
410
 
384
411
  function bindAudio() {
385
412
  const onTime = () => set({ position: audio.currentTime || 0 });
386
413
  const onDur = () => set({ duration: audio.duration || 0 });
387
- const onPlay = () => set({ playing: true, error: null });
388
- const onPause = () => { set({ playing: false }); savePlayback(); };
414
+ const onPlay = () => { set({ playing: true, error: null }); acquireWakeLock(); };
415
+ const onPause = () => { set({ playing: false }); savePlayback(); releaseWakeLock(); };
389
416
  const onEnded = () => {
390
417
  if (store.mode === 'single' && store.currentId !== null) {
391
418
  audio.currentTime = 0;
@@ -739,6 +766,7 @@ window.__ModuleLoader__.load({
739
766
  const [dirs, setDirs] = useState([]);
740
767
  const [curPath, setCurPath] = useState('');
741
768
  const [curName, setCurName] = useState('');
769
+ const [curUp, setCurUp] = useState(null);
742
770
  const [dirError, setDirError] = useState(null);
743
771
  return React.createElement('div', { className: 'dsh-music-settings' },
744
772
  React.createElement('div', { className: 'dsh-music-settings-row' },
@@ -786,20 +814,30 @@ window.__ModuleLoader__.load({
786
814
  if (data && data.error) { setDirError(data.error); return; }
787
815
  setCurPath(data.path || '');
788
816
  setCurName(data.name || '');
817
+ setCurUp(data.up || null);
789
818
  setDirs(data.dirs || []);
790
819
  } catch (err) {
791
820
  setDirError('读取目录失败:' + String((err && err.message) || err));
792
821
  }
793
822
  }
794
823
  function goUp() {
795
- const p = curPath;
796
- if (p === '' || p === '/') return;
797
- const idx = p.lastIndexOf('/');
798
- browse(idx <= 0 ? '/' : p.slice(0, idx));
824
+ // Prefer the parent path computed by the host (correct separators per OS).
825
+ // At a drive root the host reports the "__drives__" sentinel, so "up"
826
+ // jumps to the drive list and lets the user switch disks.
827
+ if (curUp === '__drives__') { browse('__drives__'); return; }
828
+ if (curUp !== null && curUp !== undefined && curUp !== '') { browse(curUp); return; }
829
+ // fallback: derive the parent locally when the host omitted `up`.
830
+ // Handle both "\" and "/" so Windows paths never dead-end (the old
831
+ // POSIX-only parse did nothing on backslash paths like C:\Users\x).
832
+ if (curPath === '' || curPath === '/' || /^[A-Za-z]:[\\/]?$/.test(curPath)) return;
833
+ const idx = Math.max(curPath.lastIndexOf('/'), curPath.lastIndexOf('\\'));
834
+ if (idx <= 0) return;
835
+ browse(curPath.slice(0, idx));
799
836
  }
800
837
  function pickCurrent() {
801
838
  const p = curPath;
802
- if (p === '') return;
839
+ // The drive-list view ("__drives__") is not a real directory.
840
+ if (p === '' || p === '__drives__') return;
803
841
  setPickerOpen(false);
804
842
  saveRoot(p);
805
843
  }
@@ -821,14 +859,35 @@ window.__ModuleLoader__.load({
821
859
  ctx.effect(() => {
822
860
  const unbind = bindAudio();
823
861
  startRaf();
824
- return () => { stopRaf(); unbind(); closeDecodeCtx(); };
862
+ // Browsers auto-release a wake lock when the page is hidden; re-acquire
863
+ // on return if playback is still running, and drop it on hide so we
864
+ // don't hold it while the tab is backgrounded.
865
+ const onVis = () => {
866
+ if (document.hidden) releaseWakeLock();
867
+ else acquireWakeLock();
868
+ };
869
+ document.addEventListener('visibilitychange', onVis);
870
+ return () => { document.removeEventListener('visibilitychange', onVis); stopRaf(); unbind(); closeDecodeCtx(); releaseWakeLock(); };
825
871
  }, 'music-player: audio + viz engine');
826
872
 
827
873
  loadTracks();
828
874
 
829
875
  const intentTimer = setInterval(() => {
830
876
  jsonGet('/dsh-music/intent').then((intent) => {
831
- if (intent === null || typeof intent !== 'object' || intent.id === undefined) return;
877
+ if (intent === null || typeof intent !== 'object') return;
878
+ const action = intent.action || 'play';
879
+ // Transport commands operate on the current playback state (no track id).
880
+ if (action === 'pause') { audio.pause(); set({ playing: false }); return; }
881
+ if (action === 'resume') {
882
+ const p = audio.play();
883
+ if (p !== undefined && typeof p.catch === 'function') p.catch(() => set({ error: '\u64ad\u653e\u5931\u8d25' }));
884
+ return;
885
+ }
886
+ if (action === 'stop') { stop(); return; }
887
+ if (action === 'next') { step(1); return; }
888
+ if (action === 'prev') { step(-1); return; }
889
+ // play (default): needs a track id.
890
+ if (intent.id === undefined) return;
832
891
  set({ pendingId: intent.id, pendingName: intent.name || '' });
833
892
  const track = trackById(intent.id);
834
893
  if (track !== null) {
package/lib/index.js CHANGED
@@ -28,8 +28,9 @@ function audioType(name) {
28
28
  return i > 0 ? (AUDIO_TYPES[name.slice(i + 1).toLowerCase()] || 'application/octet-stream') : 'application/octet-stream'
29
29
  }
30
30
 
31
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
32
- import { dirname } from 'node:path'
31
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs'
32
+ import { dirname, basename, parse as pathParse, join as pathJoin } from 'node:path'
33
+ import * as os from 'node:os'
33
34
 
34
35
  export const name = 'dsh-music-player'
35
36
  export const inject = ['webServer', 'fs', 'shell', 'tools', 'systemPrompt']
@@ -43,6 +44,14 @@ export function apply(ctx) {
43
44
 
44
45
  const getHome = async () => {
45
46
  if (home !== null) return home
47
+ try {
48
+ // os.homedir() resolves the user's home cross-platform (Windows uses
49
+ // C:\Users\<name>; POSIX /Users/<name> or /home/<name>). The $HOME shell
50
+ // variable does not exist under cmd/powershell on Windows, so fall back
51
+ // to the shell only when os.homedir() is unusable.
52
+ const osHome = (typeof os !== 'undefined' && os.homedir) ? os.homedir() : ''
53
+ if (osHome !== '') { home = osHome; return home }
54
+ } catch { /* fall through to shell */ }
46
55
  try {
47
56
  const result = await ctx.shell.run(ctx.shell.resolve({ command: 'printf %s "$HOME"' }))
48
57
  const value = String((result.stdout && result.stdout.text) || '').trim()
@@ -100,21 +109,29 @@ export function apply(ctx) {
100
109
  const found = []
101
110
  const walk = async (dir, depth) => {
102
111
  if (depth > 4 || found.length >= 500) return
103
- let entries = []
104
- try { entries = await ctx.fs.listDir(dir) } catch { return }
112
+ // Tolerant listing (all entries, see listEntries): dsh-fs-local's listDir
113
+ // aborts on the first unreadable child, so scanning a drive root (or any
114
+ // dir with protected entries) would silently yield zero tracks.
115
+ const entries = listEntries(dir)
105
116
  for (const entry of entries) {
106
117
  if (found.length >= 500) return
107
- if (entry.type === 'directory') { await walk(entry.target, depth + 1); continue }
108
- if (entry.type !== 'file' || !isAudioName(entry.name)) continue
109
- const abs = ctx.fs.processPath(entry.target)
110
- const rel = abs.startsWith(rootStr) ? abs.slice(rootStr.length + 1) : entry.name
111
- found.push({
112
- name: rel, path: abs, size: entry.size || 0,
113
- ext: entry.name.slice(entry.name.lastIndexOf('.') + 1).toLowerCase(),
114
- })
118
+ const abs = pathJoin(dir, entry.name)
119
+ try {
120
+ if (entry.isDir) { await walk(abs, depth + 1); continue }
121
+ if (!isAudioName(entry.name)) continue
122
+ const st = statSync(abs)
123
+ if (!st.isFile()) continue
124
+ const rel = abs.startsWith(rootStr) ? abs.slice(rootStr.length + 1) : entry.name
125
+ found.push({
126
+ name: rel, path: abs, size: st.size || 0,
127
+ ext: entry.name.slice(entry.name.lastIndexOf('.') + 1).toLowerCase(),
128
+ })
129
+ } catch {
130
+ // unreadable entry: skip it, keep walking the rest
131
+ }
115
132
  }
116
133
  }
117
- await walk(target, 0)
134
+ await walk(rootStr, 0)
118
135
  found.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
119
136
  return { rootPath: rootStr, found }
120
137
  }
@@ -129,7 +146,9 @@ export function apply(ctx) {
129
146
  }
130
147
  const init = async () => {
131
148
  const h = await getHome()
132
- let root = h === null ? null : h + '/Music'
149
+ // Use path.join so the default root uses the platform separator; on Windows
150
+ // a bare h + '/Music' produced a mixed "C:\Users\x/Music" root.
151
+ let root = h === null ? null : pathJoin(h, 'Music')
133
152
  // Restore the user's last chosen directory; validate it so a deleted or
134
153
  // renamed directory falls back to the default instead of erroring.
135
154
  const stored = await loadStoredRoot()
@@ -153,6 +172,29 @@ export function apply(ctx) {
153
172
  }
154
173
  const ensureStarted = () => { if (startupPromise === null) startupPromise = init(); return startupPromise }
155
174
 
175
+ // Tolerant directory listing for the picker and the scan. dsh-fs-local's
176
+ // listDir is all-or-nothing: one unreadable child (pagefile.sys, System
177
+ // Volume Information, ...) aborts the entire listing, which made drive roots
178
+ // (and any dir containing protected entries) show up empty. Enumerate with
179
+ // node:fs instead, skip entries that cannot be stat'd, and report every
180
+ // entry with an isDir flag so callers can filter (picker: dirs only;
181
+ // scan: dirs to recurse + audio files to collect).
182
+ const listEntries = (dirPath) => {
183
+ let dirents = []
184
+ try { dirents = readdirSync(dirPath, { withFileTypes: true, encoding: 'utf8' }) } catch { return [] }
185
+ const out = []
186
+ for (const ent of dirents) {
187
+ try {
188
+ const isDir = ent.isDirectory() || (ent.isSymbolicLink() && statSync(pathJoin(dirPath, ent.name)).isDirectory())
189
+ out.push({ name: ent.name, isDir })
190
+ } catch {
191
+ // unreadable entry (EPERM/EBUSY/...): skip it, keep listing the rest
192
+ }
193
+ }
194
+ out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
195
+ return out
196
+ }
197
+
156
198
  // ---- shared HTTP helpers ----
157
199
  const writeJson = (res, value, status) => {
158
200
  res.writeHead(status || 200, { 'content-type': 'application/json; charset=utf-8' })
@@ -203,6 +245,24 @@ export function apply(ctx) {
203
245
  await ensureStarted()
204
246
  const raw = url.searchParams.get('path') || ''
205
247
  try {
248
+ // Windows has no single root that lists every drive, so expose a
249
+ // sentinel ("__drives__") that enumerates the available drive roots.
250
+ // Browsing "up" from a drive root (e.g. C:\) lands here so users can
251
+ // switch to another drive.
252
+ if (raw === '__drives__') {
253
+ const isWin = typeof process !== 'undefined' && process.platform === 'win32'
254
+ if (isWin) {
255
+ const roots = []
256
+ for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
257
+ const root = letter + ':\\'
258
+ try { if (existsSync(root)) roots.push({ name: root, path: root }) } catch {}
259
+ }
260
+ writeJson(res, { path: '__drives__', name: '\u672c\u673a\u78c1\u76d8', up: null, dirs: roots })
261
+ } else {
262
+ writeJson(res, { path: '/', name: '/', up: null, dirs: [] })
263
+ }
264
+ return
265
+ }
206
266
  const base = raw === '' ? ((await getHome()) || '/') : raw
207
267
  const expanded = base.startsWith('~/') ? ((await getHome()) || '') + '/' + base.slice(2) : base
208
268
  const target = await ctx.fs.resolve(expanded)
@@ -212,13 +272,22 @@ export function apply(ctx) {
212
272
  return
213
273
  }
214
274
  const abs = ctx.fs.processPath(target)
215
- let entries = []
216
- try { entries = await ctx.fs.listDir(target) } catch { entries = [] }
217
- const dirs = entries
218
- .filter((e) => e.type === 'directory')
219
- .map((e) => ({ name: e.name, path: ctx.fs.processPath(e.target) }))
220
- .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
221
- writeJson(res, { path: abs, name: abs.slice(abs.lastIndexOf('/') + 1) || abs, dirs })
275
+ // Parent / name computation must use the host filesystem's separators
276
+ // (Windows uses "\" and drive roots like C:\, POSIX uses "/"), so do it
277
+ // with node:path rather than guessing a separator in the browser.
278
+ const atRoot = pathParse(abs).dir === abs
279
+ // On Windows, "up" from a drive root goes to the drive-list sentinel so
280
+ // users can switch drives; at the POSIX root there is nowhere to go.
281
+ const up = atRoot
282
+ ? (process.platform === 'win32' ? '__drives__' : null)
283
+ : dirname(abs)
284
+ // Tolerant listing (see listEntries): skip unreadable entries so
285
+ // drive roots like C:\ still show their normal folders instead of an
286
+ // empty list. Only directories are offered by the picker.
287
+ const dirs = listEntries(abs)
288
+ .filter((e) => e.isDir)
289
+ .map((e) => ({ name: e.name, path: pathJoin(abs, e.name) }))
290
+ writeJson(res, { path: abs, name: basename(abs) || abs, up, dirs })
222
291
  } catch (err) {
223
292
  writeJson(res, { error: String((err && err.message) || err) }, 500)
224
293
  }
@@ -284,18 +353,26 @@ export function apply(ctx) {
284
353
  ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-music', handler: serve }), 'music-player: routes')
285
354
 
286
355
  // ---- model tool: music_play ----
356
+ const PLAY_ACTIONS = ['play', 'pause', 'resume', 'stop', 'next', 'prev']
287
357
  const tool = {
288
358
  name: 'music_play',
289
- description: ' DSH 本地音乐库中搜索并播放音乐。给出歌曲名/歌手关键词,不传则播放第一首。',
359
+ description: '控制 DSH 本地音乐库的播放。播放时可按歌曲名/歌手关键词搜索并播放(不传 query 则播放第一首);也可用 action 执行暂停/继续/停止/下一首/上一首。',
290
360
  parameters: {
291
361
  type: 'object',
292
362
  additionalProperties: false,
293
- properties: { query: { type: 'string', description: '歌曲名/歌手关键词,可留空' } },
363
+ properties: {
364
+ query: { type: 'string', description: '歌曲名/歌手关键词,用于搜索并播放。仅当 action 为 play(默认)时使用,可留空' },
365
+ action: { type: 'string', enum: PLAY_ACTIONS, description: '要执行的动作:play 播放(默认)、pause 暂停、resume 继续、stop 停止、next 下一首、prev 上一首' },
366
+ },
294
367
  },
295
368
  output: {
296
369
  schema: {
297
370
  type: 'object', additionalProperties: false,
298
- properties: { played: { type: 'boolean' }, track: { type: 'string' }, notice: { type: 'string' } },
371
+ properties: {
372
+ action: { type: 'string' }, played: { type: 'boolean' },
373
+ track: { type: 'string' }, matches: { type: 'number' }, count: { type: 'number' },
374
+ notice: { type: 'string' },
375
+ },
299
376
  },
300
377
  render(_args, value) {
301
378
  return [{ type: 'text', text: (value && value.notice) || (value && value.track ? '已请求播放:' + value.track : '音乐库为空') }]
@@ -303,22 +380,39 @@ export function apply(ctx) {
303
380
  },
304
381
  async execute(args) {
305
382
  await ensureStarted()
306
- if (tracks.length === 0) {
307
- return {
308
- played: false, track: '',
309
- notice: '本地音乐库为空。请打开播放列表面板,点击「选择音乐目录」配置音乐目录。',
383
+ const count = tracks.length
384
+ const action = args && typeof args.action === 'string' && PLAY_ACTIONS.includes(args.action) ? args.action : 'play'
385
+
386
+ if (count === 0) {
387
+ const notice = '本地音乐库为空。请打开播放列表面板,点击「选择音乐目录」配置音乐目录。'
388
+ return { action, played: false, track: '', matches: 0, count: 0, notice }
389
+ }
390
+
391
+ // Non-play actions just relay a transport command to the browser player.
392
+ if (action !== 'play') {
393
+ pendingIntent = { action }
394
+ const labels = {
395
+ pause: '已请求暂停播放', resume: '已请求继续播放', stop: '已请求停止播放',
396
+ next: '已请求播放下一首', prev: '已请求播放上一首',
310
397
  }
398
+ const notice = labels[action] + '。若浏览器拦截自动操作,请在播放条上点击对应按钮。'
399
+ return { action, played: false, track: '', matches: 0, count, notice }
311
400
  }
401
+
402
+ // play: search (exact match first, then substring) and pick the first hit.
312
403
  const query = args && typeof args.query === 'string' ? args.query.trim().toLowerCase() : ''
313
- const list = query === '' ? tracks : tracks.filter((t) => t.name.toLowerCase().includes(query))
314
- if (list.length === 0) {
315
- return { played: false, track: '', notice: '没有找到包含「' + args.query + '」的音乐(音乐库共 ' + tracks.length + ' 首)。' }
404
+ const pool = query === '' ? tracks : tracks.filter((t) => t.name.toLowerCase().includes(query))
405
+ if (pool.length === 0) {
406
+ const notice = '没有找到包含「' + (args && args.query) + '」的音乐(音乐库共 ' + count + ' 首)。'
407
+ return { action, played: false, track: '', matches: 0, count, notice }
316
408
  }
317
- const pick = list[0]
318
- pendingIntent = { id: pick.id, name: pick.name }
409
+ // Prefer an exact (case-insensitive) filename match over the first substring hit.
410
+ const pick = query === '' ? pool[0]
411
+ : (tracks.find((t) => t.name.toLowerCase() === query) || pool[0])
412
+ pendingIntent = { action: 'play', id: pick.id, name: pick.name }
319
413
  return {
320
- played: true, track: pick.name,
321
- notice: '已请求播放「' + pick.name + '」。浏览器可能拦截自动播放,请在页面播放条上点击一次▶解锁。',
414
+ action, played: true, track: pick.name, matches: pool.length, count,
415
+ notice: '已请求播放「' + pick.name + '」(匹配 ' + pool.length + ' / 共 ' + count + ' 首)。浏览器可能拦截自动播放,请在页面播放条上点击一次▶解锁。',
322
416
  }
323
417
  },
324
418
  }
@@ -327,7 +421,7 @@ export function apply(ctx) {
327
421
  // ---- light prompt hint so the agent knows it can play local music ----
328
422
  ctx.systemPrompt.section({
329
423
  name: 'tool:music-player', order: 116,
330
- text: '本机已挂载本地音乐播放器:可用 music_play 工具按关键词播放 ~/Music(或设置的目录)里的音乐。',
424
+ text: '本机已挂载本地音乐播放器:可用 music_play 工具按关键词播放 ~/Music(或设置的目录)里的音乐,并支持 action 暂停/继续/停止/上下首控制。',
331
425
  })
332
426
 
333
427
  void ensureStarted()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-music-player",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "DeepSeek Harness 本地音乐库播放器:Host 扫描音乐目录并以 HTTP 流式提供音频,浏览器侧提供播放条/播放面板/设置页与实时频谱,并注册 music_play 模型工具",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",