@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 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
- # Temporary Holding Version
1
+ # @doitian/dsh-music
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -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>