@doitian/dsh-music 0.1.2 → 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,7 +2,7 @@
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 player — 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
8
  ┌──────────────────────────────────────────────────────────────────┐
@@ -144,6 +144,59 @@ Two implementation details worth knowing:
144
144
  `/music/health` reports both: `audio.preferred` and `audio.last` (what the last
145
145
  stream actually served, including `downgraded`).
146
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
+
147
200
  ## The AI DJ
148
201
 
149
202
  The DJ keeps the queue stocked. When the queue drops below
@@ -366,6 +419,7 @@ browser (DSH web GUI, http://127.0.0.1:<port>)
366
419
  ├─ lib/session.js cookie store + QR login state machine
367
420
  ├─ lib/state.js queue, cursor, transport revisions
368
421
  ├─ lib/dj.js candidate pool + model/heuristic tiers
422
+ ├─ lib/likes.js the account's like state, cached
369
423
  └─ lib/router.js JSON API, HTML, Range-capable audio proxy
370
424
  ```
371
425
 
@@ -398,8 +452,10 @@ Four design notes:
398
452
 
399
453
  - The `/music` route prefix is registered on the bare HTTP server, which owns no
400
454
  authentication of its own. Anyone who can reach the port can browse the
401
- library and stream audio; the route is loopback-only in the shipped
402
- 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
403
459
  audio bytes.
404
460
  - The NetEase cookie lives in `$DSH_HOME/music/session.json` in plain text, the
405
461
  same posture as the rest of the profile's session data. `music_login` with
@@ -424,6 +480,7 @@ when something looks wrong:
424
480
  "music_dj", "music_now_playing", "music_login"],
425
481
  "dj": { "enabled": false, "model": null, "lastRoute": null,
426
482
  "modelError": null, "lastPlanAt": null, "error": null },
483
+ "likes": { "cached": 12, "pending": 0, "error": null },
427
484
  "dataDir": "C:\\Users\\…\\.dsh\\music",
428
485
  "session": { "authenticated": true, "nickname": "…", "vip": true },
429
486
  "uptimeMs": 123456
@@ -433,7 +490,10 @@ when something looks wrong:
433
490
  `tools` is the point: the HTTP route is registered before the tools, so a tool
434
491
  that failed to register would otherwise leave a route that answers normally.
435
492
  Seeing all seven names is what proves startup completed. `panelContract` is the
436
- 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.
437
497
 
438
498
  ### If the browser blocks autoplay
439
499
 
@@ -469,8 +529,8 @@ down first. A restart brings both halves back into agreement.
469
529
 
470
530
  ```powershell
471
531
  npm run check # node --check on every module
472
- npm test # 76 deterministic tests: pure, DJ, browser half
473
- 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
474
534
  npm run test:all # both
475
535
  ```
476
536
 
@@ -486,10 +546,11 @@ network cases inside it.
486
546
  Or run one file directly:
487
547
 
488
548
  ```powershell
489
- node test/netease.test.mjs # 18 pure: normalisation, quality ladder, cookies, player state
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
490
551
  node test/dj.test.mjs # 37 AI DJ: model call identity, route resolution, failure reporting, queue invariants
491
- node test/client.test.mjs # 21 browser half: the engine against a fake DOM, and the page it pairs with
492
- node test/host.test.mjs # 23 integration: routes, streaming, curation, quality
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
493
554
  ```
494
555
 
495
556
  The DJ tests drive `ctx.llm.stream()` with a stub that emits the documented
@@ -522,7 +583,7 @@ poll, and a track change replaces the pane. Two more cover the pane's shape: it
522
583
  is capped to a few lines, collapses to its header on demand, and remembers that
523
584
  choice across loads — while still fetching the lines, so expanding is instant.
524
585
 
525
- `npm test` runs the three deterministic files in sequence (`npm run test:all`
586
+ `npm test` runs the four deterministic files in sequence (`npm run test:all`
526
587
  adds the live one), deliberately **not** `node --test <dir>`: the directory form forks one child process per file, which
527
588
  is blocked in sandboxed environments.
528
589
 
@@ -581,9 +642,10 @@ workflow, tag, and commit — `npm view @doitian/dsh-music@<version> dist.attest
581
642
  a signed-in VIP account — 周杰伦's 晴天 (`id 186016`) is the canonical example,
582
643
  because it belongs to a digital album. The plugin surfaces NetEase's own
583
644
  refusal (`403` plus a reason) rather than retrying.
584
- - **No `weapi`/`eapi` encryption**, so like/scrobble/playlist-write endpoints
585
- (which require it) are not implemented. Liking a track is recorded locally and
586
- 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.
587
649
  - **`apiPrefix` must stay `music`** unless `BASE` in `lib/client.js` is changed
588
650
  to match.
589
651
  - **The DJ's model tier is covered against stub contracts, not a live
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { Netease } from './netease.js';
22
22
  import { QrLogin, SessionStore, ANONYMOUS_COOKIE } from './session.js';
23
23
  import { Player } from './state.js';
24
24
  import { AiDj } from './dj.js';
25
+ import { LikeState } from './likes.js';
25
26
  import { MusicRouter } from './router.js';
26
27
 
27
28
  export const name = 'music';
@@ -129,6 +130,9 @@ export function apply(ctx, config = {}) {
129
130
 
130
131
  const qr = new QrLogin({ api, store, logger });
131
132
 
133
+ // The account owns its likes; this is the cache the panel's hearts read.
134
+ const likes = new LikeState({ api, logger });
135
+
132
136
  /**
133
137
  * The LLM service is optional: the DJ falls back to its heuristic tier when
134
138
  * absent, so the plugin still works in a composition without a model route.
@@ -190,10 +194,24 @@ export function apply(ctx, config = {}) {
190
194
  // ------------------------------------------------------------ session boot
191
195
  void api
192
196
  .fetchAccount()
193
- .then((account) => {
197
+ .then(async (account) => {
194
198
  if (account) logger.info(`[music] signed in as ${account.nickname}`);
195
199
  else if (api.authenticated) logger.warn('[music] session cookie present but the account check failed');
196
200
  else logger.info('[music] anonymous session (search and non-VIP playback only)');
201
+
202
+ // The account holds the likes; the local list is a copy the DJ reads.
203
+ // Reconciling it here keeps that copy honest across a like made on the
204
+ // phone, and migrates a list an earlier build recorded locally because it
205
+ // had no endpoint to send it to.
206
+ try {
207
+ const likedIds = await api.likedPlaylistIds(account?.uid);
208
+ if (likedIds) {
209
+ store.setLikedIds(likedIds);
210
+ logger.debug(`[music] like mirror reconciled with the account (${likedIds.length} track(s))`);
211
+ }
212
+ } catch (error) {
213
+ logger.warn(`[music] could not read the account's likes: ${error.message}`);
214
+ }
197
215
  })
198
216
  .catch((error) => logger.warn(`[music] account check failed: ${error.message}`));
199
217
 
@@ -223,6 +241,7 @@ export function apply(ctx, config = {}) {
223
241
  store,
224
242
  qr,
225
243
  dj,
244
+ likes,
226
245
  panelHtml: PANEL_HTML,
227
246
  qrcodeJs: QRCODE_JS,
228
247
  config,
@@ -252,6 +271,8 @@ export function apply(ctx, config = {}) {
252
271
  lastPlanAt: player.dj.lastPlanAt,
253
272
  error: player.dj.error,
254
273
  },
274
+ /** How much of the account's like state is cached, and why it is not. */
275
+ likes: likes.status(),
255
276
  dataDir,
256
277
  session: { authenticated: api.authenticated, nickname: api.account?.nickname ?? null, vip: Boolean(api.account?.vip) },
257
278
  uptimeMs: Math.round(process.uptime() * 1000),
package/lib/likes.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * NetEase's like state for the tracks the panel is showing.
3
+ *
4
+ * A like lives in the account, not in this plugin: `/api/song/like/check` is
5
+ * where the truth is, and this class is the cache in front of it. Four
6
+ * properties are deliberate:
7
+ *
8
+ * - **Unknown is not "not liked".** A track nobody has asked about has no
9
+ * answer; `isLiked` reads false for it, and `known` says which of the two
10
+ * it is. The panel renders a hollow heart either way, so the difference
11
+ * only matters to a caller that is about to write.
12
+ * - **A refusal is not an answer.** An anonymous session answers `code: 301`
13
+ * for every id. Caching that as "not liked" would leave every heart empty
14
+ * for the rest of the session, so a refused check caches nothing and is
15
+ * retried after a cool-off instead.
16
+ * - **A write is authoritative.** `set()` writes through to NetEase and then
17
+ * updates the cache, so the panel's next poll does not need a round trip to
18
+ * see the change it just made. It never claims a like the account does not
19
+ * have: a refused write leaves the cache untouched.
20
+ * - **Nothing here is persisted.** The account is the storage; a restart
21
+ * re-reads it, and a different account must not inherit the last one's
22
+ * answers, which is what `clear()` is for.
23
+ *
24
+ * @module dsh-music/likes
25
+ */
26
+
27
+ /** How long one answer is trusted before it is asked again. */
28
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
29
+
30
+ /** Ids per `/api/song/like/check` request; it takes a list, but not a URL. */
31
+ const CHECK_BATCH = 100;
32
+
33
+ /** How long a refused or failed check waits before trying again. */
34
+ const RETRY_AFTER_MS = 30_000;
35
+
36
+ export class LikeState {
37
+ /**
38
+ * @param {object} options
39
+ * @param {import('./netease.js').Netease} options.api
40
+ * @param {{warn: Function, info: Function, debug: Function}} [options.logger]
41
+ * @param {number} [options.ttlMs] how long one answer stays fresh.
42
+ * @param {number} [options.batchSize] ids per check request.
43
+ */
44
+ constructor({ api, logger, ttlMs = DEFAULT_TTL_MS, batchSize = CHECK_BATCH } = {}) {
45
+ this.api = api;
46
+ this.logger = logger;
47
+ this.ttlMs = ttlMs;
48
+ this.batchSize = batchSize;
49
+ /** @type {Map<number, {liked: boolean, at: number}>} */
50
+ this.answers = new Map();
51
+ /** @type {Map<number, Promise<void>>} in-flight checks, so polls share one. */
52
+ this.pending = new Map();
53
+ /** Earliest time a refused check may be attempted again. */
54
+ this.retryAt = 0;
55
+ /** Why the last check did not produce answers, for `/music/health`. */
56
+ this.error = null;
57
+ }
58
+
59
+ /** The cached answer; false when there is none. See {@link known}. */
60
+ isLiked(id) {
61
+ return this.answers.get(Number(id))?.liked === true;
62
+ }
63
+
64
+ /** Whether a fresh answer for this track is cached — either way. */
65
+ known(id) {
66
+ const entry = this.answers.get(Number(id));
67
+ return Boolean(entry) && Date.now() - entry.at < this.ttlMs;
68
+ }
69
+
70
+ /** Forget every answer. A session change invalidates all of them. */
71
+ clear() {
72
+ this.answers.clear();
73
+ this.pending.clear();
74
+ this.retryAt = 0;
75
+ this.error = null;
76
+ return this;
77
+ }
78
+
79
+ /**
80
+ * Resolve the like state of `ids`, asking NetEase only about the tracks with
81
+ * no fresh answer. Never throws: a like check must not fail a poll, and an
82
+ * unresolved id simply stays unknown.
83
+ */
84
+ async ensure(ids) {
85
+ // Nothing can be liked on an anonymous session, so there is nothing to ask.
86
+ if (!this.api?.authenticated) return;
87
+ const list = [...new Set([...ids].map(Number).filter(Number.isFinite))];
88
+ const stale = list.filter((id) => !this.known(id) && !this.pending.has(id));
89
+ if (stale.length === 0) return;
90
+ if (Date.now() < this.retryAt) return;
91
+
92
+ const work = this.#load(stale).finally(() => {
93
+ for (const id of stale) this.pending.delete(id);
94
+ });
95
+ for (const id of stale) this.pending.set(id, work);
96
+ await work;
97
+ }
98
+
99
+ /** Ask for `ids` in batches and cache every answer in them. */
100
+ async #load(ids) {
101
+ for (let offset = 0; offset < ids.length; offset += this.batchSize) {
102
+ const chunk = ids.slice(offset, offset + this.batchSize);
103
+ let result;
104
+ try {
105
+ result = await this.api.likedIds(chunk);
106
+ } catch (error) {
107
+ this.#coolOff(`like check failed: ${error.message}`);
108
+ return;
109
+ }
110
+ if (!result.ok) {
111
+ // Not an answer: cache nothing, so signing in or a retry can still
112
+ // resolve these tracks rather than freezing them as unliked.
113
+ this.#coolOff(`like check refused (code ${result.code}): ${result.reason}`);
114
+ return;
115
+ }
116
+ const liked = new Set(result.ids);
117
+ const at = Date.now();
118
+ for (const id of chunk) this.answers.set(id, { liked: liked.has(id), at });
119
+ this.error = null;
120
+ }
121
+ }
122
+
123
+ #coolOff(reason) {
124
+ this.retryAt = Date.now() + RETRY_AFTER_MS;
125
+ this.error = reason;
126
+ this.logger?.debug?.(`[music] ${reason}`);
127
+ }
128
+
129
+ /**
130
+ * Like or un-like one track on NetEase.
131
+ *
132
+ * The account is written first and the cache only follows a confirmed change,
133
+ * so the panel can never show a heart the account would contradict.
134
+ *
135
+ * @param {number|string} trackId
136
+ * @param {boolean} liked
137
+ * @returns {Promise<{ok: boolean, id: number, liked?: boolean, code?: number|string, reason?: string}>}
138
+ */
139
+ async set(trackId, liked) {
140
+ const id = Number(trackId);
141
+ if (!Number.isFinite(id)) {
142
+ return { ok: false, id: trackId, code: 'BAD_ID', reason: `invalid track id "${trackId}"` };
143
+ }
144
+ if (!this.api?.authenticated) {
145
+ return { ok: false, id, code: 'ANONYMOUS', reason: 'the NetEase session is anonymous' };
146
+ }
147
+ let result;
148
+ try {
149
+ result = await this.api.likeSong(id, liked);
150
+ } catch (error) {
151
+ return { ok: false, id, code: 'NETWORK', reason: error.message };
152
+ }
153
+ if (!result.ok) return result;
154
+ this.answers.set(id, { liked: Boolean(liked), at: Date.now() });
155
+ this.error = null;
156
+ return result;
157
+ }
158
+
159
+ /** What the cache holds, for `/music/health`. */
160
+ status() {
161
+ return {
162
+ cached: this.answers.size,
163
+ pending: this.pending.size,
164
+ error: this.error,
165
+ };
166
+ }
167
+ }
168
+
169
+ export { CHECK_BATCH, DEFAULT_TTL_MS, RETRY_AFTER_MS };
package/lib/netease.js CHANGED
@@ -14,6 +14,8 @@
14
14
  * - `api/toplist` anonymous OK
15
15
  * - `api/discovery/simiSong` anonymous OK
16
16
  * - `api/personalized/newsong` anonymous OK
17
+ * - `api/song/like` POST: like / un-like, needs a session
18
+ * - `api/song/like/check` which of these ids the account liked
17
19
  * - `api/login/qrcode/*` QR login handshake
18
20
  *
19
21
  * @module dsh-music/netease
@@ -544,6 +546,78 @@ export class Netease {
544
546
  return normalizeTracks(data?.data);
545
547
  }
546
548
 
549
+ // ------------------------------------------------------------------ likes
550
+
551
+ /**
552
+ * Like or un-like one track on the account.
553
+ *
554
+ * `/api/song/like` is one of the few *write* endpoints the plain web API
555
+ * still serves unencrypted — it answers `{playlistId, code: 200}` and puts the
556
+ * track in (or takes it out of) the account's 我喜欢的音乐 playlist. It takes
557
+ * the direction rather than toggling, so the caller must know which one it
558
+ * means; ask {@link likedIds} when it does not.
559
+ *
560
+ * A refusal is reported, not thrown: an anonymous session answers `code: 301`
561
+ * and a delisted track answers `code: 400` with NetEase's own reason
562
+ * ("歌曲已经下架了"), and both are ordinary outcomes of a click. Only a
563
+ * transport failure throws.
564
+ *
565
+ * @param {number|string} id track id.
566
+ * @param {boolean} like `true` to like, `false` to remove the like.
567
+ */
568
+ async likeSong(id, like = true) {
569
+ const trackId = Number(id);
570
+ if (!Number.isFinite(trackId)) {
571
+ return { ok: false, id, code: 'BAD_ID', reason: `invalid track id "${id}"` };
572
+ }
573
+ const data = await this.call('/api/song/like', {
574
+ method: 'POST',
575
+ query: { trackId, like: Boolean(like) },
576
+ });
577
+ if (Number(data?.code) !== 200) {
578
+ return {
579
+ ok: false,
580
+ id: trackId,
581
+ code: data?.code ?? 'UNKNOWN',
582
+ reason:
583
+ data?.message ||
584
+ data?.msg ||
585
+ (this.authenticated ? 'NetEase refused the change' : 'sign in to NetEase first'),
586
+ };
587
+ }
588
+ return { ok: true, id: trackId, liked: Boolean(like), playlistId: data?.playlistId ?? null };
589
+ }
590
+
591
+ /**
592
+ * Which of these tracks the account has liked.
593
+ *
594
+ * `/api/song/like/check` answers `{ids: [...the liked subset...]}`, so one
595
+ * request covers a whole queue. An anonymous session answers `code: 301` for
596
+ * every id — a refusal, not "nothing is liked" — and comes back as
597
+ * `ok: false` so a caller never caches it as an answer.
598
+ *
599
+ * @param {Iterable<number|string>} ids
600
+ * @returns {Promise<{ok: boolean, ids: number[], code?: number|string, reason?: string}>}
601
+ */
602
+ async likedIds(ids) {
603
+ const list = [...new Set([...ids].map(Number).filter(Number.isFinite))];
604
+ // NetEase answers an empty batch with `code: 400`, and an empty question has
605
+ // no answer to fetch anyway.
606
+ if (list.length === 0) return { ok: true, ids: [] };
607
+ const data = await this.call('/api/song/like/check', {
608
+ query: { trackIds: JSON.stringify(list) },
609
+ });
610
+ if (Number(data?.code) !== 200 || !Array.isArray(data?.ids)) {
611
+ return {
612
+ ok: false,
613
+ ids: [],
614
+ code: data?.code ?? 'UNKNOWN',
615
+ reason: data?.message || data?.msg || 'NetEase refused the like check',
616
+ };
617
+ }
618
+ return { ok: true, ids: data.ids.map(Number) };
619
+ }
620
+
547
621
  // -------------------------------------------------------------- account
548
622
 
549
623
  /** Account summary; `null` when the session is anonymous or expired. */
@@ -577,9 +651,32 @@ export class Netease {
577
651
  cover: resizeImage(item.coverImgUrl, 300),
578
652
  trackCount: item.trackCount ?? 0,
579
653
  subscribed: Boolean(item.subscribed),
654
+ /** `5` marks 我喜欢的音乐 — the account's likes, as a playlist. */
655
+ specialType: item.specialType ?? 0,
580
656
  }));
581
657
  }
582
658
 
659
+ /**
660
+ * Every track id in the account's 我喜欢的音乐 playlist, or `null` when there
661
+ * is no account or no such playlist.
662
+ *
663
+ * This is the whole like state in one read, which is what makes it useful for
664
+ * reconciling a local mirror; use {@link likedIds} to ask about specific
665
+ * tracks. The playlist's length is not capped by `n` — NetEase returns the
666
+ * complete `trackIds` list — so one request covers a library of any size.
667
+ *
668
+ * @param {number|string} [uid] owner; defaults to the fetched account.
669
+ */
670
+ async likedPlaylistIds(uid) {
671
+ const owner = Number(uid ?? this.account?.uid);
672
+ if (!Number.isFinite(owner)) return null;
673
+ const playlists = await this.userPlaylists(owner, { limit: 1000 });
674
+ const liked = playlists.find((playlist) => playlist.specialType === 5);
675
+ if (!liked) return null;
676
+ const detail = await this.call('/api/v6/playlist/detail', { query: { id: liked.id, n: 1 } });
677
+ return (detail?.playlist?.trackIds ?? []).map((entry) => entry.id).filter(Number.isFinite);
678
+ }
679
+
583
680
  // ------------------------------------------------------------ QR login
584
681
 
585
682
  /**