@doitian/dsh-music 0.1.2 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +206 -21
- package/lib/cache.js +156 -0
- package/lib/dj.js +443 -67
- package/lib/index.js +39 -5
- package/lib/likes.js +169 -0
- package/lib/netease.js +130 -0
- package/lib/panel.html +316 -41
- package/lib/router.js +203 -18
- package/lib/session.js +109 -18
- package/lib/state.js +55 -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,6 +100,7 @@ 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]
|
|
@@ -92,13 +108,14 @@ export class MusicRouter {
|
|
|
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;
|
|
@@ -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,
|
|
@@ -291,6 +347,101 @@ export class MusicRouter {
|
|
|
291
347
|
return 'discovered';
|
|
292
348
|
}
|
|
293
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
|
+
|
|
294
445
|
/** Route one `/music/api/*` request. */
|
|
295
446
|
async handleApi(req, res, pathname) {
|
|
296
447
|
const route = pathname.slice(`${this.base}/api`.length).replace(/\/+$/, '') || '/';
|
|
@@ -299,16 +450,16 @@ export class MusicRouter {
|
|
|
299
450
|
|
|
300
451
|
// ---- player state -----------------------------------------------------
|
|
301
452
|
if (route === '/state' && method === 'GET') {
|
|
302
|
-
return sendJson(res, 200, this.snapshot());
|
|
453
|
+
return sendJson(res, 200, await this.snapshot());
|
|
303
454
|
}
|
|
304
455
|
if (route === '/report' && method === 'POST') {
|
|
305
456
|
const body = await readJson(req);
|
|
306
457
|
this.player.report(body);
|
|
307
|
-
return sendJson(res, 200, this.snapshot());
|
|
458
|
+
return sendJson(res, 200, await this.snapshot());
|
|
308
459
|
}
|
|
309
460
|
if (route === '/seek-consumed' && method === 'POST') {
|
|
310
461
|
this.player.takeSeek();
|
|
311
|
-
return sendJson(res, 200, this.snapshot());
|
|
462
|
+
return sendJson(res, 200, await this.snapshot());
|
|
312
463
|
}
|
|
313
464
|
if (route === '/control' && method === 'POST') {
|
|
314
465
|
const { action, value } = await readJson(req);
|
|
@@ -324,7 +475,7 @@ export class MusicRouter {
|
|
|
324
475
|
case 'mode': this.player.setMode(value); break;
|
|
325
476
|
default: return sendJson(res, 400, { error: `unknown action "${action}"` });
|
|
326
477
|
}
|
|
327
|
-
return sendJson(res, 200, this.snapshot());
|
|
478
|
+
return sendJson(res, 200, await this.snapshot());
|
|
328
479
|
}
|
|
329
480
|
|
|
330
481
|
// ---- queue ------------------------------------------------------------
|
|
@@ -335,18 +486,22 @@ export class MusicRouter {
|
|
|
335
486
|
}
|
|
336
487
|
this.player.setQueue(tracks, { startIndex, source: 'user' });
|
|
337
488
|
this.store.recordPlay(this.player.current() ?? tracks[0]);
|
|
338
|
-
return sendJson(res, 200, this.snapshot());
|
|
489
|
+
return sendJson(res, 200, await this.snapshot());
|
|
339
490
|
}
|
|
340
491
|
if (route === '/queue' && method === 'POST') {
|
|
341
492
|
const { action, tracks, index } = await readJson(req);
|
|
493
|
+
let removal = null;
|
|
342
494
|
if (action === 'replace' && Array.isArray(tracks)) this.player.setQueue(tracks, { source: 'user' });
|
|
343
495
|
else if (action === 'append' && Array.isArray(tracks)) this.player.append(tracks, { source: 'user' });
|
|
344
496
|
else if (action === 'insert' && Array.isArray(tracks)) this.player.insertNext(tracks, { source: 'user' });
|
|
345
|
-
else if (action === 'remove') this
|
|
346
|
-
else if (action === 'jump') this.player.jump(Number(index));
|
|
497
|
+
else if (action === 'remove' || action === 'jump') removal = this.#queueAction(action, index);
|
|
347
498
|
else if (action === 'clear') this.player.clear();
|
|
348
499
|
else return sendJson(res, 400, { error: `unknown queue action "${action}"` });
|
|
349
|
-
|
|
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());
|
|
350
505
|
}
|
|
351
506
|
|
|
352
507
|
// ---- catalogue --------------------------------------------------------
|
|
@@ -389,14 +544,42 @@ export class MusicRouter {
|
|
|
389
544
|
});
|
|
390
545
|
}
|
|
391
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
|
+
|
|
392
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
|
+
*/
|
|
393
570
|
if (route === '/feedback' && method === 'POST') {
|
|
394
571
|
const { kind, trackId } = await readJson(req);
|
|
395
|
-
if (
|
|
396
|
-
|
|
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());
|
|
397
581
|
}
|
|
398
|
-
|
|
399
|
-
return sendJson(res, 200, { feedback: this.store.feedback });
|
|
582
|
+
return sendJson(res, 400, { error: `unknown feedback kind "${kind}"` });
|
|
400
583
|
}
|
|
401
584
|
|
|
402
585
|
// ---- streaming quality ------------------------------------------------
|
|
@@ -414,7 +597,7 @@ export class MusicRouter {
|
|
|
414
597
|
this.store.updateSettings({ level });
|
|
415
598
|
this.logger?.info?.(`[music] streaming quality set to ${level} from the player`);
|
|
416
599
|
// The running stream keeps its bytes; the next load or seek uses this.
|
|
417
|
-
return sendJson(res, 200, this.snapshot());
|
|
600
|
+
return sendJson(res, 200, await this.snapshot());
|
|
418
601
|
}
|
|
419
602
|
|
|
420
603
|
// ---- AI DJ ------------------------------------------------------------
|
|
@@ -469,7 +652,7 @@ export class MusicRouter {
|
|
|
469
652
|
} else if (prompt !== undefined && this.player.dj.enabled) {
|
|
470
653
|
void this.dj.topUp({ force: true });
|
|
471
654
|
}
|
|
472
|
-
return sendJson(res, 200, this.snapshot());
|
|
655
|
+
return sendJson(res, 200, await this.snapshot());
|
|
473
656
|
}
|
|
474
657
|
|
|
475
658
|
// ---- login ------------------------------------------------------------
|
|
@@ -495,6 +678,8 @@ export class MusicRouter {
|
|
|
495
678
|
if (route === '/login/logout' && method === 'POST') {
|
|
496
679
|
this.api.clearCookie();
|
|
497
680
|
this.store.clearSession();
|
|
681
|
+
// The likes belong to the account that just left, not to the next one.
|
|
682
|
+
this.likes.clear();
|
|
498
683
|
this.qr.reset();
|
|
499
684
|
return sendJson(res, 200, { loggedIn: false });
|
|
500
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
|
|
|
@@ -187,10 +196,11 @@ export class SessionStore {
|
|
|
187
196
|
return this.state.history.slice(0, limit).map((entry) => entry.id);
|
|
188
197
|
}
|
|
189
198
|
|
|
190
|
-
/** Most-played artists, most frequent first. */
|
|
199
|
+
/** Most-played artists, most frequent first. A skipped play is not a play. */
|
|
191
200
|
topArtists(limit = 8) {
|
|
192
201
|
const counts = new Map();
|
|
193
202
|
for (const entry of this.state.history.slice(0, 150)) {
|
|
203
|
+
if (entry.skipped) continue;
|
|
194
204
|
for (const artist of entry.artists) counts.set(artist, (counts.get(artist) ?? 0) + 1);
|
|
195
205
|
}
|
|
196
206
|
return [...counts.entries()]
|
|
@@ -199,26 +209,94 @@ export class SessionStore {
|
|
|
199
209
|
.map(([artist]) => artist);
|
|
200
210
|
}
|
|
201
211
|
|
|
202
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
212
|
+
/**
|
|
213
|
+
* One track's taste level: `liked`, `disliked`, or `none` (never rated).
|
|
214
|
+
* @param {number|string} trackId
|
|
215
|
+
* @returns {'liked'|'disliked'|'none'}
|
|
216
|
+
*/
|
|
217
|
+
taste(trackId) {
|
|
206
218
|
const id = Number(trackId);
|
|
207
|
-
|
|
208
|
-
if (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
if (this.state.feedback.likes.includes(id)) return 'liked';
|
|
220
|
+
if (this.state.feedback.dislikes.includes(id)) return 'disliked';
|
|
221
|
+
return 'none';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Set one track's taste level, keeping the two lists mutually exclusive.
|
|
226
|
+
*
|
|
227
|
+
* The caller owns the NetEase half. `setTaste(id, 'liked')` records the local
|
|
228
|
+
* mirror *after* the API call that liked the track for real, so the DJ's taste
|
|
229
|
+
* digest and the heart in the list agree with the account instead of
|
|
230
|
+
* promising a like NetEase never received.
|
|
231
|
+
*
|
|
232
|
+
* @param {number|string} trackId
|
|
233
|
+
* @param {'liked'|'none'|'disliked'} level
|
|
234
|
+
*/
|
|
235
|
+
setTaste(trackId, level) {
|
|
236
|
+
const id = Number(trackId);
|
|
237
|
+
if (!Number.isFinite(id) || !TASTE_LEVELS.includes(level)) return this.state.feedback;
|
|
238
|
+
for (const [name, list] of Object.entries({
|
|
239
|
+
liked: this.state.feedback.likes,
|
|
240
|
+
disliked: this.state.feedback.dislikes,
|
|
241
|
+
})) {
|
|
242
|
+
const index = list.indexOf(id);
|
|
243
|
+
if (name === level && index < 0) list.unshift(id);
|
|
244
|
+
else if (name !== level && index >= 0) list.splice(index, 1);
|
|
216
245
|
if (list.length > MAX_FEEDBACK) list.length = MAX_FEEDBACK;
|
|
217
|
-
// Liking and disliking a track are mutually exclusive.
|
|
218
|
-
const opposite = kind === 'likes' ? this.state.feedback.dislikes : this.state.feedback.likes;
|
|
219
|
-
const oppositeIndex = opposite.indexOf(id);
|
|
220
|
-
if (oppositeIndex >= 0) opposite.splice(oppositeIndex, 1);
|
|
221
246
|
}
|
|
247
|
+
// Written through rather than debounced like the play history: a taste level
|
|
248
|
+
// is a deliberate, rare action whose loss would be surprising.
|
|
249
|
+
this.save();
|
|
250
|
+
return this.state.feedback;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Replace the liked mirror with the account's own list.
|
|
255
|
+
*
|
|
256
|
+
* The account is where likes live, so this list is a copy rather than a
|
|
257
|
+
* record: reconciling at startup is what keeps the DJ's taste digest from
|
|
258
|
+
* carrying a like the account does not have (or missing one it does).
|
|
259
|
+
*
|
|
260
|
+
* @param {Iterable<number|string>} ids
|
|
261
|
+
*/
|
|
262
|
+
setLikedIds(ids) {
|
|
263
|
+
const list = [...new Set([...ids].map(Number).filter(Number.isFinite))];
|
|
264
|
+
this.state.feedback.likes = list.slice(0, MAX_FEEDBACK);
|
|
265
|
+
this.save();
|
|
266
|
+
return this.state.feedback;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Toggle like/dislike, or count a skip. The likes and dislikes of a track are
|
|
271
|
+
* mutually exclusive, so a dislike replaces a like and vice versa.
|
|
272
|
+
*/
|
|
273
|
+
recordFeedback(kind, trackId) {
|
|
274
|
+
const id = Number(trackId);
|
|
275
|
+
if (!Number.isFinite(id)) return this.state.feedback;
|
|
276
|
+
if (kind === 'skips') return this.recordSkip(id);
|
|
277
|
+
const level = kind === 'likes' ? 'liked' : kind === 'dislikes' ? 'disliked' : null;
|
|
278
|
+
if (!level) return this.state.feedback;
|
|
279
|
+
return this.setTaste(id, this.taste(id) === level ? 'none' : level);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Count a skip: the listener moved past the track early.
|
|
284
|
+
*
|
|
285
|
+
* Skips are counters, not membership, so each one is kept as an occurrence.
|
|
286
|
+
* The play it cut short is marked as well, because the history is what the
|
|
287
|
+
* DJ reads artist affinity from, and a track abandoned after ten seconds
|
|
288
|
+
* would otherwise count as one more play of that artist.
|
|
289
|
+
*
|
|
290
|
+
* @param {number|string} trackId
|
|
291
|
+
*/
|
|
292
|
+
recordSkip(trackId) {
|
|
293
|
+
const id = Number(trackId);
|
|
294
|
+
if (!Number.isFinite(id)) return this.state.feedback;
|
|
295
|
+
const skips = this.state.feedback.skips;
|
|
296
|
+
skips.unshift(id);
|
|
297
|
+
if (skips.length > MAX_FEEDBACK) skips.length = MAX_FEEDBACK;
|
|
298
|
+
const play = this.state.history.find((entry) => entry.id === id);
|
|
299
|
+
if (play) play.skipped = true;
|
|
222
300
|
this.touch();
|
|
223
301
|
return this.state.feedback;
|
|
224
302
|
}
|
|
@@ -235,13 +313,26 @@ export class SessionStore {
|
|
|
235
313
|
const name = (entry) =>
|
|
236
314
|
typeof entry === 'object' ? `${entry.name} — ${(entry.artists ?? []).join('/')}` : `#${entry}`;
|
|
237
315
|
const byId = new Map(this.state.history.map((entry) => [entry.id, entry]));
|
|
316
|
+
const skipCounts = new Map();
|
|
317
|
+
for (const id of this.state.feedback.skips) skipCounts.set(id, (skipCounts.get(id) ?? 0) + 1);
|
|
318
|
+
const skippedArtists = new Map();
|
|
319
|
+
for (const entry of this.state.history.slice(0, 150)) {
|
|
320
|
+
if (!entry.skipped) continue;
|
|
321
|
+
for (const artist of entry.artists ?? []) skippedArtists.set(artist, (skippedArtists.get(artist) ?? 0) + 1);
|
|
322
|
+
}
|
|
238
323
|
return {
|
|
239
324
|
recentlyPlayed: this.state.history.slice(0, history).map(name),
|
|
240
325
|
topArtists: this.topArtists(8),
|
|
241
326
|
liked: this.state.feedback.likes.slice(0, 15).map((id) => byId.get(id)).filter(Boolean).map(name),
|
|
242
327
|
disliked: this.state.feedback.dislikes.slice(0, 15).map((id) => byId.get(id)).filter(Boolean).map(name),
|
|
328
|
+
// Newest first, one line per track however often it was skipped.
|
|
329
|
+
skipped: [...skipCounts.keys()].slice(0, 15).map((id) => byId.get(id)).filter(Boolean).map(name),
|
|
243
330
|
dislikedIds: this.state.feedback.dislikes.slice(0, 80),
|
|
244
331
|
recentIds: this.recentIds(60),
|
|
332
|
+
/** Track id → how many times it was skipped. */
|
|
333
|
+
skipCounts,
|
|
334
|
+
/** Artist → skipped plays among the recent history. */
|
|
335
|
+
skippedArtists,
|
|
245
336
|
};
|
|
246
337
|
}
|
|
247
338
|
}
|
package/lib/state.js
CHANGED
|
@@ -25,6 +25,14 @@ export const MODES = ['list', 'single', 'shuffle'];
|
|
|
25
25
|
/** A pending seek is delivered once and then cleared. */
|
|
26
26
|
const SEEK_EPSILON_MS = 1_000;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A manual "next" before this much of a track has played is a skip: the
|
|
30
|
+
* listener rejected the track rather than finished with it. The window is 30 s,
|
|
31
|
+
* or a quarter of the track when that is longer.
|
|
32
|
+
*/
|
|
33
|
+
const SKIP_WINDOW_MS = 30_000;
|
|
34
|
+
const SKIP_WINDOW_FRACTION = 0.25;
|
|
35
|
+
|
|
28
36
|
export class Player {
|
|
29
37
|
/**
|
|
30
38
|
* @param {object} [options]
|
|
@@ -69,6 +77,11 @@ export class Player {
|
|
|
69
77
|
* @type {(() => Promise<void>) | undefined}
|
|
70
78
|
*/
|
|
71
79
|
this.onLowQueue = undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Called with a track the listener moved past early, for the taste record.
|
|
82
|
+
* @type {((track: object) => void) | undefined}
|
|
83
|
+
*/
|
|
84
|
+
this.onSkip = undefined;
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
/** Subscribe to state changes. @returns {() => void} unsubscribe */
|
|
@@ -152,6 +165,21 @@ export class Player {
|
|
|
152
165
|
return { added: added.length, snapshot: this.snapshot() };
|
|
153
166
|
}
|
|
154
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Remove one queue position and report what was removed.
|
|
170
|
+
*
|
|
171
|
+
* The report matters: taking a track out of the queue is a judgement about the
|
|
172
|
+
* track, not just about the list, and the caller cannot read it off the queue
|
|
173
|
+
* afterwards — the row whose index it was asking about is the one that is
|
|
174
|
+
* gone. `snapshot` is the state after the removal.
|
|
175
|
+
*
|
|
176
|
+
* @returns {{track: object|null, wasCurrent: boolean, snapshot: object}}
|
|
177
|
+
*/
|
|
178
|
+
removeAt(index) {
|
|
179
|
+
const track = this.queue[index] ?? null;
|
|
180
|
+
return { track, wasCurrent: index >= 0 && index === this.index, snapshot: this.remove(index) };
|
|
181
|
+
}
|
|
182
|
+
|
|
155
183
|
/** Remove one queue position. */
|
|
156
184
|
remove(index) {
|
|
157
185
|
if (index < 0 || index >= this.queue.length) return this.snapshot();
|
|
@@ -205,15 +233,42 @@ export class Player {
|
|
|
205
233
|
*/
|
|
206
234
|
move(step, { auto = false } = {}) {
|
|
207
235
|
if (this.queue.length === 0) return this.snapshot();
|
|
236
|
+
const left = this.current();
|
|
237
|
+
const skipped = !auto && step > 0 && this.#heardBriefly();
|
|
208
238
|
this.index = this.#nextIndex(step);
|
|
209
239
|
this.pendingSeek = 0;
|
|
210
240
|
this.playing = true;
|
|
211
241
|
this.reported = { ...this.reported, position: 0, duration: 0, error: null, at: Date.now() };
|
|
212
242
|
this.bump({ transport: true });
|
|
243
|
+
if (skipped && this.current() !== left) this.#notifySkip(left);
|
|
213
244
|
if (auto) this.#maybeExtend();
|
|
214
245
|
return this.snapshot();
|
|
215
246
|
}
|
|
216
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Whether the current track was heard, but only briefly.
|
|
250
|
+
*
|
|
251
|
+
* Only a reported position counts: a track the panel never started (paused,
|
|
252
|
+
* blocked by autoplay, still loading) was not listened to, so moving past it
|
|
253
|
+
* says nothing about it.
|
|
254
|
+
*/
|
|
255
|
+
#heardBriefly() {
|
|
256
|
+
const current = this.current();
|
|
257
|
+
if (!current || this.reported.trackId !== current.id) return false;
|
|
258
|
+
const position = Number(this.reported.position) || 0;
|
|
259
|
+
if (position <= 0) return false;
|
|
260
|
+
const duration = Number(this.reported.duration) || Number(current.duration) || 0;
|
|
261
|
+
return position < Math.max(SKIP_WINDOW_MS, duration * SKIP_WINDOW_FRACTION);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#notifySkip(track) {
|
|
265
|
+
try {
|
|
266
|
+
this.onSkip?.(track);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
this.logger?.warn?.(`[music] skip listener failed: ${error.message}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
217
272
|
/** Jump to an explicit queue position. */
|
|
218
273
|
jump(index) {
|
|
219
274
|
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.5",
|
|
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,8 @@
|
|
|
39
39
|
"lib/session.js",
|
|
40
40
|
"lib/state.js",
|
|
41
41
|
"lib/dj.js",
|
|
42
|
+
"lib/cache.js",
|
|
43
|
+
"lib/likes.js",
|
|
42
44
|
"lib/router.js",
|
|
43
45
|
"lib/panel.html",
|
|
44
46
|
"lib/vendor/qrcode.js",
|
|
@@ -53,8 +55,8 @@
|
|
|
53
55
|
"access": "public"
|
|
54
56
|
},
|
|
55
57
|
"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",
|
|
58
|
+
"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/cache.js && node --check lib/likes.js && node --check lib/router.js",
|
|
59
|
+
"test": "node test/netease.test.mjs && node test/cache.test.mjs && node test/likes.test.mjs && node test/dj.test.mjs && node test/client.test.mjs",
|
|
58
60
|
"probe:headers": "node scripts/probe-headers.mjs",
|
|
59
61
|
"test:live": "node test/host.test.mjs",
|
|
60
62
|
"test:all": "npm test && npm run test:live"
|