@doitian/dsh-music 0.1.1 → 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/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
@@ -294,21 +296,37 @@ export class Netease {
294
296
 
295
297
  /**
296
298
  * Raw streaming fetch, used by the audio proxy. The caller owns the body.
299
+ *
300
+ * The deadline covers only the connect and the response headers. The body
301
+ * then streams for as long as the browser keeps reading — potentially the
302
+ * whole track, because the element pauses and resumes its reads as its
303
+ * buffer fills and drains. A whole-request timeout here (`AbortSignal`
304
+ * watches the body too) used to cut every stream still open at that point;
305
+ * the panel read the truncation as a failed track and skipped mid-song. A
306
+ * body that stops flowing is still bounded by undici's own idle timeout.
307
+ *
297
308
  * @returns {Promise<Response>}
298
309
  */
299
- async fetchAudio(url, { range, method = 'GET' } = {}) {
310
+ async fetchAudio(url, { range, method = 'GET', headerTimeoutMs = 30_000 } = {}) {
300
311
  const headers = {
301
312
  Referer: `${API_ORIGIN}/`,
302
313
  'User-Agent': UA,
303
314
  Cookie: this.jar.header(),
304
315
  };
305
316
  if (range) headers.Range = range;
306
- const response = await fetch(url, {
307
- method,
308
- headers,
309
- redirect: 'follow',
310
- signal: AbortSignal.timeout(30_000),
311
- });
317
+ const controller = new AbortController();
318
+ const deadline = setTimeout(() => controller.abort(), headerTimeoutMs);
319
+ let response;
320
+ try {
321
+ response = await fetch(url, {
322
+ method,
323
+ headers,
324
+ redirect: 'follow',
325
+ signal: controller.signal,
326
+ });
327
+ } finally {
328
+ clearTimeout(deadline);
329
+ }
312
330
  this.jar.absorb(response);
313
331
  return response;
314
332
  }
@@ -528,6 +546,78 @@ export class Netease {
528
546
  return normalizeTracks(data?.data);
529
547
  }
530
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
+
531
621
  // -------------------------------------------------------------- account
532
622
 
533
623
  /** Account summary; `null` when the session is anonymous or expired. */
@@ -561,9 +651,32 @@ export class Netease {
561
651
  cover: resizeImage(item.coverImgUrl, 300),
562
652
  trackCount: item.trackCount ?? 0,
563
653
  subscribed: Boolean(item.subscribed),
654
+ /** `5` marks 我喜欢的音乐 — the account's likes, as a playlist. */
655
+ specialType: item.specialType ?? 0,
564
656
  }));
565
657
  }
566
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
+
567
680
  // ------------------------------------------------------------ QR login
568
681
 
569
682
  /**