@doitian/dsh-music 0.0.0-stage → 0.1.1
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 +28 -0
- package/README.md +482 -2
- package/cordis.patch.yml +22 -0
- package/icon.svg +8 -0
- package/lib/client.js +466 -0
- package/lib/dj.js +543 -0
- package/lib/index.js +618 -0
- package/lib/netease.js +609 -0
- package/lib/panel.html +1105 -0
- package/lib/router.js +569 -0
- package/lib/session.js +346 -0
- package/lib/state.js +389 -0
- package/lib/vendor/qrcode.js +2297 -0
- package/package.json +67 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 doitian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This package bundles lib/vendor/qrcode.js, the QR Code Generator for
|
|
26
|
+
JavaScript by Kazuhiko Arase (https://github.com/kazuhikoarase/qrcode-generator),
|
|
27
|
+
used under the MIT License. Its original copyright header is retained in the
|
|
28
|
+
file.
|
package/README.md
CHANGED
|
@@ -1,3 +1,483 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @doitian/dsh-music
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
NetEase Cloud Music ([music.163.com](https://music.163.com)) playback and an AI DJ, inside DeepSeek Harness.
|
|
4
|
+
|
|
5
|
+
A **Music** entry appears in the DSH sidebar. It opens a full player — search, charts, your queue, synced lyrics, an `<audio>` element that streams through the host, QR sign-in, and a continuous AI DJ. The same player is also exposed to the agent through seven tools, so it can search, queue, and curate from a conversation.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌ 音乐 music.163.com ───────────── [AI DJ] [mood input] [account] ┐
|
|
9
|
+
│ 搜索 | 榜单 | 推荐 │ ♪ 海屿你 — 马也_Crabbit │
|
|
10
|
+
│ │ ⏮ ▶ ⏭ 列表 ♡ ✕ 🔊 ──────── │
|
|
11
|
+
│ 1. 海屿你 │ │
|
|
12
|
+
│ 马也_Crabbit │ 歌词 Lyrics │
|
|
13
|
+
│ 2. 明知故犯 │ [00:12.40] ... │
|
|
14
|
+
│ Max李玄 │ 播放队列 Queue (12) │
|
|
15
|
+
└───────────────────────────┴────────────────────────────────────────┘
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
The package is self-contained — **no dependencies, no build step, no peer
|
|
21
|
+
packages** — so it installs either from npm or straight from a checkout.
|
|
22
|
+
|
|
23
|
+
### From npm
|
|
24
|
+
|
|
25
|
+
The package is published as **`@doitian/dsh-music`**. In the Desktop app, install
|
|
26
|
+
it from the GUI, because the `dsh plugin` CLI refuses this profile
|
|
27
|
+
(`profile "desktop" is managed exclusively by the Electron application`):
|
|
28
|
+
|
|
29
|
+
**Plugins** page → **Add plugin** → `@doitian/dsh-music`
|
|
30
|
+
|
|
31
|
+
For any other profile, from a shell:
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
dsh plugin --profile <name> add @doitian/dsh-music
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### From a checkout
|
|
38
|
+
|
|
39
|
+
Point the profile at a working tree instead — this is what developing the plugin
|
|
40
|
+
looks like. Add it to the profile's `package.json` directly:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@doitian/dsh-music": "link:C:/path/to/dsh-music"
|
|
46
|
+
},
|
|
47
|
+
"dsh": {
|
|
48
|
+
"profile": {
|
|
49
|
+
"bundles": [
|
|
50
|
+
"@deepseek-ai/dsh-base",
|
|
51
|
+
"@deepseek-ai/dsh-web-app",
|
|
52
|
+
"@doitian/dsh-music"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A `link:` dependency keeps the profile pointing at the working tree, so edits
|
|
60
|
+
need no reinstall. Note that this is what makes a plugin *remount* re-read the
|
|
61
|
+
served page while the module stays cached — see
|
|
62
|
+
[Reloading a change](#reloading-a-change).
|
|
63
|
+
|
|
64
|
+
### Either way
|
|
65
|
+
|
|
66
|
+
The bundle's own `cordis.patch.yml` inserts the plugin entry, so no
|
|
67
|
+
`cordis.patch.yml` edit is required. Restart the app (or reload the profile) and
|
|
68
|
+
**Music** appears in the sidebar.
|
|
69
|
+
|
|
70
|
+
## Sign in (optional)
|
|
71
|
+
|
|
72
|
+
Without credentials the plugin works anonymously: search, charts, playlists,
|
|
73
|
+
lyrics, and non-VIP playback all function. VIP tracks return no audio URL.
|
|
74
|
+
|
|
75
|
+
Sign in from the player (**登录 Sign in** in the header), which renders a QR
|
|
76
|
+
code to scan with the NetEase Cloud Music mobile app, or ask the agent to use
|
|
77
|
+
`music_login`. The session cookie is stored in `$DSH_HOME/music/session.json`.
|
|
78
|
+
|
|
79
|
+
`fee=1` (VIP-only) tracks are gated by NetEase for anonymous sessions; that is
|
|
80
|
+
an upstream entitlement rule, not a plugin limit.
|
|
81
|
+
|
|
82
|
+
## Agent tools
|
|
83
|
+
|
|
84
|
+
| Tool | Purpose |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `music_search` | Search NetEase for songs; returns ids usable below. |
|
|
87
|
+
| `music_play` | Queue and start playback, by ids or by search query. |
|
|
88
|
+
| `music_queue` | Show / append / insert / replace / remove / jump / clear the queue. |
|
|
89
|
+
| `music_control` | play, pause, toggle, next, previous, seek, volume, mute, mode. |
|
|
90
|
+
| `music_dj` | Run or configure the AI DJ, with an optional mood brief. |
|
|
91
|
+
| `music_now_playing` | What is playing, what is next, and whether the account is signed in. |
|
|
92
|
+
| `music_login` | status / qr / poll / set_cookie / logout. |
|
|
93
|
+
|
|
94
|
+
Playback happens in the browser panel, so nothing is audible until the user has
|
|
95
|
+
opened the Music page.
|
|
96
|
+
|
|
97
|
+
## Streaming quality
|
|
98
|
+
|
|
99
|
+
The transport row carries a quality picker. Levels, richest first:
|
|
100
|
+
|
|
101
|
+
| Level | What it serves |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `jymaster` / `dolby` / `sky` / `jyeffect` | the object-audio tiers; often answered with the Hi-Res stream, and `sky` is frequently refused outright |
|
|
104
|
+
| `hires` | FLAC, ~1677–1785 kbps (51–63 MB per track on a VIP account) |
|
|
105
|
+
| `lossless` | FLAC, ~908–1016 kbps (28–36 MB) |
|
|
106
|
+
| `exhigh` *(default)* | 320 kbps MP3 (~10 MB) |
|
|
107
|
+
| `higher` / `standard` | 192 / 128 kbps MP3 |
|
|
108
|
+
|
|
109
|
+
**A refusal walks down the ladder.** Entitlement and availability are per track:
|
|
110
|
+
a track with no lossless master, or a level the region does not carry, answers
|
|
111
|
+
`code -110` while the same track streams fine one tier lower. The request steps
|
|
112
|
+
down until something plays, and the caption reports what was *actually* served —
|
|
113
|
+
`▶ FLAC · 1677 kbps`, or `▶ MP3 · 320 kbps (exhigh, 已降级/downgraded)` when the
|
|
114
|
+
rich request could not be honoured. It never silently pretends, and it never
|
|
115
|
+
leaves you with silence because you asked for more than the track has.
|
|
116
|
+
|
|
117
|
+
The choice is saved in `session.json` and beats `config.audioLevel` in the
|
|
118
|
+
profile patch, the same precedence the curator model uses. An unknown value in
|
|
119
|
+
either place falls back to `exhigh` rather than failing a stream.
|
|
120
|
+
|
|
121
|
+
Two implementation details worth knowing:
|
|
122
|
+
|
|
123
|
+
- **The level rides in the stream URL** (`/music/stream/<id>?level=lossless`),
|
|
124
|
+
and the CDN-URL cache is keyed by level as well as track. The same track at
|
|
125
|
+
two qualities is two different files, so reusing one resolution while the
|
|
126
|
+
browser range-requests the other would corrupt the byte stream.
|
|
127
|
+
- **Switching quality mid-track loads a fresh resource and resumes in place**,
|
|
128
|
+
restoring the position from the previous file once metadata arrives, rather
|
|
129
|
+
than restarting the song. The resource check runs on every poll, not behind
|
|
130
|
+
the transport-revision gate — quality is not a one-shot transport change, and
|
|
131
|
+
gating it meant a switch did nothing until something else moved the revision.
|
|
132
|
+
|
|
133
|
+
`/music/health` reports both: `audio.preferred` and `audio.last` (what the last
|
|
134
|
+
stream actually served, including `downgraded`).
|
|
135
|
+
|
|
136
|
+
## The AI DJ
|
|
137
|
+
|
|
138
|
+
The DJ keeps the queue stocked. When the queue drops below
|
|
139
|
+
`djAutoExtendBelow`, it gathers candidates and appends a batch:
|
|
140
|
+
|
|
141
|
+
- similar songs for the current and recently played tracks (`simiSong`),
|
|
142
|
+
- the daily recommendations and the anonymous new-song feed,
|
|
143
|
+
- the official charts,
|
|
144
|
+
- keyword search for the mood brief.
|
|
145
|
+
|
|
146
|
+
Then one of two tiers chooses:
|
|
147
|
+
|
|
148
|
+
- **Model tier** — when `dj.provider` and `dj.model` are configured (or a
|
|
149
|
+
single provider is discoverable), the candidate pool plus a taste digest
|
|
150
|
+
(recently played, favourite artists, likes, dislikes) goes to
|
|
151
|
+
`ctx.llm.stream()`, and the model returns the picks and a one-line vibe. This
|
|
152
|
+
is the tier that reasons about a mood.
|
|
153
|
+
- **Heuristic tier** — always available: candidates are scored by artist
|
|
154
|
+
affinity, novelty against play history, and explicit like/dislike feedback,
|
|
155
|
+
then interleaved so consecutive tracks do not share an artist.
|
|
156
|
+
|
|
157
|
+
The model tier is optional and silent on failure; the DJ never blocks playback.
|
|
158
|
+
It only ever **stocks the queue** — it never starts or stops playback, so a
|
|
159
|
+
top-up while paused stays paused, and one that refills an empty queue after the
|
|
160
|
+
last track ended resumes on its own because the desired state was already
|
|
161
|
+
"playing".
|
|
162
|
+
|
|
163
|
+
### Choosing the model
|
|
164
|
+
|
|
165
|
+
**In the player.** The right column carries a **AI DJ 模型 / curator model**
|
|
166
|
+
panel: a provider picker, a model picker, and three buttons.
|
|
167
|
+
|
|
168
|
+
| Control | What it does |
|
|
169
|
+
|---|---|
|
|
170
|
+
| **保存 Save** | Applies the pair immediately and persists it in `session.json` |
|
|
171
|
+
| **测试 Test** | Runs one throwaway plan (nothing is queued) and reports the route it used, the track it would have picked, and — on failure — exactly why |
|
|
172
|
+
| **回退 Revert** | Clears the saved choice so the profile patch applies again |
|
|
173
|
+
|
|
174
|
+
The picker lists every registered provider route and that route's catalogued
|
|
175
|
+
models. The catalogue is **advisory**: core resolution accepts unlisted ids, so
|
|
176
|
+
**其他… / Other…** reveals a text field for any id the route serves — which is
|
|
177
|
+
how you keep using a model the adapter's catalogue does not list.
|
|
178
|
+
|
|
179
|
+
Selection precedence, highest first:
|
|
180
|
+
|
|
181
|
+
1. **the player's saved choice** (`settings.djProvider` / `settings.djModel`)
|
|
182
|
+
2. **the profile patch** (`config.dj.provider` / `config.dj.model`)
|
|
183
|
+
3. **discovery** — the first route, then its first catalogued model
|
|
184
|
+
|
|
185
|
+
The source is shown next to the heading: *面板 / panel*, *配置 / profile*, or
|
|
186
|
+
*自动 / auto*. Saving beats the patch deliberately; otherwise a stale patch pin
|
|
187
|
+
would silently override what you just picked. **回退 Revert** is how you hand
|
|
188
|
+
control back to the patch.
|
|
189
|
+
|
|
190
|
+
**In the profile patch**, as a default for a fresh install:
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
- id: music
|
|
194
|
+
name: '@doitian/dsh-music'
|
|
195
|
+
config:
|
|
196
|
+
dj:
|
|
197
|
+
provider: opencode-go
|
|
198
|
+
model: deepseek-v4.1-flash
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Both names must be ones the host actually serves. `provider` is a registered
|
|
202
|
+
adapter route — read them from the `llm-pi-ai` entry's `config.providers` (or
|
|
203
|
+
ask the agent for `ctx.llm.listProviders()`); `model` is any id that route
|
|
204
|
+
accepts. The simplest safe choice is to **mirror the session's own model**:
|
|
205
|
+
whatever `agent-default-model` uses is known to work.
|
|
206
|
+
|
|
207
|
+
Three things worth knowing:
|
|
208
|
+
|
|
209
|
+
- **`config` is replaced, not merged.** The loader assigns the whole object
|
|
210
|
+
(`entry.ts`: `this.options.config = value`), so if you also want, say,
|
|
211
|
+
`audioLevel`, keep every option in one block. Schema defaults fill the rest.
|
|
212
|
+
- **Discovery runs when you pin nothing, and its choice is arbitrary.** With no
|
|
213
|
+
config the DJ asks the mounted LLM service for its routes
|
|
214
|
+
(`listProviders()`), takes the first, then takes that route's first catalogue
|
|
215
|
+
entry. On this machine `opencode-go`'s catalogue is
|
|
216
|
+
`minimax-m3, deepseek-v4-flash, gpt-5.6-luna`, so discovery curates with
|
|
217
|
+
**MiniMax-M3** — not a recommendation, just the first row. Pinning both fields
|
|
218
|
+
is the only way to know which model is curating.
|
|
219
|
+
- **Partial pins are honoured.** A `provider` alone keeps that route and picks
|
|
220
|
+
its first catalogue model; a `model` alone keeps that model on the first
|
|
221
|
+
route.
|
|
222
|
+
|
|
223
|
+
Confirm it took effect:
|
|
224
|
+
|
|
225
|
+
| Where | What to look for |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `/music/health` | `dj.model` is your pinned pair; `dj.modelSource` is `panel`, `config` or `discovered`; `dj.lastRoute` is the route that actually curated the last batch; `dj.modelError` is `null` once the tier works |
|
|
228
|
+
| Player, DJ status line | **AI DJ** plus the route (heuristic runs read **AI DJ (heuristic)**, and a declined tier prints the reason) |
|
|
229
|
+
| **测试 Test** in the player | Answers "does this route work?" in one click, without touching the queue |
|
|
230
|
+
| `music_dj` tool result | `AI DJ is on (model via opencode-go/deepseek-v4.1-flash)` |
|
|
231
|
+
|
|
232
|
+
**If the tier declines, `dj.modelError` says why** — the tier is non-fatal by
|
|
233
|
+
design, so without that field every cause looks the same. It reports
|
|
234
|
+
`no LLM service is mounted (ctx.get("llm") returned nothing)` when no adapter
|
|
235
|
+
service is present, names a half-configured pin (`opencode-go/?`), or carries
|
|
236
|
+
the provider's own failure (a `LlmError` code such as `NO_ADAPTER`,
|
|
237
|
+
`MISSING_CREDENTIAL`, `AUTH`, `RATE_LIMIT`). The heuristic tier covers that batch
|
|
238
|
+
either way.
|
|
239
|
+
|
|
240
|
+
Editing `config` recomposes the running host, but **the plugin module itself is
|
|
241
|
+
cached** — restart the harness for the new route to take effect. If the model
|
|
242
|
+
tier fails for any reason (no adapter, bad credentials, unparseable reply,
|
|
243
|
+
rate limit) the DJ logs a warning and falls back to the heuristic tier for that
|
|
244
|
+
batch; it never leaves the queue empty.
|
|
245
|
+
|
|
246
|
+
### Heuristic tier
|
|
247
|
+
|
|
248
|
+
Always available, used whenever the model tier is unavailable:
|
|
249
|
+
|
|
250
|
+
## Configuration
|
|
251
|
+
|
|
252
|
+
Defaults live in the package, so the profile patch stays empty. To override,
|
|
253
|
+
add `config` to the inserted entry:
|
|
254
|
+
|
|
255
|
+
```yaml
|
|
256
|
+
- insert:
|
|
257
|
+
- id: music
|
|
258
|
+
name: '@doitian/dsh-music'
|
|
259
|
+
config:
|
|
260
|
+
audioLevel: exhigh # exhigh (320 kbps) | standard | higher | lossless
|
|
261
|
+
dataDir: D:\dsh-data\music # default $DSH_HOME/music
|
|
262
|
+
requestTimeoutMs: 15000
|
|
263
|
+
dj:
|
|
264
|
+
provider: opencode-go # enables the model tier
|
|
265
|
+
model: deepseek-v4.1-flash
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
| Field | Default | Meaning |
|
|
269
|
+
|---|---|---|
|
|
270
|
+
| `apiPrefix` | `music` | Route prefix. **Changing this also requires editing `BASE` in `lib/client.js`.** |
|
|
271
|
+
| `dataDir` | `$DSH_HOME/music` | Where `session.json` (cookie, history, feedback, settings) lives. |
|
|
272
|
+
| `audioLevel` | `exhigh` | Initial streaming quality, until the player's picker records a choice. One of the levels above; an unknown value falls back to `exhigh`. |
|
|
273
|
+
| `requestTimeoutMs` | `15000` | Per-request deadline for NetEase calls. |
|
|
274
|
+
| `dj.provider` / `dj.model` | unset | Model route for the DJ's model tier — see [Choosing the model](#choosing-the-model). Unset, the tier auto-discovers and falls back to heuristics on any failure. |
|
|
275
|
+
|
|
276
|
+
Player preferences (quality, DJ on/off, mood brief, batch size, extend
|
|
277
|
+
threshold) are persisted in `session.json` and edited from the panel.
|
|
278
|
+
|
|
279
|
+
## How it works
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
browser (DSH web GUI, http://127.0.0.1:<port>)
|
|
283
|
+
├─ client plugin — mounted for the plugin's whole lifetime
|
|
284
|
+
│ └─ audio engine ── <audio src="/music/stream/<id>"> ──▶ host audio proxy
|
|
285
|
+
│ (keeps playing while you are in a session)
|
|
286
|
+
└─ sidebar "音乐 Music" ── iframe ──▶ /music/panel (player UI, no audio)
|
|
287
|
+
│ fetch (same origin)
|
|
288
|
+
▼
|
|
289
|
+
/music/api/* (JSON)
|
|
290
|
+
/music/stream/<id> (audio bytes)
|
|
291
|
+
│
|
|
292
|
+
host plugin ───────┘
|
|
293
|
+
├─ lib/netease.js NetEase web API (no weapi encryption)
|
|
294
|
+
├─ lib/session.js cookie store + QR login state machine
|
|
295
|
+
├─ lib/state.js queue, cursor, transport revisions
|
|
296
|
+
├─ lib/dj.js candidate pool + model/heuristic tiers
|
|
297
|
+
└─ lib/router.js JSON API, HTML, Range-capable audio proxy
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Four design notes:
|
|
301
|
+
|
|
302
|
+
- **Audio is proxied, not redirected.** CDN URLs expire after 20 minutes and
|
|
303
|
+
need the session cookie at *resolution* time, so the host resolves and streams
|
|
304
|
+
the bytes (cache 15 min, `Range` forwarded upstream so seeking works). It also
|
|
305
|
+
keeps playback same-origin, avoiding mixed-content and CORS entirely.
|
|
306
|
+
- **Playback lives in the shell document, not in the page.** The layout renders
|
|
307
|
+
only the **active** `main` slot entry —
|
|
308
|
+
`renderSlot('main', {}, { entryKey: activePanelId ?? 'conversation' })` — so
|
|
309
|
+
opening a Session unmounts the Music page and discards its document. An
|
|
310
|
+
`<audio>` owned by that page would stop the music, which is exactly what
|
|
311
|
+
happened before contract 2. The engine is created on the client plugin's own
|
|
312
|
+
lifetime, survives every panel switch, and the page is a remote control:
|
|
313
|
+
`window.__dshMusicEngine.sync()` applies a command without waiting for the
|
|
314
|
+
next poll. A `panelContract` value on `/music/health` keeps a half-updated
|
|
315
|
+
browser (new bundle, old host) from playing every track twice.
|
|
316
|
+
- **The host owns the desired state; the engine owns the audio element.** They
|
|
317
|
+
reconcile through `rev` / `transportRev` counters, so agent tools, the page,
|
|
318
|
+
and the DJ all drive one state machine instead of three.
|
|
319
|
+
- **The client half is hand-written.** `lib/client.js` is a plain
|
|
320
|
+
`window.__ModuleLoader__` bundle using only baseline `react`, so there is no
|
|
321
|
+
tsdown/Vite step. It registers the sidebar row (`sidebar.panellist`, a list
|
|
322
|
+
slot) and the page (`main`, the layout's keyed slot) with the same id, and
|
|
323
|
+
`ctx.slots.inject` waits for those slots to be declared.
|
|
324
|
+
|
|
325
|
+
## Security notes
|
|
326
|
+
|
|
327
|
+
- The `/music` route prefix is registered on the bare HTTP server, which owns no
|
|
328
|
+
authentication of its own. Anyone who can reach the port can browse the
|
|
329
|
+
library and stream audio; the route is loopback-only in the shipped
|
|
330
|
+
composition. It exposes no credentials — only search results, the queue, and
|
|
331
|
+
audio bytes.
|
|
332
|
+
- The NetEase cookie lives in `$DSH_HOME/music/session.json` in plain text, the
|
|
333
|
+
same posture as the rest of the profile's session data. `music_login` with
|
|
334
|
+
`set_cookie` accepts a pasted `MUSIC_U`, and the value is never returned by any
|
|
335
|
+
endpoint.
|
|
336
|
+
|
|
337
|
+
### Diagnostics
|
|
338
|
+
|
|
339
|
+
`GET /music/health` is unauthenticated and answers with the facts worth having
|
|
340
|
+
when something looks wrong:
|
|
341
|
+
|
|
342
|
+
```json
|
|
343
|
+
{
|
|
344
|
+
"ok": true,
|
|
345
|
+
"prefix": "/music",
|
|
346
|
+
"panelContract": 2,
|
|
347
|
+
"authenticated": true,
|
|
348
|
+
"account": { "nickname": "…", "vip": true },
|
|
349
|
+
"playing": false,
|
|
350
|
+
"queued": 0,
|
|
351
|
+
"tools": ["music_search", "music_play", "music_queue", "music_control",
|
|
352
|
+
"music_dj", "music_now_playing", "music_login"],
|
|
353
|
+
"dj": { "enabled": false, "model": null, "lastRoute": null,
|
|
354
|
+
"modelError": null, "lastPlanAt": null, "error": null },
|
|
355
|
+
"dataDir": "C:\\Users\\…\\.dsh\\music",
|
|
356
|
+
"session": { "authenticated": true, "nickname": "…", "vip": true },
|
|
357
|
+
"uptimeMs": 123456
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
`tools` is the point: the HTTP route is registered before the tools, so a tool
|
|
362
|
+
that failed to register would otherwise leave a route that answers normally.
|
|
363
|
+
Seeing all seven names is what proves startup completed. `panelContract` is the
|
|
364
|
+
page/engine boundary the browser half checks before it starts playback.
|
|
365
|
+
|
|
366
|
+
### If the browser blocks autoplay
|
|
367
|
+
|
|
368
|
+
Chromium refuses to start audio until the page has been **interacted with**, and
|
|
369
|
+
the plugin's desired state can say "playing" long before that — the agent
|
|
370
|
+
queued something, or the DJ refilled the queue while the page was loading. The
|
|
371
|
+
player then shows:
|
|
372
|
+
|
|
373
|
+
> 浏览器需要你先与页面交互一次才会播放声音 / Chromium needs one interaction with this window before it will play audio
|
|
374
|
+
|
|
375
|
+
**Click the button, or anywhere in the window.** That one gesture grants sticky
|
|
376
|
+
activation for the rest of the page's life, after which agent- and DJ-initiated
|
|
377
|
+
playback works without asking again. Typing in the chat counts too.
|
|
378
|
+
|
|
379
|
+
The engine retries on every 500 ms poll, so recovery is automatic — and it must
|
|
380
|
+
be, because a refused `play()` does not change the transport revision. Gating
|
|
381
|
+
the retry behind that revision (an earlier bug) left the music stopped for good,
|
|
382
|
+
with the hint showing and pressing it doing nothing.
|
|
383
|
+
|
|
384
|
+
### Who owns the `<audio>` element
|
|
385
|
+
|
|
386
|
+
Normally the client plugin's **engine in the shell document**, so playback
|
|
387
|
+
survives opening a session (the layout renders only the *active* panel). But the
|
|
388
|
+
player page is re-read from disk on every plugin mount, while the browser bundle
|
|
389
|
+
it pairs with is pinned to its own revision — so the two can disagree. When they
|
|
390
|
+
do, `GET /music/health` will not advertise the matching `panelContract`, the
|
|
391
|
+
engine declares itself inert, and **the page owns the audio element itself**.
|
|
392
|
+
That is the older behaviour: it plays normally, it just stops when you open a
|
|
393
|
+
session. Exactly one transport ever owns playback; the unusable one is stood
|
|
394
|
+
down first. A restart brings both halves back into agreement.
|
|
395
|
+
|
|
396
|
+
## Development
|
|
397
|
+
|
|
398
|
+
```powershell
|
|
399
|
+
npm run check # node --check on every module
|
|
400
|
+
npm test # 65 deterministic tests: pure, DJ, browser half
|
|
401
|
+
npm run test:live # 23 integration tests against the live NetEase API
|
|
402
|
+
npm run test:all # both
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
The split matters for CI. `npm test` never touches the network, so it is what
|
|
406
|
+
runs on every push and gates a release. `npm run test:live` drives the real API,
|
|
407
|
+
and **GitHub's runners cannot reach it** — from a US runner
|
|
408
|
+
`/api/search/get/web` answers in ~350 ms with an empty result set, so its first
|
|
409
|
+
assertion fails on a network condition rather than a regression. It therefore
|
|
410
|
+
lives in `.github/workflows/live.yml` on manual dispatch only, and the
|
|
411
|
+
authoritative run is local, before a release. `MUSIC_OFFLINE=1` skips the
|
|
412
|
+
network cases inside it.
|
|
413
|
+
|
|
414
|
+
Or run one file directly:
|
|
415
|
+
|
|
416
|
+
```powershell
|
|
417
|
+
node test/netease.test.mjs # 16 pure: normalisation, quality ladder, cookies, player state
|
|
418
|
+
node test/dj.test.mjs # 31 AI DJ: model tier, failure reporting, picker listing, queue invariants
|
|
419
|
+
node test/client.test.mjs # 18 browser half, executed against a fake DOM
|
|
420
|
+
node test/host.test.mjs # 23 integration: routes, streaming, curation, quality
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
The DJ tests drive the model tier with a stub `ctx.llm.stream()` that emits the
|
|
424
|
+
documented chunks, so the pinned-route happy path, both chunk spellings
|
|
425
|
+
(`type`/`kind`), index filtering, prose-wrapped JSON, discovery, and every
|
|
426
|
+
fallback are covered without a provider.
|
|
427
|
+
|
|
428
|
+
The integration tests mount the plugin against stand-in `tools`/`webServer`
|
|
429
|
+
services and drive the captured route over a real `node:http` server, so they
|
|
430
|
+
exercise the same path the browser takes — including a real `Range` request
|
|
431
|
+
against the NetEase CDN and a real AI DJ plan. Because they talk to a live
|
|
432
|
+
third-party API, an occasional failure there can be the network rather than the
|
|
433
|
+
code; re-run before investigating.
|
|
434
|
+
|
|
435
|
+
The browser-half tests execute `lib/client.js` for real against a minimal fake
|
|
436
|
+
DOM (fake `window`, `document`, `fetch`), which is how the central property is
|
|
437
|
+
proven: **the engine creates and drives the `<audio>` element with no React
|
|
438
|
+
component ever rendered**, so playback cannot depend on the Music page being
|
|
439
|
+
mounted. They also cover the seek handshake, failure reporting, disposal, and
|
|
440
|
+
the contract handshake.
|
|
441
|
+
|
|
442
|
+
`npm test` runs the three deterministic files in sequence (`npm run test:all`
|
|
443
|
+
adds the live one), deliberately **not** `node --test <dir>`: the directory form forks one child process per file, which
|
|
444
|
+
is blocked in sandboxed environments.
|
|
445
|
+
|
|
446
|
+
### Reloading a change
|
|
447
|
+
|
|
448
|
+
- **Profile configuration** changes (bundle selection, `cordis.patch.yml`)
|
|
449
|
+
recompose the running host: the loader re-reads the layers and mounts or
|
|
450
|
+
unmounts the affected entry. Adding the bundle to `dsh.profile.bundles` and
|
|
451
|
+
touching `cordis.patch.yml` was enough to load the plugin into an already
|
|
452
|
+
running host.
|
|
453
|
+
- **Plugin source** changes need a **process restart**. A disable/enable
|
|
454
|
+
round-trip does remount the entry (the route genuinely goes away and comes
|
|
455
|
+
back), but Node caches the ESM module, so the remounted entry runs the *old*
|
|
456
|
+
code. Treat `lib/*.js` and `lib/panel.html` edits as restart-required.
|
|
457
|
+
- **Agent tools** register on the host, so they appear in **new** agent
|
|
458
|
+
sessions. A session that was already running keeps the tool list it started
|
|
459
|
+
with.
|
|
460
|
+
|
|
461
|
+
`lib/vendor/qrcode.js` is the MIT-licensed
|
|
462
|
+
[qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) by
|
|
463
|
+
Kazuhiko Arase, vendored so QR sign-in needs no network call or build step.
|
|
464
|
+
|
|
465
|
+
## Known limitations
|
|
466
|
+
|
|
467
|
+
- **Anonymous sessions cannot play VIP tracks**; NetEase returns `url: null`.
|
|
468
|
+
- **Some tracks are gated by *purchase*, not VIP**, and stay unplayable even for
|
|
469
|
+
a signed-in VIP account — 周杰伦's 晴天 (`id 186016`) is the canonical example,
|
|
470
|
+
because it belongs to a digital album. The plugin surfaces NetEase's own
|
|
471
|
+
refusal (`403` plus a reason) rather than retrying.
|
|
472
|
+
- **No `weapi`/`eapi` encryption**, so like/scrobble/playlist-write endpoints
|
|
473
|
+
(which require it) are not implemented. Liking a track is recorded locally and
|
|
474
|
+
fed to the DJ instead.
|
|
475
|
+
- **`apiPrefix` must stay `music`** unless `BASE` in `lib/client.js` is changed
|
|
476
|
+
to match.
|
|
477
|
+
- **The DJ's model tier is covered against the stub stream contract, not a live
|
|
478
|
+
provider** — no adapter was configured where this was built, so request
|
|
479
|
+
building, the pinned route, both chunk spellings, index filtering and every
|
|
480
|
+
fallback are tested, but a real end-to-end model call has not been observed
|
|
481
|
+
here. The heuristic tier is what the live runs exercised.
|
|
482
|
+
- **Chromium autoplay policy** may block playback the agent starts before the
|
|
483
|
+
user has interacted with the page; the panel then shows a *click to play* hint.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# NetEase Cloud Music bundle for DeepSeek Harness.
|
|
2
|
+
#
|
|
3
|
+
# This bundle inserts its own plugin entry: the host half (NetEase session,
|
|
4
|
+
# playback queue, audio proxy, agent tools) and the browser half (the sidebar
|
|
5
|
+
# page) both come from the single package named below.
|
|
6
|
+
#
|
|
7
|
+
# Everything has a default inside the package, so this profile stays empty.
|
|
8
|
+
# Optional fields under `config`:
|
|
9
|
+
# apiPrefix route prefix (default `music`) — keep it `music` unless
|
|
10
|
+
# you also rebuild the browser half.
|
|
11
|
+
# dataDir where session.json lives (default $DSH_HOME/music).
|
|
12
|
+
# audioLevel exhigh (320 kbps, default) | standard | higher | lossless
|
|
13
|
+
# requestTimeoutMs NetEase request deadline (default 15000).
|
|
14
|
+
# dj.provider model route for the AI DJ's model tier, e.g. opencode-go.
|
|
15
|
+
# dj.model exact model id, e.g. deepseek-v4.1-flash.
|
|
16
|
+
#
|
|
17
|
+
# Without `dj.provider`/`dj.model` the DJ still works: it falls back to scoring
|
|
18
|
+
# similar songs, the daily recommendations and the charts by the listener's
|
|
19
|
+
# liked artists, novelty and feedback.
|
|
20
|
+
- insert:
|
|
21
|
+
- id: music
|
|
22
|
+
name: '@doitian/dsh-music'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" role="img" aria-label="NetEase Cloud Music">
|
|
2
|
+
<rect width="32" height="32" rx="7" fill="#e8503a" />
|
|
3
|
+
<g fill="none" stroke="#fff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
|
4
|
+
<path d="M13 22V9.5l10-2V20" />
|
|
5
|
+
<circle cx="10" cy="22" r="3" />
|
|
6
|
+
<circle cx="20" cy="20" r="3" />
|
|
7
|
+
</g>
|
|
8
|
+
</svg>
|