@doitian/dsh-music 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -43
- package/cordis.patch.yml +14 -0
- package/lib/client.js +48 -11
- package/lib/dj.js +234 -48
- package/lib/index.js +57 -3
- package/lib/likes.js +169 -0
- package/lib/netease.js +120 -7
- package/lib/panel.html +749 -235
- package/lib/router.js +210 -22
- package/lib/session.js +93 -19
- package/lib/state.js +15 -0
- package/package.json +5 -3
package/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({
|
|
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
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
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
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
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
|
-
:
|
|
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 };
|