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/LICENSE +21 -0
- package/README.md +71 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +867 -0
- package/lib/index.js +330 -0
- package/package.json +43 -0
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
|
+
}
|