spotifify 0.1.1

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -0
  4. package/README.zh-CN.md +131 -0
  5. package/config.example.toml +61 -0
  6. package/package.json +66 -0
  7. package/scripts/register-task.ps1 +43 -0
  8. package/src/cli.ts +504 -0
  9. package/src/config.ts +215 -0
  10. package/src/env.d.ts +15 -0
  11. package/src/match/aliases.ts +76 -0
  12. package/src/match/fingerprint.ts +104 -0
  13. package/src/match/matcher.ts +182 -0
  14. package/src/match/normalize.ts +107 -0
  15. package/src/match/score.ts +90 -0
  16. package/src/match/search.ts +97 -0
  17. package/src/match/types.ts +45 -0
  18. package/src/sources/local/ncm.ts +198 -0
  19. package/src/sources/local/scan.ts +55 -0
  20. package/src/sources/local/source.ts +105 -0
  21. package/src/sources/local/tags.ts +67 -0
  22. package/src/sources/netease/auth.ts +91 -0
  23. package/src/sources/netease/client.ts +188 -0
  24. package/src/sources/netease/lib.ts +38 -0
  25. package/src/sources/netease/source.ts +91 -0
  26. package/src/sources/types.ts +49 -0
  27. package/src/spotify/api.ts +155 -0
  28. package/src/spotify/auth.ts +121 -0
  29. package/src/spotify/client.ts +120 -0
  30. package/src/spotify/localUri.ts +48 -0
  31. package/src/spotify/types.ts +61 -0
  32. package/src/state/db.ts +42 -0
  33. package/src/state/repo.ts +480 -0
  34. package/src/state/schema.sql +115 -0
  35. package/src/sync/apply.ts +142 -0
  36. package/src/sync/duration.ts +115 -0
  37. package/src/sync/export.ts +159 -0
  38. package/src/sync/plan.ts +205 -0
  39. package/src/sync/reorder.ts +72 -0
  40. package/src/sync/run.ts +404 -0
  41. package/src/tui/App.tsx +420 -0
  42. package/src/tui/CandidatePane.tsx +158 -0
  43. package/src/tui/ReviewList.tsx +56 -0
  44. package/src/tui/SearchInput.tsx +37 -0
  45. package/src/tui/index.ts +32 -0
  46. package/src/tui/model.ts +54 -0
  47. package/src/util/bin.ts +12 -0
  48. package/src/util/clipboard.ts +13 -0
  49. package/src/util/fs.ts +18 -0
  50. package/src/util/lock.ts +38 -0
  51. package/src/util/log.ts +31 -0
  52. package/src/util/open.ts +22 -0
  53. package/src/util/retry.ts +49 -0
@@ -0,0 +1,105 @@
1
+ import { basename, extname } from "node:path";
2
+ import type { Config } from "../../config.ts";
3
+ import type { SourceTrackRow } from "../../state/repo.ts";
4
+ import { hashFile } from "../../util/fs.ts";
5
+ import { log } from "../../util/log.ts";
6
+ import { mapLimit } from "../../util/retry.ts";
7
+ import type { Source, SourcePlaylist, SourceTrack } from "../types.ts";
8
+ import { readNcmMeta } from "./ncm.ts";
9
+ import { scanDirs, type ScannedFile } from "./scan.ts";
10
+ import { parseFilename, readTags, type FileTags } from "./tags.ts";
11
+
12
+ export const LOCAL_LIBRARY_ID = "library";
13
+
14
+ const CONCURRENCY = 8;
15
+
16
+ type Described = Pick<SourceTrack, "title" | "artists" | "album" | "durationMs" | "isrc" | "neteaseId" | "aliases">;
17
+
18
+ async function describe(path: string, pattern: Config["local"]["filename_pattern"]): Promise<Described> {
19
+ if (extname(path).toLowerCase() === ".ncm") {
20
+ const m = await readNcmMeta(path);
21
+ return {
22
+ title: m.musicName,
23
+ artists: m.artist.map(([name]) => name),
24
+ album: m.album || undefined,
25
+ durationMs: m.duration,
26
+ // 0 shows up in NCMs of locally-uploaded songs; it is not a real song id
27
+ neteaseId: m.musicId > 0 ? m.musicId : undefined,
28
+ aliases: [...(m.alias ?? []), ...(m.transNames ?? [])],
29
+ };
30
+ }
31
+ let tags: FileTags;
32
+ try {
33
+ tags = await readTags(path);
34
+ } catch (e) {
35
+ // Odd containers (fragmented m4a, truncated files) still deserve a best-effort entry from the file name.
36
+ log.debug(`tags unreadable, using file name: ${path}`, { error: e instanceof Error ? e.message : String(e) });
37
+ tags = { artists: [] };
38
+ }
39
+ const ne = tags.netease;
40
+ if (ne && ne.musicId > 0) {
41
+ // NetEase download with its "163 key" comment: the song id makes it share a canonical key with the playlist track.
42
+ return {
43
+ title: tags.title || ne.musicName,
44
+ artists: tags.artists.length > 0 ? splitNeteaseArtists(tags.artists) : ne.artist.map(([name]) => name),
45
+ album: tags.album || ne.album || undefined,
46
+ durationMs: tags.durationMs ?? ne.duration,
47
+ isrc: tags.isrc,
48
+ neteaseId: ne.musicId,
49
+ aliases: [...(ne.alias ?? []), ...(ne.transNames ?? [])],
50
+ };
51
+ }
52
+ let title = tags.title;
53
+ let artists = tags.artists;
54
+ if (title === undefined || artists.length === 0) {
55
+ const fromName = parseFilename(basename(path, extname(path)), pattern);
56
+ title ??= fromName.title;
57
+ if (artists.length === 0) artists = fromName.artists;
58
+ }
59
+ return { title, artists, album: tags.album, durationMs: tags.durationMs, isrc: tags.isrc, aliases: [] };
60
+ }
61
+
62
+ /** The NetEase client writes all artists into one tag joined by "/" (e.g. "DECO*27/初音ミク"). */
63
+ function splitNeteaseArtists(artists: string[]): string[] {
64
+ return artists.flatMap((a) => a.split("/")).map((a) => a.trim()).filter((a) => a !== "");
65
+ }
66
+
67
+ /** Every configured directory merged into a single fixed playlist, tracks in absolute-path order. */
68
+ export class LocalSource implements Source {
69
+ readonly kind = "local" as const;
70
+
71
+ constructor(
72
+ private readonly cfg: Config["local"],
73
+ /** previous pull keyed by absolute path (repo.localTracksByPath()); unchanged (size, mtime) skips hashing and tag reads */
74
+ private readonly cache: Map<string, SourceTrackRow>,
75
+ private readonly onProgress?: (done: number, total: number) => void,
76
+ ) {}
77
+
78
+ async pull(): Promise<{ playlists: Array<{ playlist: SourcePlaylist; tracks: SourceTrack[] }> }> {
79
+ const files = await scanDirs(this.cfg.dirs, this.cfg.extensions);
80
+ let done = 0;
81
+ const tracks = await mapLimit(files, CONCURRENCY, async (file) => {
82
+ const track = await this.track(file);
83
+ this.onProgress?.(++done, files.length);
84
+ return track;
85
+ });
86
+ const playlist: SourcePlaylist = { kind: "local", externalId: LOCAL_LIBRARY_ID, name: this.cfg.playlist_name };
87
+ return { playlists: [{ playlist, tracks: tracks.filter((t) => t !== null) }] };
88
+ }
89
+
90
+ private async track(file: ScannedFile): Promise<SourceTrack | null> {
91
+ const cached = this.cache.get(file.path);
92
+ if (cached?.file !== undefined && cached.file.size === file.size && cached.file.mtimeMs === file.mtimeMs) {
93
+ const { id: _id, canonicalKey: _key, lastSeenAt: _seen, ...track } = cached;
94
+ return track;
95
+ }
96
+ try {
97
+ const contentHash = await hashFile(file.path);
98
+ const meta = await describe(file.path, this.cfg.filename_pattern);
99
+ return { kind: "local", externalId: file.path, ...meta, file: { path: file.path, contentHash, size: file.size, mtimeMs: file.mtimeMs } };
100
+ } catch (e) {
101
+ log.warn(`skipping unreadable file ${file.path}: ${e instanceof Error ? e.message : String(e)}`);
102
+ return null;
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,67 @@
1
+ import { parseFile, type IAudioMetadata } from "music-metadata";
2
+ import { decode163Key, type NcmMeta } from "./ncm.ts";
3
+
4
+ export interface FileTags {
5
+ title?: string;
6
+ artists: string[];
7
+ album?: string;
8
+ durationMs?: number;
9
+ isrc?: string;
10
+ /** decoded `163 key(Don't modify):…` comment written by the NetEase client into mp3/flac/m4a downloads */
11
+ netease?: NcmMeta;
12
+ }
13
+
14
+ export async function readTags(path: string): Promise<FileTags> {
15
+ // The cheap header-only pass covers most formats; only scan the whole file when duration is still unknown.
16
+ let meta = await parseFile(path, { skipCovers: true });
17
+ if (meta.format.duration === undefined) meta = await parseFile(path, { skipCovers: true, duration: true });
18
+ const { common, format } = meta;
19
+ const artists = (common.artists ?? (common.artist === undefined ? [] : [common.artist])).map((a) => a.trim()).filter((a) => a !== "");
20
+ const isrc = common.isrc?.[0]?.replace(/[^0-9A-Za-z]/g, "").toUpperCase();
21
+ return {
22
+ title: common.title?.trim() || undefined,
23
+ artists,
24
+ album: common.album?.trim() || undefined,
25
+ durationMs: format.duration === undefined ? undefined : Math.round(format.duration * 1000),
26
+ isrc: isrc !== undefined && isrc.length === 12 ? isrc : undefined,
27
+ netease: findNeteaseKey(meta, path),
28
+ };
29
+ }
30
+
31
+ /** The NetEase key lives in ID3 COMM (mp3), Vorbis DESCRIPTION/COMMENT (flac/ogg) or ©cmt (m4a); music-metadata folds most into `common.comment`. */
32
+ function findNeteaseKey(meta: IAudioMetadata, path: string): NcmMeta | undefined {
33
+ const texts: string[] = [];
34
+ for (const c of meta.common.comment ?? []) {
35
+ const text = typeof c === "string" ? c : c.text;
36
+ if (text) texts.push(text);
37
+ }
38
+ for (const tags of Object.values(meta.native)) {
39
+ for (const t of tags) {
40
+ if (!/^(DESCRIPTION|COMMENT|COMM|TXXX:comment|©cmt)$/i.test(t.id)) continue;
41
+ const v = t.value as unknown;
42
+ if (typeof v === "string") texts.push(v);
43
+ else if (typeof v === "object" && v !== null && "text" in v && typeof v.text === "string") texts.push(v.text);
44
+ }
45
+ }
46
+ for (const text of texts) {
47
+ const m = decode163Key(text, path);
48
+ if (m) return m;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /** Leading track numbers: `01. `, `01.`, `01 - `, `1 - ` (a dot must not be followed by another digit, e.g. `1.5 - x`). */
54
+ const TRACK_NO = /^\d{1,3}(?:\.(?!\d)\s*|\s*-\s+)/;
55
+ /** First ` - ` (or en/em dash) surrounded by whitespace splits the two halves. */
56
+ const SEPARATOR = /^(.*?)\s+[-\u2013\u2014]\s+(.*)$/;
57
+ const ARTIST_SEP = /[/、;&,]/;
58
+
59
+ export function parseFilename(basenameWithoutExt: string, pattern: "artist-title" | "title-artist"): { title: string; artists: string[] } {
60
+ const name = basenameWithoutExt.replace(TRACK_NO, "").trim();
61
+ const m = SEPARATOR.exec(name);
62
+ if (!m) return { title: name, artists: [] };
63
+ const left = m[1]!.trim();
64
+ const right = m[2]!.trim();
65
+ const [artistPart, title] = pattern === "artist-title" ? [left, right] : [right, left];
66
+ return { title, artists: artistPart.split(ARTIST_SEP).map((a) => a.trim()).filter((a) => a !== "") };
67
+ }
@@ -0,0 +1,91 @@
1
+ import { login_qr_check, login_qr_create, login_qr_key } from "./lib.ts";
2
+ import { z } from "zod";
3
+ import { log } from "../../util/log.ts";
4
+ import { sleep } from "../../util/retry.ts";
5
+
6
+ const QrKeyRes = z.object({ body: z.object({ data: z.object({ unikey: z.string().min(1) }) }) });
7
+ const QrCreateRes = z.object({ body: z.object({ data: z.object({ qrurl: z.string().min(1) }) }) });
8
+ const QrCheckRes = z.object({ body: z.object({ code: z.number().optional(), message: z.string().optional(), cookie: z.string().optional() }) });
9
+
10
+ const QR_EXPIRED = 800;
11
+ const QR_WAITING = 801;
12
+ const QR_SCANNED = 802;
13
+ const QR_CONFIRMED = 803;
14
+
15
+ /**
16
+ * QR login: renders the login URL via `render`, then polls until the user confirms on the phone.
17
+ * Resolves the cookie header string (`MUSIC_U=...; __csrf=...`).
18
+ */
19
+ export async function loginByQr(render: (qrUrl: string) => void, opts: { timeoutMs?: number; pollMs?: number } = {}): Promise<string> {
20
+ const timeoutMs = opts.timeoutMs ?? 120_000;
21
+ const pollMs = opts.pollMs ?? 2000;
22
+
23
+ const keyRes = QrKeyRes.safeParse(await login_qr_key({}));
24
+ if (!keyRes.success) throw new Error("netease login_qr_key returned no unikey");
25
+ const key = keyRes.data.body.data.unikey;
26
+
27
+ const createRes = QrCreateRes.safeParse(await login_qr_create({ key, qrimg: false }));
28
+ if (!createRes.success) throw new Error("netease login_qr_create returned no qrurl");
29
+ render(createRes.data.body.data.qrurl);
30
+
31
+ const deadline = Date.now() + timeoutMs;
32
+ let scannedLogged = false;
33
+ while (true) {
34
+ await sleep(pollMs);
35
+ if (Date.now() > deadline) throw new Error(`netease QR login timed out after ${timeoutMs} ms`);
36
+ let raw: unknown;
37
+ try {
38
+ raw = await login_qr_check({ key });
39
+ } catch (e) {
40
+ // the library's own catch path throws a ReferenceError on network failure; keep polling until the deadline
41
+ log.debug("netease login_qr_check failed, retrying", { error: e instanceof Error ? e.message : String(e) });
42
+ continue;
43
+ }
44
+ const check = QrCheckRes.safeParse(raw);
45
+ if (!check.success) throw new Error("netease login_qr_check returned an unexpected response");
46
+ const body = check.data.body;
47
+ switch (body.code) {
48
+ case QR_EXPIRED:
49
+ throw new Error("netease QR code expired; run the login again");
50
+ case QR_WAITING:
51
+ continue;
52
+ case QR_SCANNED:
53
+ if (!scannedLogged) {
54
+ log.info("netease QR scanned; confirm on your phone");
55
+ scannedLogged = true;
56
+ }
57
+ continue;
58
+ case QR_CONFIRMED: {
59
+ const cookie = normalizeCookie(body.cookie ?? "");
60
+ if (!cookie.includes("MUSIC_U=")) throw new Error("netease QR login confirmed but no MUSIC_U cookie was returned");
61
+ return cookie;
62
+ }
63
+ default:
64
+ throw new Error(`netease login_qr_check unexpected code ${body.code ?? "?"}${body.message ? `: ${body.message}` : ""}`);
65
+ }
66
+ }
67
+ }
68
+
69
+ const COOKIE_ATTRIBUTE = /^(Max-Age|Expires|Path|Domain|HTTPOnly|Secure|SameSite)$/i;
70
+
71
+ /**
72
+ * Accepts "MUSIC_U=xxx", a full Cookie header, or the `;`-joined Set-Cookie list returned by login_qr_check.
73
+ * Returns a `k=v; k2=v2` header. When MUSIC_U is present, only MUSIC_U (+ __csrf if present) are kept.
74
+ */
75
+ export function normalizeCookie(raw: string): string {
76
+ const pairs: Array<[string, string]> = [];
77
+ for (const part of raw.split(/;\s*/)) {
78
+ const eq = part.indexOf("=");
79
+ if (eq <= 0) continue;
80
+ const k = part.slice(0, eq).trim();
81
+ const v = part.slice(eq + 1).trim();
82
+ if (!k || !v || COOKIE_ATTRIBUTE.test(k)) continue;
83
+ pairs.push([k, v]);
84
+ }
85
+ const musicU = pairs.find(([k]) => k === "MUSIC_U");
86
+ if (musicU) {
87
+ const csrf = pairs.find(([k]) => k === "__csrf");
88
+ return csrf ? `MUSIC_U=${musicU[1]}; __csrf=${csrf[1]}` : `MUSIC_U=${musicU[1]}`;
89
+ }
90
+ return pairs.map(([k, v]) => `${k}=${v}`).join("; ");
91
+ }
@@ -0,0 +1,188 @@
1
+ import { login_status, playlist_detail, song_detail, user_playlist } from "./lib.ts";
2
+ import { z } from "zod";
3
+ import { log } from "../../util/log.ts";
4
+ import { chunk, RetryableError, sleep, withRetry } from "../../util/retry.ts";
5
+
6
+ export interface NeteasePlaylistSummary {
7
+ id: number;
8
+ name: string;
9
+ creatorId: number;
10
+ specialType: number;
11
+ trackCount: number;
12
+ updateTime: number;
13
+ trackUpdateTime: number;
14
+ }
15
+
16
+ export interface NeteaseSong {
17
+ id: number;
18
+ name: string;
19
+ artists: string[];
20
+ album: string;
21
+ durationMs: number;
22
+ aliases: string[];
23
+ }
24
+
25
+ /** Cookie invalid / expired: the CLI exits 3 without doing anything else. */
26
+ export class NeteaseAuthError extends Error {
27
+ constructor(message = "netease login required (cookie missing or expired)") {
28
+ super(message);
29
+ this.name = "NeteaseAuthError";
30
+ }
31
+ }
32
+
33
+ // Response bodies as observed from NeteaseCloudMusicApi 4.32.0. The library resolves `{ status, body }` and
34
+ // *rejects* with the same plain-object shape (not an Error) whenever the API code is not 200 / a special code.
35
+ const LoginStatusBody = z.object({
36
+ data: z.object({ profile: z.object({ userId: z.number(), nickname: z.string().default("") }).nullish() }).optional(),
37
+ });
38
+ const UserPlaylistBody = z.object({
39
+ more: z.boolean().default(false),
40
+ playlist: z
41
+ .array(
42
+ z.object({
43
+ id: z.number(),
44
+ name: z.string(),
45
+ creator: z.object({ userId: z.number() }).nullish(),
46
+ userId: z.number().optional(),
47
+ specialType: z.number().default(0),
48
+ trackCount: z.number().default(0),
49
+ updateTime: z.number().default(0),
50
+ trackUpdateTime: z.number().optional(),
51
+ }),
52
+ )
53
+ .default([]),
54
+ });
55
+ const PlaylistDetailBody = z.object({
56
+ playlist: z.object({
57
+ trackIds: z.array(z.object({ id: z.number() })).default([]),
58
+ updateTime: z.number().default(0),
59
+ trackUpdateTime: z.number().optional(),
60
+ }),
61
+ });
62
+ // Cloud-disk uploads (and some delisted songs) carry null `name` / `ar[].name` / `al.name`; uploads keep the
63
+ // original file metadata under `pc` (sn = title, ar = artist, alb = album).
64
+ const SongDetailBody = z.object({
65
+ songs: z
66
+ .array(
67
+ z.object({
68
+ id: z.number(),
69
+ name: z.string().nullish(),
70
+ ar: z.array(z.object({ name: z.string().nullish() })).nullish(),
71
+ al: z.object({ name: z.string().nullish() }).nullish(),
72
+ dt: z.number().nullish(),
73
+ alia: z.array(z.string().nullish()).nullish(),
74
+ tns: z.array(z.string().nullish()).nullish(),
75
+ pc: z.object({ sn: z.string().nullish(), ar: z.string().nullish(), alb: z.string().nullish() }).nullish(),
76
+ }),
77
+ )
78
+ .default([]),
79
+ });
80
+ const Failure = z.object({
81
+ status: z.number().optional(),
82
+ body: z.object({ code: z.number().optional(), msg: z.string().optional(), message: z.string().optional() }).optional(),
83
+ });
84
+ const Envelope = z.object({ status: z.number(), body: z.object({ code: z.number().optional() }).passthrough() });
85
+
86
+ export const REQUEST_GAP_MS = 200;
87
+ const SONG_DETAIL_BATCH = 500;
88
+ const PLAYLIST_PAGE = 1000;
89
+
90
+ /** Normalizes a library rejection (plain object) or thrown Error into our error taxonomy. */
91
+ function toError(e: unknown): Error {
92
+ if (e instanceof Error) {
93
+ // the library routes network failures into status-502 objects; a genuine throw is a client-side transport fault
94
+ return new RetryableError(`netease request failed: ${e.message}`);
95
+ }
96
+ const parsed = Failure.safeParse(e);
97
+ const f = parsed.success ? parsed.data : {};
98
+ const status = f.status ?? 0;
99
+ const code = f.body?.code ?? status;
100
+ const msg = f.body?.msg ?? f.body?.message ?? "";
101
+ if (code === 301 || status === 301 || msg.includes("需要登录")) return new NeteaseAuthError();
102
+ if (status >= 500 || code >= 500) return new RetryableError(`netease upstream error ${code}${msg ? `: ${msg}` : ""}`);
103
+ return new Error(`netease api error ${code}${msg ? `: ${msg}` : ""}`);
104
+ }
105
+
106
+ async function call<S extends z.ZodType>(what: string, schema: S, fn: () => Promise<unknown>): Promise<z.infer<S>> {
107
+ return withRetry(async () => {
108
+ let raw: unknown;
109
+ try {
110
+ raw = await fn();
111
+ } catch (e) {
112
+ throw toError(e);
113
+ }
114
+ const env = Envelope.safeParse(raw);
115
+ if (!env.success) throw new Error(`netease ${what}: unexpected response envelope`);
116
+ const code = env.data.body.code;
117
+ if (code !== undefined && code !== 200) throw toError(env.data);
118
+ const body = schema.safeParse(env.data.body);
119
+ if (!body.success) throw new Error(`netease ${what}: unexpected body shape: ${body.error.message}`);
120
+ return body.data;
121
+ });
122
+ }
123
+
124
+ export class NeteaseClient {
125
+ constructor(private readonly cookie: string) {}
126
+
127
+ async loginStatus(): Promise<{ uid: number; nickname: string } | null> {
128
+ const body = await call("login_status", LoginStatusBody, () => login_status({ cookie: this.cookie }));
129
+ const profile = body.data?.profile;
130
+ return profile ? { uid: profile.userId, nickname: profile.nickname } : null;
131
+ }
132
+
133
+ async userPlaylists(uid: number): Promise<NeteasePlaylistSummary[]> {
134
+ const out: NeteasePlaylistSummary[] = [];
135
+ for (let offset = 0; ; offset += PLAYLIST_PAGE) {
136
+ if (offset > 0) await sleep(REQUEST_GAP_MS);
137
+ const body = await call("user_playlist", UserPlaylistBody, () => user_playlist({ cookie: this.cookie, uid, limit: PLAYLIST_PAGE, offset }));
138
+ for (const p of body.playlist) {
139
+ out.push({
140
+ id: p.id,
141
+ name: p.name,
142
+ creatorId: p.creator?.userId ?? p.userId ?? -1,
143
+ specialType: p.specialType,
144
+ trackCount: p.trackCount,
145
+ updateTime: p.updateTime,
146
+ trackUpdateTime: p.trackUpdateTime ?? p.updateTime,
147
+ });
148
+ }
149
+ if (!body.more || body.playlist.length === 0) return out;
150
+ }
151
+ }
152
+
153
+ async playlistTrackIds(id: number): Promise<{ ids: number[]; updateTime: number; trackUpdateTime: number }> {
154
+ const body = await call("playlist_detail", PlaylistDetailBody, () => playlist_detail({ cookie: this.cookie, id }));
155
+ const pl = body.playlist;
156
+ return { ids: pl.trackIds.map((t) => t.id), updateTime: pl.updateTime, trackUpdateTime: pl.trackUpdateTime ?? pl.updateTime };
157
+ }
158
+
159
+ /** Fetches song metadata in serial batches of 500 with a 200 ms gap; output order follows `ids`. */
160
+ async songDetails(ids: number[]): Promise<NeteaseSong[]> {
161
+ const byId = new Map<number, NeteaseSong>();
162
+ const batches = chunk(ids, SONG_DETAIL_BATCH);
163
+ for (let i = 0; i < batches.length; i++) {
164
+ if (i > 0) await sleep(REQUEST_GAP_MS);
165
+ const batch = batches[i]!;
166
+ const body = await call("song_detail", SongDetailBody, () => song_detail({ cookie: this.cookie, ids: batch.join(",") }));
167
+ for (const s of body.songs) {
168
+ const artists = (s.ar ?? []).map((a) => a.name ?? "").filter((n) => n.length > 0);
169
+ if (artists.length === 0 && s.pc?.ar) artists.push(s.pc.ar);
170
+ byId.set(s.id, {
171
+ id: s.id,
172
+ name: s.name || s.pc?.sn || "",
173
+ artists,
174
+ album: s.al?.name || s.pc?.alb || "",
175
+ durationMs: s.dt ?? 0,
176
+ aliases: [...(s.alia ?? []), ...(s.tns ?? [])].filter((a): a is string => typeof a === "string" && a.length > 0),
177
+ });
178
+ }
179
+ log.debug("netease song_detail", { batch: i + 1, of: batches.length, requested: batch.length, got: body.songs.length });
180
+ }
181
+ const out: NeteaseSong[] = [];
182
+ for (const id of ids) {
183
+ const s = byId.get(id);
184
+ if (s) out.push(s);
185
+ }
186
+ return out;
187
+ }
188
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Bundle-safe entry to NeteaseCloudMusicApi. Its `main.js` discovers endpoints with
3
+ * `readdirSync(__dirname/module)` + dynamic `require` at load time, which only works with node_modules on
4
+ * disk and breaks inside a compiled binary. The endpoint modules are plain `(query, request) => …`
5
+ * functions, so load the ones we use explicitly and reproduce main.js's cookie handling here.
6
+ *
7
+ * `util/request.js` reads `<tmpdir>/anonymous_token` synchronously while loading and throws when it is
8
+ * missing (main.js normally creates it). Bun evaluates CommonJS dependencies before any ESM module body
9
+ * runs, so a side-effect `import` cannot create the file first; the modules are pulled in with `require`
10
+ * after the file exists. The string-literal requires are still bundled statically by `bun build`.
11
+ */
12
+ import { existsSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ const anonymousTokenPath = join(tmpdir(), "anonymous_token");
17
+ if (!existsSync(anonymousTokenPath)) writeFileSync(anonymousTokenPath, "", "utf-8");
18
+
19
+ const request = require("NeteaseCloudMusicApi/util/request.js") as NeteaseApi.Request;
20
+ const { cookieToJson } = require("NeteaseCloudMusicApi/util/index.js") as { cookieToJson: (cookie: string) => Record<string, string> };
21
+ const asModule = (m: unknown): NeteaseApi.Module => m as NeteaseApi.Module;
22
+
23
+ export type NeteaseApiCall = (data?: Record<string, unknown>) => Promise<unknown>;
24
+
25
+ function wrap(mod: NeteaseApi.Module): NeteaseApiCall {
26
+ return (data = {}) => {
27
+ const cookie = typeof data.cookie === "string" ? cookieToJson(data.cookie) : (data.cookie ?? {});
28
+ return mod({ ...data, cookie }, request);
29
+ };
30
+ }
31
+
32
+ export const login_status = wrap(asModule(require("NeteaseCloudMusicApi/module/login_status.js")));
33
+ export const user_playlist = wrap(asModule(require("NeteaseCloudMusicApi/module/user_playlist.js")));
34
+ export const playlist_detail = wrap(asModule(require("NeteaseCloudMusicApi/module/playlist_detail.js")));
35
+ export const song_detail = wrap(asModule(require("NeteaseCloudMusicApi/module/song_detail.js")));
36
+ export const login_qr_key = wrap(asModule(require("NeteaseCloudMusicApi/module/login_qr_key.js")));
37
+ export const login_qr_create = wrap(asModule(require("NeteaseCloudMusicApi/module/login_qr_create.js")));
38
+ export const login_qr_check = wrap(asModule(require("NeteaseCloudMusicApi/module/login_qr_check.js")));
@@ -0,0 +1,91 @@
1
+ import type { Config } from "../../config.ts";
2
+ import { log } from "../../util/log.ts";
3
+ import type { Source, SourceKind, SourcePlaylist, SourceTrack } from "../types.ts";
4
+ import { NeteaseAuthError, NeteaseClient, type NeteasePlaylistSummary, type NeteaseSong } from "./client.ts";
5
+
6
+ /** Read-only view of previously pulled netease state (backed by the repo) used to skip unchanged playlists / known songs. */
7
+ export interface NeteaseCache {
8
+ /** `sourceUpdatedAt` stored for the playlist on the last pull, undefined if never pulled */
9
+ playlistUpdatedAt(externalId: string): number | undefined;
10
+ /** tracks in stored position order; empty when unknown */
11
+ playlistTracks(externalId: string): SourceTrack[];
12
+ /** already-known songs keyed by externalId (netease song id as string) */
13
+ knownSongs(externalIds: string[]): Map<string, SourceTrack>;
14
+ }
15
+
16
+ /** netease `specialType` of the user's own "我喜欢的音乐" playlist */
17
+ const LIKED_SPECIAL_TYPE = 5;
18
+
19
+ function toSourceTrack(s: NeteaseSong): SourceTrack {
20
+ return {
21
+ kind: "netease",
22
+ externalId: String(s.id),
23
+ title: s.name,
24
+ artists: s.artists,
25
+ album: s.album || undefined,
26
+ durationMs: s.durationMs || undefined,
27
+ neteaseId: s.id,
28
+ aliases: s.aliases,
29
+ };
30
+ }
31
+
32
+ export class NeteaseSource implements Source {
33
+ readonly kind: SourceKind = "netease";
34
+
35
+ constructor(
36
+ private readonly client: NeteaseClient,
37
+ private readonly cfg: Config["netease"],
38
+ private readonly cache: NeteaseCache,
39
+ ) {}
40
+
41
+ async pull(): Promise<{ playlists: Array<{ playlist: SourcePlaylist; tracks: SourceTrack[] }> }> {
42
+ const me = await this.client.loginStatus();
43
+ if (!me) throw new NeteaseAuthError();
44
+ log.info("netease logged in", { uid: me.uid, nickname: me.nickname });
45
+
46
+ const excluded = new Set(this.cfg.exclude_playlists);
47
+ const included = new Set(this.cfg.include_playlists);
48
+ const selected = (p: NeteasePlaylistSummary) => {
49
+ const liked = p.specialType === LIKED_SPECIAL_TYPE;
50
+ if (liked && !this.cfg.include_liked) return false;
51
+ if (excluded.has(p.name) || excluded.has(String(p.id))) return false;
52
+ if (included.size === 0) return true;
53
+ return included.has(p.name) || included.has(String(p.id)) || (liked && included.has("liked"));
54
+ };
55
+ const summaries = (await this.client.userPlaylists(me.uid)).filter((p) => p.creatorId === me.uid && selected(p));
56
+
57
+ const playlists: Array<{ playlist: SourcePlaylist; tracks: SourceTrack[] }> = [];
58
+ for (const summary of summaries) {
59
+ const externalId = String(summary.id);
60
+ const playlist: SourcePlaylist = { kind: "netease", externalId, name: summary.name, sourceUpdatedAt: summary.trackUpdateTime };
61
+
62
+ if (this.cache.playlistUpdatedAt(externalId) === summary.trackUpdateTime) {
63
+ const cached = this.cache.playlistTracks(externalId);
64
+ if (cached.length > 0 || summary.trackCount === 0) {
65
+ log.info("netease playlist unchanged", { name: summary.name, tracks: cached.length });
66
+ playlists.push({ playlist, tracks: cached });
67
+ continue;
68
+ }
69
+ }
70
+
71
+ const detail = await this.client.playlistTrackIds(summary.id);
72
+ const ids = detail.ids.map(String);
73
+ const known = this.cache.knownSongs(ids);
74
+ const missing = detail.ids.filter((id) => !known.has(String(id)));
75
+ const fetched = new Map<string, SourceTrack>();
76
+ if (missing.length > 0) {
77
+ for (const song of await this.client.songDetails(missing)) fetched.set(String(song.id), toSourceTrack(song));
78
+ }
79
+
80
+ const tracks: SourceTrack[] = [];
81
+ for (const id of ids) {
82
+ const t = known.get(id) ?? fetched.get(id);
83
+ if (t) tracks.push(t);
84
+ else log.warn("netease song_detail omitted a track", { playlist: summary.name, id });
85
+ }
86
+ log.info("netease playlist pulled", { name: summary.name, tracks: tracks.length, fetched: fetched.size, cached: known.size });
87
+ playlists.push({ playlist, tracks });
88
+ }
89
+ return { playlists };
90
+ }
91
+ }
@@ -0,0 +1,49 @@
1
+ export type SourceKind = "netease" | "local";
2
+
3
+ export interface SourcePlaylist {
4
+ kind: SourceKind;
5
+ /** netease playlist id, or the fixed "library" id for the local source */
6
+ externalId: string;
7
+ name: string;
8
+ /** source-side modification time (ms); undefined when the source cannot report one */
9
+ sourceUpdatedAt?: number;
10
+ }
11
+
12
+ export interface SourceTrack {
13
+ kind: SourceKind;
14
+ /** netease song id, or the absolute file path for local tracks */
15
+ externalId: string;
16
+ title: string;
17
+ artists: string[];
18
+ album?: string;
19
+ durationMs?: number;
20
+ isrc?: string;
21
+ /** known for netease-source tracks and for .ncm files (header musicId) */
22
+ neteaseId?: number;
23
+ /** alternative titles: netease `alia`/`tns`, ncm `alias`/`transNames` */
24
+ aliases: string[];
25
+ file?: {
26
+ path: string;
27
+ /** blake2b256 of the file as stored on disk (encrypted bytes for .ncm) */
28
+ contentHash: string;
29
+ size: number;
30
+ mtimeMs: number;
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Identity under which match decisions are shared across sources.
36
+ * Priority: netease id > ISRC > file content hash.
37
+ */
38
+ export function canonicalKey(t: SourceTrack): string {
39
+ if (t.neteaseId !== undefined) return `netease:${t.neteaseId}`;
40
+ if (t.isrc) return `isrc:${t.isrc.toUpperCase()}`;
41
+ if (t.file) return `local:${t.file.contentHash}`;
42
+ throw new Error(`track ${t.kind}:${t.externalId} has no identity (no neteaseId, isrc, or file)`);
43
+ }
44
+
45
+ /** Pull one source into canonical playlists + ordered tracks. Implementations must be side-effect free. */
46
+ export interface Source {
47
+ readonly kind: SourceKind;
48
+ pull(): Promise<{ playlists: Array<{ playlist: SourcePlaylist; tracks: SourceTrack[] }> }>;
49
+ }