@doitian/dsh-music 0.1.0 → 0.1.2
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 +152 -34
- package/cordis.patch.yml +14 -0
- package/lib/client.js +48 -11
- package/lib/dj.js +234 -48
- package/lib/index.js +35 -2
- package/lib/netease.js +23 -7
- package/lib/panel.html +451 -212
- package/lib/router.js +7 -4
- package/lib/session.js +14 -2
- package/package.json +2 -1
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
|
@@ -141,11 +141,39 @@ export function apply(ctx, config = {}) {
|
|
|
141
141
|
}
|
|
142
142
|
};
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The deployment's default model selection: the route a freshly created agent
|
|
146
|
+
* starts on, and the same choice the composer/command model picker writes.
|
|
147
|
+
*
|
|
148
|
+
* This is the seam that makes the agent-loop model and the DJ's model two
|
|
149
|
+
* separate decisions. `agent-default-model` owns the first; `dj.provider` /
|
|
150
|
+
* `dj.model` owns the second; and the DJ follows the session model when the
|
|
151
|
+
* plugin pins nothing, instead of picking an arbitrary first route.
|
|
152
|
+
*/
|
|
153
|
+
const readAgentDefault = () => {
|
|
154
|
+
try {
|
|
155
|
+
const service = ctx.get?.('agentDefaultModel') ?? ctx.agentDefaultModel;
|
|
156
|
+
const selection = service?.currentSelection?.();
|
|
157
|
+
if (!selection?.provider || !selection?.model) return null;
|
|
158
|
+
return {
|
|
159
|
+
provider: String(selection.provider),
|
|
160
|
+
model: String(selection.model),
|
|
161
|
+
...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: String(selection.reasoningEffort) }),
|
|
162
|
+
};
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
144
168
|
const dj = new AiDj({
|
|
145
169
|
api,
|
|
146
170
|
player,
|
|
147
171
|
store,
|
|
148
172
|
resolveLlm,
|
|
173
|
+
readAgentDefault,
|
|
174
|
+
// An operator-supplied identity for the DJ's model calls; otherwise one is
|
|
175
|
+
// minted on first use and persisted in session.json.
|
|
176
|
+
sessionId: config.dj?.sessionId,
|
|
149
177
|
logger,
|
|
150
178
|
// The picker's saved choice wins over the profile patch, so a route chosen
|
|
151
179
|
// in the GUI is not silently overridden by a stale `config.dj` pin.
|
|
@@ -206,16 +234,21 @@ export function apply(ctx, config = {}) {
|
|
|
206
234
|
busy: player.dj.busy,
|
|
207
235
|
source: player.dj.source,
|
|
208
236
|
model: dj.modelConfigured ? `${dj.model.provider}/${dj.model.model}` : null,
|
|
209
|
-
/** Where the live route came from: the player, the patch, or
|
|
237
|
+
/** Where the live route came from: the player, the patch, or a default. */
|
|
210
238
|
modelSource: store.settings.djProvider || store.settings.djModel
|
|
211
239
|
? 'panel'
|
|
212
240
|
: config.dj?.provider || config.dj?.model
|
|
213
241
|
? 'config'
|
|
214
|
-
:
|
|
242
|
+
: readAgentDefault()
|
|
243
|
+
? 'agent-default'
|
|
244
|
+
: 'discovered',
|
|
215
245
|
/** Route that actually curated the last batch, model tier or not. */
|
|
216
246
|
lastRoute: player.dj.lastPlan?.route ?? null,
|
|
217
247
|
/** Why the model tier last declined; null once it succeeds. */
|
|
218
248
|
modelError: player.dj.modelError ?? null,
|
|
249
|
+
/** Where the route comes from, what the last plan resolved, and the
|
|
250
|
+
* identity every call carries (the provider sees it as a header). */
|
|
251
|
+
...dj.modelStatus(),
|
|
219
252
|
lastPlanAt: player.dj.lastPlanAt,
|
|
220
253
|
error: player.dj.error,
|
|
221
254
|
},
|
package/lib/netease.js
CHANGED
|
@@ -294,21 +294,37 @@ export class Netease {
|
|
|
294
294
|
|
|
295
295
|
/**
|
|
296
296
|
* Raw streaming fetch, used by the audio proxy. The caller owns the body.
|
|
297
|
+
*
|
|
298
|
+
* The deadline covers only the connect and the response headers. The body
|
|
299
|
+
* then streams for as long as the browser keeps reading — potentially the
|
|
300
|
+
* whole track, because the element pauses and resumes its reads as its
|
|
301
|
+
* buffer fills and drains. A whole-request timeout here (`AbortSignal`
|
|
302
|
+
* watches the body too) used to cut every stream still open at that point;
|
|
303
|
+
* the panel read the truncation as a failed track and skipped mid-song. A
|
|
304
|
+
* body that stops flowing is still bounded by undici's own idle timeout.
|
|
305
|
+
*
|
|
297
306
|
* @returns {Promise<Response>}
|
|
298
307
|
*/
|
|
299
|
-
async fetchAudio(url, { range, method = 'GET' } = {}) {
|
|
308
|
+
async fetchAudio(url, { range, method = 'GET', headerTimeoutMs = 30_000 } = {}) {
|
|
300
309
|
const headers = {
|
|
301
310
|
Referer: `${API_ORIGIN}/`,
|
|
302
311
|
'User-Agent': UA,
|
|
303
312
|
Cookie: this.jar.header(),
|
|
304
313
|
};
|
|
305
314
|
if (range) headers.Range = range;
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
315
|
+
const controller = new AbortController();
|
|
316
|
+
const deadline = setTimeout(() => controller.abort(), headerTimeoutMs);
|
|
317
|
+
let response;
|
|
318
|
+
try {
|
|
319
|
+
response = await fetch(url, {
|
|
320
|
+
method,
|
|
321
|
+
headers,
|
|
322
|
+
redirect: 'follow',
|
|
323
|
+
signal: controller.signal,
|
|
324
|
+
});
|
|
325
|
+
} finally {
|
|
326
|
+
clearTimeout(deadline);
|
|
327
|
+
}
|
|
312
328
|
this.jar.absorb(response);
|
|
313
329
|
return response;
|
|
314
330
|
}
|