@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/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
- /** The panel's poll target: full player snapshot including account and DJ. */
270
- snapshot(extra = {}) {
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
- ...this.player.snapshot(),
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.player.remove(Number(index));
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
- return sendJson(res, 200, this.snapshot());
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 (!['likes', 'dislikes', 'skips'].includes(kind)) {
396
- return sendJson(res, 400, { error: `unknown feedback kind "${kind}"` });
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
- this.store.recordFeedback(kind, trackId);
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
- /** Record like/dislike/skip feedback for one track. */
203
- recordFeedback(kind, trackId) {
204
- const list = this.state.feedback[kind];
205
- if (!list || !Number.isFinite(Number(trackId))) return this.state.feedback;
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
- const index = list.indexOf(id);
208
- if (kind === 'skips') {
209
- // Skips are counters, not membership: keep the newest occurrence.
210
- list.unshift(id);
211
- if (list.length > MAX_FEEDBACK) list.length = MAX_FEEDBACK;
212
- } else if (index >= 0) {
213
- list.splice(index, 1);
214
- } else {
215
- list.unshift(id);
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.2",
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"