@doitian/dsh-music 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -43
- package/cordis.patch.yml +14 -0
- package/lib/client.js +48 -11
- package/lib/dj.js +234 -48
- package/lib/index.js +57 -3
- package/lib/likes.js +169 -0
- package/lib/netease.js +120 -7
- package/lib/panel.html +749 -235
- package/lib/router.js +210 -22
- package/lib/session.js +93 -19
- package/lib/state.js +15 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -2,17 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
NetEase Cloud Music ([music.163.com](https://music.163.com)) playback and an AI DJ, inside DeepSeek Harness.
|
|
4
4
|
|
|
5
|
-
A **Music** entry appears in the DSH sidebar. It opens a
|
|
5
|
+
A **Music** entry appears in the DSH sidebar. It opens a player — your queue, synced lyrics, an `<audio>` element that streams through the host, QR sign-in, NetEase likes, 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
6
|
|
|
7
7
|
```
|
|
8
|
-
|
|
9
|
-
│
|
|
10
|
-
│
|
|
11
|
-
│
|
|
12
|
-
│
|
|
13
|
-
│
|
|
14
|
-
│
|
|
15
|
-
|
|
8
|
+
┌──────────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ 音乐 music.163.com [AI DJ] [mood] [account] │
|
|
10
|
+
│ │
|
|
11
|
+
│ ♪ 海屿你 — 马也_Crabbit │
|
|
12
|
+
│ 上一首 · 播放 · 下一首 · 列表 · 喜欢 · 静音 · 音量 │
|
|
13
|
+
│ │
|
|
14
|
+
│ 歌词 Lyrics │
|
|
15
|
+
│ [00:12.40] ... │
|
|
16
|
+
│ 播放队列 Queue (12) │
|
|
17
|
+
└──────────────────────────────────────────────────────────────────┘
|
|
16
18
|
```
|
|
17
19
|
|
|
18
20
|
## Install
|
|
@@ -61,6 +63,15 @@ need no reinstall. Note that this is what makes a plugin *remount* re-read the
|
|
|
61
63
|
served page while the module stays cached — see
|
|
62
64
|
[Reloading a change](#reloading-a-change).
|
|
63
65
|
|
|
66
|
+
**Language.** The UI follows the Harness language (Settings → Language): the
|
|
67
|
+
sidebar label and error page register a zh/en dictionary with the host's
|
|
68
|
+
`locale` service, and the player page follows the shell's `<html lang>` live.
|
|
69
|
+
Switching language re-renders both in place — no restart.
|
|
70
|
+
|
|
71
|
+
While a profile is linked, the Desktop app's **Add plugin** flow is the wrong
|
|
72
|
+
tool for it: that flow installs the published tarball, which replaces the
|
|
73
|
+
`link:` dependency. Re-add the link if it happens.
|
|
74
|
+
|
|
64
75
|
### Either way
|
|
65
76
|
|
|
66
77
|
The bundle's own `cordis.patch.yml` inserts the plugin entry, so no
|
|
@@ -115,7 +126,7 @@ rich request could not be honoured. It never silently pretends, and it never
|
|
|
115
126
|
leaves you with silence because you asked for more than the track has.
|
|
116
127
|
|
|
117
128
|
The choice is saved in `session.json` and beats `config.audioLevel` in the
|
|
118
|
-
profile patch, the same precedence the
|
|
129
|
+
profile patch, the same precedence the DJ's model route uses. An unknown value in
|
|
119
130
|
either place falls back to `exhigh` rather than failing a stream.
|
|
120
131
|
|
|
121
132
|
Two implementation details worth knowing:
|
|
@@ -133,6 +144,59 @@ Two implementation details worth knowing:
|
|
|
133
144
|
`/music/health` reports both: `audio.preferred` and `audio.last` (what the last
|
|
134
145
|
stream actually served, including `downgraded`).
|
|
135
146
|
|
|
147
|
+
## Likes and taste
|
|
148
|
+
|
|
149
|
+
A track holds one of three taste levels — **liked**, **unrated**, or
|
|
150
|
+
**disliked** — and the player draws whichever it is:
|
|
151
|
+
|
|
152
|
+
| Control | Level it sets |
|
|
153
|
+
|---|---|
|
|
154
|
+
| the **♡ / ♥** in a queue row, or the heart in the transport row | **liked** — the track goes into the account's 我喜欢的音乐 playlist on NetEase. Clicking the filled heart takes the like back. |
|
|
155
|
+
| the **✕** in the transport row | **disliked** — local, and fed to the DJ, which stops picking the track. Clicking the filled ✕ clears it. |
|
|
156
|
+
| the **✕** on a queue row | **disliked** as well. Taking a track out of the queue is a judgement about the track, not just about the list — without recording it the DJ re-derives the same track from the same similarity and charts within a batch or two. |
|
|
157
|
+
| neither | unrated. |
|
|
158
|
+
|
|
159
|
+
A like is a **NetEase** like, not a plugin flag, and the two halves of what that
|
|
160
|
+
needs are both plain endpoints:
|
|
161
|
+
|
|
162
|
+
- `POST /api/song/like` — one of the few *write* endpoints the web API still
|
|
163
|
+
serves unencrypted, so no `weapi` client is needed. It takes the direction
|
|
164
|
+
rather than toggling: the page always says which level it wants, so a stale
|
|
165
|
+
poll cannot turn a click into the wrong one, and disliking a liked track is one
|
|
166
|
+
request instead of two racing ones.
|
|
167
|
+
- `GET /api/song/like/check` — which of a batch of ids are liked. The answer is
|
|
168
|
+
cached per track (5 minutes), so a whole queue's hearts cost one request, and
|
|
169
|
+
only tracks with no fresh answer ever cost another.
|
|
170
|
+
|
|
171
|
+
Four properties are worth knowing, because they are what makes a heart mean
|
|
172
|
+
something:
|
|
173
|
+
|
|
174
|
+
- **An unknown track is not an unliked one.** A track nobody has asked about has
|
|
175
|
+
no answer, and renders as unrated until NetEase gives one.
|
|
176
|
+
- **A refusal is not an answer.** An anonymous session answers `code 301` for
|
|
177
|
+
every id, and a delisted track answers `400` with NetEase's own reason
|
|
178
|
+
(`下架歌曲无法收藏` for 晴天, for instance). Both leave the heart empty and say
|
|
179
|
+
why in the toast; a refused check is retried later instead of being cached as
|
|
180
|
+
"not liked", so signing in mid-session fills the hearts without a restart.
|
|
181
|
+
- **The account is the record.** `feedback.likes` in `session.json` is a *copy*
|
|
182
|
+
of the account's likes, kept for the DJ's taste digest. It is reconciled with
|
|
183
|
+
the account at startup — one read of the liked playlist — so a like made on
|
|
184
|
+
the phone counts, and a like an earlier build recorded locally (because it had
|
|
185
|
+
nowhere to send it) does not linger as a phantom.
|
|
186
|
+
- **A dislike is local.** The plain web API has no dislike endpoint, so this is
|
|
187
|
+
where the plugin's own taste memory is the only record. Liking and disliking
|
|
188
|
+
are the same three levels rather than two flags: one replaces the other, and
|
|
189
|
+
disliking a liked track removes it on NetEase as well.
|
|
190
|
+
- **Removing a queued track is a dislike, once.** The row's ✕ goes through the
|
|
191
|
+
queue endpoint, whose removal *is* the judgement: the row leaves the list and
|
|
192
|
+
an unrated track is recorded as disliked in the same request, so the DJ does
|
|
193
|
+
not derive it again from the same similarity. The confirm names the removal —
|
|
194
|
+
the judgement is the host's side of it. A track that already carries a level
|
|
195
|
+
keeps it, because removing is often just queue housekeeping (clearing out what
|
|
196
|
+
has already been heard) and rewriting a like into a dislike would be worse
|
|
197
|
+
than missing the signal. Removing the track that is playing also advances
|
|
198
|
+
playback, which a removal otherwise would not.
|
|
199
|
+
|
|
136
200
|
## The AI DJ
|
|
137
201
|
|
|
138
202
|
The DJ keeps the queue stocked. When the queue drops below
|
|
@@ -154,6 +218,28 @@ Then one of two tiers chooses:
|
|
|
154
218
|
affinity, novelty against play history, and explicit like/dislike feedback,
|
|
155
219
|
then interleaved so consecutive tracks do not share an artist.
|
|
156
220
|
|
|
221
|
+
**Every model call carries a session identity.** A leaf call cannot set headers
|
|
222
|
+
— `GenerateOptions` has no `headers` field — so `sessionId` is the only identity
|
|
223
|
+
a plugin can give a provider, and each adapter maps it onto whatever that
|
|
224
|
+
provider calls a per-conversation header: pi-ai emits `x-opencode-session` for
|
|
225
|
+
the `opencode-go` route. Without it, a provider that keys on the conversation
|
|
226
|
+
sees an anonymous client ignoring its conventions.
|
|
227
|
+
|
|
228
|
+
The identity is minted once per install and persisted in `session.json`, so
|
|
229
|
+
every plan and every restart stays inside one conversation; `dj.sessionId` pins
|
|
230
|
+
it explicitly instead. A request with no identity is refused rather than sent
|
|
231
|
+
bare, so the failure is a recorded `dj.modelError` rather than a silent one.
|
|
232
|
+
|
|
233
|
+
Two other properties are worth knowing:
|
|
234
|
+
|
|
235
|
+
- **The DJ is not an agent, and does not start one.** It is plugin code that
|
|
236
|
+
gathers candidates, makes one model call, and stocks the queue. Routing that
|
|
237
|
+
call through a DSH agent would buy conversation memory and an audit trail at
|
|
238
|
+
the cost of a session lifecycle (rotation, tool masking, disposal).
|
|
239
|
+
- **The model tier is stateless.** Each plan sends its own pool and gets its
|
|
240
|
+
picks back; there is no accumulated transcript, no context growth and no drift
|
|
241
|
+
from a previous mood — which is the property, not a limitation, for this job.
|
|
242
|
+
|
|
157
243
|
The model tier is optional and silent on failure; the DJ never blocks playback.
|
|
158
244
|
It only ever **stocks the queue** — it never starts or stops playback, so a
|
|
159
245
|
top-up while paused stays paused, and one that refills an empty queue after the
|
|
@@ -162,7 +248,7 @@ last track ended resumes on its own because the desired state was already
|
|
|
162
248
|
|
|
163
249
|
### Choosing the model
|
|
164
250
|
|
|
165
|
-
**In the player.** The right column carries a **AI DJ 模型 /
|
|
251
|
+
**In the player.** The right column carries a **AI DJ 模型 / DJ model**
|
|
166
252
|
panel: a provider picker, a model picker, and three buttons.
|
|
167
253
|
|
|
168
254
|
| Control | What it does |
|
|
@@ -201,21 +287,35 @@ control back to the patch.
|
|
|
201
287
|
Both names must be ones the host actually serves. `provider` is a registered
|
|
202
288
|
adapter route — read them from the `llm-pi-ai` entry's `config.providers` (or
|
|
203
289
|
ask the agent for `ctx.llm.listProviders()`); `model` is any id that route
|
|
204
|
-
accepts.
|
|
205
|
-
|
|
290
|
+
accepts.
|
|
291
|
+
|
|
292
|
+
**Pin it, or follow the session.** With both fields unset the DJ inherits
|
|
293
|
+
`agent-default-model` — the same selection the composer's model picker writes,
|
|
294
|
+
so it is the model the agent loop itself runs on — which is why "use my session
|
|
295
|
+
model" needs no configuration. Pinning `dj.provider`/`dj.model` curates on
|
|
296
|
+
something else instead, and pinning is what makes the two choices independent:
|
|
297
|
+
|
|
298
|
+
```yaml
|
|
299
|
+
- id: agent-default-model # the agent loop / session model
|
|
300
|
+
config: { provider: opencode-go, model: deepseek-v4.1-flash }
|
|
301
|
+
- id: music
|
|
302
|
+
config:
|
|
303
|
+
dj: # the DJ's own model, when you want a different one
|
|
304
|
+
provider: opencode-go
|
|
305
|
+
model: minimax-m3
|
|
306
|
+
```
|
|
206
307
|
|
|
207
308
|
Three things worth knowing:
|
|
208
309
|
|
|
209
310
|
- **`config` is replaced, not merged.** The loader assigns the whole object
|
|
210
311
|
(`entry.ts`: `this.options.config = value`), so if you also want, say,
|
|
211
312
|
`audioLevel`, keep every option in one block. Schema defaults fill the rest.
|
|
212
|
-
- **Discovery
|
|
213
|
-
|
|
214
|
-
(`listProviders()`),
|
|
215
|
-
entry. On this machine `opencode-go`'s catalogue is
|
|
313
|
+
- **Discovery is the last resort, and its choice is arbitrary.** Only when
|
|
314
|
+
neither a pin nor `agent-default-model` exists does the DJ ask the mounted LLM
|
|
315
|
+
service for its routes (`listProviders()`), take the first, then take that
|
|
316
|
+
route's first catalogue entry. On this machine `opencode-go`'s catalogue is
|
|
216
317
|
`minimax-m3, deepseek-v4-flash, gpt-5.6-luna`, so discovery curates with
|
|
217
|
-
**MiniMax-M3** — not a recommendation, just the first row.
|
|
218
|
-
is the only way to know which model is curating.
|
|
318
|
+
**MiniMax-M3** — not a recommendation, just the first row.
|
|
219
319
|
- **Partial pins are honoured.** A `provider` alone keeps that route and picks
|
|
220
320
|
its first catalogue model; a `model` alone keeps that model on the first
|
|
221
321
|
route.
|
|
@@ -224,7 +324,7 @@ Confirm it took effect:
|
|
|
224
324
|
|
|
225
325
|
| Where | What to look for |
|
|
226
326
|
|---|---|
|
|
227
|
-
| `/music/health` | `dj.model` is your pinned pair; `dj.modelSource` is `panel`, `config` or `discovered`; `dj.lastRoute` is the route that
|
|
327
|
+
| `/music/health` | `dj.model` is your pinned pair; `dj.modelSource` is `panel`, `config`, `agent-default` or `discovered`; `dj.resolvedFrom` is what the last plan actually used (`pin`, `agent-default`, `discovered`); `dj.sessionModel` is the deployment's own model; `dj.lastRoute` is the route that curated the last batch; `dj.sessionId` is the identity every call carries; `dj.modelError` is `null` once the tier works |
|
|
228
328
|
| Player, DJ status line | **AI DJ** plus the route (heuristic runs read **AI DJ (heuristic)**, and a declined tier prints the reason) |
|
|
229
329
|
| **测试 Test** in the player | Answers "does this route work?" in one click, without touching the queue |
|
|
230
330
|
| `music_dj` tool result | `AI DJ is on (model via opencode-go/deepseek-v4.1-flash)` |
|
|
@@ -237,6 +337,29 @@ the provider's own failure (a `LlmError` code such as `NO_ADAPTER`,
|
|
|
237
337
|
`MISSING_CREDENTIAL`, `AUTH`, `RATE_LIMIT`). The heuristic tier covers that batch
|
|
238
338
|
either way.
|
|
239
339
|
|
|
340
|
+
**If a provider says your usage does not follow its conventions**, the first
|
|
341
|
+
thing to check is the route's identity, not the request: pi-ai adds
|
|
342
|
+
`x-opencode-session` only for its own catalog routes `opencode-go` and
|
|
343
|
+
`opencode`. A hand-declared route id that merely points at
|
|
344
|
+
`https://opencode.ai/zen/go/v1` gets no wrapper and therefore no header, with
|
|
345
|
+
nothing in the logs to say so — keep the catalog route name. For the same
|
|
346
|
+
reason, do not set `x-opencode-session` in a profile's `headers`: the adapter
|
|
347
|
+
lets a configured value win over the generated one, which would freeze a single
|
|
348
|
+
id across every conversation. What remains after that is entitlement rather
|
|
349
|
+
than convention — a plan that only permits its own client's traffic cannot be
|
|
350
|
+
satisfied by a header, only by delegating transport to that client.
|
|
351
|
+
|
|
352
|
+
To see the bytes rather than trust the reasoning, run the bundled probe, point a
|
|
353
|
+
route's `baseURL` at it for one plan, and read what actually leaves the process:
|
|
354
|
+
|
|
355
|
+
```powershell
|
|
356
|
+
npm run probe:headers # http://127.0.0.1:8787 -> https://opencode.ai
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
You should see `x-opencode-session` carrying the DJ's id, and
|
|
360
|
+
`user-agent: deepseek-harness/<version> (+…)` — attribution that must never be
|
|
361
|
+
replaced by a provider-shaped one. Take the `baseURL` back out afterwards.
|
|
362
|
+
|
|
240
363
|
Editing `config` recomposes the running host, but **the plugin module itself is
|
|
241
364
|
cached** — restart the harness for the new route to take effect. If the model
|
|
242
365
|
tier fails for any reason (no adapter, bad credentials, unparseable reply,
|
|
@@ -263,6 +386,7 @@ add `config` to the inserted entry:
|
|
|
263
386
|
dj:
|
|
264
387
|
provider: opencode-go # enables the model tier
|
|
265
388
|
model: deepseek-v4.1-flash
|
|
389
|
+
sessionId: music-dj-mine # optional: pin the provider-visible identity
|
|
266
390
|
```
|
|
267
391
|
|
|
268
392
|
| Field | Default | Meaning |
|
|
@@ -271,7 +395,8 @@ add `config` to the inserted entry:
|
|
|
271
395
|
| `dataDir` | `$DSH_HOME/music` | Where `session.json` (cookie, history, feedback, settings) lives. |
|
|
272
396
|
| `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
397
|
| `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
|
|
398
|
+
| `dj.provider` / `dj.model` | unset | Model route for the DJ's model tier — see [Choosing the model](#choosing-the-model). Unset, the tier follows `agent-default-model` and only then falls back to discovery; any failure falls back to heuristics. |
|
|
399
|
+
| `dj.sessionId` | minted, persisted | The identity every model call carries. Adapters map it onto the provider's per-conversation header. Pin it to control what a provider sees, or leave it unset and let the DJ mint one per install. |
|
|
275
400
|
|
|
276
401
|
Player preferences (quality, DJ on/off, mood brief, batch size, extend
|
|
277
402
|
threshold) are persisted in `session.json` and edited from the panel.
|
|
@@ -294,6 +419,7 @@ browser (DSH web GUI, http://127.0.0.1:<port>)
|
|
|
294
419
|
├─ lib/session.js cookie store + QR login state machine
|
|
295
420
|
├─ lib/state.js queue, cursor, transport revisions
|
|
296
421
|
├─ lib/dj.js candidate pool + model/heuristic tiers
|
|
422
|
+
├─ lib/likes.js the account's like state, cached
|
|
297
423
|
└─ lib/router.js JSON API, HTML, Range-capable audio proxy
|
|
298
424
|
```
|
|
299
425
|
|
|
@@ -326,8 +452,10 @@ Four design notes:
|
|
|
326
452
|
|
|
327
453
|
- The `/music` route prefix is registered on the bare HTTP server, which owns no
|
|
328
454
|
authentication of its own. Anyone who can reach the port can browse the
|
|
329
|
-
library
|
|
330
|
-
|
|
455
|
+
library, stream audio, and **change the account's likes** — `POST
|
|
456
|
+
/music/api/taste` is the panel's own control, so it is a real write to the
|
|
457
|
+
NetEase account. The route is loopback-only in the shipped composition. It
|
|
458
|
+
exposes no credentials — only search results, the queue, the like state, and
|
|
331
459
|
audio bytes.
|
|
332
460
|
- The NetEase cookie lives in `$DSH_HOME/music/session.json` in plain text, the
|
|
333
461
|
same posture as the rest of the profile's session data. `music_login` with
|
|
@@ -352,6 +480,7 @@ when something looks wrong:
|
|
|
352
480
|
"music_dj", "music_now_playing", "music_login"],
|
|
353
481
|
"dj": { "enabled": false, "model": null, "lastRoute": null,
|
|
354
482
|
"modelError": null, "lastPlanAt": null, "error": null },
|
|
483
|
+
"likes": { "cached": 12, "pending": 0, "error": null },
|
|
355
484
|
"dataDir": "C:\\Users\\…\\.dsh\\music",
|
|
356
485
|
"session": { "authenticated": true, "nickname": "…", "vip": true },
|
|
357
486
|
"uptimeMs": 123456
|
|
@@ -361,7 +490,10 @@ when something looks wrong:
|
|
|
361
490
|
`tools` is the point: the HTTP route is registered before the tools, so a tool
|
|
362
491
|
that failed to register would otherwise leave a route that answers normally.
|
|
363
492
|
Seeing all seven names is what proves startup completed. `panelContract` is the
|
|
364
|
-
page/engine boundary the browser half checks before it starts playback.
|
|
493
|
+
page/engine boundary the browser half checks before it starts playback. `likes`
|
|
494
|
+
is the cache behind the hearts: `cached` is how many answers it holds, and
|
|
495
|
+
`error` names why a check produced none — an anonymous session never asks, so
|
|
496
|
+
both stay empty.
|
|
365
497
|
|
|
366
498
|
### If the browser blocks autoplay
|
|
367
499
|
|
|
@@ -397,8 +529,8 @@ down first. A restart brings both halves back into agreement.
|
|
|
397
529
|
|
|
398
530
|
```powershell
|
|
399
531
|
npm run check # node --check on every module
|
|
400
|
-
npm test #
|
|
401
|
-
npm run test:live #
|
|
532
|
+
npm test # 114 deterministic tests: pure, like state, DJ, browser half
|
|
533
|
+
npm run test:live # 33 integration tests against the live NetEase API
|
|
402
534
|
npm run test:all # both
|
|
403
535
|
```
|
|
404
536
|
|
|
@@ -414,16 +546,18 @@ network cases inside it.
|
|
|
414
546
|
Or run one file directly:
|
|
415
547
|
|
|
416
548
|
```powershell
|
|
417
|
-
node test/netease.test.mjs #
|
|
418
|
-
node test/
|
|
419
|
-
node test/
|
|
420
|
-
node test/
|
|
549
|
+
node test/netease.test.mjs # 32 pure: normalisation, quality ladder, likes, cookies, taste, player state
|
|
550
|
+
node test/likes.test.mjs # 12 like-state cache: what counts as an answer, refusals, batching, writes
|
|
551
|
+
node test/dj.test.mjs # 37 AI DJ: model call identity, route resolution, failure reporting, queue invariants
|
|
552
|
+
node test/client.test.mjs # 38 browser half: the engine against a fake DOM, and the page it pairs with
|
|
553
|
+
node test/host.test.mjs # 33 integration: routes, streaming, curation, quality, taste
|
|
421
554
|
```
|
|
422
555
|
|
|
423
|
-
The DJ tests drive
|
|
424
|
-
|
|
425
|
-
(`type`/`kind`), index filtering,
|
|
426
|
-
|
|
556
|
+
The DJ tests drive `ctx.llm.stream()` with a stub that emits the documented
|
|
557
|
+
chunks, and stub `ctx.agentDefaultModel` for route inheritance. So the
|
|
558
|
+
pinned-route happy path, both chunk spellings (`type`/`kind`), index filtering,
|
|
559
|
+
prose-wrapped JSON, the session identity every call must carry, inheritance from
|
|
560
|
+
the session model, discovery, and every fallback are covered without a provider.
|
|
427
561
|
|
|
428
562
|
The integration tests mount the plugin against stand-in `tools`/`webServer`
|
|
429
563
|
services and drive the captured route over a real `node:http` server, so they
|
|
@@ -439,7 +573,17 @@ component ever rendered**, so playback cannot depend on the Music page being
|
|
|
439
573
|
mounted. They also cover the seek handshake, failure reporting, disposal, and
|
|
440
574
|
the contract handshake.
|
|
441
575
|
|
|
442
|
-
|
|
576
|
+
The same fake DOM boots `lib/panel.html` itself, with an engine that reports
|
|
577
|
+
`playback: true` — the shell document owns the audio element. That is the case
|
|
578
|
+
where the page must fetch everything it renders on its own: lyrics used to be
|
|
579
|
+
requested only from the local fallback transport's `applySource`, so with the
|
|
580
|
+
engine playing the pane stayed empty for every track. Three tests hold the
|
|
581
|
+
line: the rendered track is asked for, one fetch per track rather than one per
|
|
582
|
+
poll, and a track change replaces the pane. Two more cover the pane's shape: it
|
|
583
|
+
is capped to a few lines, collapses to its header on demand, and remembers that
|
|
584
|
+
choice across loads — while still fetching the lines, so expanding is instant.
|
|
585
|
+
|
|
586
|
+
`npm test` runs the four deterministic files in sequence (`npm run test:all`
|
|
443
587
|
adds the live one), deliberately **not** `node --test <dir>`: the directory form forks one child process per file, which
|
|
444
588
|
is blocked in sandboxed environments.
|
|
445
589
|
|
|
@@ -462,6 +606,35 @@ is blocked in sandboxed environments.
|
|
|
462
606
|
[qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) by
|
|
463
607
|
Kazuhiko Arase, vendored so QR sign-in needs no network call or build step.
|
|
464
608
|
|
|
609
|
+
### Releasing
|
|
610
|
+
|
|
611
|
+
Releases are cut from GitHub and published by the `publish` workflow with **npm
|
|
612
|
+
trusted publishing (OIDC)**. There is no `NPM_TOKEN` secret and no
|
|
613
|
+
`NODE_AUTH_TOKEN` in the repository, and adding one would disable the OIDC
|
|
614
|
+
exchange and break publishing.
|
|
615
|
+
|
|
616
|
+
1. Bump `version` in `package.json`, commit, and push.
|
|
617
|
+
2. Create a GitHub release whose tag is `v<version>` — `v0.1.2` for `0.1.2` —
|
|
618
|
+
pointing at that commit.
|
|
619
|
+
|
|
620
|
+
The workflow runs `npm run check`, the deterministic suites, asserts the tag
|
|
621
|
+
matches `package.json`, and publishes. A tag push alone publishes nothing: the
|
|
622
|
+
trigger is `release: published`.
|
|
623
|
+
|
|
624
|
+
Two timings look like failures and are not:
|
|
625
|
+
|
|
626
|
+
- **A green job is not yet a live version.** The registry records a
|
|
627
|
+
`0.0.0-stage` placeholder first and reports the package as *being processed*;
|
|
628
|
+
the version becomes installable roughly a minute later. Inside that window the
|
|
629
|
+
packument still answers `404`, and publishing the same version from a second
|
|
630
|
+
place is refused with `409 Cannot publish over previously staged version`.
|
|
631
|
+
- **Registry reads can lag the publish**, so an `npm view` run immediately
|
|
632
|
+
afterwards may still say `404`. Wait a minute, or read it from CI instead.
|
|
633
|
+
|
|
634
|
+
Versions published this way carry a SLSA provenance attestation naming the
|
|
635
|
+
workflow, tag, and commit — `npm view @doitian/dsh-music@<version> dist.attestations`.
|
|
636
|
+
`0.1.0` predates the trusted publisher: it was published by hand and has none.
|
|
637
|
+
|
|
465
638
|
## Known limitations
|
|
466
639
|
|
|
467
640
|
- **Anonymous sessions cannot play VIP tracks**; NetEase returns `url: null`.
|
|
@@ -469,15 +642,22 @@ Kazuhiko Arase, vendored so QR sign-in needs no network call or build step.
|
|
|
469
642
|
a signed-in VIP account — 周杰伦's 晴天 (`id 186016`) is the canonical example,
|
|
470
643
|
because it belongs to a digital album. The plugin surfaces NetEase's own
|
|
471
644
|
refusal (`403` plus a reason) rather than retrying.
|
|
472
|
-
- **No `weapi`/`eapi` encryption**, so
|
|
473
|
-
|
|
474
|
-
|
|
645
|
+
- **No `weapi`/`eapi` encryption**, so scrobbling and playlist writes are not
|
|
646
|
+
implemented. Liking a track needs none of it — see
|
|
647
|
+
[Likes and taste](#likes-and-taste) — but a *dislike* stays local, because
|
|
648
|
+
there is no plain endpoint for one.
|
|
475
649
|
- **`apiPrefix` must stay `music`** unless `BASE` in `lib/client.js` is changed
|
|
476
650
|
to match.
|
|
477
|
-
- **The DJ's model tier is covered against
|
|
478
|
-
provider** —
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
651
|
+
- **The DJ's model tier is covered against stub contracts, not a live
|
|
652
|
+
provider** — request building, the session identity, route resolution, both
|
|
653
|
+
chunk spellings, index filtering and every fallback are tested, but a real
|
|
654
|
+
end-to-end model call has not been observed here. The heuristic tier is what
|
|
655
|
+
the live runs exercised.
|
|
656
|
+
- **The identity only becomes a header on the catalog routes that define one.**
|
|
657
|
+
pi-ai adds `x-opencode-session` for its own `opencode-go`/`opencode` routes. A
|
|
658
|
+
hand-declared route id pointing at the same endpoint gets no wrapper, so the
|
|
659
|
+
header is simply absent — keep the catalog route name, and never pin
|
|
660
|
+
`x-opencode-session` in a profile's `headers` (a configured value wins over
|
|
661
|
+
the generated one and freezes a single id for every conversation).
|
|
482
662
|
- **Chromium autoplay policy** may block playback the agent starts before the
|
|
483
663
|
user has interacted with the page; the panel then shows a *click to play* hint.
|
package/cordis.patch.yml
CHANGED
|
@@ -13,6 +13,20 @@
|
|
|
13
13
|
# requestTimeoutMs NetEase request deadline (default 15000).
|
|
14
14
|
# dj.provider model route for the AI DJ's model tier, e.g. opencode-go.
|
|
15
15
|
# dj.model exact model id, e.g. deepseek-v4.1-flash.
|
|
16
|
+
# dj.sessionId the identity every model call carries. A leaf call cannot
|
|
17
|
+
# set headers, so this string is the only identity the DJ
|
|
18
|
+
# can give a provider — and each adapter maps it onto that
|
|
19
|
+
# provider's own per-conversation header (pi-ai emits
|
|
20
|
+
# `x-opencode-session` for the `opencode-go` route). Left
|
|
21
|
+
# unset, one is minted on first use and persisted in
|
|
22
|
+
# session.json, so every plan and restart stays inside one
|
|
23
|
+
# conversation.
|
|
24
|
+
#
|
|
25
|
+
# Route resolution, highest first: the player's picker -> `dj.provider`/`dj.model`
|
|
26
|
+
# -> the deployment's own `agent-default-model` (the model the agent loop runs
|
|
27
|
+
# on, which is why "use my session model" needs no configuration) -> discovery,
|
|
28
|
+
# whose first row is arbitrary. The DJ is not an agent and starts none: each
|
|
29
|
+
# plan is one stateless leaf call, so there is no session to rotate or dispose.
|
|
16
30
|
#
|
|
17
31
|
# Without `dj.provider`/`dj.model` the DJ still works: it falls back to scoring
|
|
18
32
|
# similar songs, the daily recommendations and the charts by the listener's
|
package/lib/client.js
CHANGED
|
@@ -43,8 +43,34 @@ window.__ModuleLoader__.load({
|
|
|
43
43
|
const API = `${BASE}/api`;
|
|
44
44
|
/** Shared by the sidebar entry and the main panel it opens. */
|
|
45
45
|
const PANEL_ID = 'music';
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The locale namespace and its dictionary, registered with the host's
|
|
49
|
+
* `locale` service so the UI follows the Settings language. The service
|
|
50
|
+
* requires both shipped locales up front.
|
|
51
|
+
*/
|
|
52
|
+
const NS = 'music';
|
|
53
|
+
const DICTIONARY = {
|
|
54
|
+
zh: {
|
|
55
|
+
panel: '音乐',
|
|
56
|
+
iframeTitle: '网易云音乐播放器',
|
|
57
|
+
unreachableTitle: '无法连接到音乐插件',
|
|
58
|
+
unreachableBody: '音乐插件路由没有响应。请确认插件已在当前 profile 中启用,然后',
|
|
59
|
+
unreachableLink: '直接打开播放器',
|
|
60
|
+
unreachableSuffix: '。',
|
|
61
|
+
},
|
|
62
|
+
en: {
|
|
63
|
+
panel: 'Music',
|
|
64
|
+
iframeTitle: 'NetEase Cloud Music player',
|
|
65
|
+
unreachableTitle: 'Music is not reachable',
|
|
66
|
+
unreachableBody:
|
|
67
|
+
'The music plugin route did not answer. Check that the plugin is enabled in this profile, then ',
|
|
68
|
+
unreachableLink: 'open the player directly',
|
|
69
|
+
unreachableSuffix: '.',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
/** Bound in apply() once the dictionary is registered; English until then. */
|
|
73
|
+
let t = (key) => DICTIONARY.en[key] ?? key;
|
|
48
74
|
|
|
49
75
|
/** How often the engine re-reads the host state; bounds control latency. */
|
|
50
76
|
const POLL_MS = 500;
|
|
@@ -53,7 +79,7 @@ window.__ModuleLoader__.load({
|
|
|
53
79
|
const IDLE_REPORT_MS = 5000;
|
|
54
80
|
|
|
55
81
|
/** Services required before the slot registrations can be made. */
|
|
56
|
-
const inject = ['slots'];
|
|
82
|
+
const inject = ['slots', 'locale'];
|
|
57
83
|
|
|
58
84
|
/** Absolute URL helper; the shell page and the panel share one origin. */
|
|
59
85
|
function origin() {
|
|
@@ -357,13 +383,13 @@ window.__ModuleLoader__.load({
|
|
|
357
383
|
opacity: 0.75,
|
|
358
384
|
},
|
|
359
385
|
},
|
|
360
|
-
h('div', { style: { fontWeight: 650, marginBottom: 6 } }, '
|
|
386
|
+
h('div', { style: { fontWeight: 650, marginBottom: 6 } }, t('unreachableTitle')),
|
|
361
387
|
h(
|
|
362
388
|
'div',
|
|
363
389
|
null,
|
|
364
|
-
'
|
|
365
|
-
h('a', { href: panelUrl(), target: '_blank', rel: 'noreferrer' }, '
|
|
366
|
-
'
|
|
390
|
+
t('unreachableBody'),
|
|
391
|
+
h('a', { href: panelUrl(), target: '_blank', rel: 'noreferrer' }, t('unreachableLink')),
|
|
392
|
+
t('unreachableSuffix'),
|
|
367
393
|
),
|
|
368
394
|
);
|
|
369
395
|
}
|
|
@@ -390,7 +416,7 @@ window.__ModuleLoader__.load({
|
|
|
390
416
|
|
|
391
417
|
return h('iframe', {
|
|
392
418
|
src: panelUrl(),
|
|
393
|
-
title: '
|
|
419
|
+
title: t('iframeTitle'),
|
|
394
420
|
allow: 'autoplay; clipboard-write; encrypted-media',
|
|
395
421
|
style: {
|
|
396
422
|
width: '100%',
|
|
@@ -425,13 +451,19 @@ window.__ModuleLoader__.load({
|
|
|
425
451
|
);
|
|
426
452
|
}
|
|
427
453
|
|
|
428
|
-
/**
|
|
454
|
+
/**
|
|
455
|
+
* Register the sidebar entry and the page it opens.
|
|
456
|
+
*
|
|
457
|
+
* Both registrations carry `locale: NS` and resolve their text through
|
|
458
|
+
* `t` at render time, so the slot system re-renders them in place when
|
|
459
|
+
* the user switches the Harness language — no remount, no refresh.
|
|
460
|
+
*/
|
|
429
461
|
function registerUi(ctx) {
|
|
430
462
|
ctx.slots.inject('main', () =>
|
|
431
|
-
ctx.slots.register({ name: 'main', key: PANEL_ID }, MusicPanel),
|
|
463
|
+
ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, MusicPanel),
|
|
432
464
|
);
|
|
433
465
|
ctx.slots.inject('sidebar.panellist', () =>
|
|
434
|
-
ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 20, label:
|
|
466
|
+
ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 20, locale: NS, label: () => t('panel') }, MusicIcon),
|
|
435
467
|
);
|
|
436
468
|
}
|
|
437
469
|
|
|
@@ -447,6 +479,10 @@ window.__ModuleLoader__.load({
|
|
|
447
479
|
* @returns disposer stopping playback and withdrawing both registrations.
|
|
448
480
|
*/
|
|
449
481
|
async function apply(ctx) {
|
|
482
|
+
// The dictionary goes in before anything renders, and bound `t` reads
|
|
483
|
+
// the live snapshot, so a later language switch needs no rewiring.
|
|
484
|
+
const disposeLocale = ctx.locale.register(NS, DICTIONARY);
|
|
485
|
+
t = ctx.locale.bind(NS);
|
|
450
486
|
const ui = ctx.inject(['slots'], () => registerUi(ctx));
|
|
451
487
|
const engine = await createEngine();
|
|
452
488
|
// The panel is same-origin, so it can nudge the engine after a command.
|
|
@@ -454,6 +490,7 @@ window.__ModuleLoader__.load({
|
|
|
454
490
|
return () => {
|
|
455
491
|
ui?.dispose?.();
|
|
456
492
|
engine.dispose();
|
|
493
|
+
disposeLocale?.();
|
|
457
494
|
if (window.__dshMusicEngine === engine) delete window.__dshMusicEngine;
|
|
458
495
|
};
|
|
459
496
|
}
|