@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 CHANGED
@@ -197,15 +197,109 @@ something:
197
197
  than missing the signal. Removing the track that is playing also advances
198
198
  playback, which a removal otherwise would not.
199
199
 
200
+ ### Skips
201
+
202
+ A skip is a fourth, softer signal, and nobody has to press anything for it:
203
+ **moving to the next track before 30 seconds, or before a quarter of the track
204
+ when that is longer**, records one — from the transport row or the agent's
205
+ `music_control next`; jumping to a queue row does not count. It is local, like
206
+ a dislike.
207
+
208
+ What does *not* count is as deliberate as what does: going back, a track ending
209
+ or failing on its own, single-repeat mode (which stays on the track), and a
210
+ track that never reported a position — paused, blocked by autoplay, or still
211
+ loading — because a track nobody heard was not rejected. Nor does the next
212
+ that the transport row's **✕** presses after a dislike: the dislike is already
213
+ the record, and counting it as a skip too would weigh an early dislike more
214
+ than a later one.
215
+
216
+ The DJ reads skips at two grains:
217
+
218
+ - **Per track.** One skip is a penalty, since it may have been the wrong
219
+ moment rather than the wrong song. A second takes the track out of the pool
220
+ for good, like a dislike.
221
+ - **Per artist.** The skip marks the play it cut short in the history, and that
222
+ play stops counting toward the artist's affinity — otherwise abandoning an
223
+ artist's songs would keep making them a "favourite". Skipped plays then count
224
+ against the artist instead, and the model is shown the recently skipped
225
+ tracks and the artists skipped more than once.
226
+
200
227
  ## The AI DJ
201
228
 
202
229
  The DJ keeps the queue stocked. When the queue drops below
203
230
  `djAutoExtendBelow`, it gathers candidates and appends a batch:
204
231
 
205
- - similar songs for the current and recently played tracks (`simiSong`),
232
+ - similar songs (`simiSong`) for the current track and recent plays the
233
+ listener heard through — never a disliked or skipped one, and topped up from
234
+ the likes when the history is thin,
235
+ - personal FM (私人FM), two calls of three tracks, when signed in,
236
+ - up to six liked tracks not played in the last three days, marked `[liked]`
237
+ for the model,
206
238
  - the daily recommendations and the anonymous new-song feed,
207
- - the official charts,
208
- - keyword search for the mood brief.
239
+ - the four general charts — 飙升榜, 新歌榜, 原创榜, 热歌榜 — sampled together,
240
+ - for a mood brief, its best-loved playlists, and a keyword song search.
241
+
242
+ Personal FM stands in for heartbeat mode (心动模式), which would be the better
243
+ personalised source but answers `code 500` on the plain web API for every seed;
244
+ it needs the `weapi` transport this plugin does not implement. `simiSong`
245
+ answers five tracks whatever `limit` asks for.
246
+
247
+ **A brief finds music through playlists.** A song search only matches titles
248
+ and lyrics, so "rainy afternoon jazz" mostly finds library tracks literally
249
+ named *Rainy Afternoon Jazz*. Listeners name and tag playlists by mood (雨天,
250
+ 爵士, 深夜…), so the brief searches playlists, takes the most played among
251
+ the six most relevant (skipping any under ten tracks), and samples across
252
+ them.
253
+
254
+ **The model rewrites the brief first.** Playlists are named in Chinese, so an
255
+ English brief searched as written matches badly — live, "90s cantopop" found
256
+ Notorious B.I.G. and Eminem. When a model route is available, one call turns
257
+ the brief into up to three Chinese search terms the way listeners name
258
+ playlists (`90年代 粤语金曲`, `港乐 经典`, `粤语老歌`), and the same brief then
259
+ found 张国荣, 刘德华 and 许冠杰. Each term gets its own playlist, so one broad
260
+ term cannot take every slot, and the song search uses the first.
261
+
262
+ The rewrite is made **once per brief, not per plan**, and kept for a week: a
263
+ brief means the same thing next week. It uses the curation route and the same
264
+ session identity. Without a model, or when the rewrite answers nothing usable,
265
+ the brief is searched as written — the plan never fails over it — and the
266
+ rewrite is tried again after half an hour rather than on every plan. The terms
267
+ used are reported as `searchedAs` on the plan and on `dj.lastPlan`, and the
268
+ `music_dj` result says *Brief searched as: …*.
269
+
270
+ ### Caching, sampling and pacing
271
+
272
+ Most sources change on the scale of hours or days, so each is cached for
273
+ about as long as it stays the same, per account where it is personal:
274
+
275
+ | Source | Kept for |
276
+ |---|---|
277
+ | similar songs (per seed), track details (per id) | 24 h |
278
+ | a playlist's track list | 12 h |
279
+ | a brief's rewrite into search terms | 7 days (30 min after a failed one) |
280
+ | the charts, a brief's searches | 6 h |
281
+ | the daily recommendations | 3 h |
282
+ | the new-song feed | 1 h |
283
+ | personal FM | never — every call answers a different three |
284
+
285
+ **Every plan samples.** Each source contributes a random handful up to its
286
+ quota — similar 14, daily 8, charts 8, new songs 6, likes 6, FM 6, and for a
287
+ brief 15 from its playlists plus 8 from the song search — drawn only from
288
+ tracks still eligible, and kept in the source's own order. Taking the first
289
+ few would serve the same tracks until the source changed; sampling a cached
290
+ list yields a different handful every plan for free. It also reaches past
291
+ what NetEase sends in full: for a playlist the account does not own, only
292
+ the first 20 tracks come with details, but every id does, so the DJ samples
293
+ ids from the whole list and looks up just the ones it chose. The quotas also
294
+ keep any one source from crowding the others out of the pool.
295
+
296
+ **Requests are paced, not fired as a burst.** Each request starts a random
297
+ 0.25–0.75 s after the previous one, so a plan reads like someone clicking
298
+ through the app rather than a crawler. Only the DJ's own probing is paced;
299
+ searches and plays you ask for go out at once. Live, a first plan with a
300
+ brief sends 14 requests over about 7 s; the next one, mostly from cache,
301
+ sends 2. The DJ stocks the queue before it runs dry, so the wait is not
302
+ heard.
209
303
 
210
304
  Then one of two tiers chooses:
211
305
 
@@ -214,9 +308,15 @@ Then one of two tiers chooses:
214
308
  (recently played, favourite artists, likes, dislikes) goes to
215
309
  `ctx.llm.stream()`, and the model returns the picks and a one-line vibe. This
216
310
  is the tier that reasons about a mood.
217
- - **Heuristic tier** — always available: candidates are scored by artist
218
- affinity, novelty against play history, and explicit like/dislike feedback,
219
- then interleaved so consecutive tracks do not share an artist.
311
+ - **Heuristic tier** — always available: candidates are scored by mood-brief
312
+ match and artist affinity, then interleaved so consecutive tracks do not
313
+ share an artist. See [Heuristic tier](#heuristic-tier).
314
+
315
+ Either way, the pool never holds a track that is disliked, recently played, or
316
+ **already in the queue** — a batch is appended, so a pick the queue already
317
+ holds would be dropped and the batch would come up short. The model is also
318
+ told which track its picks will follow, and is held to the count it was asked
319
+ for.
220
320
 
221
321
  **Every model call carries a session identity.** A leaf call cannot set headers
222
322
  — `GenerateOptions` has no `headers` field — so `sessionId` is the only identity
@@ -368,7 +468,28 @@ batch; it never leaves the queue empty.
368
468
 
369
469
  ### Heuristic tier
370
470
 
371
- Always available, used whenever the model tier is unavailable:
471
+ Always available, used whenever the model tier is unavailable. Each candidate
472
+ scores:
473
+
474
+ | Signal | Weight |
475
+ |---|---|
476
+ | matches the mood brief (keyword search) | +3.0 |
477
+ | from personal FM | +1.0 |
478
+ | similar to the current or recent tracks | +0.6 |
479
+ | a rested like | +0.5 |
480
+ | per artist among the listener's most played (skipped plays excluded) | +2.2 |
481
+ | per artist shared with the current track | +1.4 |
482
+ | per artist play count in the last 60 plays, skips excluded | +0.15 each, capped at +1.0 |
483
+ | per artist skip among the last 150 plays | −0.6 each, capped at −2.4 |
484
+ | skipped once (twice excludes it) | −1.5 |
485
+ | VIP-only | −0.4 |
486
+ | jitter, so repeat plans differ | 0–0.8 |
487
+
488
+ The brief outweighs a favourite artist on purpose: it is the listener's explicit
489
+ ask, and without that weight a chart hit by a favourite would outrank every
490
+ track that matches it. The ranked list is then interleaved starting from the
491
+ track the batch is appended after, so no two adjacent tracks share an artist —
492
+ including the seam between the old queue and the new batch.
372
493
 
373
494
  ## Configuration
374
495
 
@@ -419,6 +540,7 @@ browser (DSH web GUI, http://127.0.0.1:<port>)
419
540
  ├─ lib/session.js cookie store + QR login state machine
420
541
  ├─ lib/state.js queue, cursor, transport revisions
421
542
  ├─ lib/dj.js candidate pool + model/heuristic tiers
543
+ ├─ lib/cache.js source cache, pool sampling, request pacing
422
544
  ├─ lib/likes.js the account's like state, cached
423
545
  └─ lib/router.js JSON API, HTML, Range-capable audio proxy
424
546
  ```
@@ -529,8 +651,8 @@ down first. A restart brings both halves back into agreement.
529
651
 
530
652
  ```powershell
531
653
  npm run check # node --check on every module
532
- npm test # 114 deterministic tests: pure, like state, DJ, browser half
533
- npm run test:live # 33 integration tests against the live NetEase API
654
+ npm test # 154 deterministic tests: pure, like state, DJ, browser half
655
+ npm run test:live # 34 integration tests against the live NetEase API
534
656
  npm run test:all # both
535
657
  ```
536
658
 
@@ -546,11 +668,12 @@ network cases inside it.
546
668
  Or run one file directly:
547
669
 
548
670
  ```powershell
549
- node test/netease.test.mjs # 32 pure: normalisation, quality ladder, likes, cookies, taste, player state
671
+ node test/netease.test.mjs # 34 pure: normalisation, quality ladder, likes, cookies, taste, skips, player state
672
+ node test/cache.test.mjs # 10 source cache: lifetimes, shared loads, sampling, pacing
550
673
  node test/likes.test.mjs # 12 like-state cache: what counts as an answer, refusals, batching, writes
551
- node test/dj.test.mjs # 37 AI DJ: model call identity, route resolution, failure reporting, queue invariants
674
+ node test/dj.test.mjs # 60 AI DJ: model call identity, route resolution, failure reporting, curation, skips, pool sources, caching, brief rewriting, queue invariants
552
675
  node test/client.test.mjs # 38 browser half: the engine against a fake DOM, and the page it pairs with
553
- node test/host.test.mjs # 33 integration: routes, streaming, curation, quality, taste
676
+ node test/host.test.mjs # 34 integration: routes, streaming, curation, quality, taste, skips
554
677
  ```
555
678
 
556
679
  The DJ tests drive `ctx.llm.stream()` with a stub that emits the documented
@@ -583,7 +706,7 @@ poll, and a track change replaces the pane. Two more cover the pane's shape: it
583
706
  is capped to a few lines, collapses to its header on demand, and remembers that
584
707
  choice across loads — while still fetching the lines, so expanding is instant.
585
708
 
586
- `npm test` runs the four deterministic files in sequence (`npm run test:all`
709
+ `npm test` runs the five deterministic files in sequence (`npm run test:all`
587
710
  adds the live one), deliberately **not** `node --test <dir>`: the directory form forks one child process per file, which
588
711
  is blocked in sandboxed environments.
589
712
 
package/lib/cache.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * How the DJ reads its sources: a time-bounded cache, the sampling it draws
3
+ * its pool with, and the pacing its requests are sent at.
4
+ *
5
+ * Most of the DJ's sources change on the scale of hours or days — a chart
6
+ * daily, similarity effectively never — so fetching each one on every plan
7
+ * spends requests to learn nothing. The cache keeps each source for as long as
8
+ * it plausibly stays the same, and the DJ samples from what it holds, so a
9
+ * long cached list still yields a different handful each time. What does go
10
+ * out is paced like a person browsing, not fired as a burst.
11
+ *
12
+ * @module dsh-music/cache
13
+ */
14
+
15
+ /** Entries kept at most; the oldest are dropped first. */
16
+ const MAX_ENTRIES = 2_000;
17
+
18
+ export class TtlCache {
19
+ /**
20
+ * @param {object} [options]
21
+ * @param {() => number} [options.now] clock, injectable for tests.
22
+ * @param {number} [options.maxEntries]
23
+ */
24
+ constructor({ now = Date.now, maxEntries = MAX_ENTRIES } = {}) {
25
+ this.now = now;
26
+ this.maxEntries = maxEntries;
27
+ /** @type {Map<string, {value: unknown, expiresAt: number}>} */
28
+ this.entries = new Map();
29
+ }
30
+
31
+ /** The live value for `key`, or `undefined` when absent or expired. */
32
+ peek(key) {
33
+ const entry = this.entries.get(key);
34
+ if (!entry) return undefined;
35
+ if (entry.expiresAt <= this.now()) {
36
+ this.entries.delete(key);
37
+ return undefined;
38
+ }
39
+ return entry.value;
40
+ }
41
+
42
+ set(key, value, ttlMs) {
43
+ this.entries.delete(key);
44
+ this.entries.set(key, { value, expiresAt: this.now() + ttlMs });
45
+ while (this.entries.size > this.maxEntries) this.entries.delete(this.entries.keys().next().value);
46
+ return value;
47
+ }
48
+
49
+ /**
50
+ * The cached value for `key`, loading it when absent or expired.
51
+ *
52
+ * The pending load is what gets cached, so two plans racing for the same
53
+ * source share one request. A failed load is forgotten rather than cached:
54
+ * the next plan should try again, not inherit the outage for the whole TTL.
55
+ *
56
+ * @template T
57
+ * @param {string} key
58
+ * @param {number} ttlMs
59
+ * @param {() => Promise<T>} load
60
+ * @returns {Promise<T>}
61
+ */
62
+ get(key, ttlMs, load) {
63
+ const cached = this.peek(key);
64
+ if (cached !== undefined) return cached;
65
+ const pending = Promise.resolve().then(load);
66
+ this.set(key, pending, ttlMs);
67
+ pending.catch(() => {
68
+ if (this.entries.get(key)?.value === pending) this.entries.delete(key);
69
+ });
70
+ return pending;
71
+ }
72
+
73
+ clear() {
74
+ this.entries.clear();
75
+ }
76
+
77
+ get size() {
78
+ return this.entries.size;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * A random `count` of `items`, kept in their original order.
84
+ *
85
+ * Random so a cached list still yields a different handful each plan — taking
86
+ * the first `count` would serve the same tracks until the source changes. In
87
+ * order, because a source's order carries meaning (chart rank, search
88
+ * relevance), and the pool keeps it.
89
+ *
90
+ * @template T
91
+ * @param {T[]} items
92
+ * @param {number} count
93
+ * @param {() => number} [random]
94
+ * @returns {T[]}
95
+ */
96
+ export function sampleInOrder(items, count, random = Math.random) {
97
+ if (count >= items.length) return [...items];
98
+ if (count <= 0) return [];
99
+ const indices = items.map((_, index) => index);
100
+ for (let index = 0; index < count; index += 1) {
101
+ const swap = index + Math.floor(random() * (indices.length - index));
102
+ [indices[index], indices[swap]] = [indices[swap], indices[index]];
103
+ }
104
+ return indices
105
+ .slice(0, count)
106
+ .sort((a, b) => a - b)
107
+ .map((index) => items[index]);
108
+ }
109
+
110
+ /**
111
+ * Spaces out request starts by a randomised gap.
112
+ *
113
+ * A plan touches up to a dozen sources, and firing them at once is a crawler's
114
+ * signature: a tight burst at a machine-regular rhythm. Starts are spaced
115
+ * instead, each by its own random gap, so the traffic reads like someone
116
+ * clicking through the app. Only starts are spaced — a request already sent may
117
+ * still be in flight when the next starts, as on any page that is loading.
118
+ */
119
+ export class Pacer {
120
+ /**
121
+ * @param {object} [options]
122
+ * @param {number} [options.minGapMs] the shortest gap between two starts.
123
+ * @param {number} [options.jitterMs] the random span added to each gap.
124
+ * @param {() => number} [options.now]
125
+ * @param {(ms: number) => Promise<void>} [options.sleep]
126
+ * @param {() => number} [options.random]
127
+ */
128
+ constructor({
129
+ minGapMs = 250,
130
+ jitterMs = 500,
131
+ now = Date.now,
132
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
133
+ random = Math.random,
134
+ } = {}) {
135
+ this.minGapMs = minGapMs;
136
+ this.jitterMs = jitterMs;
137
+ this.now = now;
138
+ this.sleep = sleep;
139
+ this.random = random;
140
+ /** The earliest the next request may start. */
141
+ this.nextAt = 0;
142
+ }
143
+
144
+ /**
145
+ * Run `task` at the next free start slot.
146
+ * @template T
147
+ * @param {() => Promise<T>} task
148
+ * @returns {Promise<T>}
149
+ */
150
+ run(task) {
151
+ const at = Math.max(this.now(), this.nextAt);
152
+ this.nextAt = at + this.minGapMs + this.random() * this.jitterMs;
153
+ const wait = at - this.now();
154
+ return (wait > 0 ? this.sleep(wait) : Promise.resolve()).then(task);
155
+ }
156
+ }