@doitian/dsh-music 0.1.2 → 0.1.5

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/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.
@@ -183,6 +187,14 @@ export function apply(ctx, config = {}) {
183
187
  },
184
188
  });
185
189
  player.onLowQueue = () => dj.topUp();
190
+ player.onSkip = (track) => {
191
+ // The panel's ✕ records the dislike and then presses next, so an early
192
+ // dislike would also count as a skip — but only inside the skip window,
193
+ // which would make the same judgement weigh differently by when it came.
194
+ if (store.taste(track.id) === 'disliked') return;
195
+ store.recordSkip(track.id);
196
+ logger.debug(`[music] skip recorded for ${track.id}`);
197
+ };
186
198
 
187
199
  /** Tool names that registered successfully; surfaced by `/music/health`. */
188
200
  const registeredTools = [];
@@ -190,10 +202,24 @@ export function apply(ctx, config = {}) {
190
202
  // ------------------------------------------------------------ session boot
191
203
  void api
192
204
  .fetchAccount()
193
- .then((account) => {
205
+ .then(async (account) => {
194
206
  if (account) logger.info(`[music] signed in as ${account.nickname}`);
195
207
  else if (api.authenticated) logger.warn('[music] session cookie present but the account check failed');
196
208
  else logger.info('[music] anonymous session (search and non-VIP playback only)');
209
+
210
+ // The account holds the likes; the local list is a copy the DJ reads.
211
+ // Reconciling it here keeps that copy honest across a like made on the
212
+ // phone, and migrates a list an earlier build recorded locally because it
213
+ // had no endpoint to send it to.
214
+ try {
215
+ const likedIds = await api.likedPlaylistIds(account?.uid);
216
+ if (likedIds) {
217
+ store.setLikedIds(likedIds);
218
+ logger.debug(`[music] like mirror reconciled with the account (${likedIds.length} track(s))`);
219
+ }
220
+ } catch (error) {
221
+ logger.warn(`[music] could not read the account's likes: ${error.message}`);
222
+ }
197
223
  })
198
224
  .catch((error) => logger.warn(`[music] account check failed: ${error.message}`));
199
225
 
@@ -223,6 +249,7 @@ export function apply(ctx, config = {}) {
223
249
  store,
224
250
  qr,
225
251
  dj,
252
+ likes,
226
253
  panelHtml: PANEL_HTML,
227
254
  qrcodeJs: QRCODE_JS,
228
255
  config,
@@ -252,6 +279,8 @@ export function apply(ctx, config = {}) {
252
279
  lastPlanAt: player.dj.lastPlanAt,
253
280
  error: player.dj.error,
254
281
  },
282
+ /** How much of the account's like state is cached, and why it is not. */
283
+ likes: likes.status(),
255
284
  dataDir,
256
285
  session: { authenticated: api.authenticated, nickname: api.account?.nickname ?? null, vip: Boolean(api.account?.vip) },
257
286
  uptimeMs: Math.round(process.uptime() * 1000),
@@ -482,8 +511,8 @@ export function apply(ctx, config = {}) {
482
511
  title: 'AI DJ (NetEase Cloud Music)',
483
512
  description:
484
513
  'Run or configure the continuous AI DJ for the user\'s NetEase Cloud Music panel. ' +
485
- 'With `enabled: true` the DJ keeps the queue stocked: it gathers candidates from similar songs, the daily ' +
486
- 'recommendations, the charts and any mood brief, then either asks the model to choose or scores them by the ' +
514
+ 'With `enabled: true` the DJ keeps the queue stocked: it gathers candidates from similar songs, personal FM, ' +
515
+ 'liked songs not heard lately, the daily recommendations, the charts and any mood brief, then either asks the model to choose or scores them by the ' +
487
516
  'listener\'s liked artists, novelty and feedback. Pass `prompt` for a mood or vibe, and `replan` to refill now.',
488
517
  parameters: {
489
518
  type: 'object',
@@ -492,7 +521,10 @@ export function apply(ctx, config = {}) {
492
521
  enabled: { type: 'boolean', description: 'Turn continuous queue topping-up on or off.' },
493
522
  prompt: { type: 'string', description: 'Mood or request, e.g. "rainy afternoon jazz", "90s cantopop".' },
494
523
  replan: { type: 'boolean', description: 'Plan and queue a fresh batch immediately.' },
495
- count: { type: 'integer', description: 'How many tracks to plan (1-25, default 8 for `replan`).' },
524
+ count: {
525
+ type: 'integer',
526
+ description: 'How many tracks to plan (1-25; default 8 for `start`, the player\'s batch size for `replan`).',
527
+ },
496
528
  start: { type: 'boolean', description: 'Replace the queue with a freshly planned batch (a full DJ session).' },
497
529
  },
498
530
  },
@@ -503,6 +535,7 @@ export function apply(ctx, config = {}) {
503
535
  `AI DJ is ${value.enabled ? 'on' : 'off'} (${value.source}${value.route ? ` via ${value.route}` : ''}). ` +
504
536
  (value.modelError ? `Model tier unavailable: ${value.modelError}. ` : '') +
505
537
  (value.vibe ? `Vibe: ${value.vibe}. ` : '') +
538
+ (value.searchedAs.length ? `Brief searched as: ${value.searchedAs.join('; ')}. ` : '') +
506
539
  (value.names.length
507
540
  ? `Queued ${value.added} track(s):\n${value.names.map((entry, index) => `${index + 1}. ${entry}`).join('\n')}`
508
541
  : value.note || 'No new tracks were queued.') +
@@ -519,7 +552,7 @@ export function apply(ctx, config = {}) {
519
552
  if (args.start) {
520
553
  plan = await dj.start({ prompt, count });
521
554
  } else if (args.replan || (enabled === true && player.queue.length === 0)) {
522
- plan = await dj.topUp({ force: true });
555
+ plan = await dj.topUp({ force: true, count: args.count === undefined ? undefined : count });
523
556
  }
524
557
 
525
558
  const summary = player.summary();
@@ -529,6 +562,7 @@ export function apply(ctx, config = {}) {
529
562
  route: plan?.route ?? summary.dj.lastPlan?.route ?? null,
530
563
  modelError: player.dj.modelError ?? null,
531
564
  vibe: plan?.vibe ?? summary.dj.lastPlan?.vibe ?? '',
565
+ searchedAs: plan?.searchedAs ?? summary.dj.lastPlan?.searchedAs ?? [],
532
566
  note: plan?.note ?? (plan && plan.tracks.length === 0 ? 'no candidates were available' : ''),
533
567
  added: plan?.tracks.length ?? 0,
534
568
  names: (plan?.tracks ?? []).map((track) => `${track.name} — ${(track.artists ?? []).join('/')}`),
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
@@ -370,6 +372,21 @@ export class Netease {
370
372
  return data?.result ?? {};
371
373
  }
372
374
 
375
+ /**
376
+ * Playlists matching a keyword. Listeners name and tag playlists by mood
377
+ * (雨天, 爵士, 深夜…), so this is how a mood finds music, where a song search
378
+ * only matches titles and lyrics.
379
+ */
380
+ async searchPlaylists(keyword, { limit = 10 } = {}) {
381
+ const result = await this.search(keyword, { type: 1000, limit });
382
+ return (result.playlists ?? []).map((item) => ({
383
+ id: item.id,
384
+ name: item.name,
385
+ trackCount: item.trackCount ?? 0,
386
+ playCount: item.playCount ?? 0,
387
+ }));
388
+ }
389
+
373
390
  /** Track metadata, including cover art and the VIP fee code. */
374
391
  async songDetail(ids) {
375
392
  const list = [...ids].map(Number).filter(Number.isFinite);
@@ -474,6 +491,12 @@ export class Netease {
474
491
  playCount: playlist.playCount ?? 0,
475
492
  creator: playlist.creator?.nickname ?? '',
476
493
  tracks: normalizeTracks(playlist.tracks),
494
+ /**
495
+ * Every track id, in order. For a playlist the account does not own,
496
+ * `tracks` stops at the first 20 while this list is complete, so it is
497
+ * the only way to reach the rest.
498
+ */
499
+ trackIds: (playlist.trackIds ?? []).map((entry) => entry.id).filter(Number.isFinite),
477
500
  };
478
501
  }
479
502
 
@@ -522,6 +545,18 @@ export class Netease {
522
545
  return normalizeTracks(result?.dailySongs ?? result?.recommend);
523
546
  }
524
547
 
548
+ /**
549
+ * 私人FM (personal FM): a few tracks NetEase picks for this account, a
550
+ * different few on every call. Requires a session.
551
+ *
552
+ * This is the personalised feed the plain API still serves; heartbeat mode
553
+ * ({@link intelligenceList}) answers `code 500` there for every seed.
554
+ */
555
+ async personalFm() {
556
+ const data = await this.call('/api/v1/radio/get');
557
+ return normalizeTracks(data?.data);
558
+ }
559
+
525
560
  /**
526
561
  * 心动模式 (heartbeat mode): a personalised continuation of a seed track
527
562
  * inside a playlist context. Requires a session.
@@ -544,6 +579,78 @@ export class Netease {
544
579
  return normalizeTracks(data?.data);
545
580
  }
546
581
 
582
+ // ------------------------------------------------------------------ likes
583
+
584
+ /**
585
+ * Like or un-like one track on the account.
586
+ *
587
+ * `/api/song/like` is one of the few *write* endpoints the plain web API
588
+ * still serves unencrypted — it answers `{playlistId, code: 200}` and puts the
589
+ * track in (or takes it out of) the account's 我喜欢的音乐 playlist. It takes
590
+ * the direction rather than toggling, so the caller must know which one it
591
+ * means; ask {@link likedIds} when it does not.
592
+ *
593
+ * A refusal is reported, not thrown: an anonymous session answers `code: 301`
594
+ * and a delisted track answers `code: 400` with NetEase's own reason
595
+ * ("歌曲已经下架了"), and both are ordinary outcomes of a click. Only a
596
+ * transport failure throws.
597
+ *
598
+ * @param {number|string} id track id.
599
+ * @param {boolean} like `true` to like, `false` to remove the like.
600
+ */
601
+ async likeSong(id, like = true) {
602
+ const trackId = Number(id);
603
+ if (!Number.isFinite(trackId)) {
604
+ return { ok: false, id, code: 'BAD_ID', reason: `invalid track id "${id}"` };
605
+ }
606
+ const data = await this.call('/api/song/like', {
607
+ method: 'POST',
608
+ query: { trackId, like: Boolean(like) },
609
+ });
610
+ if (Number(data?.code) !== 200) {
611
+ return {
612
+ ok: false,
613
+ id: trackId,
614
+ code: data?.code ?? 'UNKNOWN',
615
+ reason:
616
+ data?.message ||
617
+ data?.msg ||
618
+ (this.authenticated ? 'NetEase refused the change' : 'sign in to NetEase first'),
619
+ };
620
+ }
621
+ return { ok: true, id: trackId, liked: Boolean(like), playlistId: data?.playlistId ?? null };
622
+ }
623
+
624
+ /**
625
+ * Which of these tracks the account has liked.
626
+ *
627
+ * `/api/song/like/check` answers `{ids: [...the liked subset...]}`, so one
628
+ * request covers a whole queue. An anonymous session answers `code: 301` for
629
+ * every id — a refusal, not "nothing is liked" — and comes back as
630
+ * `ok: false` so a caller never caches it as an answer.
631
+ *
632
+ * @param {Iterable<number|string>} ids
633
+ * @returns {Promise<{ok: boolean, ids: number[], code?: number|string, reason?: string}>}
634
+ */
635
+ async likedIds(ids) {
636
+ const list = [...new Set([...ids].map(Number).filter(Number.isFinite))];
637
+ // NetEase answers an empty batch with `code: 400`, and an empty question has
638
+ // no answer to fetch anyway.
639
+ if (list.length === 0) return { ok: true, ids: [] };
640
+ const data = await this.call('/api/song/like/check', {
641
+ query: { trackIds: JSON.stringify(list) },
642
+ });
643
+ if (Number(data?.code) !== 200 || !Array.isArray(data?.ids)) {
644
+ return {
645
+ ok: false,
646
+ ids: [],
647
+ code: data?.code ?? 'UNKNOWN',
648
+ reason: data?.message || data?.msg || 'NetEase refused the like check',
649
+ };
650
+ }
651
+ return { ok: true, ids: data.ids.map(Number) };
652
+ }
653
+
547
654
  // -------------------------------------------------------------- account
548
655
 
549
656
  /** Account summary; `null` when the session is anonymous or expired. */
@@ -577,9 +684,32 @@ export class Netease {
577
684
  cover: resizeImage(item.coverImgUrl, 300),
578
685
  trackCount: item.trackCount ?? 0,
579
686
  subscribed: Boolean(item.subscribed),
687
+ /** `5` marks 我喜欢的音乐 — the account's likes, as a playlist. */
688
+ specialType: item.specialType ?? 0,
580
689
  }));
581
690
  }
582
691
 
692
+ /**
693
+ * Every track id in the account's 我喜欢的音乐 playlist, or `null` when there
694
+ * is no account or no such playlist.
695
+ *
696
+ * This is the whole like state in one read, which is what makes it useful for
697
+ * reconciling a local mirror; use {@link likedIds} to ask about specific
698
+ * tracks. The playlist's length is not capped by `n` — NetEase returns the
699
+ * complete `trackIds` list — so one request covers a library of any size.
700
+ *
701
+ * @param {number|string} [uid] owner; defaults to the fetched account.
702
+ */
703
+ async likedPlaylistIds(uid) {
704
+ const owner = Number(uid ?? this.account?.uid);
705
+ if (!Number.isFinite(owner)) return null;
706
+ const playlists = await this.userPlaylists(owner, { limit: 1000 });
707
+ const liked = playlists.find((playlist) => playlist.specialType === 5);
708
+ if (!liked) return null;
709
+ const detail = await this.call('/api/v6/playlist/detail', { query: { id: liked.id, n: 1 } });
710
+ return (detail?.playlist?.trackIds ?? []).map((entry) => entry.id).filter(Number.isFinite);
711
+ }
712
+
583
713
  // ------------------------------------------------------------ QR login
584
714
 
585
715
  /**