@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 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 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.
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
- ┌ 音乐 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
- └───────────────────────────┴────────────────────────────────────────┘
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 curator model uses. An unknown value in
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 模型 / curator model**
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. The simplest safe choice is to **mirror the session's own model**:
205
- whatever `agent-default-model` uses is known to work.
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 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
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. Pinning both fields
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 actually curated the last batch; `dj.modelError` is `null` once the tier works |
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 auto-discovers and falls back to heuristics on any failure. |
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 and stream audio; the route is loopback-only in the shipped
330
- composition. It exposes no credentials — only search results, the queue, and
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 # 65 deterministic tests: pure, DJ, browser half
401
- npm run test:live # 23 integration tests against the live NetEase API
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 # 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
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 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.
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
- `npm test` runs the three deterministic files in sequence (`npm run test:all`
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 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.
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 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.
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
- /** Visible label and collapsed-rail tooltip. */
47
- const LABEL = '音乐 Music';
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 } }, '音乐 Music is not reachable'),
386
+ h('div', { style: { fontWeight: 650, marginBottom: 6 } }, t('unreachableTitle')),
361
387
  h(
362
388
  'div',
363
389
  null,
364
- 'The music plugin route did not answer. Check that the plugin is enabled in this profile, then ',
365
- h('a', { href: panelUrl(), target: '_blank', rel: 'noreferrer' }, 'open the player directly'),
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: 'NetEase Cloud Music player',
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
- /** Register the sidebar entry and the page it opens. */
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: LABEL }, MusicIcon),
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
  }