@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/index.js
CHANGED
|
@@ -187,6 +187,14 @@ export function apply(ctx, config = {}) {
|
|
|
187
187
|
},
|
|
188
188
|
});
|
|
189
189
|
player.onLowQueue = () => dj.topUp();
|
|
190
|
+
player.onSkip = (track) => {
|
|
191
|
+
// The panel's ✕ records the dislike and then presses next, so an early
|
|
192
|
+
// dislike would also count as a skip — but only inside the skip window,
|
|
193
|
+
// which would make the same judgement weigh differently by when it came.
|
|
194
|
+
if (store.taste(track.id) === 'disliked') return;
|
|
195
|
+
store.recordSkip(track.id);
|
|
196
|
+
logger.debug(`[music] skip recorded for ${track.id}`);
|
|
197
|
+
};
|
|
190
198
|
|
|
191
199
|
/** Tool names that registered successfully; surfaced by `/music/health`. */
|
|
192
200
|
const registeredTools = [];
|
|
@@ -503,8 +511,8 @@ export function apply(ctx, config = {}) {
|
|
|
503
511
|
title: 'AI DJ (NetEase Cloud Music)',
|
|
504
512
|
description:
|
|
505
513
|
'Run or configure the continuous AI DJ for the user\'s NetEase Cloud Music panel. ' +
|
|
506
|
-
'With `enabled: true` the DJ keeps the queue stocked: it gathers candidates from similar songs,
|
|
507
|
-
'recommendations, the charts and any mood brief, then either asks the model to choose or scores them by the ' +
|
|
514
|
+
'With `enabled: true` the DJ keeps the queue stocked: it gathers candidates from similar songs, personal FM, ' +
|
|
515
|
+
'liked songs not heard lately, the daily recommendations, the charts and any mood brief, then either asks the model to choose or scores them by the ' +
|
|
508
516
|
'listener\'s liked artists, novelty and feedback. Pass `prompt` for a mood or vibe, and `replan` to refill now.',
|
|
509
517
|
parameters: {
|
|
510
518
|
type: 'object',
|
|
@@ -513,7 +521,10 @@ export function apply(ctx, config = {}) {
|
|
|
513
521
|
enabled: { type: 'boolean', description: 'Turn continuous queue topping-up on or off.' },
|
|
514
522
|
prompt: { type: 'string', description: 'Mood or request, e.g. "rainy afternoon jazz", "90s cantopop".' },
|
|
515
523
|
replan: { type: 'boolean', description: 'Plan and queue a fresh batch immediately.' },
|
|
516
|
-
count: {
|
|
524
|
+
count: {
|
|
525
|
+
type: 'integer',
|
|
526
|
+
description: 'How many tracks to plan (1-25; default 8 for `start`, the player\'s batch size for `replan`).',
|
|
527
|
+
},
|
|
517
528
|
start: { type: 'boolean', description: 'Replace the queue with a freshly planned batch (a full DJ session).' },
|
|
518
529
|
},
|
|
519
530
|
},
|
|
@@ -524,6 +535,7 @@ export function apply(ctx, config = {}) {
|
|
|
524
535
|
`AI DJ is ${value.enabled ? 'on' : 'off'} (${value.source}${value.route ? ` via ${value.route}` : ''}). ` +
|
|
525
536
|
(value.modelError ? `Model tier unavailable: ${value.modelError}. ` : '') +
|
|
526
537
|
(value.vibe ? `Vibe: ${value.vibe}. ` : '') +
|
|
538
|
+
(value.searchedAs.length ? `Brief searched as: ${value.searchedAs.join('; ')}. ` : '') +
|
|
527
539
|
(value.names.length
|
|
528
540
|
? `Queued ${value.added} track(s):\n${value.names.map((entry, index) => `${index + 1}. ${entry}`).join('\n')}`
|
|
529
541
|
: value.note || 'No new tracks were queued.') +
|
|
@@ -540,7 +552,7 @@ export function apply(ctx, config = {}) {
|
|
|
540
552
|
if (args.start) {
|
|
541
553
|
plan = await dj.start({ prompt, count });
|
|
542
554
|
} else if (args.replan || (enabled === true && player.queue.length === 0)) {
|
|
543
|
-
plan = await dj.topUp({ force: true });
|
|
555
|
+
plan = await dj.topUp({ force: true, count: args.count === undefined ? undefined : count });
|
|
544
556
|
}
|
|
545
557
|
|
|
546
558
|
const summary = player.summary();
|
|
@@ -550,6 +562,7 @@ export function apply(ctx, config = {}) {
|
|
|
550
562
|
route: plan?.route ?? summary.dj.lastPlan?.route ?? null,
|
|
551
563
|
modelError: player.dj.modelError ?? null,
|
|
552
564
|
vibe: plan?.vibe ?? summary.dj.lastPlan?.vibe ?? '',
|
|
565
|
+
searchedAs: plan?.searchedAs ?? summary.dj.lastPlan?.searchedAs ?? [],
|
|
553
566
|
note: plan?.note ?? (plan && plan.tracks.length === 0 ? 'no candidates were available' : ''),
|
|
554
567
|
added: plan?.tracks.length ?? 0,
|
|
555
568
|
names: (plan?.tracks ?? []).map((track) => `${track.name} — ${(track.artists ?? []).join('/')}`),
|
package/lib/netease.js
CHANGED
|
@@ -372,6 +372,21 @@ export class Netease {
|
|
|
372
372
|
return data?.result ?? {};
|
|
373
373
|
}
|
|
374
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Playlists matching a keyword. Listeners name and tag playlists by mood
|
|
377
|
+
* (雨天, 爵士, 深夜…), so this is how a mood finds music, where a song search
|
|
378
|
+
* only matches titles and lyrics.
|
|
379
|
+
*/
|
|
380
|
+
async searchPlaylists(keyword, { limit = 10 } = {}) {
|
|
381
|
+
const result = await this.search(keyword, { type: 1000, limit });
|
|
382
|
+
return (result.playlists ?? []).map((item) => ({
|
|
383
|
+
id: item.id,
|
|
384
|
+
name: item.name,
|
|
385
|
+
trackCount: item.trackCount ?? 0,
|
|
386
|
+
playCount: item.playCount ?? 0,
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
|
|
375
390
|
/** Track metadata, including cover art and the VIP fee code. */
|
|
376
391
|
async songDetail(ids) {
|
|
377
392
|
const list = [...ids].map(Number).filter(Number.isFinite);
|
|
@@ -476,6 +491,12 @@ export class Netease {
|
|
|
476
491
|
playCount: playlist.playCount ?? 0,
|
|
477
492
|
creator: playlist.creator?.nickname ?? '',
|
|
478
493
|
tracks: normalizeTracks(playlist.tracks),
|
|
494
|
+
/**
|
|
495
|
+
* Every track id, in order. For a playlist the account does not own,
|
|
496
|
+
* `tracks` stops at the first 20 while this list is complete, so it is
|
|
497
|
+
* the only way to reach the rest.
|
|
498
|
+
*/
|
|
499
|
+
trackIds: (playlist.trackIds ?? []).map((entry) => entry.id).filter(Number.isFinite),
|
|
479
500
|
};
|
|
480
501
|
}
|
|
481
502
|
|
|
@@ -524,6 +545,18 @@ export class Netease {
|
|
|
524
545
|
return normalizeTracks(result?.dailySongs ?? result?.recommend);
|
|
525
546
|
}
|
|
526
547
|
|
|
548
|
+
/**
|
|
549
|
+
* 私人FM (personal FM): a few tracks NetEase picks for this account, a
|
|
550
|
+
* different few on every call. Requires a session.
|
|
551
|
+
*
|
|
552
|
+
* This is the personalised feed the plain API still serves; heartbeat mode
|
|
553
|
+
* ({@link intelligenceList}) answers `code 500` there for every seed.
|
|
554
|
+
*/
|
|
555
|
+
async personalFm() {
|
|
556
|
+
const data = await this.call('/api/v1/radio/get');
|
|
557
|
+
return normalizeTracks(data?.data);
|
|
558
|
+
}
|
|
559
|
+
|
|
527
560
|
/**
|
|
528
561
|
* 心动模式 (heartbeat mode): a personalised continuation of a seed track
|
|
529
562
|
* inside a playlist context. Requires a session.
|
package/lib/session.js
CHANGED
|
@@ -196,10 +196,11 @@ export class SessionStore {
|
|
|
196
196
|
return this.state.history.slice(0, limit).map((entry) => entry.id);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
/** Most-played artists, most frequent first. */
|
|
199
|
+
/** Most-played artists, most frequent first. A skipped play is not a play. */
|
|
200
200
|
topArtists(limit = 8) {
|
|
201
201
|
const counts = new Map();
|
|
202
202
|
for (const entry of this.state.history.slice(0, 150)) {
|
|
203
|
+
if (entry.skipped) continue;
|
|
203
204
|
for (const artist of entry.artists) counts.set(artist, (counts.get(artist) ?? 0) + 1);
|
|
204
205
|
}
|
|
205
206
|
return [...counts.entries()]
|
|
@@ -272,19 +273,34 @@ export class SessionStore {
|
|
|
272
273
|
recordFeedback(kind, trackId) {
|
|
273
274
|
const id = Number(trackId);
|
|
274
275
|
if (!Number.isFinite(id)) return this.state.feedback;
|
|
275
|
-
if (kind === 'skips')
|
|
276
|
-
// Skips are counters, not membership: keep the newest occurrence.
|
|
277
|
-
const skips = this.state.feedback.skips;
|
|
278
|
-
skips.unshift(id);
|
|
279
|
-
if (skips.length > MAX_FEEDBACK) skips.length = MAX_FEEDBACK;
|
|
280
|
-
this.touch();
|
|
281
|
-
return this.state.feedback;
|
|
282
|
-
}
|
|
276
|
+
if (kind === 'skips') return this.recordSkip(id);
|
|
283
277
|
const level = kind === 'likes' ? 'liked' : kind === 'dislikes' ? 'disliked' : null;
|
|
284
278
|
if (!level) return this.state.feedback;
|
|
285
279
|
return this.setTaste(id, this.taste(id) === level ? 'none' : level);
|
|
286
280
|
}
|
|
287
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Count a skip: the listener moved past the track early.
|
|
284
|
+
*
|
|
285
|
+
* Skips are counters, not membership, so each one is kept as an occurrence.
|
|
286
|
+
* The play it cut short is marked as well, because the history is what the
|
|
287
|
+
* DJ reads artist affinity from, and a track abandoned after ten seconds
|
|
288
|
+
* would otherwise count as one more play of that artist.
|
|
289
|
+
*
|
|
290
|
+
* @param {number|string} trackId
|
|
291
|
+
*/
|
|
292
|
+
recordSkip(trackId) {
|
|
293
|
+
const id = Number(trackId);
|
|
294
|
+
if (!Number.isFinite(id)) return this.state.feedback;
|
|
295
|
+
const skips = this.state.feedback.skips;
|
|
296
|
+
skips.unshift(id);
|
|
297
|
+
if (skips.length > MAX_FEEDBACK) skips.length = MAX_FEEDBACK;
|
|
298
|
+
const play = this.state.history.find((entry) => entry.id === id);
|
|
299
|
+
if (play) play.skipped = true;
|
|
300
|
+
this.touch();
|
|
301
|
+
return this.state.feedback;
|
|
302
|
+
}
|
|
303
|
+
|
|
288
304
|
get feedback() {
|
|
289
305
|
return this.state.feedback;
|
|
290
306
|
}
|
|
@@ -297,13 +313,26 @@ export class SessionStore {
|
|
|
297
313
|
const name = (entry) =>
|
|
298
314
|
typeof entry === 'object' ? `${entry.name} — ${(entry.artists ?? []).join('/')}` : `#${entry}`;
|
|
299
315
|
const byId = new Map(this.state.history.map((entry) => [entry.id, entry]));
|
|
316
|
+
const skipCounts = new Map();
|
|
317
|
+
for (const id of this.state.feedback.skips) skipCounts.set(id, (skipCounts.get(id) ?? 0) + 1);
|
|
318
|
+
const skippedArtists = new Map();
|
|
319
|
+
for (const entry of this.state.history.slice(0, 150)) {
|
|
320
|
+
if (!entry.skipped) continue;
|
|
321
|
+
for (const artist of entry.artists ?? []) skippedArtists.set(artist, (skippedArtists.get(artist) ?? 0) + 1);
|
|
322
|
+
}
|
|
300
323
|
return {
|
|
301
324
|
recentlyPlayed: this.state.history.slice(0, history).map(name),
|
|
302
325
|
topArtists: this.topArtists(8),
|
|
303
326
|
liked: this.state.feedback.likes.slice(0, 15).map((id) => byId.get(id)).filter(Boolean).map(name),
|
|
304
327
|
disliked: this.state.feedback.dislikes.slice(0, 15).map((id) => byId.get(id)).filter(Boolean).map(name),
|
|
328
|
+
// Newest first, one line per track however often it was skipped.
|
|
329
|
+
skipped: [...skipCounts.keys()].slice(0, 15).map((id) => byId.get(id)).filter(Boolean).map(name),
|
|
305
330
|
dislikedIds: this.state.feedback.dislikes.slice(0, 80),
|
|
306
331
|
recentIds: this.recentIds(60),
|
|
332
|
+
/** Track id → how many times it was skipped. */
|
|
333
|
+
skipCounts,
|
|
334
|
+
/** Artist → skipped plays among the recent history. */
|
|
335
|
+
skippedArtists,
|
|
307
336
|
};
|
|
308
337
|
}
|
|
309
338
|
}
|
package/lib/state.js
CHANGED
|
@@ -25,6 +25,14 @@ export const MODES = ['list', 'single', 'shuffle'];
|
|
|
25
25
|
/** A pending seek is delivered once and then cleared. */
|
|
26
26
|
const SEEK_EPSILON_MS = 1_000;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A manual "next" before this much of a track has played is a skip: the
|
|
30
|
+
* listener rejected the track rather than finished with it. The window is 30 s,
|
|
31
|
+
* or a quarter of the track when that is longer.
|
|
32
|
+
*/
|
|
33
|
+
const SKIP_WINDOW_MS = 30_000;
|
|
34
|
+
const SKIP_WINDOW_FRACTION = 0.25;
|
|
35
|
+
|
|
28
36
|
export class Player {
|
|
29
37
|
/**
|
|
30
38
|
* @param {object} [options]
|
|
@@ -69,6 +77,11 @@ export class Player {
|
|
|
69
77
|
* @type {(() => Promise<void>) | undefined}
|
|
70
78
|
*/
|
|
71
79
|
this.onLowQueue = undefined;
|
|
80
|
+
/**
|
|
81
|
+
* Called with a track the listener moved past early, for the taste record.
|
|
82
|
+
* @type {((track: object) => void) | undefined}
|
|
83
|
+
*/
|
|
84
|
+
this.onSkip = undefined;
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
/** Subscribe to state changes. @returns {() => void} unsubscribe */
|
|
@@ -220,15 +233,42 @@ export class Player {
|
|
|
220
233
|
*/
|
|
221
234
|
move(step, { auto = false } = {}) {
|
|
222
235
|
if (this.queue.length === 0) return this.snapshot();
|
|
236
|
+
const left = this.current();
|
|
237
|
+
const skipped = !auto && step > 0 && this.#heardBriefly();
|
|
223
238
|
this.index = this.#nextIndex(step);
|
|
224
239
|
this.pendingSeek = 0;
|
|
225
240
|
this.playing = true;
|
|
226
241
|
this.reported = { ...this.reported, position: 0, duration: 0, error: null, at: Date.now() };
|
|
227
242
|
this.bump({ transport: true });
|
|
243
|
+
if (skipped && this.current() !== left) this.#notifySkip(left);
|
|
228
244
|
if (auto) this.#maybeExtend();
|
|
229
245
|
return this.snapshot();
|
|
230
246
|
}
|
|
231
247
|
|
|
248
|
+
/**
|
|
249
|
+
* Whether the current track was heard, but only briefly.
|
|
250
|
+
*
|
|
251
|
+
* Only a reported position counts: a track the panel never started (paused,
|
|
252
|
+
* blocked by autoplay, still loading) was not listened to, so moving past it
|
|
253
|
+
* says nothing about it.
|
|
254
|
+
*/
|
|
255
|
+
#heardBriefly() {
|
|
256
|
+
const current = this.current();
|
|
257
|
+
if (!current || this.reported.trackId !== current.id) return false;
|
|
258
|
+
const position = Number(this.reported.position) || 0;
|
|
259
|
+
if (position <= 0) return false;
|
|
260
|
+
const duration = Number(this.reported.duration) || Number(current.duration) || 0;
|
|
261
|
+
return position < Math.max(SKIP_WINDOW_MS, duration * SKIP_WINDOW_FRACTION);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#notifySkip(track) {
|
|
265
|
+
try {
|
|
266
|
+
this.onSkip?.(track);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
this.logger?.warn?.(`[music] skip listener failed: ${error.message}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
232
272
|
/** Jump to an explicit queue position. */
|
|
233
273
|
jump(index) {
|
|
234
274
|
if (index < 0 || index >= this.queue.length) return this.snapshot();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doitian/dsh-music",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "NetEase Cloud Music (music.163.com) player and AI DJ for DeepSeek Harness: a sidebar page with in-app playback, QR sign-in, and agent tools that curate the queue.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"lib/session.js",
|
|
40
40
|
"lib/state.js",
|
|
41
41
|
"lib/dj.js",
|
|
42
|
+
"lib/cache.js",
|
|
42
43
|
"lib/likes.js",
|
|
43
44
|
"lib/router.js",
|
|
44
45
|
"lib/panel.html",
|
|
@@ -54,8 +55,8 @@
|
|
|
54
55
|
"access": "public"
|
|
55
56
|
},
|
|
56
57
|
"scripts": {
|
|
57
|
-
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/netease.js && node --check lib/session.js && node --check lib/state.js && node --check lib/dj.js && node --check lib/likes.js && node --check lib/router.js",
|
|
58
|
-
"test": "node test/netease.test.mjs && node test/likes.test.mjs && node test/dj.test.mjs && node test/client.test.mjs",
|
|
58
|
+
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/netease.js && node --check lib/session.js && node --check lib/state.js && node --check lib/dj.js && node --check lib/cache.js && node --check lib/likes.js && node --check lib/router.js",
|
|
59
|
+
"test": "node test/netease.test.mjs && node test/cache.test.mjs && node test/likes.test.mjs && node test/dj.test.mjs && node test/client.test.mjs",
|
|
59
60
|
"probe:headers": "node scripts/probe-headers.mjs",
|
|
60
61
|
"test:live": "node test/host.test.mjs",
|
|
61
62
|
"test:all": "npm test && npm run test:live"
|