@doitian/dsh-music 0.1.2 → 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 +206 -21
- package/lib/cache.js +156 -0
- package/lib/dj.js +443 -67
- package/lib/index.js +39 -5
- package/lib/likes.js +169 -0
- package/lib/netease.js +130 -0
- package/lib/panel.html +316 -41
- package/lib/router.js +203 -18
- package/lib/session.js +109 -18
- package/lib/state.js +55 -0
- package/package.json +5 -3
package/lib/index.js
CHANGED
|
@@ -22,6 +22,7 @@ import { Netease } from './netease.js';
|
|
|
22
22
|
import { QrLogin, SessionStore, ANONYMOUS_COOKIE } from './session.js';
|
|
23
23
|
import { Player } from './state.js';
|
|
24
24
|
import { AiDj } from './dj.js';
|
|
25
|
+
import { LikeState } from './likes.js';
|
|
25
26
|
import { MusicRouter } from './router.js';
|
|
26
27
|
|
|
27
28
|
export const name = 'music';
|
|
@@ -129,6 +130,9 @@ export function apply(ctx, config = {}) {
|
|
|
129
130
|
|
|
130
131
|
const qr = new QrLogin({ api, store, logger });
|
|
131
132
|
|
|
133
|
+
// The account owns its likes; this is the cache the panel's hearts read.
|
|
134
|
+
const likes = new LikeState({ api, logger });
|
|
135
|
+
|
|
132
136
|
/**
|
|
133
137
|
* The LLM service is optional: the DJ falls back to its heuristic tier when
|
|
134
138
|
* absent, so the plugin still works in a composition without a model route.
|
|
@@ -183,6 +187,14 @@ export function apply(ctx, config = {}) {
|
|
|
183
187
|
},
|
|
184
188
|
});
|
|
185
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
|
+
};
|
|
186
198
|
|
|
187
199
|
/** Tool names that registered successfully; surfaced by `/music/health`. */
|
|
188
200
|
const registeredTools = [];
|
|
@@ -190,10 +202,24 @@ export function apply(ctx, config = {}) {
|
|
|
190
202
|
// ------------------------------------------------------------ session boot
|
|
191
203
|
void api
|
|
192
204
|
.fetchAccount()
|
|
193
|
-
.then((account) => {
|
|
205
|
+
.then(async (account) => {
|
|
194
206
|
if (account) logger.info(`[music] signed in as ${account.nickname}`);
|
|
195
207
|
else if (api.authenticated) logger.warn('[music] session cookie present but the account check failed');
|
|
196
208
|
else logger.info('[music] anonymous session (search and non-VIP playback only)');
|
|
209
|
+
|
|
210
|
+
// The account holds the likes; the local list is a copy the DJ reads.
|
|
211
|
+
// Reconciling it here keeps that copy honest across a like made on the
|
|
212
|
+
// phone, and migrates a list an earlier build recorded locally because it
|
|
213
|
+
// had no endpoint to send it to.
|
|
214
|
+
try {
|
|
215
|
+
const likedIds = await api.likedPlaylistIds(account?.uid);
|
|
216
|
+
if (likedIds) {
|
|
217
|
+
store.setLikedIds(likedIds);
|
|
218
|
+
logger.debug(`[music] like mirror reconciled with the account (${likedIds.length} track(s))`);
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
logger.warn(`[music] could not read the account's likes: ${error.message}`);
|
|
222
|
+
}
|
|
197
223
|
})
|
|
198
224
|
.catch((error) => logger.warn(`[music] account check failed: ${error.message}`));
|
|
199
225
|
|
|
@@ -223,6 +249,7 @@ export function apply(ctx, config = {}) {
|
|
|
223
249
|
store,
|
|
224
250
|
qr,
|
|
225
251
|
dj,
|
|
252
|
+
likes,
|
|
226
253
|
panelHtml: PANEL_HTML,
|
|
227
254
|
qrcodeJs: QRCODE_JS,
|
|
228
255
|
config,
|
|
@@ -252,6 +279,8 @@ export function apply(ctx, config = {}) {
|
|
|
252
279
|
lastPlanAt: player.dj.lastPlanAt,
|
|
253
280
|
error: player.dj.error,
|
|
254
281
|
},
|
|
282
|
+
/** How much of the account's like state is cached, and why it is not. */
|
|
283
|
+
likes: likes.status(),
|
|
255
284
|
dataDir,
|
|
256
285
|
session: { authenticated: api.authenticated, nickname: api.account?.nickname ?? null, vip: Boolean(api.account?.vip) },
|
|
257
286
|
uptimeMs: Math.round(process.uptime() * 1000),
|
|
@@ -482,8 +511,8 @@ export function apply(ctx, config = {}) {
|
|
|
482
511
|
title: 'AI DJ (NetEase Cloud Music)',
|
|
483
512
|
description:
|
|
484
513
|
'Run or configure the continuous AI DJ for the user\'s NetEase Cloud Music panel. ' +
|
|
485
|
-
'With `enabled: true` the DJ keeps the queue stocked: it gathers candidates from similar songs,
|
|
486
|
-
'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 ' +
|
|
487
516
|
'listener\'s liked artists, novelty and feedback. Pass `prompt` for a mood or vibe, and `replan` to refill now.',
|
|
488
517
|
parameters: {
|
|
489
518
|
type: 'object',
|
|
@@ -492,7 +521,10 @@ export function apply(ctx, config = {}) {
|
|
|
492
521
|
enabled: { type: 'boolean', description: 'Turn continuous queue topping-up on or off.' },
|
|
493
522
|
prompt: { type: 'string', description: 'Mood or request, e.g. "rainy afternoon jazz", "90s cantopop".' },
|
|
494
523
|
replan: { type: 'boolean', description: 'Plan and queue a fresh batch immediately.' },
|
|
495
|
-
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
|
+
},
|
|
496
528
|
start: { type: 'boolean', description: 'Replace the queue with a freshly planned batch (a full DJ session).' },
|
|
497
529
|
},
|
|
498
530
|
},
|
|
@@ -503,6 +535,7 @@ export function apply(ctx, config = {}) {
|
|
|
503
535
|
`AI DJ is ${value.enabled ? 'on' : 'off'} (${value.source}${value.route ? ` via ${value.route}` : ''}). ` +
|
|
504
536
|
(value.modelError ? `Model tier unavailable: ${value.modelError}. ` : '') +
|
|
505
537
|
(value.vibe ? `Vibe: ${value.vibe}. ` : '') +
|
|
538
|
+
(value.searchedAs.length ? `Brief searched as: ${value.searchedAs.join('; ')}. ` : '') +
|
|
506
539
|
(value.names.length
|
|
507
540
|
? `Queued ${value.added} track(s):\n${value.names.map((entry, index) => `${index + 1}. ${entry}`).join('\n')}`
|
|
508
541
|
: value.note || 'No new tracks were queued.') +
|
|
@@ -519,7 +552,7 @@ export function apply(ctx, config = {}) {
|
|
|
519
552
|
if (args.start) {
|
|
520
553
|
plan = await dj.start({ prompt, count });
|
|
521
554
|
} else if (args.replan || (enabled === true && player.queue.length === 0)) {
|
|
522
|
-
plan = await dj.topUp({ force: true });
|
|
555
|
+
plan = await dj.topUp({ force: true, count: args.count === undefined ? undefined : count });
|
|
523
556
|
}
|
|
524
557
|
|
|
525
558
|
const summary = player.summary();
|
|
@@ -529,6 +562,7 @@ export function apply(ctx, config = {}) {
|
|
|
529
562
|
route: plan?.route ?? summary.dj.lastPlan?.route ?? null,
|
|
530
563
|
modelError: player.dj.modelError ?? null,
|
|
531
564
|
vibe: plan?.vibe ?? summary.dj.lastPlan?.vibe ?? '',
|
|
565
|
+
searchedAs: plan?.searchedAs ?? summary.dj.lastPlan?.searchedAs ?? [],
|
|
532
566
|
note: plan?.note ?? (plan && plan.tracks.length === 0 ? 'no candidates were available' : ''),
|
|
533
567
|
added: plan?.tracks.length ?? 0,
|
|
534
568
|
names: (plan?.tracks ?? []).map((track) => `${track.name} — ${(track.artists ?? []).join('/')}`),
|
package/lib/likes.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetEase's like state for the tracks the panel is showing.
|
|
3
|
+
*
|
|
4
|
+
* A like lives in the account, not in this plugin: `/api/song/like/check` is
|
|
5
|
+
* where the truth is, and this class is the cache in front of it. Four
|
|
6
|
+
* properties are deliberate:
|
|
7
|
+
*
|
|
8
|
+
* - **Unknown is not "not liked".** A track nobody has asked about has no
|
|
9
|
+
* answer; `isLiked` reads false for it, and `known` says which of the two
|
|
10
|
+
* it is. The panel renders a hollow heart either way, so the difference
|
|
11
|
+
* only matters to a caller that is about to write.
|
|
12
|
+
* - **A refusal is not an answer.** An anonymous session answers `code: 301`
|
|
13
|
+
* for every id. Caching that as "not liked" would leave every heart empty
|
|
14
|
+
* for the rest of the session, so a refused check caches nothing and is
|
|
15
|
+
* retried after a cool-off instead.
|
|
16
|
+
* - **A write is authoritative.** `set()` writes through to NetEase and then
|
|
17
|
+
* updates the cache, so the panel's next poll does not need a round trip to
|
|
18
|
+
* see the change it just made. It never claims a like the account does not
|
|
19
|
+
* have: a refused write leaves the cache untouched.
|
|
20
|
+
* - **Nothing here is persisted.** The account is the storage; a restart
|
|
21
|
+
* re-reads it, and a different account must not inherit the last one's
|
|
22
|
+
* answers, which is what `clear()` is for.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-music/likes
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** How long one answer is trusted before it is asked again. */
|
|
28
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/** Ids per `/api/song/like/check` request; it takes a list, but not a URL. */
|
|
31
|
+
const CHECK_BATCH = 100;
|
|
32
|
+
|
|
33
|
+
/** How long a refused or failed check waits before trying again. */
|
|
34
|
+
const RETRY_AFTER_MS = 30_000;
|
|
35
|
+
|
|
36
|
+
export class LikeState {
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} options
|
|
39
|
+
* @param {import('./netease.js').Netease} options.api
|
|
40
|
+
* @param {{warn: Function, info: Function, debug: Function}} [options.logger]
|
|
41
|
+
* @param {number} [options.ttlMs] how long one answer stays fresh.
|
|
42
|
+
* @param {number} [options.batchSize] ids per check request.
|
|
43
|
+
*/
|
|
44
|
+
constructor({ api, logger, ttlMs = DEFAULT_TTL_MS, batchSize = CHECK_BATCH } = {}) {
|
|
45
|
+
this.api = api;
|
|
46
|
+
this.logger = logger;
|
|
47
|
+
this.ttlMs = ttlMs;
|
|
48
|
+
this.batchSize = batchSize;
|
|
49
|
+
/** @type {Map<number, {liked: boolean, at: number}>} */
|
|
50
|
+
this.answers = new Map();
|
|
51
|
+
/** @type {Map<number, Promise<void>>} in-flight checks, so polls share one. */
|
|
52
|
+
this.pending = new Map();
|
|
53
|
+
/** Earliest time a refused check may be attempted again. */
|
|
54
|
+
this.retryAt = 0;
|
|
55
|
+
/** Why the last check did not produce answers, for `/music/health`. */
|
|
56
|
+
this.error = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The cached answer; false when there is none. See {@link known}. */
|
|
60
|
+
isLiked(id) {
|
|
61
|
+
return this.answers.get(Number(id))?.liked === true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether a fresh answer for this track is cached — either way. */
|
|
65
|
+
known(id) {
|
|
66
|
+
const entry = this.answers.get(Number(id));
|
|
67
|
+
return Boolean(entry) && Date.now() - entry.at < this.ttlMs;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Forget every answer. A session change invalidates all of them. */
|
|
71
|
+
clear() {
|
|
72
|
+
this.answers.clear();
|
|
73
|
+
this.pending.clear();
|
|
74
|
+
this.retryAt = 0;
|
|
75
|
+
this.error = null;
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the like state of `ids`, asking NetEase only about the tracks with
|
|
81
|
+
* no fresh answer. Never throws: a like check must not fail a poll, and an
|
|
82
|
+
* unresolved id simply stays unknown.
|
|
83
|
+
*/
|
|
84
|
+
async ensure(ids) {
|
|
85
|
+
// Nothing can be liked on an anonymous session, so there is nothing to ask.
|
|
86
|
+
if (!this.api?.authenticated) return;
|
|
87
|
+
const list = [...new Set([...ids].map(Number).filter(Number.isFinite))];
|
|
88
|
+
const stale = list.filter((id) => !this.known(id) && !this.pending.has(id));
|
|
89
|
+
if (stale.length === 0) return;
|
|
90
|
+
if (Date.now() < this.retryAt) return;
|
|
91
|
+
|
|
92
|
+
const work = this.#load(stale).finally(() => {
|
|
93
|
+
for (const id of stale) this.pending.delete(id);
|
|
94
|
+
});
|
|
95
|
+
for (const id of stale) this.pending.set(id, work);
|
|
96
|
+
await work;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Ask for `ids` in batches and cache every answer in them. */
|
|
100
|
+
async #load(ids) {
|
|
101
|
+
for (let offset = 0; offset < ids.length; offset += this.batchSize) {
|
|
102
|
+
const chunk = ids.slice(offset, offset + this.batchSize);
|
|
103
|
+
let result;
|
|
104
|
+
try {
|
|
105
|
+
result = await this.api.likedIds(chunk);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
this.#coolOff(`like check failed: ${error.message}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
// Not an answer: cache nothing, so signing in or a retry can still
|
|
112
|
+
// resolve these tracks rather than freezing them as unliked.
|
|
113
|
+
this.#coolOff(`like check refused (code ${result.code}): ${result.reason}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const liked = new Set(result.ids);
|
|
117
|
+
const at = Date.now();
|
|
118
|
+
for (const id of chunk) this.answers.set(id, { liked: liked.has(id), at });
|
|
119
|
+
this.error = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#coolOff(reason) {
|
|
124
|
+
this.retryAt = Date.now() + RETRY_AFTER_MS;
|
|
125
|
+
this.error = reason;
|
|
126
|
+
this.logger?.debug?.(`[music] ${reason}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Like or un-like one track on NetEase.
|
|
131
|
+
*
|
|
132
|
+
* The account is written first and the cache only follows a confirmed change,
|
|
133
|
+
* so the panel can never show a heart the account would contradict.
|
|
134
|
+
*
|
|
135
|
+
* @param {number|string} trackId
|
|
136
|
+
* @param {boolean} liked
|
|
137
|
+
* @returns {Promise<{ok: boolean, id: number, liked?: boolean, code?: number|string, reason?: string}>}
|
|
138
|
+
*/
|
|
139
|
+
async set(trackId, liked) {
|
|
140
|
+
const id = Number(trackId);
|
|
141
|
+
if (!Number.isFinite(id)) {
|
|
142
|
+
return { ok: false, id: trackId, code: 'BAD_ID', reason: `invalid track id "${trackId}"` };
|
|
143
|
+
}
|
|
144
|
+
if (!this.api?.authenticated) {
|
|
145
|
+
return { ok: false, id, code: 'ANONYMOUS', reason: 'the NetEase session is anonymous' };
|
|
146
|
+
}
|
|
147
|
+
let result;
|
|
148
|
+
try {
|
|
149
|
+
result = await this.api.likeSong(id, liked);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return { ok: false, id, code: 'NETWORK', reason: error.message };
|
|
152
|
+
}
|
|
153
|
+
if (!result.ok) return result;
|
|
154
|
+
this.answers.set(id, { liked: Boolean(liked), at: Date.now() });
|
|
155
|
+
this.error = null;
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What the cache holds, for `/music/health`. */
|
|
160
|
+
status() {
|
|
161
|
+
return {
|
|
162
|
+
cached: this.answers.size,
|
|
163
|
+
pending: this.pending.size,
|
|
164
|
+
error: this.error,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export { CHECK_BATCH, DEFAULT_TTL_MS, RETRY_AFTER_MS };
|
package/lib/netease.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* - `api/toplist` anonymous OK
|
|
15
15
|
* - `api/discovery/simiSong` anonymous OK
|
|
16
16
|
* - `api/personalized/newsong` anonymous OK
|
|
17
|
+
* - `api/song/like` POST: like / un-like, needs a session
|
|
18
|
+
* - `api/song/like/check` which of these ids the account liked
|
|
17
19
|
* - `api/login/qrcode/*` QR login handshake
|
|
18
20
|
*
|
|
19
21
|
* @module dsh-music/netease
|
|
@@ -370,6 +372,21 @@ export class Netease {
|
|
|
370
372
|
return data?.result ?? {};
|
|
371
373
|
}
|
|
372
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
|
+
|
|
373
390
|
/** Track metadata, including cover art and the VIP fee code. */
|
|
374
391
|
async songDetail(ids) {
|
|
375
392
|
const list = [...ids].map(Number).filter(Number.isFinite);
|
|
@@ -474,6 +491,12 @@ export class Netease {
|
|
|
474
491
|
playCount: playlist.playCount ?? 0,
|
|
475
492
|
creator: playlist.creator?.nickname ?? '',
|
|
476
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),
|
|
477
500
|
};
|
|
478
501
|
}
|
|
479
502
|
|
|
@@ -522,6 +545,18 @@ export class Netease {
|
|
|
522
545
|
return normalizeTracks(result?.dailySongs ?? result?.recommend);
|
|
523
546
|
}
|
|
524
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
|
+
|
|
525
560
|
/**
|
|
526
561
|
* 心动模式 (heartbeat mode): a personalised continuation of a seed track
|
|
527
562
|
* inside a playlist context. Requires a session.
|
|
@@ -544,6 +579,78 @@ export class Netease {
|
|
|
544
579
|
return normalizeTracks(data?.data);
|
|
545
580
|
}
|
|
546
581
|
|
|
582
|
+
// ------------------------------------------------------------------ likes
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Like or un-like one track on the account.
|
|
586
|
+
*
|
|
587
|
+
* `/api/song/like` is one of the few *write* endpoints the plain web API
|
|
588
|
+
* still serves unencrypted — it answers `{playlistId, code: 200}` and puts the
|
|
589
|
+
* track in (or takes it out of) the account's 我喜欢的音乐 playlist. It takes
|
|
590
|
+
* the direction rather than toggling, so the caller must know which one it
|
|
591
|
+
* means; ask {@link likedIds} when it does not.
|
|
592
|
+
*
|
|
593
|
+
* A refusal is reported, not thrown: an anonymous session answers `code: 301`
|
|
594
|
+
* and a delisted track answers `code: 400` with NetEase's own reason
|
|
595
|
+
* ("歌曲已经下架了"), and both are ordinary outcomes of a click. Only a
|
|
596
|
+
* transport failure throws.
|
|
597
|
+
*
|
|
598
|
+
* @param {number|string} id track id.
|
|
599
|
+
* @param {boolean} like `true` to like, `false` to remove the like.
|
|
600
|
+
*/
|
|
601
|
+
async likeSong(id, like = true) {
|
|
602
|
+
const trackId = Number(id);
|
|
603
|
+
if (!Number.isFinite(trackId)) {
|
|
604
|
+
return { ok: false, id, code: 'BAD_ID', reason: `invalid track id "${id}"` };
|
|
605
|
+
}
|
|
606
|
+
const data = await this.call('/api/song/like', {
|
|
607
|
+
method: 'POST',
|
|
608
|
+
query: { trackId, like: Boolean(like) },
|
|
609
|
+
});
|
|
610
|
+
if (Number(data?.code) !== 200) {
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
id: trackId,
|
|
614
|
+
code: data?.code ?? 'UNKNOWN',
|
|
615
|
+
reason:
|
|
616
|
+
data?.message ||
|
|
617
|
+
data?.msg ||
|
|
618
|
+
(this.authenticated ? 'NetEase refused the change' : 'sign in to NetEase first'),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
return { ok: true, id: trackId, liked: Boolean(like), playlistId: data?.playlistId ?? null };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Which of these tracks the account has liked.
|
|
626
|
+
*
|
|
627
|
+
* `/api/song/like/check` answers `{ids: [...the liked subset...]}`, so one
|
|
628
|
+
* request covers a whole queue. An anonymous session answers `code: 301` for
|
|
629
|
+
* every id — a refusal, not "nothing is liked" — and comes back as
|
|
630
|
+
* `ok: false` so a caller never caches it as an answer.
|
|
631
|
+
*
|
|
632
|
+
* @param {Iterable<number|string>} ids
|
|
633
|
+
* @returns {Promise<{ok: boolean, ids: number[], code?: number|string, reason?: string}>}
|
|
634
|
+
*/
|
|
635
|
+
async likedIds(ids) {
|
|
636
|
+
const list = [...new Set([...ids].map(Number).filter(Number.isFinite))];
|
|
637
|
+
// NetEase answers an empty batch with `code: 400`, and an empty question has
|
|
638
|
+
// no answer to fetch anyway.
|
|
639
|
+
if (list.length === 0) return { ok: true, ids: [] };
|
|
640
|
+
const data = await this.call('/api/song/like/check', {
|
|
641
|
+
query: { trackIds: JSON.stringify(list) },
|
|
642
|
+
});
|
|
643
|
+
if (Number(data?.code) !== 200 || !Array.isArray(data?.ids)) {
|
|
644
|
+
return {
|
|
645
|
+
ok: false,
|
|
646
|
+
ids: [],
|
|
647
|
+
code: data?.code ?? 'UNKNOWN',
|
|
648
|
+
reason: data?.message || data?.msg || 'NetEase refused the like check',
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
return { ok: true, ids: data.ids.map(Number) };
|
|
652
|
+
}
|
|
653
|
+
|
|
547
654
|
// -------------------------------------------------------------- account
|
|
548
655
|
|
|
549
656
|
/** Account summary; `null` when the session is anonymous or expired. */
|
|
@@ -577,9 +684,32 @@ export class Netease {
|
|
|
577
684
|
cover: resizeImage(item.coverImgUrl, 300),
|
|
578
685
|
trackCount: item.trackCount ?? 0,
|
|
579
686
|
subscribed: Boolean(item.subscribed),
|
|
687
|
+
/** `5` marks 我喜欢的音乐 — the account's likes, as a playlist. */
|
|
688
|
+
specialType: item.specialType ?? 0,
|
|
580
689
|
}));
|
|
581
690
|
}
|
|
582
691
|
|
|
692
|
+
/**
|
|
693
|
+
* Every track id in the account's 我喜欢的音乐 playlist, or `null` when there
|
|
694
|
+
* is no account or no such playlist.
|
|
695
|
+
*
|
|
696
|
+
* This is the whole like state in one read, which is what makes it useful for
|
|
697
|
+
* reconciling a local mirror; use {@link likedIds} to ask about specific
|
|
698
|
+
* tracks. The playlist's length is not capped by `n` — NetEase returns the
|
|
699
|
+
* complete `trackIds` list — so one request covers a library of any size.
|
|
700
|
+
*
|
|
701
|
+
* @param {number|string} [uid] owner; defaults to the fetched account.
|
|
702
|
+
*/
|
|
703
|
+
async likedPlaylistIds(uid) {
|
|
704
|
+
const owner = Number(uid ?? this.account?.uid);
|
|
705
|
+
if (!Number.isFinite(owner)) return null;
|
|
706
|
+
const playlists = await this.userPlaylists(owner, { limit: 1000 });
|
|
707
|
+
const liked = playlists.find((playlist) => playlist.specialType === 5);
|
|
708
|
+
if (!liked) return null;
|
|
709
|
+
const detail = await this.call('/api/v6/playlist/detail', { query: { id: liked.id, n: 1 } });
|
|
710
|
+
return (detail?.playlist?.trackIds ?? []).map((entry) => entry.id).filter(Number.isFinite);
|
|
711
|
+
}
|
|
712
|
+
|
|
583
713
|
// ------------------------------------------------------------ QR login
|
|
584
714
|
|
|
585
715
|
/**
|