dsh-music-player 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/index.js ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * dsh-music-player host half: a plain Cordis plugin running in the host
3
+ * process. It scans the local music directory (default $HOME/Music, or a
4
+ * directory configured through the settings page), streams audio per track
5
+ * through /dsh-music/<id> with Range/seek support, and answers the browser
6
+ * half's JSON calls (manifest / intent / set-root) over the same webServer.
7
+ * It also registers the `music_play` model tool, which lets the CLI/agent ask
8
+ * to play a track; the browser half polls /dsh-music/intent to pick it up.
9
+ *
10
+ * All registrations are effects so the row unmounts cleanly.
11
+ */
12
+
13
+ // ---- settings constants, mirrored by the client via the manifest route ----
14
+ const DEFAULT_KEYS = { mode: 'dsh-music-mode', volume: 'dsh-music-volume', playback: 'dsh-music-playback' }
15
+
16
+ const AUDIO_TYPES = {
17
+ mp3: 'audio/mpeg', m4a: 'audio/mp4', m4b: 'audio/mp4', aac: 'audio/aac',
18
+ flac: 'audio/flac', wav: 'audio/wav', ogg: 'audio/ogg', oga: 'audio/ogg',
19
+ opus: 'audio/ogg', webm: 'audio/webm', aiff: 'audio/aiff', aif: 'audio/aiff',
20
+ }
21
+
22
+ function isAudioName(name) {
23
+ const i = name.lastIndexOf('.')
24
+ return i > 0 && Object.prototype.hasOwnProperty.call(AUDIO_TYPES, name.slice(i + 1).toLowerCase())
25
+ }
26
+ function audioType(name) {
27
+ const i = name.lastIndexOf('.')
28
+ return i > 0 ? (AUDIO_TYPES[name.slice(i + 1).toLowerCase()] || 'application/octet-stream') : 'application/octet-stream'
29
+ }
30
+
31
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
32
+ import { dirname } from 'node:path'
33
+
34
+ export const name = 'dsh-music-player'
35
+ export const inject = ['webServer', 'fs', 'shell', 'tools', 'systemPrompt']
36
+
37
+ export function apply(ctx) {
38
+ let home = null
39
+ let musicRoot = null
40
+ let tracks = []
41
+ let pendingIntent = null
42
+ let startupPromise = null
43
+
44
+ const getHome = async () => {
45
+ if (home !== null) return home
46
+ try {
47
+ const result = await ctx.shell.run(ctx.shell.resolve({ command: 'printf %s "$HOME"' }))
48
+ const value = String((result.stdout && result.stdout.text) || '').trim()
49
+ home = value || null
50
+ } catch {
51
+ home = null
52
+ }
53
+ return home
54
+ }
55
+ // ---- persisted music root (survives DSH restarts) ----
56
+ // A tiny JSON state file under the DSH home keeps the configured root across
57
+ // process restarts; an unreadable or non-directory stored root is ignored so
58
+ // the player falls back to the default ~/Music instead of failing to load.
59
+ const stateFile = async () => {
60
+ const h = await getHome()
61
+ const base = (typeof process !== 'undefined' && process.env && process.env.DSH_HOME)
62
+ || (h === null ? null : h + '/.dsh')
63
+ return base === null ? null : base + '/music-player-state.json'
64
+ }
65
+ const loadStoredRoot = async () => {
66
+ const file = await stateFile()
67
+ if (file === null) return null
68
+ try {
69
+ const text = readFileSync(file, 'utf8')
70
+ const data = JSON.parse(text)
71
+ return data && typeof data.root === 'string' && data.root !== '' ? data.root : null
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+ const saveRoot = async (root) => {
77
+ const file = await stateFile()
78
+ if (file === null) return
79
+ try {
80
+ // Write directly with node:fs: the host ctx.fs service may fence writes
81
+ // under a workspace policy, which silently dropped the state file.
82
+ mkdirSync(dirname(file), { recursive: true })
83
+ writeFileSync(file, JSON.stringify({ root }, null, 2) + '\n', 'utf8')
84
+ } catch {
85
+ // persistence is best-effort; an unwritable state file only loses the
86
+ // remembered directory, never breaks playback
87
+ }
88
+ }
89
+ const publicTracks = () => tracks.map((t) => ({
90
+ id: t.id, name: t.name, url: t.url, size: t.size, ext: t.ext,
91
+ }))
92
+
93
+ const scan = async (rootPath) => {
94
+ const target = await ctx.fs.resolve(rootPath)
95
+ const info = await ctx.fs.stat(target)
96
+ if (info === undefined || info.type !== 'directory') {
97
+ throw new Error('不是有效的音乐目录: ' + rootPath)
98
+ }
99
+ const rootStr = ctx.fs.processPath(target)
100
+ const found = []
101
+ const walk = async (dir, depth) => {
102
+ if (depth > 4 || found.length >= 500) return
103
+ let entries = []
104
+ try { entries = await ctx.fs.listDir(dir) } catch { return }
105
+ for (const entry of entries) {
106
+ 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
+ })
115
+ }
116
+ }
117
+ await walk(target, 0)
118
+ found.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
119
+ return { rootPath: rootStr, found }
120
+ }
121
+
122
+ const refresh = async () => {
123
+ if (musicRoot === null) { tracks = []; return { root: null, tracks: [], count: 0 } }
124
+ const { rootPath, found } = await scan(musicRoot)
125
+ tracks = found.map((t, i) => ({
126
+ id: String(i), name: t.name, path: t.path, size: t.size, ext: t.ext, url: '/dsh-music/' + i,
127
+ }))
128
+ return { root: rootPath, tracks: publicTracks(), count: tracks.length }
129
+ }
130
+ const init = async () => {
131
+ const h = await getHome()
132
+ let root = h === null ? null : h + '/Music'
133
+ // Restore the user's last chosen directory; validate it so a deleted or
134
+ // renamed directory falls back to the default instead of erroring.
135
+ const stored = await loadStoredRoot()
136
+ if (stored !== null && stored !== '') {
137
+ try {
138
+ const target = await ctx.fs.resolve(stored)
139
+ const info = await ctx.fs.stat(target)
140
+ if (info !== undefined && info.type === 'directory') root = ctx.fs.processPath(target)
141
+ } catch {
142
+ // unreadable stored path -> keep the default
143
+ }
144
+ }
145
+ musicRoot = root
146
+ try {
147
+ return await refresh()
148
+ } catch (err) {
149
+ musicRoot = null
150
+ tracks = []
151
+ return { root: null, tracks: [], count: 0, error: String((err && err.message) || err) }
152
+ }
153
+ }
154
+ const ensureStarted = () => { if (startupPromise === null) startupPromise = init(); return startupPromise }
155
+
156
+ // ---- shared HTTP helpers ----
157
+ const writeJson = (res, value, status) => {
158
+ res.writeHead(status || 200, { 'content-type': 'application/json; charset=utf-8' })
159
+ res.end(JSON.stringify(value))
160
+ }
161
+ async function readBody(req) {
162
+ let text = ''
163
+ for await (const chunk of req) text += chunk
164
+ if (text === '') return {}
165
+ try { return JSON.parse(text) } catch { return {} }
166
+ }
167
+ const serve = async (req, res) => {
168
+ try {
169
+ const url = new URL(req.url || '/', 'http://x')
170
+ const pathname = url.pathname
171
+ // JSON API routes
172
+ if (pathname === '/dsh-music/manifest' && req.method === 'GET') {
173
+ await ensureStarted()
174
+ writeJson(res, { root: musicRoot, tracks: publicTracks(), count: tracks.length })
175
+ return
176
+ }
177
+ if (pathname === '/dsh-music/set-root' && req.method === 'POST') {
178
+ const body = await readBody(req)
179
+ const rawPath = body && typeof body.path === 'string' ? body.path.trim() : ''
180
+ if (rawPath === '') { writeJson(res, { ok: false, error: '路径不能为空' }, 400); return }
181
+ const expanded = rawPath.startsWith('~/') ? ((await getHome()) || '') + '/' + rawPath.slice(2) : rawPath
182
+ try {
183
+ const target = await ctx.fs.resolve(expanded)
184
+ const info = await ctx.fs.stat(target)
185
+ if (info === undefined || info.type !== 'directory') {
186
+ writeJson(res, { ok: false, error: '目录不存在或不可读: ' + expanded }, 400)
187
+ return
188
+ }
189
+ musicRoot = ctx.fs.processPath(target)
190
+ const data = await refresh()
191
+ await saveRoot(musicRoot)
192
+ writeJson(res, { ok: true, root: data.root, tracks: data.tracks, count: data.count })
193
+ } catch (err) {
194
+ writeJson(res, { ok: false, error: String((err && err.message) || err) }, 500)
195
+ }
196
+ return
197
+ }
198
+ // List the immediate subdirectories of a library-visible path, for the
199
+ // browser directory picker used by the playback-list "选择音乐目录" button.
200
+ // An empty/missing path starts from the user's home directory. Only
201
+ // directories are returned; file entries are skipped.
202
+ if (pathname === '/dsh-music/dir' && req.method === 'GET') {
203
+ await ensureStarted()
204
+ const raw = url.searchParams.get('path') || ''
205
+ try {
206
+ const base = raw === '' ? ((await getHome()) || '/') : raw
207
+ const expanded = base.startsWith('~/') ? ((await getHome()) || '') + '/' + base.slice(2) : base
208
+ const target = await ctx.fs.resolve(expanded)
209
+ const info = await ctx.fs.stat(target)
210
+ if (info === undefined || info.type !== 'directory') {
211
+ writeJson(res, { error: '目录不存在或不可读', path: expanded }, 400)
212
+ return
213
+ }
214
+ 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 })
222
+ } catch (err) {
223
+ writeJson(res, { error: String((err && err.message) || err) }, 500)
224
+ }
225
+ return
226
+ }
227
+ if (pathname === '/dsh-music/intent' && req.method === 'GET') {
228
+ const it = pendingIntent
229
+ pendingIntent = null
230
+ writeJson(res, it || null)
231
+ return
232
+ }
233
+ // audio streaming
234
+ if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); res.end(); return }
235
+ const id = pathname.slice('/dsh-music/'.length)
236
+ const track = tracks.find((t) => t.id === id)
237
+ if (track === undefined) { res.writeHead(404); res.end(); return }
238
+ const target = await ctx.fs.resolve(track.path)
239
+ const info = await ctx.fs.stat(target)
240
+ if (info === undefined || info.type !== 'file' || info.size === undefined) { res.writeHead(404); res.end(); return }
241
+ const size = info.size
242
+ let start = 0
243
+ let end = size - 1
244
+ let status = 200
245
+ const range = req.headers.range
246
+ if (typeof range === 'string') {
247
+ const m = /^bytes=(\d*)-(\d*)$/.exec(range.trim())
248
+ if (m !== null && (m[1] !== '' || m[2] !== '')) {
249
+ if (m[1] !== '') {
250
+ start = parseInt(m[1], 10)
251
+ end = m[2] !== '' ? Math.min(parseInt(m[2], 10), size - 1) : size - 1
252
+ } else {
253
+ start = Math.max(size - parseInt(m[2], 10), 0)
254
+ end = size - 1
255
+ }
256
+ if (!Number.isFinite(start) || start > end || start >= size) {
257
+ res.writeHead(416, { 'Content-Range': 'bytes */' + size })
258
+ res.end()
259
+ return
260
+ }
261
+ status = 206
262
+ }
263
+ }
264
+ const bytes = await ctx.fs.readBytes(target, undefined, size)
265
+ const slice = bytes.slice(start, end + 1)
266
+ const headers = {
267
+ 'Content-Type': audioType(track.name),
268
+ 'Accept-Ranges': 'bytes',
269
+ 'Content-Length': String(end - start + 1),
270
+ 'Cache-Control': 'no-store',
271
+ }
272
+ if (status === 206) headers['Content-Range'] = 'bytes ' + start + '-' + end + '/' + size
273
+ res.writeHead(status, headers)
274
+ if (req.method === 'HEAD') { res.end(); return }
275
+ res.end(slice)
276
+ } catch (err) {
277
+ try { res.writeHead(500); res.end() } catch {}
278
+ }
279
+ }
280
+ ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-music', handler: serve }), 'music-player: routes')
281
+
282
+ // ---- model tool: music_play ----
283
+ const tool = {
284
+ name: 'music_play',
285
+ description: '在 DSH 本地音乐库中搜索并播放音乐。给出歌曲名/歌手关键词,不传则播放第一首。',
286
+ parameters: {
287
+ type: 'object',
288
+ additionalProperties: false,
289
+ properties: { query: { type: 'string', description: '歌曲名/歌手关键词,可留空' } },
290
+ },
291
+ output: {
292
+ schema: {
293
+ type: 'object', additionalProperties: false,
294
+ properties: { played: { type: 'boolean' }, track: { type: 'string' }, notice: { type: 'string' } },
295
+ },
296
+ render(_args, value) {
297
+ return [{ type: 'text', text: (value && value.notice) || (value && value.track ? '已请求播放:' + value.track : '音乐库为空') }]
298
+ },
299
+ },
300
+ async execute(args) {
301
+ await ensureStarted()
302
+ if (tracks.length === 0) {
303
+ return {
304
+ played: false, track: '',
305
+ notice: '本地音乐库为空。请打开播放列表面板,点击「选择音乐目录」配置音乐目录。',
306
+ }
307
+ }
308
+ const query = args && typeof args.query === 'string' ? args.query.trim().toLowerCase() : ''
309
+ const list = query === '' ? tracks : tracks.filter((t) => t.name.toLowerCase().includes(query))
310
+ if (list.length === 0) {
311
+ return { played: false, track: '', notice: '没有找到包含「' + args.query + '」的音乐(音乐库共 ' + tracks.length + ' 首)。' }
312
+ }
313
+ const pick = list[0]
314
+ pendingIntent = { id: pick.id, name: pick.name }
315
+ return {
316
+ played: true, track: pick.name,
317
+ notice: '已请求播放「' + pick.name + '」。浏览器可能拦截自动播放,请在页面播放条上点击一次▶解锁。',
318
+ }
319
+ },
320
+ }
321
+ ctx.effect(() => ctx.tools.register(tool), 'music-player: music_play tool')
322
+
323
+ // ---- light prompt hint so the agent knows it can play local music ----
324
+ ctx.systemPrompt.section({
325
+ name: 'tool:music-player', order: 116,
326
+ text: '本机已挂载本地音乐播放器:可用 music_play 工具按关键词播放 ~/Music(或设置的目录)里的音乐。',
327
+ })
328
+
329
+ void ensureStarted()
330
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "dsh-music-player",
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek Harness 本地音乐库播放器:Host 扫描音乐目录并以 HTTP 流式提供音频,浏览器侧提供播放条/播放面板/设置页与实时频谱,并注册 music_play 模型工具",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "LICENSE",
16
+ "README.md"
17
+ ],
18
+ "keywords": [
19
+ "deepseek-harness",
20
+ "dsh",
21
+ "dsh-plugin",
22
+ "music",
23
+ "player",
24
+ "audio"
25
+ ],
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/kendu76/dsh-music-player.git"
29
+ },
30
+ "license": "MIT",
31
+ "dsh": {
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "client": {
36
+ "inject": [],
37
+ "platform": "web"
38
+ }
39
+ },
40
+ "peerDependencies": {
41
+ "react": "^18.2.0"
42
+ }
43
+ }