@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/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
|
|
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
|
|
208
|
-
-
|
|
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
|
|
218
|
-
affinity,
|
|
219
|
-
|
|
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 #
|
|
533
|
-
npm run test:live #
|
|
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 #
|
|
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 #
|
|
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 #
|
|
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
|
|
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
|
+
}
|