@doitian/dsh-music 0.1.4 → 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/index.js CHANGED
@@ -187,6 +187,14 @@ export function apply(ctx, config = {}) {
187
187
  },
188
188
  });
189
189
  player.onLowQueue = () => dj.topUp();
190
+ player.onSkip = (track) => {
191
+ // The panel's ✕ records the dislike and then presses next, so an early
192
+ // dislike would also count as a skip — but only inside the skip window,
193
+ // which would make the same judgement weigh differently by when it came.
194
+ if (store.taste(track.id) === 'disliked') return;
195
+ store.recordSkip(track.id);
196
+ logger.debug(`[music] skip recorded for ${track.id}`);
197
+ };
190
198
 
191
199
  /** Tool names that registered successfully; surfaced by `/music/health`. */
192
200
  const registeredTools = [];
@@ -503,8 +511,8 @@ export function apply(ctx, config = {}) {
503
511
  title: 'AI DJ (NetEase Cloud Music)',
504
512
  description:
505
513
  'Run or configure the continuous AI DJ for the user\'s NetEase Cloud Music panel. ' +
506
- 'With `enabled: true` the DJ keeps the queue stocked: it gathers candidates from similar songs, the daily ' +
507
- 'recommendations, the charts and any mood brief, then either asks the model to choose or scores them by the ' +
514
+ 'With `enabled: true` the DJ keeps the queue stocked: it gathers candidates from similar songs, personal FM, ' +
515
+ 'liked songs not heard lately, the daily recommendations, the charts and any mood brief, then either asks the model to choose or scores them by the ' +
508
516
  'listener\'s liked artists, novelty and feedback. Pass `prompt` for a mood or vibe, and `replan` to refill now.',
509
517
  parameters: {
510
518
  type: 'object',
@@ -513,7 +521,10 @@ export function apply(ctx, config = {}) {
513
521
  enabled: { type: 'boolean', description: 'Turn continuous queue topping-up on or off.' },
514
522
  prompt: { type: 'string', description: 'Mood or request, e.g. "rainy afternoon jazz", "90s cantopop".' },
515
523
  replan: { type: 'boolean', description: 'Plan and queue a fresh batch immediately.' },
516
- count: { type: 'integer', description: 'How many tracks to plan (1-25, default 8 for `replan`).' },
524
+ count: {
525
+ type: 'integer',
526
+ description: 'How many tracks to plan (1-25; default 8 for `start`, the player\'s batch size for `replan`).',
527
+ },
517
528
  start: { type: 'boolean', description: 'Replace the queue with a freshly planned batch (a full DJ session).' },
518
529
  },
519
530
  },
@@ -524,6 +535,7 @@ export function apply(ctx, config = {}) {
524
535
  `AI DJ is ${value.enabled ? 'on' : 'off'} (${value.source}${value.route ? ` via ${value.route}` : ''}). ` +
525
536
  (value.modelError ? `Model tier unavailable: ${value.modelError}. ` : '') +
526
537
  (value.vibe ? `Vibe: ${value.vibe}. ` : '') +
538
+ (value.searchedAs.length ? `Brief searched as: ${value.searchedAs.join('; ')}. ` : '') +
527
539
  (value.names.length
528
540
  ? `Queued ${value.added} track(s):\n${value.names.map((entry, index) => `${index + 1}. ${entry}`).join('\n')}`
529
541
  : value.note || 'No new tracks were queued.') +
@@ -540,7 +552,7 @@ export function apply(ctx, config = {}) {
540
552
  if (args.start) {
541
553
  plan = await dj.start({ prompt, count });
542
554
  } else if (args.replan || (enabled === true && player.queue.length === 0)) {
543
- plan = await dj.topUp({ force: true });
555
+ plan = await dj.topUp({ force: true, count: args.count === undefined ? undefined : count });
544
556
  }
545
557
 
546
558
  const summary = player.summary();
@@ -550,6 +562,7 @@ export function apply(ctx, config = {}) {
550
562
  route: plan?.route ?? summary.dj.lastPlan?.route ?? null,
551
563
  modelError: player.dj.modelError ?? null,
552
564
  vibe: plan?.vibe ?? summary.dj.lastPlan?.vibe ?? '',
565
+ searchedAs: plan?.searchedAs ?? summary.dj.lastPlan?.searchedAs ?? [],
553
566
  note: plan?.note ?? (plan && plan.tracks.length === 0 ? 'no candidates were available' : ''),
554
567
  added: plan?.tracks.length ?? 0,
555
568
  names: (plan?.tracks ?? []).map((track) => `${track.name} — ${(track.artists ?? []).join('/')}`),
package/lib/netease.js CHANGED
@@ -372,6 +372,21 @@ export class Netease {
372
372
  return data?.result ?? {};
373
373
  }
374
374
 
375
+ /**
376
+ * Playlists matching a keyword. Listeners name and tag playlists by mood
377
+ * (雨天, 爵士, 深夜…), so this is how a mood finds music, where a song search
378
+ * only matches titles and lyrics.
379
+ */
380
+ async searchPlaylists(keyword, { limit = 10 } = {}) {
381
+ const result = await this.search(keyword, { type: 1000, limit });
382
+ return (result.playlists ?? []).map((item) => ({
383
+ id: item.id,
384
+ name: item.name,
385
+ trackCount: item.trackCount ?? 0,
386
+ playCount: item.playCount ?? 0,
387
+ }));
388
+ }
389
+
375
390
  /** Track metadata, including cover art and the VIP fee code. */
376
391
  async songDetail(ids) {
377
392
  const list = [...ids].map(Number).filter(Number.isFinite);
@@ -476,6 +491,12 @@ export class Netease {
476
491
  playCount: playlist.playCount ?? 0,
477
492
  creator: playlist.creator?.nickname ?? '',
478
493
  tracks: normalizeTracks(playlist.tracks),
494
+ /**
495
+ * Every track id, in order. For a playlist the account does not own,
496
+ * `tracks` stops at the first 20 while this list is complete, so it is
497
+ * the only way to reach the rest.
498
+ */
499
+ trackIds: (playlist.trackIds ?? []).map((entry) => entry.id).filter(Number.isFinite),
479
500
  };
480
501
  }
481
502
 
@@ -524,6 +545,18 @@ export class Netease {
524
545
  return normalizeTracks(result?.dailySongs ?? result?.recommend);
525
546
  }
526
547
 
548
+ /**
549
+ * 私人FM (personal FM): a few tracks NetEase picks for this account, a
550
+ * different few on every call. Requires a session.
551
+ *
552
+ * This is the personalised feed the plain API still serves; heartbeat mode
553
+ * ({@link intelligenceList}) answers `code 500` there for every seed.
554
+ */
555
+ async personalFm() {
556
+ const data = await this.call('/api/v1/radio/get');
557
+ return normalizeTracks(data?.data);
558
+ }
559
+
527
560
  /**
528
561
  * 心动模式 (heartbeat mode): a personalised continuation of a seed track
529
562
  * inside a playlist context. Requires a session.
package/lib/session.js CHANGED
@@ -196,10 +196,11 @@ export class SessionStore {
196
196
  return this.state.history.slice(0, limit).map((entry) => entry.id);
197
197
  }
198
198
 
199
- /** Most-played artists, most frequent first. */
199
+ /** Most-played artists, most frequent first. A skipped play is not a play. */
200
200
  topArtists(limit = 8) {
201
201
  const counts = new Map();
202
202
  for (const entry of this.state.history.slice(0, 150)) {
203
+ if (entry.skipped) continue;
203
204
  for (const artist of entry.artists) counts.set(artist, (counts.get(artist) ?? 0) + 1);
204
205
  }
205
206
  return [...counts.entries()]
@@ -272,19 +273,34 @@ export class SessionStore {
272
273
  recordFeedback(kind, trackId) {
273
274
  const id = Number(trackId);
274
275
  if (!Number.isFinite(id)) return this.state.feedback;
275
- if (kind === 'skips') {
276
- // Skips are counters, not membership: keep the newest occurrence.
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;
282
- }
276
+ if (kind === 'skips') return this.recordSkip(id);
283
277
  const level = kind === 'likes' ? 'liked' : kind === 'dislikes' ? 'disliked' : null;
284
278
  if (!level) return this.state.feedback;
285
279
  return this.setTaste(id, this.taste(id) === level ? 'none' : level);
286
280
  }
287
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;
300
+ this.touch();
301
+ return this.state.feedback;
302
+ }
303
+
288
304
  get feedback() {
289
305
  return this.state.feedback;
290
306
  }
@@ -297,13 +313,26 @@ export class SessionStore {
297
313
  const name = (entry) =>
298
314
  typeof entry === 'object' ? `${entry.name} — ${(entry.artists ?? []).join('/')}` : `#${entry}`;
299
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
+ }
300
323
  return {
301
324
  recentlyPlayed: this.state.history.slice(0, history).map(name),
302
325
  topArtists: this.topArtists(8),
303
326
  liked: this.state.feedback.likes.slice(0, 15).map((id) => byId.get(id)).filter(Boolean).map(name),
304
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),
305
330
  dislikedIds: this.state.feedback.dislikes.slice(0, 80),
306
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,
307
336
  };
308
337
  }
309
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 */
@@ -220,15 +233,42 @@ export class Player {
220
233
  */
221
234
  move(step, { auto = false } = {}) {
222
235
  if (this.queue.length === 0) return this.snapshot();
236
+ const left = this.current();
237
+ const skipped = !auto && step > 0 && this.#heardBriefly();
223
238
  this.index = this.#nextIndex(step);
224
239
  this.pendingSeek = 0;
225
240
  this.playing = true;
226
241
  this.reported = { ...this.reported, position: 0, duration: 0, error: null, at: Date.now() };
227
242
  this.bump({ transport: true });
243
+ if (skipped && this.current() !== left) this.#notifySkip(left);
228
244
  if (auto) this.#maybeExtend();
229
245
  return this.snapshot();
230
246
  }
231
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
+
232
272
  /** Jump to an explicit queue position. */
233
273
  jump(index) {
234
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.4",
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,7 @@
39
39
  "lib/session.js",
40
40
  "lib/state.js",
41
41
  "lib/dj.js",
42
+ "lib/cache.js",
42
43
  "lib/likes.js",
43
44
  "lib/router.js",
44
45
  "lib/panel.html",
@@ -54,8 +55,8 @@
54
55
  "access": "public"
55
56
  },
56
57
  "scripts": {
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",
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",
59
60
  "probe:headers": "node scripts/probe-headers.mjs",
60
61
  "test:live": "node test/host.test.mjs",
61
62
  "test:all": "npm test && npm run test:live"