@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/lib/dj.js CHANGED
@@ -8,6 +8,14 @@
8
8
  * candidate pool plus a taste digest go to `ctx.llm.stream()`, and the
9
9
  * model returns the picks and a one-line vibe. This is what makes the DJ
10
10
  * reason about a mood brief rather than only follow similarity edges.
11
+ *
12
+ * The call carries a **session id** ({@link curationSessionId}). A plugin
13
+ * cannot set headers on a leaf call — `GenerateOptions` has no `headers`
14
+ * field — so the session id is the only identity lever it has, and each
15
+ * adapter maps it onto whatever its provider calls a per-conversation
16
+ * header (pi-ai emits `x-opencode-session` for the `opencode-go` route).
17
+ * Without it the request reaches such a provider looking like an anonymous
18
+ * client that ignores its conventions.
11
19
  * 2. **Heuristic tier** — always available, no model required: NetEase's own
12
20
  * signals (similar songs, daily recommendations, anonymous new-song feed,
13
21
  * charts, keyword search) scored by artist affinity, novelty, and the
@@ -20,6 +28,8 @@
20
28
  * @module dsh-music/dj
21
29
  */
22
30
 
31
+ import { randomUUID } from 'node:crypto';
32
+
23
33
  /**
24
34
  * Render a model-tier failure so its stable code survives.
25
35
  *
@@ -47,6 +57,59 @@ const MAX_POOL = 45;
47
57
  /** Only the first `MAX_SEEDS` history entries seed similarity expansion. */
48
58
  const MAX_SEEDS = 4;
49
59
 
60
+ /** Prefix for the minted curation identity, so logs and health read clearly. */
61
+ const SESSION_ID_PREFIX = 'music-dj-';
62
+
63
+ /**
64
+ * The provider-visible identity of the DJ's curation conversation.
65
+ *
66
+ * Minted once and persisted, so every plan of one installation is the *same*
67
+ * conversation to the provider: session affinity and prompt caching work, and a
68
+ * restart does not look like a new client. `config.dj.sessionId` overrides it,
69
+ * and a store that cannot be written still yields a usable in-memory id.
70
+ *
71
+ * @param {import('./session.js').SessionStore} [store] the plugin's store.
72
+ * @param {string} [configured] an operator-supplied id.
73
+ * @returns {string} a non-empty session id.
74
+ */
75
+ export function curationSessionId(store, configured) {
76
+ if (typeof configured === 'string' && configured.length > 0) return configured;
77
+ const persisted = store?.settings?.djSessionId;
78
+ if (typeof persisted === 'string' && persisted.length > 0) return persisted;
79
+ const minted = `${SESSION_ID_PREFIX}${randomUUID()}`;
80
+ try {
81
+ store?.updateSettings?.({ djSessionId: minted });
82
+ } catch {
83
+ // A read-only store costs stability across restarts, not correctness now.
84
+ }
85
+ return minted;
86
+ }
87
+
88
+ /**
89
+ * Build one model-tier request.
90
+ *
91
+ * The guard is the point: a leaf call with no `sessionId` still succeeds against
92
+ * a permissive provider, so the failure this prevents is silent — a request
93
+ * that quietly stops following the provider's conventions. Throwing here turns
94
+ * it into a recorded `dj.modelError` instead. No headers are set, because a
95
+ * leaf call cannot set any.
96
+ *
97
+ * @param {object} options
98
+ * @param {{provider: string, model: string}} options.target the resolved route.
99
+ * @param {object[]} options.messages the prompt.
100
+ * @param {string} options.sessionId the provider-visible conversation identity.
101
+ * @returns {object} the request to hand to `ctx.llm.stream()`.
102
+ */
103
+ export function curationRequest({ target, messages, sessionId }) {
104
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
105
+ throw new Error(
106
+ 'the DJ model call needs a non-empty sessionId: it is the only identity a leaf call can ' +
107
+ "carry, and the adapter maps it onto the provider's per-conversation header",
108
+ );
109
+ }
110
+ return { ...target, messages, sessionId };
111
+ }
112
+
50
113
  /**
51
114
  * @typedef {object} Plan
52
115
  * @property {object[]} tracks chosen tracks, in play order.
@@ -63,16 +126,41 @@ export class AiDj {
63
126
  * @param {import('./state.js').Player} options.player
64
127
  * @param {import('./session.js').SessionStore} options.store
65
128
  * @param {() => object | undefined} [options.resolveLlm] returns the LLM service when mounted.
129
+ * @param {() => {provider: string, model: string, reasoningEffort?: string} | null} [options.readAgentDefault]
130
+ * reads the deployment's default model selection — the model a fresh agent
131
+ * starts on, which is the same choice the composer picker writes.
132
+ * @param {string} [options.sessionId] configured curation identity, overriding the minted one.
66
133
  * @param {{warn: Function, info: Function, debug: Function}} [options.logger]
67
134
  * @param {object} [options.model] `{ provider, model }` enabling the model tier.
68
135
  */
69
- constructor({ api, player, store, resolveLlm, logger, model = {} } = {}) {
136
+ constructor({
137
+ api,
138
+ player,
139
+ store,
140
+ resolveLlm,
141
+ readAgentDefault,
142
+ sessionId,
143
+ logger,
144
+ model = {},
145
+ } = {}) {
70
146
  this.api = api;
71
147
  this.player = player;
72
148
  this.store = store;
73
149
  this.resolveLlm = resolveLlm;
150
+ this.readAgentDefault = readAgentDefault;
74
151
  this.logger = logger;
75
152
  this.model = { provider: model.provider ?? null, model: model.model ?? null };
153
+ /**
154
+ * The curation conversation's provider-visible identity. Minted once, then
155
+ * persisted, so every plan and every restart is the same conversation.
156
+ */
157
+ this.sessionId = curationSessionId(store, sessionId);
158
+ /**
159
+ * Where the last resolved route came from: `pin`, `agent-default`, or
160
+ * `discovered`, or `null` when none resolved. Reported by `/music/health`
161
+ * so "which model actually curated" never has to be inferred.
162
+ */
163
+ this.resolvedFrom = null;
76
164
  /**
77
165
  * Why the model tier last declined, or `null` when it last succeeded.
78
166
  *
@@ -105,11 +193,52 @@ export class AiDj {
105
193
  return this.model;
106
194
  }
107
195
 
196
+ /** The model tier's observable state, for `/music/health`. */
197
+ modelStatus() {
198
+ const sessionModel = this.sessionModel();
199
+ return {
200
+ /** The route pinned from the panel or the profile patch, if any. */
201
+ pinned: this.modelConfigured ? `${this.model.provider}/${this.model.model}` : null,
202
+ /** The deployment's own model, which an unpinned DJ follows. */
203
+ sessionModel: sessionModel ? `${sessionModel.provider}/${sessionModel.model}` : null,
204
+ /** Where the last plan's route came from: pin, agent-default, discovered. */
205
+ resolvedFrom: this.resolvedFrom,
206
+ /** The identity every model call carries; the provider sees it as a header. */
207
+ sessionId: this.sessionId,
208
+ };
209
+ }
210
+
108
211
  /** Whether a model tier is configured. */
109
212
  get modelConfigured() {
110
213
  return Boolean(this.model.provider && this.model.model);
111
214
  }
112
215
 
216
+ /**
217
+ * The model the session itself runs on, when the deployment publishes one.
218
+ *
219
+ * `agent-default-model` owns this selection, and the composer/command model
220
+ * picker writes it. Reading it here is what lets the DJ's model be a
221
+ * *separate* choice: pin `dj.provider`/`dj.model` to curate on something
222
+ * else, or leave both unset and follow the session.
223
+ *
224
+ * @returns {{provider: string, model: string, reasoningEffort?: string} | null}
225
+ */
226
+ sessionModel() {
227
+ try {
228
+ const selection = this.readAgentDefault?.();
229
+ if (!selection?.provider || !selection?.model) return null;
230
+ return {
231
+ provider: String(selection.provider),
232
+ model: String(selection.model),
233
+ ...(selection.reasoningEffort === undefined || selection.reasoningEffort === null
234
+ ? {}
235
+ : { reasoningEffort: String(selection.reasoningEffort) }),
236
+ };
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
113
242
  /**
114
243
  * Enumerate the provider routes and catalogued models the DJ could use, for
115
244
  * the player's picker.
@@ -256,18 +385,29 @@ export class AiDj {
256
385
  /**
257
386
  * Decide which provider route to call.
258
387
  *
259
- * An explicit pin always wins. Otherwise the gaps are filled from the
260
- * registered adapters' catalogues, preferring a pinned provider (or model)
261
- * over the first one. Discovery is a convenience, not a recommendation: the
262
- * catalogue's first entry is arbitrary, so pinning both fields is the only
263
- * way to know which model curates.
388
+ * An explicit pin always wins. Otherwise the deployment's own model selection
389
+ * is inherited — `agent-default-model`, which is the same choice the composer
390
+ * picker writes and therefore the model the agent loop runs on — so the
391
+ * session model and the DJ's model are two separable decisions that agree
392
+ * by default. Only when neither exists does discovery run.
393
+ *
394
+ * Discovery is a convenience, not a recommendation: the catalogue's first
395
+ * entry is arbitrary, so pinning both fields is the only way to know which
396
+ * model curates.
264
397
  *
265
- * @param {object} llm the mounted LLM service.
266
- * @returns {Promise<{provider: string, model: string} | null>}
398
+ * @param {object} llm the mounted LLM service, for the catalogue.
399
+ * @returns {Promise<{provider: string, model: string, source: string, reasoningEffort?: string} | null>}
267
400
  */
268
401
  async #resolveTarget(llm) {
269
402
  const pinned = { provider: this.model.provider, model: this.model.model };
270
- if (pinned.provider && pinned.model) return pinned;
403
+ if (pinned.provider && pinned.model) return { ...pinned, source: 'pin' };
404
+
405
+ if (!pinned.provider && !pinned.model) {
406
+ const inherited = this.sessionModel();
407
+ if (inherited?.provider && inherited.model) return { ...inherited, source: 'agent-default' };
408
+ }
409
+
410
+ if (!llm?.stream) return null;
271
411
 
272
412
  let providers = [];
273
413
  try {
@@ -289,32 +429,20 @@ export class AiDj {
289
429
  model = null;
290
430
  }
291
431
  }
292
- return model ? { provider, model } : null;
432
+ return model ? { provider, model, source: 'discovered' } : null;
293
433
  }
294
434
 
295
435
  /**
296
- * Ask the model to choose from the pool.
297
- * @returns {Promise<{picks: number[], vibe: string} | null>} `null` when the
298
- * tier is unavailable or answered with nothing usable.
436
+ * The candidate catalogue and the listener's taste, as one prompt.
437
+ *
438
+ * One place builds the prompt, so a leaf call and any future caller reason
439
+ * about exactly the same brief.
299
440
  */
300
- async #askModel({ pool, digest, prompt, count }) {
301
- const llm = this.resolveLlm?.();
302
- if (!llm?.stream) {
303
- return this.#decline('no LLM service is mounted (ctx.get("llm") returned nothing)');
304
- }
305
- const target = await this.#resolveTarget(llm);
306
- if (!target?.provider || !target?.model) {
307
- return this.#decline(
308
- this.model.provider || this.model.model
309
- ? `no usable route for the configured provider/model (${this.model.provider ?? '?'}/${this.model.model ?? '?'})`
310
- : 'no provider route is registered, and none could be discovered',
311
- );
312
- }
313
-
441
+ #brief({ pool, digest, prompt, count }) {
314
442
  const catalogue = pool
315
443
  .map((track, index) => `${index}. ${track.name} — ${(track.artists ?? []).join('/')}${track.album ? ` (${track.album})` : ''}`)
316
444
  .join('\n');
317
- const brief = [
445
+ return [
318
446
  `Mood or request: ${prompt?.trim() || '(none — continue the current listening session)'}`,
319
447
  `Currently playing: ${this.player.current() ? `${this.player.current().name} — ${this.player.current().artists.join('/')}` : '(nothing)'}`,
320
448
  digest.recentlyPlayed.length ? `Recently played: ${digest.recentlyPlayed.join('; ')}` : '',
@@ -328,8 +456,11 @@ export class AiDj {
328
456
  ]
329
457
  .filter(Boolean)
330
458
  .join('\n');
459
+ }
331
460
 
332
- const messages = [
461
+ /** The two-block user message both transports send: instruction, then brief. */
462
+ #messages(brief) {
463
+ return [
333
464
  {
334
465
  role: 'user',
335
466
  content: [
@@ -347,27 +478,82 @@ export class AiDj {
347
478
  ],
348
479
  },
349
480
  ];
481
+ }
482
+
483
+ /**
484
+ * Turn an answer into ordered candidate indices.
485
+ *
486
+ * Tolerant on purpose: a model may wrap the object in prose or a code fence,
487
+ * and indices outside the pool are dropped rather than trusted.
488
+ *
489
+ * @throws {Error} when the answer carries no JSON object or no usable index.
490
+ */
491
+ #parseAnswer(text, pool) {
492
+ const json = /\{[\s\S]*\}/.exec(text ?? '');
493
+ if (!json) throw new Error('response contained no JSON object');
494
+ const parsed = JSON.parse(json[0]);
495
+ const picks = (parsed.picks ?? [])
496
+ .map((pick) => Number(typeof pick === 'object' ? pick.index : pick))
497
+ .filter((index) => Number.isInteger(index) && index >= 0 && index < pool.length);
498
+ if (picks.length === 0) throw new Error('the model returned no usable picks');
499
+ const seen = new Set();
500
+ const ordered = [];
501
+ for (const index of picks) {
502
+ if (seen.has(index)) continue;
503
+ seen.add(index);
504
+ ordered.push(index);
505
+ }
506
+ return { picks: ordered, vibe: typeof parsed.vibe === 'string' ? parsed.vibe : '' };
507
+ }
508
+
509
+ /** Record a model-tier success: clear the stale reason and log what ran. */
510
+ #accept(answer, target) {
511
+ this.modelError = null;
512
+ this.player.dj.modelError = null;
513
+ this.logger?.info?.(
514
+ `[music] DJ model tier chose ${answer.picks.length} track(s) via ${target.provider}/${target.model}` +
515
+ `${target.source === 'discovered' ? ' (discovered)' : ''}`,
516
+ );
517
+ return { ...answer, target };
518
+ }
519
+
520
+ /**
521
+ * Ask the model to choose from the pool.
522
+ *
523
+ * One leaf call, carrying the DJ's session identity. A failure declines to the
524
+ * heuristic tier and records the coded reason rather than being retried: a
525
+ * second full prompt against a provider that just failed is how one outage
526
+ * becomes two.
527
+ *
528
+ * @returns {Promise<{picks: number[], vibe: string} | null>}
529
+ * `null` when the tier is unavailable or answered with nothing usable.
530
+ */
531
+ async #askModel({ pool, digest, prompt, count }) {
532
+ const llm = this.resolveLlm?.();
533
+ if (!llm?.stream) {
534
+ return this.#decline('no LLM service is mounted (ctx.get("llm") returned nothing)');
535
+ }
536
+
537
+ const target = await this.#resolveTarget(llm);
538
+ if (!target?.provider || !target?.model) {
539
+ this.resolvedFrom = null;
540
+ return this.#decline(
541
+ this.model.provider || this.model.model
542
+ ? `no usable route for the configured provider/model (${this.model.provider ?? '?'}/${this.model.model ?? '?'})`
543
+ : 'no provider route is registered, and none could be discovered',
544
+ );
545
+ }
546
+ this.resolvedFrom = target.source;
350
547
 
548
+ const brief = this.#brief({ pool, digest, prompt, count });
351
549
  try {
352
- const text = await this.#collect(llm.stream({ ...target, messages }));
353
- const json = /\{[\s\S]*\}/.exec(text);
354
- if (!json) throw new Error('response contained no JSON object');
355
- const parsed = JSON.parse(json[0]);
356
- const picks = (parsed.picks ?? [])
357
- .map((pick) => Number(typeof pick === 'object' ? pick.index : pick))
358
- .filter((index) => Number.isInteger(index) && index >= 0 && index < pool.length);
359
- if (picks.length === 0) return this.#decline('the model returned no usable picks');
360
- const seen = new Set();
361
- const ordered = [];
362
- for (const index of picks) {
363
- if (seen.has(index)) continue;
364
- seen.add(index);
365
- ordered.push(index);
366
- }
367
- this.logger?.info?.(`[music] DJ model tier chose ${ordered.length} track(s) via ${target.provider}/${target.model}`);
368
- this.modelError = null;
369
- this.player.dj.modelError = null;
370
- return { picks: ordered, vibe: typeof parsed.vibe === 'string' ? parsed.vibe : '', target };
550
+ const request = curationRequest({
551
+ target,
552
+ messages: this.#messages(brief),
553
+ sessionId: this.sessionId,
554
+ });
555
+ const text = await this.#collect(llm.stream(request));
556
+ return this.#accept(this.#parseAnswer(text, pool), target);
371
557
  } catch (error) {
372
558
  return this.#decline(describeFailure(error));
373
559
  }
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.
@@ -141,11 +145,39 @@ export function apply(ctx, config = {}) {
141
145
  }
142
146
  };
143
147
 
148
+ /**
149
+ * The deployment's default model selection: the route a freshly created agent
150
+ * starts on, and the same choice the composer/command model picker writes.
151
+ *
152
+ * This is the seam that makes the agent-loop model and the DJ's model two
153
+ * separate decisions. `agent-default-model` owns the first; `dj.provider` /
154
+ * `dj.model` owns the second; and the DJ follows the session model when the
155
+ * plugin pins nothing, instead of picking an arbitrary first route.
156
+ */
157
+ const readAgentDefault = () => {
158
+ try {
159
+ const service = ctx.get?.('agentDefaultModel') ?? ctx.agentDefaultModel;
160
+ const selection = service?.currentSelection?.();
161
+ if (!selection?.provider || !selection?.model) return null;
162
+ return {
163
+ provider: String(selection.provider),
164
+ model: String(selection.model),
165
+ ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: String(selection.reasoningEffort) }),
166
+ };
167
+ } catch {
168
+ return null;
169
+ }
170
+ };
171
+
144
172
  const dj = new AiDj({
145
173
  api,
146
174
  player,
147
175
  store,
148
176
  resolveLlm,
177
+ readAgentDefault,
178
+ // An operator-supplied identity for the DJ's model calls; otherwise one is
179
+ // minted on first use and persisted in session.json.
180
+ sessionId: config.dj?.sessionId,
149
181
  logger,
150
182
  // The picker's saved choice wins over the profile patch, so a route chosen
151
183
  // in the GUI is not silently overridden by a stale `config.dj` pin.
@@ -162,10 +194,24 @@ export function apply(ctx, config = {}) {
162
194
  // ------------------------------------------------------------ session boot
163
195
  void api
164
196
  .fetchAccount()
165
- .then((account) => {
197
+ .then(async (account) => {
166
198
  if (account) logger.info(`[music] signed in as ${account.nickname}`);
167
199
  else if (api.authenticated) logger.warn('[music] session cookie present but the account check failed');
168
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
+ }
169
215
  })
170
216
  .catch((error) => logger.warn(`[music] account check failed: ${error.message}`));
171
217
 
@@ -195,6 +241,7 @@ export function apply(ctx, config = {}) {
195
241
  store,
196
242
  qr,
197
243
  dj,
244
+ likes,
198
245
  panelHtml: PANEL_HTML,
199
246
  qrcodeJs: QRCODE_JS,
200
247
  config,
@@ -206,19 +253,26 @@ export function apply(ctx, config = {}) {
206
253
  busy: player.dj.busy,
207
254
  source: player.dj.source,
208
255
  model: dj.modelConfigured ? `${dj.model.provider}/${dj.model.model}` : null,
209
- /** Where the live route came from: the player, the patch, or discovery. */
256
+ /** Where the live route came from: the player, the patch, or a default. */
210
257
  modelSource: store.settings.djProvider || store.settings.djModel
211
258
  ? 'panel'
212
259
  : config.dj?.provider || config.dj?.model
213
260
  ? 'config'
214
- : 'discovered',
261
+ : readAgentDefault()
262
+ ? 'agent-default'
263
+ : 'discovered',
215
264
  /** Route that actually curated the last batch, model tier or not. */
216
265
  lastRoute: player.dj.lastPlan?.route ?? null,
217
266
  /** Why the model tier last declined; null once it succeeds. */
218
267
  modelError: player.dj.modelError ?? null,
268
+ /** Where the route comes from, what the last plan resolved, and the
269
+ * identity every call carries (the provider sees it as a header). */
270
+ ...dj.modelStatus(),
219
271
  lastPlanAt: player.dj.lastPlanAt,
220
272
  error: player.dj.error,
221
273
  },
274
+ /** How much of the account's like state is cached, and why it is not. */
275
+ likes: likes.status(),
222
276
  dataDir,
223
277
  session: { authenticated: api.authenticated, nickname: api.account?.nickname ?? null, vip: Boolean(api.account?.vip) },
224
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 };