@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/README.md +136 -13
- package/lib/cache.js +156 -0
- package/lib/dj.js +443 -67
- package/lib/index.js +17 -4
- package/lib/netease.js +33 -0
- package/lib/session.js +38 -9
- package/lib/state.js +40 -0
- package/package.json +4 -3
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,
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 =
|
|
58
|
+
const MAX_POOL = 70;
|
|
56
59
|
|
|
57
|
-
/**
|
|
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 = ''
|
|
409
|
+
async #candidates({ prompt = '' } = {}) {
|
|
295
410
|
const digest = this.store.tasteDigest();
|
|
296
|
-
|
|
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
|
|
300
|
-
for (const track of
|
|
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
|
-
|
|
308
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
628
|
+
this.cache.set(key, pending, TTL.termsRetry);
|
|
629
|
+
return pending;
|
|
630
|
+
}
|
|
333
631
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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) =>
|
|
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: ${
|
|
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
|
-
|
|
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:
|
|
568
|
-
*
|
|
569
|
-
*
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|