@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/dj.js CHANGED
@@ -17,10 +17,11 @@
17
17
  * Without it the request reaches such a provider looking like an anonymous
18
18
  * client that ignores its conventions.
19
19
  * 2. **Heuristic tier** — always available, no model required: NetEase's own
20
- * signals (similar songs, daily recommendations, anonymous new-song feed,
21
- * charts, keyword search) scored by artist affinity, novelty, and the
22
- * listener's like/dislike/skip feedback, then interleaved so consecutive
23
- * tracks do not share an artist.
20
+ * signals (similar songs, personal FM, rested likes, daily
21
+ * recommendations, anonymous new-song feed, charts, keyword search)
22
+ * scored by mood match, artist affinity, and the listener's
23
+ * like/dislike/skip feedback, then interleaved so consecutive tracks do
24
+ * not share an artist.
24
25
  *
25
26
  * The DJ never blocks playback: every entry point resolves to a plan or to
26
27
  * `null`, and the caller keeps whatever is already queued.
@@ -28,7 +29,9 @@
28
29
  * @module dsh-music/dj
29
30
  */
30
31
 
31
- import { randomUUID } from 'node:crypto';
32
+ import { createHash, randomUUID } from 'node:crypto';
33
+
34
+ import { Pacer, TtlCache, sampleInOrder } from './cache.js';
32
35
 
33
36
  /**
34
37
  * Render a model-tier failure so its stable code survives.
@@ -52,11 +55,79 @@ function describeFailure(error) {
52
55
  }
53
56
 
54
57
  /** Candidate pool handed to the model; keeps the prompt inside a small budget. */
55
- const MAX_POOL = 45;
58
+ const MAX_POOL = 70;
56
59
 
57
- /** Only the first `MAX_SEEDS` history entries seed similarity expansion. */
60
+ /** How many tracks seed similarity expansion. */
58
61
  const MAX_SEEDS = 4;
59
62
 
63
+ /** How far back in the history a seed may come from, so seeds stay current. */
64
+ const SEED_HISTORY = 20;
65
+
66
+ /** Liked tracks offered back to the pool per plan. */
67
+ const LIKED_SAMPLE = 6;
68
+
69
+ /** A liked track played more recently than this is not offered back yet. */
70
+ const LIKED_REST_MS = 3 * 24 * 60 * 60 * 1000;
71
+
72
+ /**
73
+ * The order the pool is assembled in: a track two sources share is claimed by
74
+ * the earlier one, and the model reads the catalogue in this order.
75
+ */
76
+ const ORIGINS = ['prompt', 'liked', 'fm', 'similar', 'daily', 'new', 'chart'];
77
+
78
+ /**
79
+ * Tracks each source may contribute per plan. Every source gets a share, so
80
+ * no single one can crowd discovery out of the pool; without a brief they sum
81
+ * to 48, and a brief adds up to 23 that match it.
82
+ */
83
+ const QUOTA = { promptPlaylists: 15, promptSongs: 8, similar: 14, daily: 8, new: 6, chart: 8 };
84
+
85
+ const HOUR = 60 * 60 * 1000;
86
+
87
+ /**
88
+ * How long each source is kept. Matched to how often it actually changes:
89
+ * similarity effectively never, a chart daily or weekly, the daily
90
+ * recommendations once a day. Personal FM is never cached, because every call
91
+ * answers something new.
92
+ */
93
+ const TTL = {
94
+ similar: 24 * HOUR,
95
+ track: 24 * HOUR,
96
+ playlist: 12 * HOUR,
97
+ chart: 6 * HOUR,
98
+ search: 6 * HOUR,
99
+ daily: 3 * HOUR,
100
+ new: HOUR,
101
+ /** A brief's rewrite into search terms: what it means does not drift. */
102
+ terms: 7 * 24 * HOUR,
103
+ /** A brief that could not be rewritten is searched as written, and retried after this. */
104
+ termsRetry: HOUR / 2,
105
+ };
106
+
107
+ /** 飙升榜, 新歌榜, 原创榜, 热歌榜: the general charts, updated daily or weekly. */
108
+ const CHARTS = [19723756, 3779629, 2884035, 3778678];
109
+
110
+ /** How many of a brief's playlists are drawn from. */
111
+ const BRIEF_PLAYLISTS = 3;
112
+
113
+ /** How many search terms a brief is rewritten into. */
114
+ const BRIEF_TERMS = 3;
115
+
116
+ /** A playlist shorter than this is too thin to stand for a mood. */
117
+ const MIN_PLAYLIST_TRACKS = 10;
118
+
119
+ /** Personal FM calls per plan; NetEase answers three tracks per call. */
120
+ const FM_CALLS = 2;
121
+
122
+ /**
123
+ * A track skipped this often leaves the pool for good, like a dislike. One
124
+ * skip is only a penalty: it may have been the wrong moment, not the wrong song.
125
+ */
126
+ const SKIPS_TO_EXCLUDE = 2;
127
+
128
+ /** An artist is reported to the model as often skipped from this many skips. */
129
+ const SKIPS_TO_REPORT_ARTIST = 2;
130
+
60
131
  /** Prefix for the minted curation identity, so logs and health read clearly. */
61
132
  const SESSION_ID_PREFIX = 'music-dj-';
62
133
 
@@ -132,6 +203,8 @@ export class AiDj {
132
203
  * @param {string} [options.sessionId] configured curation identity, overriding the minted one.
133
204
  * @param {{warn: Function, info: Function, debug: Function}} [options.logger]
134
205
  * @param {object} [options.model] `{ provider, model }` enabling the model tier.
206
+ * @param {() => number} [options.now] the source cache's clock, injectable for tests.
207
+ * @param {Pacer} [options.pacer] spaces out the NetEase requests a plan sends.
135
208
  */
136
209
  constructor({
137
210
  api,
@@ -142,6 +215,8 @@ export class AiDj {
142
215
  sessionId,
143
216
  logger,
144
217
  model = {},
218
+ now = Date.now,
219
+ pacer = new Pacer(),
145
220
  } = {}) {
146
221
  this.api = api;
147
222
  this.player = player;
@@ -172,6 +247,10 @@ export class AiDj {
172
247
  this.modelError = null;
173
248
  /** Guards against overlapping plans when a track ends mid-plan. */
174
249
  this.busy = false;
250
+ /** The sources' answers, each kept for as long as it plausibly stays the same. */
251
+ this.cache = new TtlCache({ now });
252
+ /** Every source request goes through this, so a plan never arrives as a burst. */
253
+ this.pacer = pacer;
175
254
  }
176
255
 
177
256
  /** Record a model-tier decline and carry on with the heuristic tier. */
@@ -285,58 +364,314 @@ export class AiDj {
285
364
 
286
365
  // ------------------------------------------------------------- candidates
287
366
 
367
+ /**
368
+ * The tracks similarity expands from: what is playing and what was recently
369
+ * heard through, topped up with likes when the history is thin.
370
+ *
371
+ * A rejected track never seeds. The ✕ records a dislike and then moves on, so
372
+ * seeding from the raw history would make the very next plan fetch more of
373
+ * exactly what was just turned down.
374
+ */
375
+ #seeds(digest) {
376
+ const rejected = (id) => this.store.taste(id) === 'disliked' || digest.skipCounts.has(id);
377
+ const seeds = [];
378
+ const offer = (id) => {
379
+ if (seeds.length < MAX_SEEDS && id && !rejected(id) && !seeds.includes(id)) seeds.push(id);
380
+ };
381
+ offer(this.player.current()?.id);
382
+ for (const entry of this.store.state.history.slice(0, SEED_HISTORY)) {
383
+ if (!entry.skipped) offer(entry.id);
384
+ }
385
+ for (const id of this.store.feedback.likes) offer(id);
386
+ return seeds;
387
+ }
388
+
389
+ /**
390
+ * A random handful of liked tracks that have rested long enough to be worth
391
+ * hearing again. A personal DJ that never returns to what the listener loves
392
+ * is only a discovery feed.
393
+ */
394
+ #likedSample(excluded) {
395
+ const restedSince = Date.now() - LIKED_REST_MS;
396
+ const heardRecently = new Set(
397
+ this.store.state.history.filter((entry) => (entry.at ?? 0) > restedSince).map((entry) => entry.id),
398
+ );
399
+ const ids = this.store.feedback.likes.filter((id) => !excluded.has(id) && !heardRecently.has(id));
400
+ return sampleInOrder(ids, LIKED_SAMPLE);
401
+ }
402
+
288
403
  /**
289
404
  * Gather a de-duplicated candidate pool from every available signal.
290
405
  *
291
406
  * Every source is independent and failure-tolerant: a source that throws or
292
407
  * needs a session the listener lacks contributes nothing.
293
408
  */
294
- async #candidates({ prompt = '', count = 30 } = {}) {
409
+ async #candidates({ prompt = '' } = {}) {
295
410
  const digest = this.store.tasteDigest();
296
- const excluded = new Set([...digest.dislikedIds, ...digest.recentIds]);
411
+ // Queued tracks are excluded too: a batch is appended, and a pick the queue
412
+ // already holds is dropped there, so the batch would silently come up short.
413
+ const excluded = new Set([
414
+ ...digest.dislikedIds,
415
+ ...digest.recentIds,
416
+ ...this.player.queue.map((track) => track.id),
417
+ ...[...digest.skipCounts].filter(([, skips]) => skips >= SKIPS_TO_EXCLUDE).map(([id]) => id),
418
+ ]);
419
+ const brief = prompt.trim();
420
+ const seeds = this.#seeds(digest);
421
+ // Every source is fetched at once and fails alone; the pool is then
422
+ // assembled in priority order, so a track two sources share is claimed by
423
+ // the one that ranks it higher, however the requests happened to finish.
424
+ const settled = (promise) => promise.catch(() => []);
425
+ const briefing = brief ? this.#briefTracks(brief, excluded).catch(() => ({ tracks: [], terms: [brief] })) : null;
426
+ const sources = {
427
+ prompt: brief ? settled(briefing.then((result) => result.tracks)) : [],
428
+ liked: settled(this.#tracksByIds(this.#likedSample(excluded))),
429
+ fm: settled(this.#fmTracks()),
430
+ similar: settled(this.#similarTracks(seeds, excluded)),
431
+ daily: settled(this.#feedTracks(`daily:${this.#account()}`, TTL.daily, () => this.api.recommendSongs(30), QUOTA.daily, excluded)),
432
+ new: settled(this.#feedTracks('new', TTL.new, () => this.api.personalizedNewsongs(30), QUOTA.new, excluded)),
433
+ chart: settled(this.#chartTracks(excluded)),
434
+ };
435
+
297
436
  const pool = [];
298
437
  const seen = new Set();
299
- const add = (tracks, origin) => {
300
- for (const track of tracks ?? []) {
438
+ for (const origin of ORIGINS) {
439
+ for (const track of await sources[origin]) {
301
440
  if (!track?.id || seen.has(track.id) || excluded.has(track.id)) continue;
302
441
  seen.add(track.id);
303
442
  pool.push({ ...track, origin });
304
443
  }
305
- };
444
+ }
445
+ const trimmed = pool.slice(0, MAX_POOL);
446
+ // Covers cost a request only when some track came without one.
447
+ const covered = trimmed.some((track) => !track.picUrl)
448
+ ? await this.pacer.run(() => this.api.withCovers(trimmed)).catch(() => trimmed)
449
+ : trimmed;
450
+ return { pool: covered, digest, terms: briefing ? (await briefing).terms : [] };
451
+ }
306
452
 
307
- const current = this.player.current();
308
- const seeds = [current?.id, ...this.store.recentIds(MAX_SEEDS)].filter(Boolean).slice(0, MAX_SEEDS);
453
+ /**
454
+ * Who the personal sources belong to. The cookie, not the account record,
455
+ * because the record is fetched after a sign-in and would briefly read the
456
+ * same for two different people. Only a digest of it is ever kept.
457
+ */
458
+ #account() {
459
+ const cookie = this.api.jar?.get?.('MUSIC_U');
460
+ if (cookie) return createHash('sha256').update(cookie).digest('hex').slice(0, 12);
461
+ return this.api.authenticated ? 'session' : 'anonymous';
462
+ }
309
463
 
310
- const tasks = [];
311
- // Similarity expansion from what is playing and what was played recently.
312
- for (const seed of seeds) {
313
- tasks.push(
314
- this.api.simiSongs(seed, { limit: 12 }).then((tracks) => add(tracks, 'similar')).catch(() => {}),
315
- );
464
+ /** Track details by id, each cached on its own so a sample costs only the misses. */
465
+ async #tracksByIds(ids) {
466
+ if (ids.length === 0 || !this.api.songDetail) return [];
467
+ const missing = ids.filter((id) => this.cache.peek(`track:${id}`) === undefined);
468
+ if (missing.length > 0) {
469
+ for (const track of await this.pacer.run(() => this.api.songDetail(missing))) {
470
+ if (track?.id) this.cache.set(`track:${track.id}`, track, TTL.track);
471
+ }
316
472
  }
317
- // A mood brief is a search term; the raw text searches well enough.
318
- if (prompt.trim()) {
319
- tasks.push(
320
- this.api.searchSongs(prompt.trim(), { limit: 15 }).then((r) => add(r.tracks, 'prompt')).catch(() => {}),
321
- );
473
+ return ids.map((id) => this.cache.peek(`track:${id}`)).filter(Boolean);
474
+ }
475
+
476
+ /** A cached list, sampled down to its quota of eligible tracks. */
477
+ async #feedTracks(key, ttlMs, load, quota, excluded) {
478
+ const tracks = await this.cache.get(key, ttlMs, () => this.pacer.run(load));
479
+ return sampleInOrder((tracks ?? []).filter((track) => track?.id && !excluded.has(track.id)), quota);
480
+ }
481
+
482
+ /** Personal FM answers a different three on every call, so it is never cached. */
483
+ async #fmTracks() {
484
+ if (!this.api.authenticated || !this.api.personalFm) return [];
485
+ const calls = Array.from({ length: FM_CALLS }, () => this.pacer.run(() => this.api.personalFm()).catch(() => []));
486
+ return (await Promise.all(calls)).flat();
487
+ }
488
+
489
+ /**
490
+ * Similar tracks for each seed. Every eligible track of the first seed — the
491
+ * one playing — is kept, and the other seeds share what is left of the quota.
492
+ */
493
+ async #similarTracks(seeds, excluded) {
494
+ const lists = await Promise.all(
495
+ seeds.map((seed) =>
496
+ this.cache
497
+ .get(`similar:${seed}`, TTL.similar, () => this.pacer.run(() => this.api.simiSongs(seed, { limit: 20 })))
498
+ .catch(() => []),
499
+ ),
500
+ );
501
+ const eligible = (tracks) => (tracks ?? []).filter((track) => track?.id && !excluded.has(track.id));
502
+ const [first = [], ...rest] = lists.map(eligible);
503
+ const lead = first.slice(0, QUOTA.similar);
504
+ return [...lead, ...sampleInOrder(rest.flat(), QUOTA.similar - lead.length)];
505
+ }
506
+
507
+ /**
508
+ * One playlist's track ids and whatever full tracks came with them, cached.
509
+ *
510
+ * For a playlist the account does not own NetEase returns full tracks only
511
+ * for the first 20, but every id. Sampling from the ids is what reaches the
512
+ * rest of a 100-track playlist, rather than serving its first 20 forever.
513
+ */
514
+ async #playlist(id, ttlMs) {
515
+ const playlist = await this.cache.get(`playlist:${id}`, ttlMs, () =>
516
+ this.pacer.run(() => this.api.playlistDetail(id, { limit: 1000 })),
517
+ );
518
+ const tracks = playlist?.tracks ?? [];
519
+ return {
520
+ ids: playlist?.trackIds?.length ? playlist.trackIds : tracks.map((track) => track.id),
521
+ known: new Map(tracks.map((track) => [track.id, track])),
522
+ };
523
+ }
524
+
525
+ /** Sample eligible ids from playlists, resolving only those that came without details. */
526
+ async #samplePlaylists(playlists, quota, excluded) {
527
+ const known = new Map();
528
+ const ids = [];
529
+ for (const playlist of playlists) {
530
+ for (const [id, track] of playlist.known) known.set(id, track);
531
+ for (const id of playlist.ids) if (!excluded.has(id) && !ids.includes(id)) ids.push(id);
322
532
  }
323
- // Editorial and anonymous feeds keep the pool from collapsing to one genre.
324
- tasks.push(this.api.recommendSongs(20).then((tracks) => add(tracks, 'daily')).catch(() => {}));
325
- tasks.push(this.api.personalizedNewsongs(15).then((tracks) => add(tracks, 'new')).catch(() => {}));
326
- tasks.push(
327
- this.api
328
- .playlistDetail('3778678', { limit: 40 })
329
- .then((playlist) => add(playlist.tracks, 'chart'))
330
- .catch(() => {}),
533
+ const chosen = sampleInOrder(ids, quota);
534
+ const resolved = await this.#tracksByIds(chosen.filter((id) => !known.has(id))).catch(() => []);
535
+ for (const track of resolved) known.set(track.id, track);
536
+ return chosen.map((id) => known.get(id)).filter(Boolean);
537
+ }
538
+
539
+ /**
540
+ * A sample across the four general charts. One chart alone served the same
541
+ * forty hits every plan; the genre charts are left out because a classical
542
+ * or rap chart is as likely to be off-taste as on it.
543
+ */
544
+ async #chartTracks(excluded) {
545
+ const charts = await Promise.all(CHARTS.map((id) => this.#playlist(id, TTL.chart).catch(() => null)));
546
+ return this.#samplePlaylists(charts.filter(Boolean), QUOTA.chart, excluded);
547
+ }
548
+
549
+ /**
550
+ * Tracks for a mood brief. Song search only matches titles and lyrics, so a
551
+ * mood mostly finds tracks literally named after it; listeners name and tag
552
+ * playlists by mood, so the brief's best-loved playlists carry most of it.
553
+ *
554
+ * @returns {Promise<{tracks: object[], terms: string[]}>}
555
+ */
556
+ async #briefTracks(brief, excluded) {
557
+ const terms = await this.#briefTerms(brief);
558
+ const songs = this.#feedTracks(
559
+ `songs:${terms[0]}`,
560
+ TTL.search,
561
+ () => this.api.searchSongs(terms[0], { limit: 30 }).then((result) => result.tracks),
562
+ QUOTA.promptSongs,
563
+ excluded,
564
+ ).catch(() => []);
565
+ const playlists = (async () => {
566
+ if (!this.api.searchPlaylists) return [];
567
+ // Every term gets its share, so one broad term cannot take every slot.
568
+ const perTerm = Math.ceil(BRIEF_PLAYLISTS / terms.length);
569
+ const found = await Promise.all(
570
+ terms.map((term) =>
571
+ this.cache
572
+ .get(`playlists:${term}`, TTL.search, () => this.pacer.run(() => this.api.searchPlaylists(term, { limit: 10 })))
573
+ .catch(() => []),
574
+ ),
575
+ );
576
+ // Among the most relevant few, the most played: relevance alone surfaces
577
+ // a 14-track playlist nobody listens to as readily as a curated one.
578
+ const chosen = new Map();
579
+ for (const results of found) {
580
+ const best = results
581
+ .slice(0, 6)
582
+ .filter((playlist) => playlist.trackCount >= MIN_PLAYLIST_TRACKS && !chosen.has(playlist.id))
583
+ .sort((a, b) => b.playCount - a.playCount)
584
+ .slice(0, perTerm);
585
+ for (const playlist of best) chosen.set(playlist.id, playlist);
586
+ }
587
+ const ids = [...chosen.keys()].slice(0, BRIEF_PLAYLISTS);
588
+ const loaded = await Promise.all(ids.map((id) => this.#playlist(id, TTL.playlist).catch(() => null)));
589
+ return this.#samplePlaylists(loaded.filter(Boolean), QUOTA.promptPlaylists, excluded);
590
+ })().catch(() => []);
591
+ // Playlist tracks first: they are the stronger match for a mood.
592
+ return { tracks: [...(await playlists), ...(await songs)], terms };
593
+ }
594
+
595
+ /**
596
+ * The search terms a brief is looked up by: the model's rewrite when there is
597
+ * one, the brief as written otherwise.
598
+ *
599
+ * NetEase playlists are named in Chinese, so an English brief matches badly —
600
+ * "90s cantopop" finds 90s hip-hop. One model call turns the brief into the
601
+ * terms a listener would name such a playlist, and it is made once per brief,
602
+ * not per plan: a brief means the same thing next week. A brief that could
603
+ * not be rewritten is searched as written, and retried after a while rather
604
+ * than on every plan.
605
+ *
606
+ * @returns {Promise<string[]>}
607
+ */
608
+ #briefTerms(brief) {
609
+ const key = `terms:${brief}`;
610
+ const cached = this.cache.peek(key);
611
+ if (cached !== undefined) return cached;
612
+ const pending = this.#rewriteBrief(brief).then(
613
+ (terms) => {
614
+ if (!terms) {
615
+ this.cache.set(key, Promise.resolve([brief]), TTL.termsRetry);
616
+ return [brief];
617
+ }
618
+ this.cache.set(key, Promise.resolve(terms), TTL.terms);
619
+ this.logger?.info?.(`[music] DJ brief "${brief}" will be searched as: ${terms.join('; ')}`);
620
+ return terms;
621
+ },
622
+ (error) => {
623
+ this.logger?.warn?.(`[music] DJ brief could not be rewritten, searching it as written: ${describeFailure(error)}`);
624
+ this.cache.set(key, Promise.resolve([brief]), TTL.termsRetry);
625
+ return [brief];
626
+ },
331
627
  );
332
- await Promise.all(tasks);
628
+ this.cache.set(key, pending, TTL.termsRetry);
629
+ return pending;
630
+ }
333
631
 
334
- // Keep the prompt cheap when the pool overflowed: prefer prompt/similar
335
- // matches, then the feeds.
336
- const priority = { prompt: 0, similar: 1, daily: 2, new: 3, chart: 4 };
337
- pool.sort((a, b) => (priority[a.origin] ?? 9) - (priority[b.origin] ?? 9));
338
- const trimmed = pool.slice(0, Math.max(count, MAX_POOL));
339
- return { pool: await this.api.withCovers(trimmed).catch(() => trimmed), digest };
632
+ /**
633
+ * Ask the model for a brief's search terms, on the same route and under the
634
+ * same identity as curation.
635
+ *
636
+ * @returns {Promise<string[] | null>} `null` when no model route is available.
637
+ * @throws {Error} when the model answered with nothing usable.
638
+ */
639
+ async #rewriteBrief(brief) {
640
+ const llm = this.resolveLlm?.();
641
+ if (!llm?.stream) return null;
642
+ const target = await this.#resolveTarget(llm);
643
+ if (!target?.provider || !target?.model) return null;
644
+ const request = curationRequest({
645
+ target,
646
+ sessionId: this.sessionId,
647
+ messages: [
648
+ {
649
+ role: 'user',
650
+ content: [
651
+ {
652
+ type: 'text',
653
+ text:
654
+ "Rewrite a listener's music brief into short search keywords for NetEase Cloud Music playlist search. " +
655
+ `Give up to ${BRIEF_TERMS} keywords, each 2 to 8 words, the way listeners name playlists there: ` +
656
+ 'in Chinese, by genre, mood, era, language or scene; keep an artist or genre name in its usual form. ' +
657
+ 'Each keyword should find the brief on its own. ' +
658
+ 'Reply with JSON only, no prose, no code fence: {"terms":["<keyword>"]}',
659
+ },
660
+ { type: 'text', text: `Brief: ${brief}` },
661
+ ],
662
+ },
663
+ ],
664
+ });
665
+ const text = await this.#collect(llm.stream(request));
666
+ const json = /\{[\s\S]*\}/.exec(text ?? '');
667
+ if (!json) throw new Error('the rewrite contained no JSON object');
668
+ const terms = (JSON.parse(json[0]).terms ?? [])
669
+ .filter((term) => typeof term === 'string')
670
+ .map((term) => term.trim())
671
+ .filter((term) => term.length > 0 && term.length <= 40);
672
+ const unique = [...new Set(terms)].slice(0, BRIEF_TERMS);
673
+ if (unique.length === 0) throw new Error('the model returned no search terms');
674
+ return unique;
340
675
  }
341
676
 
342
677
  // --------------------------------------------------------------- model tier
@@ -438,17 +773,30 @@ export class AiDj {
438
773
  * One place builds the prompt, so a leaf call and any future caller reason
439
774
  * about exactly the same brief.
440
775
  */
441
- #brief({ pool, digest, prompt, count }) {
776
+ #brief({ pool, digest, prompt, count, follows }) {
777
+ const label = (track) => `${track.name} — ${(track.artists ?? []).join('/')}`;
442
778
  const catalogue = pool
443
- .map((track, index) => `${index}. ${track.name} — ${(track.artists ?? []).join('/')}${track.album ? ` (${track.album})` : ''}`)
779
+ .map((track, index) =>
780
+ `${index}. ${label(track)}${track.album ? ` (${track.album})` : ''}${track.origin === 'liked' ? ' [liked]' : ''}`)
444
781
  .join('\n');
782
+ const current = this.player.current();
783
+ const upNext = follows ? this.player.remaining().slice(-5) : [];
784
+ const skippedArtists = [...digest.skippedArtists]
785
+ .filter(([, skips]) => skips >= SKIPS_TO_REPORT_ARTIST)
786
+ .sort((a, b) => b[1] - a[1])
787
+ .slice(0, 8)
788
+ .map(([artist, skips]) => `${artist} (${skips})`);
445
789
  return [
446
790
  `Mood or request: ${prompt?.trim() || '(none — continue the current listening session)'}`,
447
- `Currently playing: ${this.player.current() ? `${this.player.current().name} — ${this.player.current().artists.join('/')}` : '(nothing)'}`,
791
+ `Currently playing: ${current ? label(current) : '(nothing)'}`,
792
+ upNext.length ? `Already queued after it: ${upNext.map(label).join('; ')}` : '',
793
+ follows && follows !== current ? `Your picks play after: ${label(follows)}` : '',
448
794
  digest.recentlyPlayed.length ? `Recently played: ${digest.recentlyPlayed.join('; ')}` : '',
449
795
  digest.topArtists.length ? `Favourite artists: ${digest.topArtists.join(', ')}` : '',
450
796
  digest.liked.length ? `Liked: ${digest.liked.join('; ')}` : '',
451
797
  digest.disliked.length ? `Disliked (never pick): ${digest.disliked.join('; ')}` : '',
798
+ digest.skipped.length ? `Skipped early (steer away from these): ${digest.skipped.join('; ')}` : '',
799
+ skippedArtists.length ? `Often skipped artists (skips): ${skippedArtists.join(', ')}` : '',
452
800
  '',
453
801
  `CANDIDATES (choose only by index):\n${catalogue}`,
454
802
  '',
@@ -470,7 +818,8 @@ export class AiDj {
470
818
  'You are the music programmer for a personal NetEase Cloud Music player. ' +
471
819
  'Choose the next tracks from the CANDIDATES list only, by index. ' +
472
820
  'Favour artists the listener likes and the requested mood; keep variety — never place two tracks by the same artist back to back; ' +
473
- 'avoid anything listed as recently played or disliked. ' +
821
+ 'avoid anything listed as recently played or disliked, and pick less of what the listener skips. ' +
822
+ 'Candidates marked [liked] are the listener\'s own likes, not heard for a while: mix one in when it fits, but do not fill the set with them. ' +
474
823
  'Reply with JSON only, no prose, no code fence: ' +
475
824
  '{"vibe":"<one short line describing the set>","picks":[{"index":<candidate index>,"why":"<max 8 words>"}]}',
476
825
  },
@@ -488,7 +837,7 @@ export class AiDj {
488
837
  *
489
838
  * @throws {Error} when the answer carries no JSON object or no usable index.
490
839
  */
491
- #parseAnswer(text, pool) {
840
+ #parseAnswer(text, pool, count) {
492
841
  const json = /\{[\s\S]*\}/.exec(text ?? '');
493
842
  if (!json) throw new Error('response contained no JSON object');
494
843
  const parsed = JSON.parse(json[0]);
@@ -503,7 +852,8 @@ export class AiDj {
503
852
  seen.add(index);
504
853
  ordered.push(index);
505
854
  }
506
- return { picks: ordered, vibe: typeof parsed.vibe === 'string' ? parsed.vibe : '' };
855
+ // A model asked for five that answers with twenty would otherwise flood the queue.
856
+ return { picks: ordered.slice(0, count), vibe: typeof parsed.vibe === 'string' ? parsed.vibe : '' };
507
857
  }
508
858
 
509
859
  /** Record a model-tier success: clear the stale reason and log what ran. */
@@ -528,7 +878,7 @@ export class AiDj {
528
878
  * @returns {Promise<{picks: number[], vibe: string} | null>}
529
879
  * `null` when the tier is unavailable or answered with nothing usable.
530
880
  */
531
- async #askModel({ pool, digest, prompt, count }) {
881
+ async #askModel({ pool, digest, prompt, count, follows }) {
532
882
  const llm = this.resolveLlm?.();
533
883
  if (!llm?.stream) {
534
884
  return this.#decline('no LLM service is mounted (ctx.get("llm") returned nothing)');
@@ -545,7 +895,7 @@ export class AiDj {
545
895
  }
546
896
  this.resolvedFrom = target.source;
547
897
 
548
- const brief = this.#brief({ pool, digest, prompt, count });
898
+ const brief = this.#brief({ pool, digest, prompt, count, follows });
549
899
  try {
550
900
  const request = curationRequest({
551
901
  target,
@@ -553,7 +903,7 @@ export class AiDj {
553
903
  sessionId: this.sessionId,
554
904
  });
555
905
  const text = await this.#collect(llm.stream(request));
556
- return this.#accept(this.#parseAnswer(text, pool), target);
906
+ return this.#accept(this.#parseAnswer(text, pool, count), target);
557
907
  } catch (error) {
558
908
  return this.#decline(describeFailure(error));
559
909
  }
@@ -564,41 +914,52 @@ export class AiDj {
564
914
  /**
565
915
  * Score and interleave candidates without a model.
566
916
  *
567
- * Scoring is deliberately simple and explainable: artist affinity dominates,
568
- * familiarity is penalised, explicit dislikes are excluded earlier, and a
569
- * small jitter keeps repeat requests from returning an identical order.
917
+ * Scoring is deliberately simple and explainable: a mood-brief match and
918
+ * artist affinity dominate, skips count against a track and its artists,
919
+ * recently played and disliked tracks were already excluded from the pool,
920
+ * and a small jitter keeps repeat requests from returning an identical order.
570
921
  */
571
- #rank({ pool, digest, count }) {
922
+ #rank({ pool, digest, count, follows }) {
572
923
  const favourites = new Set(digest.topArtists);
573
924
  const currentArtists = new Set(this.player.current()?.artists ?? []);
574
925
  const playedArtists = new Map();
575
926
  for (const entry of this.store.state.history.slice(0, 60)) {
927
+ if (entry.skipped) continue;
576
928
  for (const artist of entry.artists ?? []) playedArtists.set(artist, (playedArtists.get(artist) ?? 0) + 1);
577
929
  }
578
- const recentlyPlayed = new Set(digest.recentIds);
579
930
 
580
931
  const scored = pool.map((track) => {
581
932
  let score = Math.random() * 0.8;
933
+ // The brief is the listener's explicit ask; without this weight a chart
934
+ // hit by a favourite artist outranks every track that matches it.
935
+ // FM is personalised, similarity is not; a like is a known good song,
936
+ // weighted lightly so the set stays a discovery rather than a replay.
937
+ if (track.origin === 'prompt') score += 3.0;
938
+ else if (track.origin === 'fm') score += 1.0;
939
+ else if (track.origin === 'similar') score += 0.6;
940
+ else if (track.origin === 'liked') score += 0.5;
582
941
  for (const artist of track.artists ?? []) {
583
942
  if (favourites.has(artist)) score += 2.2;
584
943
  if (currentArtists.has(artist)) score += 1.4;
585
944
  score += Math.min((playedArtists.get(artist) ?? 0) * 0.15, 1.0);
945
+ score -= Math.min((digest.skippedArtists.get(artist) ?? 0) * 0.6, 2.4);
586
946
  }
587
- if (!recentlyPlayed.has(track.id)) score += 1.0;
947
+ if (digest.skipCounts.has(track.id)) score -= 1.5;
588
948
  if (track.vip) score -= 0.4; // still playable when signed in, but riskier
589
949
  return { track, score };
590
950
  });
591
951
  scored.sort((a, b) => b.score - a.score);
592
952
 
593
- // Interleave so one artist cannot own consecutive slots.
953
+ // Interleave so one artist cannot own consecutive slots, starting from the
954
+ // track the batch will actually follow.
594
955
  const picked = [];
595
- let lastArtist = this.player.current()?.artists?.[0] ?? null;
956
+ let lastArtists = new Set(follows?.artists ?? []);
596
957
  while (picked.length < count && scored.length > 0) {
597
- let chosen = scored.findIndex((entry) => entry.track.artists?.[0] !== lastArtist);
958
+ let chosen = scored.findIndex((entry) => !(entry.track.artists ?? []).some((artist) => lastArtists.has(artist)));
598
959
  if (chosen === -1) chosen = 0;
599
960
  const [entry] = scored.splice(chosen, 1);
600
961
  picked.push(entry.track);
601
- lastArtist = entry.track.artists?.[0] ?? null;
962
+ lastArtists = new Set(entry.track.artists ?? []);
602
963
  }
603
964
  return picked;
604
965
  }
@@ -610,15 +971,19 @@ export class AiDj {
610
971
  * @param {object} [options]
611
972
  * @param {string} [options.prompt] mood/request brief.
612
973
  * @param {number} [options.count] how many tracks to choose.
974
+ * @param {object|null} [options.follows] the track the batch will play after;
975
+ * the queue's last track by default, since a batch is appended.
613
976
  * @returns {Promise<Plan>}
614
977
  */
615
- async plan({ prompt = '', count = 5 } = {}) {
978
+ async plan({ prompt = '', count = 5, follows = this.player.queue.at(-1) ?? null } = {}) {
616
979
  const size = Math.min(Math.max(Number(count) || 5, 1), 25);
617
- const { pool, digest } = await this.#candidates({ prompt, count: size });
980
+ const { pool, digest, terms } = await this.#candidates({ prompt });
981
+ /** What the brief was searched as, so a rewrite is observable. */
982
+ const searchedAs = terms;
618
983
  if (pool.length === 0) {
619
- return { tracks: [], source: 'heuristic', note: 'no candidates were available', poolSize: 0 };
984
+ return { tracks: [], source: 'heuristic', note: 'no candidates were available', poolSize: 0, searchedAs };
620
985
  }
621
- const answer = await this.#askModel({ pool, digest, prompt, count: size });
986
+ const answer = await this.#askModel({ pool, digest, prompt, count: size, follows });
622
987
  if (answer) {
623
988
  return {
624
989
  tracks: answer.picks.map((index) => pool[index]),
@@ -627,24 +992,29 @@ export class AiDj {
627
992
  /** Which provider route curated, so the choice is observable. */
628
993
  route: `${answer.target.provider}/${answer.target.model}`,
629
994
  poolSize: pool.length,
995
+ searchedAs,
630
996
  };
631
997
  }
632
998
  return {
633
- tracks: this.#rank({ pool, digest, count: size }),
999
+ tracks: this.#rank({ pool, digest, count: size, follows }),
634
1000
  source: 'heuristic',
635
1001
  note: this.modelError
636
1002
  ? `model tier unavailable (${this.modelError}); used similarity, charts and taste feedback`
637
1003
  : 'no model configured; used similarity, charts and taste feedback',
638
1004
  poolSize: pool.length,
1005
+ searchedAs,
639
1006
  };
640
1007
  }
641
1008
 
642
1009
  /**
643
1010
  * Extend the queue when it runs low. Safe to call on every track change: the
644
1011
  * busy flag and the threshold check make it a no-op most of the time.
1012
+ * @param {object} [options]
1013
+ * @param {boolean} [options.force] plan even when disabled or the queue is deep.
1014
+ * @param {number} [options.count] batch size, overriding the player's setting.
645
1015
  * @returns {Promise<Plan | null>}
646
1016
  */
647
- async topUp({ force = false } = {}) {
1017
+ async topUp({ force = false, count } = {}) {
648
1018
  const settings = this.store.settings;
649
1019
  if (!this.player.dj.enabled && !force) return null;
650
1020
  if (this.busy) return null;
@@ -655,7 +1025,10 @@ export class AiDj {
655
1025
  this.player.dj.busy = true;
656
1026
  this.player.bump();
657
1027
  try {
658
- const plan = await this.plan({ prompt: settings.djPrompt ?? '', count: settings.djBatchSize ?? 5 });
1028
+ const planned = await this.plan({ prompt: settings.djPrompt ?? '', count: count ?? settings.djBatchSize ?? 5 });
1029
+ // The queue can change while the plan is in flight; report only what lands.
1030
+ const queued = new Set(this.player.queue.map((track) => track.id));
1031
+ const plan = { ...planned, tracks: planned.tracks.filter((track) => !queued.has(track.id)) };
659
1032
  if (plan.tracks.length === 0) {
660
1033
  this.player.dj = { ...this.player.dj, busy: false, error: plan.note ?? 'no tracks found', lastPlanAt: Date.now() };
661
1034
  return plan;
@@ -676,6 +1049,7 @@ export class AiDj {
676
1049
  route: plan.route ?? null,
677
1050
  added: plan.tracks.length,
678
1051
  names: plan.tracks.map((track) => `${track.name} — ${(track.artists ?? []).join('/')}`),
1052
+ searchedAs: plan.searchedAs ?? [],
679
1053
  },
680
1054
  };
681
1055
  this.player.bump();
@@ -699,7 +1073,8 @@ export class AiDj {
699
1073
  async start({ prompt, count = 8 } = {}) {
700
1074
  this.store.updateSettings({ djEnabled: true, ...(prompt === undefined ? {} : { djPrompt: prompt }) });
701
1075
  this.player.dj.enabled = true;
702
- const plan = await this.plan({ prompt: prompt ?? this.store.settings.djPrompt ?? '', count });
1076
+ // The batch replaces the queue, so nothing precedes its first track.
1077
+ const plan = await this.plan({ prompt: prompt ?? this.store.settings.djPrompt ?? '', count, follows: null });
703
1078
  if (plan.tracks.length > 0) {
704
1079
  this.player.setQueue(plan.tracks, { startIndex: 0, play: true, source: plan.source });
705
1080
  this.player.dj.lastPlanAt = Date.now();
@@ -709,6 +1084,7 @@ export class AiDj {
709
1084
  route: plan.route ?? null,
710
1085
  added: plan.tracks.length,
711
1086
  names: plan.tracks.map((track) => `${track.name} — ${(track.artists ?? []).join('/')}`),
1087
+ searchedAs: plan.searchedAs ?? [],
712
1088
  };
713
1089
  this.player.dj.error = null;
714
1090
  } else {