@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/router.js
CHANGED
|
@@ -12,12 +12,17 @@
|
|
|
12
12
|
* only lives 20 minutes and needs the session cookie at *resolution* time, and
|
|
13
13
|
* proxying keeps playback same-origin so the page never mixes http and https.
|
|
14
14
|
*
|
|
15
|
+
* Every state response annotates each queued track with its taste — `liked`
|
|
16
|
+
* from the NetEase account, `disliked` from the local feedback — because that
|
|
17
|
+
* is what the panel draws its hearts from. See {@link MusicRouter#snapshot}.
|
|
18
|
+
*
|
|
15
19
|
* @module dsh-music/router
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import { Readable } from 'node:stream';
|
|
19
23
|
|
|
20
24
|
import { DEFAULT_LEVEL, LEVELS, isLevel } from './netease.js';
|
|
25
|
+
import { TASTE_LEVELS } from './session.js';
|
|
21
26
|
|
|
22
27
|
/** Resolved CDN URLs are reused for this long (they live 20 minutes). */
|
|
23
28
|
const URL_CACHE_MS = 15 * 60 * 1000;
|
|
@@ -42,6 +47,16 @@ const FORWARDED_HEADERS = {
|
|
|
42
47
|
'last-modified': 'Last-Modified',
|
|
43
48
|
};
|
|
44
49
|
|
|
50
|
+
/**
|
|
51
|
+
* HTTP status for a refused taste change, so the panel can tell "sign in" from
|
|
52
|
+
* "NetEase refused" from "the network is down" without parsing a sentence.
|
|
53
|
+
*/
|
|
54
|
+
const TASTE_STATUS = {
|
|
55
|
+
BAD_ID: 400,
|
|
56
|
+
ANONYMOUS: 401,
|
|
57
|
+
NETWORK: 502,
|
|
58
|
+
};
|
|
59
|
+
|
|
45
60
|
/** A small JSON/HTTP helper set. */
|
|
46
61
|
function sendJson(res, status, value) {
|
|
47
62
|
const body = Buffer.from(JSON.stringify(value ?? null), 'utf8');
|
|
@@ -85,20 +100,22 @@ export class MusicRouter {
|
|
|
85
100
|
* @param {import('./session.js').SessionStore} options.store
|
|
86
101
|
* @param {import('./session.js').QrLogin} options.qr
|
|
87
102
|
* @param {import('./dj.js').AiDj} options.dj
|
|
103
|
+
* @param {import('./likes.js').LikeState} options.likes
|
|
88
104
|
* @param {string} options.panelHtml
|
|
89
105
|
* @param {string} options.qrcodeJs
|
|
90
106
|
* @param {object} [options.config]
|
|
91
|
-
* @param {() => Promise<object>} [options.listRoutes]
|
|
107
|
+
* @param {() => Promise<object>} [options.listRoutes] model routes for the picker.
|
|
92
108
|
* @param {() => object} [options.diagnostics] extra facts for `/health`.
|
|
93
109
|
* @param {{warn: Function, info: Function, debug: Function}} [options.logger]
|
|
94
110
|
*/
|
|
95
|
-
constructor({ base, api, player, store, qr, dj, panelHtml, qrcodeJs, config = {}, listRoutes, diagnostics, logger }) {
|
|
111
|
+
constructor({ base, api, player, store, qr, dj, likes, panelHtml, qrcodeJs, config = {}, listRoutes, diagnostics, logger }) {
|
|
96
112
|
this.base = base.replace(/\/+$/, '');
|
|
97
113
|
this.api = api;
|
|
98
114
|
this.player = player;
|
|
99
115
|
this.store = store;
|
|
100
116
|
this.qr = qr;
|
|
101
117
|
this.dj = dj;
|
|
118
|
+
this.likes = likes;
|
|
102
119
|
this.panelHtml = panelHtml;
|
|
103
120
|
this.qrcodeJs = qrcodeJs;
|
|
104
121
|
this.config = config;
|
|
@@ -117,7 +134,7 @@ export class MusicRouter {
|
|
|
117
134
|
* The level to stream at.
|
|
118
135
|
*
|
|
119
136
|
* The player's saved choice wins over the profile patch, matching how the
|
|
120
|
-
*
|
|
137
|
+
* DJ's model route resolves — a GUI choice should not be silently overridden.
|
|
121
138
|
* An unknown value falls back rather than failing a request.
|
|
122
139
|
*/
|
|
123
140
|
preferredLevel() {
|
|
@@ -266,10 +283,49 @@ export class MusicRouter {
|
|
|
266
283
|
|
|
267
284
|
// -------------------------------------------------------------------- API
|
|
268
285
|
|
|
269
|
-
/**
|
|
270
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Resolve the like state of everything the panel is about to draw.
|
|
288
|
+
*
|
|
289
|
+
* A like is the account's, so this is a network read — but a cached one: only
|
|
290
|
+
* tracks with no fresh answer cost a call, which after the first poll of a
|
|
291
|
+
* queue is normally none of them.
|
|
292
|
+
*/
|
|
293
|
+
async refreshLikes() {
|
|
294
|
+
const ids = [this.player.current()?.id, ...this.player.queue.map((track) => track.id)].filter(Boolean);
|
|
295
|
+
if (ids.length > 0) await this.likes.ensure(ids);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* One track as the panel needs it: the metadata plus its taste.
|
|
300
|
+
*
|
|
301
|
+
* `liked` is the account's answer (cached, see {@link LikeState}) and
|
|
302
|
+
* `disliked` is the local level. They are two values of one three-level
|
|
303
|
+
* taste, never both true: liking a disliked track clears the dislike and
|
|
304
|
+
* disliking a liked track removes it on NetEase.
|
|
305
|
+
*/
|
|
306
|
+
tasteView(track) {
|
|
307
|
+
if (!track) return null;
|
|
308
|
+
return {
|
|
309
|
+
...track,
|
|
310
|
+
liked: this.likes.isLiked(track.id),
|
|
311
|
+
disliked: this.store.taste(track.id) === 'disliked',
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The panel's poll target: full player snapshot including account and DJ.
|
|
317
|
+
*
|
|
318
|
+
* Async because `liked` is NetEase's answer rather than the host's: the queued
|
|
319
|
+
* tracks' like state is resolved (once, then cached) before the document is
|
|
320
|
+
* written, so a heart never lags a poll behind the track it belongs to.
|
|
321
|
+
*/
|
|
322
|
+
async snapshot(extra = {}) {
|
|
323
|
+
await this.refreshLikes();
|
|
324
|
+
const base = this.player.snapshot();
|
|
271
325
|
return {
|
|
272
|
-
...
|
|
326
|
+
...base,
|
|
327
|
+
queue: base.queue.map((track) => this.tasteView(track)),
|
|
328
|
+
current: this.tasteView(base.current),
|
|
273
329
|
account: this.api.account,
|
|
274
330
|
authenticated: this.api.authenticated,
|
|
275
331
|
settings: this.store.settings,
|
|
@@ -280,14 +336,112 @@ export class MusicRouter {
|
|
|
280
336
|
};
|
|
281
337
|
}
|
|
282
338
|
|
|
283
|
-
/** Where the
|
|
339
|
+
/** Where the DJ's model route comes from. */
|
|
284
340
|
modelSource() {
|
|
285
341
|
const settings = this.store.settings;
|
|
286
342
|
if (settings.djProvider || settings.djModel) return 'panel';
|
|
287
343
|
if (this.config?.dj?.provider || this.config?.dj?.model) return 'config';
|
|
344
|
+
// Nothing pinned: the DJ follows the session's own model when the
|
|
345
|
+
// deployment publishes one, and falls back to discovery when it does not.
|
|
346
|
+
if (this.dj.sessionModel?.()) return 'agent-default';
|
|
288
347
|
return 'discovered';
|
|
289
348
|
}
|
|
290
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Move one track to a taste level, keeping the account and the local mirror
|
|
352
|
+
* in step.
|
|
353
|
+
*
|
|
354
|
+
* The NetEase half is written first: a level that claimed a like the account
|
|
355
|
+
* never received would be contradicted by the next poll's heart. Removing a
|
|
356
|
+
* like is therefore a real request, which is why a track the account has not
|
|
357
|
+
* liked can be cleared locally without one.
|
|
358
|
+
*
|
|
359
|
+
* @param {number|string} trackId
|
|
360
|
+
* @param {'liked'|'none'|'disliked'} level
|
|
361
|
+
* @returns {Promise<{ok: boolean, id?: number, level?: string, status?: number, code?: string, reason?: string}>}
|
|
362
|
+
*/
|
|
363
|
+
async applyTaste(trackId, level) {
|
|
364
|
+
const id = Number(trackId);
|
|
365
|
+
if (!Number.isFinite(id)) {
|
|
366
|
+
return { ok: false, status: 400, code: 'BAD_ID', reason: `invalid track id "${trackId}"` };
|
|
367
|
+
}
|
|
368
|
+
if (!TASTE_LEVELS.includes(level)) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
status: 400,
|
|
372
|
+
code: 'BAD_LEVEL',
|
|
373
|
+
reason: `unknown taste level "${level}" (expected ${TASTE_LEVELS.join(', ')})`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Any level but `liked` means the account must not still have the like —
|
|
378
|
+
// including `disliked`, because the two are exclusive. A track with no
|
|
379
|
+
// cached answer is resolved first, so the two levels cannot both end up
|
|
380
|
+
// true; an anonymous session has nothing to resolve and skips the call.
|
|
381
|
+
if (level !== 'liked' && !this.likes.known(id)) await this.likes.ensure([id]);
|
|
382
|
+
if (level !== 'liked' && this.likes.isLiked(id)) {
|
|
383
|
+
const removed = await this.likes.set(id, false);
|
|
384
|
+
if (!removed.ok) return { ok: false, status: TASTE_STATUS[removed.code] ?? 502, code: removed.code, reason: removed.reason };
|
|
385
|
+
}
|
|
386
|
+
if (level === 'liked') {
|
|
387
|
+
const added = await this.likes.set(id, true);
|
|
388
|
+
if (!added.ok) return { ok: false, status: TASTE_STATUS[added.code] ?? 502, code: added.code, reason: added.reason };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
this.store.setTaste(id, level);
|
|
392
|
+
return { ok: true, id, level };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Start a queue action and report the track it acts on.
|
|
397
|
+
*
|
|
398
|
+
* Only `jump` and `remove` name a position, and only `remove` is read as a
|
|
399
|
+
* judgement about the track — see {@link MusicRouter#removeFromQueue}. The two
|
|
400
|
+
* share this so the index is resolved the same way, and so a track is never
|
|
401
|
+
* re-read from a queue it has already left.
|
|
402
|
+
*
|
|
403
|
+
* @returns {{track: object|null, wasCurrent: boolean, dropped: boolean}}
|
|
404
|
+
*/
|
|
405
|
+
#queueAction(action, index) {
|
|
406
|
+
const at = Number(index);
|
|
407
|
+
const track = Number.isInteger(at) ? (this.player.queue[at] ?? null) : null;
|
|
408
|
+
if (action === 'remove') return { ...this.player.removeAt(at), dropped: Boolean(track) };
|
|
409
|
+
this.player.jump(at);
|
|
410
|
+
return { track, wasCurrent: false, dropped: false };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Take one track out of the queue, and read that as a dislike of it.
|
|
415
|
+
*
|
|
416
|
+
* Removing a queued track is the listener saying "not this one", and the row
|
|
417
|
+
* is gone afterwards — so the judgement has to be recorded now or not at all.
|
|
418
|
+
* Without it the DJ re-derives the same track from the same similarity and
|
|
419
|
+
* charts within a batch or two, which is what "removing does nothing" looks
|
|
420
|
+
* like from the outside.
|
|
421
|
+
*
|
|
422
|
+
* Two edges are deliberate. A track already rated does not get its level
|
|
423
|
+
* changed, because `remove` is often just queue housekeeping — clearing out
|
|
424
|
+
* music already heard — and silently rewriting a like would be worse than
|
|
425
|
+
* missing a signal. And a *failed* dislike write never comes back to the
|
|
426
|
+
* panel as a failed removal: the queue action is what the click asked for, and
|
|
427
|
+
* the taste write is a consequence of it.
|
|
428
|
+
*
|
|
429
|
+
* @param {object|null} track the track that was removed.
|
|
430
|
+
* @param {boolean} wasCurrent whether it was the playing one.
|
|
431
|
+
* @returns {Promise<object|null>} the applied level, or null when nothing was.
|
|
432
|
+
*/
|
|
433
|
+
async removeFromQueue(track, wasCurrent) {
|
|
434
|
+
if (!track) return null;
|
|
435
|
+
if (this.store.taste(track.id) !== 'none') return null;
|
|
436
|
+
const applied = await this.applyTaste(track.id, 'disliked');
|
|
437
|
+
if (applied.ok) return applied;
|
|
438
|
+
this.logger?.debug?.(`[music] track ${track.id} removed but not marked disliked: ${applied.reason}`);
|
|
439
|
+
// The track left the queue and it was the one playing, so playback has
|
|
440
|
+
// nowhere to go. A disliked track auto-advances; a removal must too.
|
|
441
|
+
if (wasCurrent) this.player.move(1, { auto: true });
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
291
445
|
/** Route one `/music/api/*` request. */
|
|
292
446
|
async handleApi(req, res, pathname) {
|
|
293
447
|
const route = pathname.slice(`${this.base}/api`.length).replace(/\/+$/, '') || '/';
|
|
@@ -296,16 +450,16 @@ export class MusicRouter {
|
|
|
296
450
|
|
|
297
451
|
// ---- player state -----------------------------------------------------
|
|
298
452
|
if (route === '/state' && method === 'GET') {
|
|
299
|
-
return sendJson(res, 200, this.snapshot());
|
|
453
|
+
return sendJson(res, 200, await this.snapshot());
|
|
300
454
|
}
|
|
301
455
|
if (route === '/report' && method === 'POST') {
|
|
302
456
|
const body = await readJson(req);
|
|
303
457
|
this.player.report(body);
|
|
304
|
-
return sendJson(res, 200, this.snapshot());
|
|
458
|
+
return sendJson(res, 200, await this.snapshot());
|
|
305
459
|
}
|
|
306
460
|
if (route === '/seek-consumed' && method === 'POST') {
|
|
307
461
|
this.player.takeSeek();
|
|
308
|
-
return sendJson(res, 200, this.snapshot());
|
|
462
|
+
return sendJson(res, 200, await this.snapshot());
|
|
309
463
|
}
|
|
310
464
|
if (route === '/control' && method === 'POST') {
|
|
311
465
|
const { action, value } = await readJson(req);
|
|
@@ -321,7 +475,7 @@ export class MusicRouter {
|
|
|
321
475
|
case 'mode': this.player.setMode(value); break;
|
|
322
476
|
default: return sendJson(res, 400, { error: `unknown action "${action}"` });
|
|
323
477
|
}
|
|
324
|
-
return sendJson(res, 200, this.snapshot());
|
|
478
|
+
return sendJson(res, 200, await this.snapshot());
|
|
325
479
|
}
|
|
326
480
|
|
|
327
481
|
// ---- queue ------------------------------------------------------------
|
|
@@ -332,18 +486,22 @@ export class MusicRouter {
|
|
|
332
486
|
}
|
|
333
487
|
this.player.setQueue(tracks, { startIndex, source: 'user' });
|
|
334
488
|
this.store.recordPlay(this.player.current() ?? tracks[0]);
|
|
335
|
-
return sendJson(res, 200, this.snapshot());
|
|
489
|
+
return sendJson(res, 200, await this.snapshot());
|
|
336
490
|
}
|
|
337
491
|
if (route === '/queue' && method === 'POST') {
|
|
338
492
|
const { action, tracks, index } = await readJson(req);
|
|
493
|
+
let removal = null;
|
|
339
494
|
if (action === 'replace' && Array.isArray(tracks)) this.player.setQueue(tracks, { source: 'user' });
|
|
340
495
|
else if (action === 'append' && Array.isArray(tracks)) this.player.append(tracks, { source: 'user' });
|
|
341
496
|
else if (action === 'insert' && Array.isArray(tracks)) this.player.insertNext(tracks, { source: 'user' });
|
|
342
|
-
else if (action === 'remove') this
|
|
343
|
-
else if (action === 'jump') this.player.jump(Number(index));
|
|
497
|
+
else if (action === 'remove' || action === 'jump') removal = this.#queueAction(action, index);
|
|
344
498
|
else if (action === 'clear') this.player.clear();
|
|
345
499
|
else return sendJson(res, 400, { error: `unknown queue action "${action}"` });
|
|
346
|
-
|
|
500
|
+
// Answer with the queue as it now stands, but record the judgement first:
|
|
501
|
+
// the snapshot's hearts should already show it.
|
|
502
|
+
if (removal?.dropped && removal.wasCurrent) await this.removeFromQueue(removal.track, true);
|
|
503
|
+
else if (removal?.dropped) void this.removeFromQueue(removal.track, false);
|
|
504
|
+
return sendJson(res, 200, await this.snapshot());
|
|
347
505
|
}
|
|
348
506
|
|
|
349
507
|
// ---- catalogue --------------------------------------------------------
|
|
@@ -386,14 +544,42 @@ export class MusicRouter {
|
|
|
386
544
|
});
|
|
387
545
|
}
|
|
388
546
|
|
|
547
|
+
// ---- taste ------------------------------------------------------------
|
|
548
|
+
/**
|
|
549
|
+
* Move one track to a taste level: `liked`, `none` (the like removed), or
|
|
550
|
+
* `disliked`.
|
|
551
|
+
*
|
|
552
|
+
* One endpoint rather than a like/unlike pair, because the three levels are
|
|
553
|
+
* exclusive states of one thing: the panel always says which level it wants,
|
|
554
|
+
* so a click cannot be turned into the wrong direction by a stale poll, and
|
|
555
|
+
* disliking a liked track is one request rather than two racing ones.
|
|
556
|
+
*/
|
|
557
|
+
if (route === '/taste' && method === 'POST') {
|
|
558
|
+
const { trackId, level } = await readJson(req);
|
|
559
|
+
const applied = await this.applyTaste(trackId, level);
|
|
560
|
+
if (!applied.ok) return sendJson(res, applied.status ?? 502, { error: applied.reason, code: applied.code });
|
|
561
|
+
return sendJson(res, 200, await this.snapshot());
|
|
562
|
+
}
|
|
563
|
+
|
|
389
564
|
// ---- feedback ---------------------------------------------------------
|
|
565
|
+
/**
|
|
566
|
+
* The local taste signals: skips, and dislikes, plus the older toggle
|
|
567
|
+
* spelling of a like. Kept because a page cached from an earlier build
|
|
568
|
+
* still reaches for it.
|
|
569
|
+
*/
|
|
390
570
|
if (route === '/feedback' && method === 'POST') {
|
|
391
571
|
const { kind, trackId } = await readJson(req);
|
|
392
|
-
if (
|
|
393
|
-
|
|
572
|
+
if (kind === 'skips') {
|
|
573
|
+
this.store.recordFeedback(kind, trackId);
|
|
574
|
+
return sendJson(res, 200, await this.snapshot());
|
|
575
|
+
}
|
|
576
|
+
if (kind === 'likes' || kind === 'dislikes') {
|
|
577
|
+
const level = kind === 'likes' ? 'liked' : 'disliked';
|
|
578
|
+
const applied = await this.applyTaste(trackId, this.store.taste(trackId) === level ? 'none' : level);
|
|
579
|
+
if (!applied.ok) return sendJson(res, applied.status ?? 502, { error: applied.reason, code: applied.code });
|
|
580
|
+
return sendJson(res, 200, await this.snapshot());
|
|
394
581
|
}
|
|
395
|
-
|
|
396
|
-
return sendJson(res, 200, { feedback: this.store.feedback });
|
|
582
|
+
return sendJson(res, 400, { error: `unknown feedback kind "${kind}"` });
|
|
397
583
|
}
|
|
398
584
|
|
|
399
585
|
// ---- streaming quality ------------------------------------------------
|
|
@@ -411,7 +597,7 @@ export class MusicRouter {
|
|
|
411
597
|
this.store.updateSettings({ level });
|
|
412
598
|
this.logger?.info?.(`[music] streaming quality set to ${level} from the player`);
|
|
413
599
|
// The running stream keeps its bytes; the next load or seek uses this.
|
|
414
|
-
return sendJson(res, 200, this.snapshot());
|
|
600
|
+
return sendJson(res, 200, await this.snapshot());
|
|
415
601
|
}
|
|
416
602
|
|
|
417
603
|
// ---- AI DJ ------------------------------------------------------------
|
|
@@ -455,7 +641,7 @@ export class MusicRouter {
|
|
|
455
641
|
model: nextModel || configured.model || null,
|
|
456
642
|
});
|
|
457
643
|
this.logger?.info?.(
|
|
458
|
-
`[music]
|
|
644
|
+
`[music] DJ model route set to ${this.dj.model.provider ?? '(none)'}/${this.dj.model.model ?? '(none)'} from the player`,
|
|
459
645
|
);
|
|
460
646
|
}
|
|
461
647
|
if (prompt !== undefined) this.store.updateSettings({ djPrompt: String(prompt) });
|
|
@@ -466,7 +652,7 @@ export class MusicRouter {
|
|
|
466
652
|
} else if (prompt !== undefined && this.player.dj.enabled) {
|
|
467
653
|
void this.dj.topUp({ force: true });
|
|
468
654
|
}
|
|
469
|
-
return sendJson(res, 200, this.snapshot());
|
|
655
|
+
return sendJson(res, 200, await this.snapshot());
|
|
470
656
|
}
|
|
471
657
|
|
|
472
658
|
// ---- login ------------------------------------------------------------
|
|
@@ -492,6 +678,8 @@ export class MusicRouter {
|
|
|
492
678
|
if (route === '/login/logout' && method === 'POST') {
|
|
493
679
|
this.api.clearCookie();
|
|
494
680
|
this.store.clearSession();
|
|
681
|
+
// The likes belong to the account that just left, not to the next one.
|
|
682
|
+
this.likes.clear();
|
|
495
683
|
this.qr.reset();
|
|
496
684
|
return sendJson(res, 200, { loggedIn: false });
|
|
497
685
|
}
|
package/lib/session.js
CHANGED
|
@@ -20,6 +20,15 @@ const MAX_HISTORY = 300;
|
|
|
20
20
|
/** Feedback lists are bounded so the file cannot grow without limit. */
|
|
21
21
|
const MAX_FEEDBACK = 500;
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The three levels a track's taste can hold, mutually exclusive.
|
|
25
|
+
*
|
|
26
|
+
* `liked` mirrors the NetEase account — the API call that changes it there is
|
|
27
|
+
* what writes this — while `disliked` is local, because the plain web API has
|
|
28
|
+
* no dislike endpoint. `none` is "never rated", not a fourth signal.
|
|
29
|
+
*/
|
|
30
|
+
export const TASTE_LEVELS = ['liked', 'none', 'disliked'];
|
|
31
|
+
|
|
23
32
|
/** QR codes are refreshed before the API reports them expired. */
|
|
24
33
|
const QR_TTL_MS = 110_000;
|
|
25
34
|
|
|
@@ -42,13 +51,23 @@ function emptyState() {
|
|
|
42
51
|
/** Free-text taste/mood brief the DJ includes in its prompt. */
|
|
43
52
|
djPrompt: '',
|
|
44
53
|
/**
|
|
45
|
-
*
|
|
54
|
+
* DJ model route chosen from the player. Empty means "use the profile
|
|
46
55
|
* patch's `config.dj`", so clearing the picker reverts to it.
|
|
47
56
|
*/
|
|
48
57
|
djProvider: '',
|
|
49
58
|
djModel: '',
|
|
50
59
|
djAutoExtendBelow: 3,
|
|
51
60
|
djBatchSize: 5,
|
|
61
|
+
/**
|
|
62
|
+
* The DJ's model-call identity, minted on first use and persisted here.
|
|
63
|
+
*
|
|
64
|
+
* A leaf call cannot carry headers, so this string is the only identity
|
|
65
|
+
* the DJ can give a provider — and adapters map it onto whatever that
|
|
66
|
+
* provider calls a per-conversation header (pi-ai emits
|
|
67
|
+
* `x-opencode-session` for `opencode-go`). Persisting it keeps every plan
|
|
68
|
+
* and every restart inside one conversation.
|
|
69
|
+
*/
|
|
70
|
+
djSessionId: '',
|
|
52
71
|
},
|
|
53
72
|
history: [],
|
|
54
73
|
feedback: { likes: [], dislikes: [], skips: [] },
|
|
@@ -189,28 +208,81 @@ export class SessionStore {
|
|
|
189
208
|
.map(([artist]) => artist);
|
|
190
209
|
}
|
|
191
210
|
|
|
192
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* One track's taste level: `liked`, `disliked`, or `none` (never rated).
|
|
213
|
+
* @param {number|string} trackId
|
|
214
|
+
* @returns {'liked'|'disliked'|'none'}
|
|
215
|
+
*/
|
|
216
|
+
taste(trackId) {
|
|
217
|
+
const id = Number(trackId);
|
|
218
|
+
if (this.state.feedback.likes.includes(id)) return 'liked';
|
|
219
|
+
if (this.state.feedback.dislikes.includes(id)) return 'disliked';
|
|
220
|
+
return 'none';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Set one track's taste level, keeping the two lists mutually exclusive.
|
|
225
|
+
*
|
|
226
|
+
* The caller owns the NetEase half. `setTaste(id, 'liked')` records the local
|
|
227
|
+
* mirror *after* the API call that liked the track for real, so the DJ's taste
|
|
228
|
+
* digest and the heart in the list agree with the account instead of
|
|
229
|
+
* promising a like NetEase never received.
|
|
230
|
+
*
|
|
231
|
+
* @param {number|string} trackId
|
|
232
|
+
* @param {'liked'|'none'|'disliked'} level
|
|
233
|
+
*/
|
|
234
|
+
setTaste(trackId, level) {
|
|
235
|
+
const id = Number(trackId);
|
|
236
|
+
if (!Number.isFinite(id) || !TASTE_LEVELS.includes(level)) return this.state.feedback;
|
|
237
|
+
for (const [name, list] of Object.entries({
|
|
238
|
+
liked: this.state.feedback.likes,
|
|
239
|
+
disliked: this.state.feedback.dislikes,
|
|
240
|
+
})) {
|
|
241
|
+
const index = list.indexOf(id);
|
|
242
|
+
if (name === level && index < 0) list.unshift(id);
|
|
243
|
+
else if (name !== level && index >= 0) list.splice(index, 1);
|
|
244
|
+
if (list.length > MAX_FEEDBACK) list.length = MAX_FEEDBACK;
|
|
245
|
+
}
|
|
246
|
+
// Written through rather than debounced like the play history: a taste level
|
|
247
|
+
// is a deliberate, rare action whose loss would be surprising.
|
|
248
|
+
this.save();
|
|
249
|
+
return this.state.feedback;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Replace the liked mirror with the account's own list.
|
|
254
|
+
*
|
|
255
|
+
* The account is where likes live, so this list is a copy rather than a
|
|
256
|
+
* record: reconciling at startup is what keeps the DJ's taste digest from
|
|
257
|
+
* carrying a like the account does not have (or missing one it does).
|
|
258
|
+
*
|
|
259
|
+
* @param {Iterable<number|string>} ids
|
|
260
|
+
*/
|
|
261
|
+
setLikedIds(ids) {
|
|
262
|
+
const list = [...new Set([...ids].map(Number).filter(Number.isFinite))];
|
|
263
|
+
this.state.feedback.likes = list.slice(0, MAX_FEEDBACK);
|
|
264
|
+
this.save();
|
|
265
|
+
return this.state.feedback;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Toggle like/dislike, or count a skip. The likes and dislikes of a track are
|
|
270
|
+
* mutually exclusive, so a dislike replaces a like and vice versa.
|
|
271
|
+
*/
|
|
193
272
|
recordFeedback(kind, trackId) {
|
|
194
|
-
const list = this.state.feedback[kind];
|
|
195
|
-
if (!list || !Number.isFinite(Number(trackId))) return this.state.feedback;
|
|
196
273
|
const id = Number(trackId);
|
|
197
|
-
|
|
274
|
+
if (!Number.isFinite(id)) return this.state.feedback;
|
|
198
275
|
if (kind === 'skips') {
|
|
199
276
|
// Skips are counters, not membership: keep the newest occurrence.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
list.unshift(id);
|
|
206
|
-
if (list.length > MAX_FEEDBACK) list.length = MAX_FEEDBACK;
|
|
207
|
-
// Liking and disliking a track are mutually exclusive.
|
|
208
|
-
const opposite = kind === 'likes' ? this.state.feedback.dislikes : this.state.feedback.likes;
|
|
209
|
-
const oppositeIndex = opposite.indexOf(id);
|
|
210
|
-
if (oppositeIndex >= 0) opposite.splice(oppositeIndex, 1);
|
|
277
|
+
const skips = this.state.feedback.skips;
|
|
278
|
+
skips.unshift(id);
|
|
279
|
+
if (skips.length > MAX_FEEDBACK) skips.length = MAX_FEEDBACK;
|
|
280
|
+
this.touch();
|
|
281
|
+
return this.state.feedback;
|
|
211
282
|
}
|
|
212
|
-
|
|
213
|
-
return this.state.feedback;
|
|
283
|
+
const level = kind === 'likes' ? 'liked' : kind === 'dislikes' ? 'disliked' : null;
|
|
284
|
+
if (!level) return this.state.feedback;
|
|
285
|
+
return this.setTaste(id, this.taste(id) === level ? 'none' : level);
|
|
214
286
|
}
|
|
215
287
|
|
|
216
288
|
get feedback() {
|
|
@@ -309,7 +381,9 @@ export class QrLogin {
|
|
|
309
381
|
} catch (error) {
|
|
310
382
|
this.lastError = error.message;
|
|
311
383
|
this.logger?.warn?.(`[music] QR poll failed: ${error.message}`);
|
|
312
|
-
|
|
384
|
+
// `error` is the machine-usable half; the panel localizes around it,
|
|
385
|
+
// while `message` stays bilingual for the agent-facing login tool.
|
|
386
|
+
return { state: attempt.state, message: `轮询失败 (poll failed): ${error.message}`, error: error.message, loginUrl: attempt.loginUrl };
|
|
313
387
|
}
|
|
314
388
|
|
|
315
389
|
if (result.code === 800) {
|
package/lib/state.js
CHANGED
|
@@ -152,6 +152,21 @@ export class Player {
|
|
|
152
152
|
return { added: added.length, snapshot: this.snapshot() };
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Remove one queue position and report what was removed.
|
|
157
|
+
*
|
|
158
|
+
* The report matters: taking a track out of the queue is a judgement about the
|
|
159
|
+
* track, not just about the list, and the caller cannot read it off the queue
|
|
160
|
+
* afterwards — the row whose index it was asking about is the one that is
|
|
161
|
+
* gone. `snapshot` is the state after the removal.
|
|
162
|
+
*
|
|
163
|
+
* @returns {{track: object|null, wasCurrent: boolean, snapshot: object}}
|
|
164
|
+
*/
|
|
165
|
+
removeAt(index) {
|
|
166
|
+
const track = this.queue[index] ?? null;
|
|
167
|
+
return { track, wasCurrent: index >= 0 && index === this.index, snapshot: this.remove(index) };
|
|
168
|
+
}
|
|
169
|
+
|
|
155
170
|
/** Remove one queue position. */
|
|
156
171
|
remove(index) {
|
|
157
172
|
if (index < 0 || index >= this.queue.length) return this.snapshot();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doitian/dsh-music",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "NetEase Cloud Music (music.163.com) player and AI DJ for DeepSeek Harness: a sidebar page with in-app playback, QR sign-in, and agent tools that curate the queue.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"lib/session.js",
|
|
40
40
|
"lib/state.js",
|
|
41
41
|
"lib/dj.js",
|
|
42
|
+
"lib/likes.js",
|
|
42
43
|
"lib/router.js",
|
|
43
44
|
"lib/panel.html",
|
|
44
45
|
"lib/vendor/qrcode.js",
|
|
@@ -53,8 +54,9 @@
|
|
|
53
54
|
"access": "public"
|
|
54
55
|
},
|
|
55
56
|
"scripts": {
|
|
56
|
-
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/netease.js && node --check lib/session.js && node --check lib/state.js && node --check lib/dj.js && node --check lib/router.js",
|
|
57
|
-
"test": "node test/netease.test.mjs && node test/dj.test.mjs && node test/client.test.mjs",
|
|
57
|
+
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/netease.js && node --check lib/session.js && node --check lib/state.js && node --check lib/dj.js && node --check lib/likes.js && node --check lib/router.js",
|
|
58
|
+
"test": "node test/netease.test.mjs && node test/likes.test.mjs && node test/dj.test.mjs && node test/client.test.mjs",
|
|
59
|
+
"probe:headers": "node scripts/probe-headers.mjs",
|
|
58
60
|
"test:live": "node test/host.test.mjs",
|
|
59
61
|
"test:all": "npm test && npm run test:live"
|
|
60
62
|
},
|