@doitian/dsh-music 0.1.2 → 0.1.4
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 +75 -13
- package/lib/index.js +22 -1
- package/lib/likes.js +169 -0
- package/lib/netease.js +97 -0
- package/lib/panel.html +316 -41
- package/lib/router.js +203 -18
- package/lib/session.js +79 -17
- package/lib/state.js +15 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
NetEase Cloud Music ([music.163.com](https://music.163.com)) playback and an AI DJ, inside DeepSeek Harness.
|
|
4
4
|
|
|
5
|
-
A **Music** entry appears in the DSH sidebar. It opens a player — your queue, synced lyrics, an `<audio>` element that streams through the host, QR sign-in, and a continuous AI DJ. The same player is also exposed to the agent through seven tools, so it can search, queue, and curate from a conversation.
|
|
5
|
+
A **Music** entry appears in the DSH sidebar. It opens a player — your queue, synced lyrics, an `<audio>` element that streams through the host, QR sign-in, NetEase likes, and a continuous AI DJ. The same player is also exposed to the agent through seven tools, so it can search, queue, and curate from a conversation.
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
┌──────────────────────────────────────────────────────────────────┐
|
|
@@ -144,6 +144,59 @@ Two implementation details worth knowing:
|
|
|
144
144
|
`/music/health` reports both: `audio.preferred` and `audio.last` (what the last
|
|
145
145
|
stream actually served, including `downgraded`).
|
|
146
146
|
|
|
147
|
+
## Likes and taste
|
|
148
|
+
|
|
149
|
+
A track holds one of three taste levels — **liked**, **unrated**, or
|
|
150
|
+
**disliked** — and the player draws whichever it is:
|
|
151
|
+
|
|
152
|
+
| Control | Level it sets |
|
|
153
|
+
|---|---|
|
|
154
|
+
| the **♡ / ♥** in a queue row, or the heart in the transport row | **liked** — the track goes into the account's 我喜欢的音乐 playlist on NetEase. Clicking the filled heart takes the like back. |
|
|
155
|
+
| the **✕** in the transport row | **disliked** — local, and fed to the DJ, which stops picking the track. Clicking the filled ✕ clears it. |
|
|
156
|
+
| the **✕** on a queue row | **disliked** as well. Taking a track out of the queue is a judgement about the track, not just about the list — without recording it the DJ re-derives the same track from the same similarity and charts within a batch or two. |
|
|
157
|
+
| neither | unrated. |
|
|
158
|
+
|
|
159
|
+
A like is a **NetEase** like, not a plugin flag, and the two halves of what that
|
|
160
|
+
needs are both plain endpoints:
|
|
161
|
+
|
|
162
|
+
- `POST /api/song/like` — one of the few *write* endpoints the web API still
|
|
163
|
+
serves unencrypted, so no `weapi` client is needed. It takes the direction
|
|
164
|
+
rather than toggling: the page always says which level it wants, so a stale
|
|
165
|
+
poll cannot turn a click into the wrong one, and disliking a liked track is one
|
|
166
|
+
request instead of two racing ones.
|
|
167
|
+
- `GET /api/song/like/check` — which of a batch of ids are liked. The answer is
|
|
168
|
+
cached per track (5 minutes), so a whole queue's hearts cost one request, and
|
|
169
|
+
only tracks with no fresh answer ever cost another.
|
|
170
|
+
|
|
171
|
+
Four properties are worth knowing, because they are what makes a heart mean
|
|
172
|
+
something:
|
|
173
|
+
|
|
174
|
+
- **An unknown track is not an unliked one.** A track nobody has asked about has
|
|
175
|
+
no answer, and renders as unrated until NetEase gives one.
|
|
176
|
+
- **A refusal is not an answer.** An anonymous session answers `code 301` for
|
|
177
|
+
every id, and a delisted track answers `400` with NetEase's own reason
|
|
178
|
+
(`下架歌曲无法收藏` for 晴天, for instance). Both leave the heart empty and say
|
|
179
|
+
why in the toast; a refused check is retried later instead of being cached as
|
|
180
|
+
"not liked", so signing in mid-session fills the hearts without a restart.
|
|
181
|
+
- **The account is the record.** `feedback.likes` in `session.json` is a *copy*
|
|
182
|
+
of the account's likes, kept for the DJ's taste digest. It is reconciled with
|
|
183
|
+
the account at startup — one read of the liked playlist — so a like made on
|
|
184
|
+
the phone counts, and a like an earlier build recorded locally (because it had
|
|
185
|
+
nowhere to send it) does not linger as a phantom.
|
|
186
|
+
- **A dislike is local.** The plain web API has no dislike endpoint, so this is
|
|
187
|
+
where the plugin's own taste memory is the only record. Liking and disliking
|
|
188
|
+
are the same three levels rather than two flags: one replaces the other, and
|
|
189
|
+
disliking a liked track removes it on NetEase as well.
|
|
190
|
+
- **Removing a queued track is a dislike, once.** The row's ✕ goes through the
|
|
191
|
+
queue endpoint, whose removal *is* the judgement: the row leaves the list and
|
|
192
|
+
an unrated track is recorded as disliked in the same request, so the DJ does
|
|
193
|
+
not derive it again from the same similarity. The confirm names the removal —
|
|
194
|
+
the judgement is the host's side of it. A track that already carries a level
|
|
195
|
+
keeps it, because removing is often just queue housekeeping (clearing out what
|
|
196
|
+
has already been heard) and rewriting a like into a dislike would be worse
|
|
197
|
+
than missing the signal. Removing the track that is playing also advances
|
|
198
|
+
playback, which a removal otherwise would not.
|
|
199
|
+
|
|
147
200
|
## The AI DJ
|
|
148
201
|
|
|
149
202
|
The DJ keeps the queue stocked. When the queue drops below
|
|
@@ -366,6 +419,7 @@ browser (DSH web GUI, http://127.0.0.1:<port>)
|
|
|
366
419
|
├─ lib/session.js cookie store + QR login state machine
|
|
367
420
|
├─ lib/state.js queue, cursor, transport revisions
|
|
368
421
|
├─ lib/dj.js candidate pool + model/heuristic tiers
|
|
422
|
+
├─ lib/likes.js the account's like state, cached
|
|
369
423
|
└─ lib/router.js JSON API, HTML, Range-capable audio proxy
|
|
370
424
|
```
|
|
371
425
|
|
|
@@ -398,8 +452,10 @@ Four design notes:
|
|
|
398
452
|
|
|
399
453
|
- The `/music` route prefix is registered on the bare HTTP server, which owns no
|
|
400
454
|
authentication of its own. Anyone who can reach the port can browse the
|
|
401
|
-
library
|
|
402
|
-
|
|
455
|
+
library, stream audio, and **change the account's likes** — `POST
|
|
456
|
+
/music/api/taste` is the panel's own control, so it is a real write to the
|
|
457
|
+
NetEase account. The route is loopback-only in the shipped composition. It
|
|
458
|
+
exposes no credentials — only search results, the queue, the like state, and
|
|
403
459
|
audio bytes.
|
|
404
460
|
- The NetEase cookie lives in `$DSH_HOME/music/session.json` in plain text, the
|
|
405
461
|
same posture as the rest of the profile's session data. `music_login` with
|
|
@@ -424,6 +480,7 @@ when something looks wrong:
|
|
|
424
480
|
"music_dj", "music_now_playing", "music_login"],
|
|
425
481
|
"dj": { "enabled": false, "model": null, "lastRoute": null,
|
|
426
482
|
"modelError": null, "lastPlanAt": null, "error": null },
|
|
483
|
+
"likes": { "cached": 12, "pending": 0, "error": null },
|
|
427
484
|
"dataDir": "C:\\Users\\…\\.dsh\\music",
|
|
428
485
|
"session": { "authenticated": true, "nickname": "…", "vip": true },
|
|
429
486
|
"uptimeMs": 123456
|
|
@@ -433,7 +490,10 @@ when something looks wrong:
|
|
|
433
490
|
`tools` is the point: the HTTP route is registered before the tools, so a tool
|
|
434
491
|
that failed to register would otherwise leave a route that answers normally.
|
|
435
492
|
Seeing all seven names is what proves startup completed. `panelContract` is the
|
|
436
|
-
page/engine boundary the browser half checks before it starts playback.
|
|
493
|
+
page/engine boundary the browser half checks before it starts playback. `likes`
|
|
494
|
+
is the cache behind the hearts: `cached` is how many answers it holds, and
|
|
495
|
+
`error` names why a check produced none — an anonymous session never asks, so
|
|
496
|
+
both stay empty.
|
|
437
497
|
|
|
438
498
|
### If the browser blocks autoplay
|
|
439
499
|
|
|
@@ -469,8 +529,8 @@ down first. A restart brings both halves back into agreement.
|
|
|
469
529
|
|
|
470
530
|
```powershell
|
|
471
531
|
npm run check # node --check on every module
|
|
472
|
-
npm test #
|
|
473
|
-
npm run test:live #
|
|
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
|
|
474
534
|
npm run test:all # both
|
|
475
535
|
```
|
|
476
536
|
|
|
@@ -486,10 +546,11 @@ network cases inside it.
|
|
|
486
546
|
Or run one file directly:
|
|
487
547
|
|
|
488
548
|
```powershell
|
|
489
|
-
node test/netease.test.mjs #
|
|
549
|
+
node test/netease.test.mjs # 32 pure: normalisation, quality ladder, likes, cookies, taste, player state
|
|
550
|
+
node test/likes.test.mjs # 12 like-state cache: what counts as an answer, refusals, batching, writes
|
|
490
551
|
node test/dj.test.mjs # 37 AI DJ: model call identity, route resolution, failure reporting, queue invariants
|
|
491
|
-
node test/client.test.mjs #
|
|
492
|
-
node test/host.test.mjs #
|
|
552
|
+
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
|
|
493
554
|
```
|
|
494
555
|
|
|
495
556
|
The DJ tests drive `ctx.llm.stream()` with a stub that emits the documented
|
|
@@ -522,7 +583,7 @@ poll, and a track change replaces the pane. Two more cover the pane's shape: it
|
|
|
522
583
|
is capped to a few lines, collapses to its header on demand, and remembers that
|
|
523
584
|
choice across loads — while still fetching the lines, so expanding is instant.
|
|
524
585
|
|
|
525
|
-
`npm test` runs the
|
|
586
|
+
`npm test` runs the four deterministic files in sequence (`npm run test:all`
|
|
526
587
|
adds the live one), deliberately **not** `node --test <dir>`: the directory form forks one child process per file, which
|
|
527
588
|
is blocked in sandboxed environments.
|
|
528
589
|
|
|
@@ -581,9 +642,10 @@ workflow, tag, and commit — `npm view @doitian/dsh-music@<version> dist.attest
|
|
|
581
642
|
a signed-in VIP account — 周杰伦's 晴天 (`id 186016`) is the canonical example,
|
|
582
643
|
because it belongs to a digital album. The plugin surfaces NetEase's own
|
|
583
644
|
refusal (`403` plus a reason) rather than retrying.
|
|
584
|
-
- **No `weapi`/`eapi` encryption**, so
|
|
585
|
-
|
|
586
|
-
|
|
645
|
+
- **No `weapi`/`eapi` encryption**, so scrobbling and playlist writes are not
|
|
646
|
+
implemented. Liking a track needs none of it — see
|
|
647
|
+
[Likes and taste](#likes-and-taste) — but a *dislike* stays local, because
|
|
648
|
+
there is no plain endpoint for one.
|
|
587
649
|
- **`apiPrefix` must stay `music`** unless `BASE` in `lib/client.js` is changed
|
|
588
650
|
to match.
|
|
589
651
|
- **The DJ's model tier is covered against stub contracts, not a live
|
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.
|
|
@@ -190,10 +194,24 @@ export function apply(ctx, config = {}) {
|
|
|
190
194
|
// ------------------------------------------------------------ session boot
|
|
191
195
|
void api
|
|
192
196
|
.fetchAccount()
|
|
193
|
-
.then((account) => {
|
|
197
|
+
.then(async (account) => {
|
|
194
198
|
if (account) logger.info(`[music] signed in as ${account.nickname}`);
|
|
195
199
|
else if (api.authenticated) logger.warn('[music] session cookie present but the account check failed');
|
|
196
200
|
else logger.info('[music] anonymous session (search and non-VIP playback only)');
|
|
201
|
+
|
|
202
|
+
// The account holds the likes; the local list is a copy the DJ reads.
|
|
203
|
+
// Reconciling it here keeps that copy honest across a like made on the
|
|
204
|
+
// phone, and migrates a list an earlier build recorded locally because it
|
|
205
|
+
// had no endpoint to send it to.
|
|
206
|
+
try {
|
|
207
|
+
const likedIds = await api.likedPlaylistIds(account?.uid);
|
|
208
|
+
if (likedIds) {
|
|
209
|
+
store.setLikedIds(likedIds);
|
|
210
|
+
logger.debug(`[music] like mirror reconciled with the account (${likedIds.length} track(s))`);
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
logger.warn(`[music] could not read the account's likes: ${error.message}`);
|
|
214
|
+
}
|
|
197
215
|
})
|
|
198
216
|
.catch((error) => logger.warn(`[music] account check failed: ${error.message}`));
|
|
199
217
|
|
|
@@ -223,6 +241,7 @@ export function apply(ctx, config = {}) {
|
|
|
223
241
|
store,
|
|
224
242
|
qr,
|
|
225
243
|
dj,
|
|
244
|
+
likes,
|
|
226
245
|
panelHtml: PANEL_HTML,
|
|
227
246
|
qrcodeJs: QRCODE_JS,
|
|
228
247
|
config,
|
|
@@ -252,6 +271,8 @@ export function apply(ctx, config = {}) {
|
|
|
252
271
|
lastPlanAt: player.dj.lastPlanAt,
|
|
253
272
|
error: player.dj.error,
|
|
254
273
|
},
|
|
274
|
+
/** How much of the account's like state is cached, and why it is not. */
|
|
275
|
+
likes: likes.status(),
|
|
255
276
|
dataDir,
|
|
256
277
|
session: { authenticated: api.authenticated, nickname: api.account?.nickname ?? null, vip: Boolean(api.account?.vip) },
|
|
257
278
|
uptimeMs: Math.round(process.uptime() * 1000),
|
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
|
|
@@ -544,6 +546,78 @@ export class Netease {
|
|
|
544
546
|
return normalizeTracks(data?.data);
|
|
545
547
|
}
|
|
546
548
|
|
|
549
|
+
// ------------------------------------------------------------------ likes
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Like or un-like one track on the account.
|
|
553
|
+
*
|
|
554
|
+
* `/api/song/like` is one of the few *write* endpoints the plain web API
|
|
555
|
+
* still serves unencrypted — it answers `{playlistId, code: 200}` and puts the
|
|
556
|
+
* track in (or takes it out of) the account's 我喜欢的音乐 playlist. It takes
|
|
557
|
+
* the direction rather than toggling, so the caller must know which one it
|
|
558
|
+
* means; ask {@link likedIds} when it does not.
|
|
559
|
+
*
|
|
560
|
+
* A refusal is reported, not thrown: an anonymous session answers `code: 301`
|
|
561
|
+
* and a delisted track answers `code: 400` with NetEase's own reason
|
|
562
|
+
* ("歌曲已经下架了"), and both are ordinary outcomes of a click. Only a
|
|
563
|
+
* transport failure throws.
|
|
564
|
+
*
|
|
565
|
+
* @param {number|string} id track id.
|
|
566
|
+
* @param {boolean} like `true` to like, `false` to remove the like.
|
|
567
|
+
*/
|
|
568
|
+
async likeSong(id, like = true) {
|
|
569
|
+
const trackId = Number(id);
|
|
570
|
+
if (!Number.isFinite(trackId)) {
|
|
571
|
+
return { ok: false, id, code: 'BAD_ID', reason: `invalid track id "${id}"` };
|
|
572
|
+
}
|
|
573
|
+
const data = await this.call('/api/song/like', {
|
|
574
|
+
method: 'POST',
|
|
575
|
+
query: { trackId, like: Boolean(like) },
|
|
576
|
+
});
|
|
577
|
+
if (Number(data?.code) !== 200) {
|
|
578
|
+
return {
|
|
579
|
+
ok: false,
|
|
580
|
+
id: trackId,
|
|
581
|
+
code: data?.code ?? 'UNKNOWN',
|
|
582
|
+
reason:
|
|
583
|
+
data?.message ||
|
|
584
|
+
data?.msg ||
|
|
585
|
+
(this.authenticated ? 'NetEase refused the change' : 'sign in to NetEase first'),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
return { ok: true, id: trackId, liked: Boolean(like), playlistId: data?.playlistId ?? null };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Which of these tracks the account has liked.
|
|
593
|
+
*
|
|
594
|
+
* `/api/song/like/check` answers `{ids: [...the liked subset...]}`, so one
|
|
595
|
+
* request covers a whole queue. An anonymous session answers `code: 301` for
|
|
596
|
+
* every id — a refusal, not "nothing is liked" — and comes back as
|
|
597
|
+
* `ok: false` so a caller never caches it as an answer.
|
|
598
|
+
*
|
|
599
|
+
* @param {Iterable<number|string>} ids
|
|
600
|
+
* @returns {Promise<{ok: boolean, ids: number[], code?: number|string, reason?: string}>}
|
|
601
|
+
*/
|
|
602
|
+
async likedIds(ids) {
|
|
603
|
+
const list = [...new Set([...ids].map(Number).filter(Number.isFinite))];
|
|
604
|
+
// NetEase answers an empty batch with `code: 400`, and an empty question has
|
|
605
|
+
// no answer to fetch anyway.
|
|
606
|
+
if (list.length === 0) return { ok: true, ids: [] };
|
|
607
|
+
const data = await this.call('/api/song/like/check', {
|
|
608
|
+
query: { trackIds: JSON.stringify(list) },
|
|
609
|
+
});
|
|
610
|
+
if (Number(data?.code) !== 200 || !Array.isArray(data?.ids)) {
|
|
611
|
+
return {
|
|
612
|
+
ok: false,
|
|
613
|
+
ids: [],
|
|
614
|
+
code: data?.code ?? 'UNKNOWN',
|
|
615
|
+
reason: data?.message || data?.msg || 'NetEase refused the like check',
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
return { ok: true, ids: data.ids.map(Number) };
|
|
619
|
+
}
|
|
620
|
+
|
|
547
621
|
// -------------------------------------------------------------- account
|
|
548
622
|
|
|
549
623
|
/** Account summary; `null` when the session is anonymous or expired. */
|
|
@@ -577,9 +651,32 @@ export class Netease {
|
|
|
577
651
|
cover: resizeImage(item.coverImgUrl, 300),
|
|
578
652
|
trackCount: item.trackCount ?? 0,
|
|
579
653
|
subscribed: Boolean(item.subscribed),
|
|
654
|
+
/** `5` marks 我喜欢的音乐 — the account's likes, as a playlist. */
|
|
655
|
+
specialType: item.specialType ?? 0,
|
|
580
656
|
}));
|
|
581
657
|
}
|
|
582
658
|
|
|
659
|
+
/**
|
|
660
|
+
* Every track id in the account's 我喜欢的音乐 playlist, or `null` when there
|
|
661
|
+
* is no account or no such playlist.
|
|
662
|
+
*
|
|
663
|
+
* This is the whole like state in one read, which is what makes it useful for
|
|
664
|
+
* reconciling a local mirror; use {@link likedIds} to ask about specific
|
|
665
|
+
* tracks. The playlist's length is not capped by `n` — NetEase returns the
|
|
666
|
+
* complete `trackIds` list — so one request covers a library of any size.
|
|
667
|
+
*
|
|
668
|
+
* @param {number|string} [uid] owner; defaults to the fetched account.
|
|
669
|
+
*/
|
|
670
|
+
async likedPlaylistIds(uid) {
|
|
671
|
+
const owner = Number(uid ?? this.account?.uid);
|
|
672
|
+
if (!Number.isFinite(owner)) return null;
|
|
673
|
+
const playlists = await this.userPlaylists(owner, { limit: 1000 });
|
|
674
|
+
const liked = playlists.find((playlist) => playlist.specialType === 5);
|
|
675
|
+
if (!liked) return null;
|
|
676
|
+
const detail = await this.call('/api/v6/playlist/detail', { query: { id: liked.id, n: 1 } });
|
|
677
|
+
return (detail?.playlist?.trackIds ?? []).map((entry) => entry.id).filter(Number.isFinite);
|
|
678
|
+
}
|
|
679
|
+
|
|
583
680
|
// ------------------------------------------------------------ QR login
|
|
584
681
|
|
|
585
682
|
/**
|