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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/README.zh-CN.md +131 -0
- package/config.example.toml +61 -0
- package/package.json +66 -0
- package/scripts/register-task.ps1 +43 -0
- package/src/cli.ts +504 -0
- package/src/config.ts +215 -0
- package/src/env.d.ts +15 -0
- package/src/match/aliases.ts +76 -0
- package/src/match/fingerprint.ts +104 -0
- package/src/match/matcher.ts +182 -0
- package/src/match/normalize.ts +107 -0
- package/src/match/score.ts +90 -0
- package/src/match/search.ts +97 -0
- package/src/match/types.ts +45 -0
- package/src/sources/local/ncm.ts +198 -0
- package/src/sources/local/scan.ts +55 -0
- package/src/sources/local/source.ts +105 -0
- package/src/sources/local/tags.ts +67 -0
- package/src/sources/netease/auth.ts +91 -0
- package/src/sources/netease/client.ts +188 -0
- package/src/sources/netease/lib.ts +38 -0
- package/src/sources/netease/source.ts +91 -0
- package/src/sources/types.ts +49 -0
- package/src/spotify/api.ts +155 -0
- package/src/spotify/auth.ts +121 -0
- package/src/spotify/client.ts +120 -0
- package/src/spotify/localUri.ts +48 -0
- package/src/spotify/types.ts +61 -0
- package/src/state/db.ts +42 -0
- package/src/state/repo.ts +480 -0
- package/src/state/schema.sql +115 -0
- package/src/sync/apply.ts +142 -0
- package/src/sync/duration.ts +115 -0
- package/src/sync/export.ts +159 -0
- package/src/sync/plan.ts +205 -0
- package/src/sync/reorder.ts +72 -0
- package/src/sync/run.ts +404 -0
- package/src/tui/App.tsx +420 -0
- package/src/tui/CandidatePane.tsx +158 -0
- package/src/tui/ReviewList.tsx +56 -0
- package/src/tui/SearchInput.tsx +37 -0
- package/src/tui/index.ts +32 -0
- package/src/tui/model.ts +54 -0
- package/src/util/bin.ts +12 -0
- package/src/util/clipboard.ts +13 -0
- package/src/util/fs.ts +18 -0
- package/src/util/lock.ts +38 -0
- package/src/util/log.ts +31 -0
- package/src/util/open.ts +22 -0
- package/src/util/retry.ts +49 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { distance } from "fastest-levenshtein";
|
|
2
|
+
import { Converter } from "opencc-js/t2cn";
|
|
3
|
+
|
|
4
|
+
const toSimplified = Converter({ from: "tw", to: "cn" });
|
|
5
|
+
|
|
6
|
+
/** NFKC → lowercase → traditional→simplified → drop everything but letters, digits, and combining marks. */
|
|
7
|
+
export function normalizeText(s: string): string {
|
|
8
|
+
return toSimplified(s.normalize("NFKC").toLowerCase()).replace(/[^\p{L}\p{N}\p{M}]/gu, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface NormalizedTitle {
|
|
12
|
+
core: string;
|
|
13
|
+
versionTags: Set<string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Keyword → canonical version tag. Tested against a lowercased, simplified annotation segment with spaces intact. */
|
|
17
|
+
const VERSION_TAGS: ReadonlyArray<readonly [RegExp, string]> = [
|
|
18
|
+
[/\blive\b|现场|演唱会/, "live"],
|
|
19
|
+
[/\bremix\b|混音/, "remix"],
|
|
20
|
+
[/\binstrumental\b|\bkaraoke\b|\boff vocal\b|伴奏|纯音乐|无人声/, "instrumental"],
|
|
21
|
+
[/\bacoustic\b|\bunplugged\b|不插电/, "acoustic"],
|
|
22
|
+
[/\bdemo\b/, "demo"],
|
|
23
|
+
[/\bcover\b|翻唱/, "cover"],
|
|
24
|
+
[/\bdj\b/, "dj"],
|
|
25
|
+
[/\bpiano\b|钢琴/, "piano"],
|
|
26
|
+
[/\bradio edit\b/, "radio"],
|
|
27
|
+
[/\bextended\b/, "extended"],
|
|
28
|
+
[/\bsped up\b|\bspeed up\b/, "spedup"],
|
|
29
|
+
[/\bslowed\b/, "slowed"],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const BRACKETS = /\(([^()]*)\)|\[([^[\]]*)\]|【([^【】]*)】|(([^()]*))/g;
|
|
33
|
+
const DASH_SUFFIX = /\s+[-–—]\s+(.*)$/;
|
|
34
|
+
const FEAT_SUFFIX = /\s*\b(?:feat|ft)\.?\s+.*$|\s*\bfeaturing\s+.*$/i;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Splits a title into its base text and annotation segments (bracketed groups and the ` - ` suffix).
|
|
38
|
+
* A trailing `feat. X` is dropped from the base (artists are matched separately).
|
|
39
|
+
*/
|
|
40
|
+
export function splitAnnotations(title: string): { base: string; segments: string[] } {
|
|
41
|
+
const segments: string[] = [];
|
|
42
|
+
let base = title.normalize("NFKC").replace(BRACKETS, (_, a?: string, b?: string, c?: string, d?: string) => {
|
|
43
|
+
segments.push(a ?? b ?? c ?? d ?? "");
|
|
44
|
+
return " ";
|
|
45
|
+
});
|
|
46
|
+
const dash = DASH_SUFFIX.exec(base);
|
|
47
|
+
if (dash) {
|
|
48
|
+
segments.push(dash[1]!);
|
|
49
|
+
base = base.slice(0, dash.index);
|
|
50
|
+
}
|
|
51
|
+
return { base: base.replace(FEAT_SUFFIX, "").trim(), segments };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Canonical version tags named by an annotation segment such as `Live at Wembley` or `伴奏`. */
|
|
55
|
+
export function versionTagsOf(segment: string): string[] {
|
|
56
|
+
const text = toSimplified(segment.toLowerCase());
|
|
57
|
+
const tags: string[] = [];
|
|
58
|
+
for (const [re, tag] of VERSION_TAGS) if (re.test(text)) tags.push(tag);
|
|
59
|
+
return tags;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function normalizeTitle(s: string): NormalizedTitle {
|
|
63
|
+
const { base, segments } = splitAnnotations(s);
|
|
64
|
+
const versionTags = new Set<string>();
|
|
65
|
+
for (const seg of segments) for (const tag of versionTagsOf(seg)) versionTags.add(tag);
|
|
66
|
+
const core = normalizeText(base);
|
|
67
|
+
return { core: core || normalizeText(s), versionTags };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ARTIST_SPLIT = /\s*(?:[/、;,&×]|\bfeat\.?\s|\bft\.?\s|\bfeaturing\s)\s*|\s+x\s+/i;
|
|
71
|
+
|
|
72
|
+
const normalizedAliasCache = new WeakMap<Record<string, string>, Map<string, string>>();
|
|
73
|
+
|
|
74
|
+
function normalizedAliases(aliases: Record<string, string>): Map<string, string> {
|
|
75
|
+
let m = normalizedAliasCache.get(aliases);
|
|
76
|
+
if (!m) {
|
|
77
|
+
m = new Map();
|
|
78
|
+
for (const [k, v] of Object.entries(aliases)) m.set(normalizeText(k), normalizeText(v));
|
|
79
|
+
normalizedAliasCache.set(aliases, m);
|
|
80
|
+
}
|
|
81
|
+
return m;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Raw credit parts (trimmed, non-empty) without normalization; used when a human-readable name is needed. */
|
|
85
|
+
export function splitArtists(artists: string[]): string[] {
|
|
86
|
+
return artists.flatMap((a) => a.split(ARTIST_SPLIT)).map((p) => p.trim()).filter((p) => p !== "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Splits joint credits, normalizes each name, applies the alias table, and dedupes. */
|
|
90
|
+
export function normalizeArtists(artists: string[], aliases: Record<string, string>): string[] {
|
|
91
|
+
const table = normalizedAliases(aliases);
|
|
92
|
+
const out = new Set<string>();
|
|
93
|
+
for (const a of artists) {
|
|
94
|
+
for (const part of a.split(ARTIST_SPLIT)) {
|
|
95
|
+
const n = normalizeText(part);
|
|
96
|
+
if (!n) continue;
|
|
97
|
+
out.add(table.get(n) ?? n);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return [...out];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 1 - levenshtein / maxLen, in [0, 1]; two empty strings are identical. */
|
|
104
|
+
export function similarity(a: string, b: string): number {
|
|
105
|
+
const maxLen = Math.max(a.length, b.length);
|
|
106
|
+
return maxLen === 0 ? 1 : 1 - distance(a, b) / maxLen;
|
|
107
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Config } from "../config.ts";
|
|
2
|
+
import type { SpotifyTrack } from "../spotify/types.ts";
|
|
3
|
+
import { normalizeArtists, normalizeTitle, similarity, type NormalizedTitle } from "./normalize.ts";
|
|
4
|
+
import type { ScoreParts } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export interface ScoreInput {
|
|
7
|
+
title: string;
|
|
8
|
+
aliases: string[];
|
|
9
|
+
artists: string[];
|
|
10
|
+
album?: string;
|
|
11
|
+
durationMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Source side of a comparison, normalized once so it can be scored against many candidates. */
|
|
15
|
+
export interface PreparedSource {
|
|
16
|
+
titles: NormalizedTitle[];
|
|
17
|
+
versionTags: Set<string>;
|
|
18
|
+
artists: string[];
|
|
19
|
+
album: string | null;
|
|
20
|
+
durationMs: number | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const W_TITLE = 0.45;
|
|
24
|
+
const W_ARTIST = 0.3;
|
|
25
|
+
const W_ALBUM = 0.1;
|
|
26
|
+
const W_DURATION = 0.15;
|
|
27
|
+
|
|
28
|
+
export function prepareSource(src: ScoreInput, cfg: Config["matching"]): PreparedSource {
|
|
29
|
+
const main = normalizeTitle(src.title);
|
|
30
|
+
return {
|
|
31
|
+
titles: [main, ...src.aliases.map(normalizeTitle)],
|
|
32
|
+
versionTags: main.versionTags,
|
|
33
|
+
artists: normalizeArtists(src.artists, cfg.artist_aliases),
|
|
34
|
+
album: src.album ? normalizeTitle(src.album).core : null,
|
|
35
|
+
durationMs: src.durationMs,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
|
40
|
+
if (a.size !== b.size) return false;
|
|
41
|
+
for (const x of a) if (!b.has(x)) return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function artistSimilarity(a: string[], b: string[]): number {
|
|
46
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
47
|
+
let best = 0;
|
|
48
|
+
for (const x of a) {
|
|
49
|
+
for (const y of b) {
|
|
50
|
+
if (x === y) return 1;
|
|
51
|
+
const s = similarity(x, y);
|
|
52
|
+
if (s > best) best = s;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return best;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function scorePrepared(src: PreparedSource, cand: SpotifyTrack, cfg: Config["matching"]): { score: number; parts: ScoreParts } {
|
|
59
|
+
const candTitle = normalizeTitle(cand.name);
|
|
60
|
+
let title = 0;
|
|
61
|
+
for (const t of src.titles) {
|
|
62
|
+
const s = similarity(t.core, candTitle.core);
|
|
63
|
+
if (s > title) title = s;
|
|
64
|
+
}
|
|
65
|
+
const artist = artistSimilarity(
|
|
66
|
+
src.artists,
|
|
67
|
+
normalizeArtists(
|
|
68
|
+
cand.artists.map((a) => a.name),
|
|
69
|
+
cfg.artist_aliases,
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
const album = src.album === null ? 0.5 : similarity(src.album, normalizeTitle(cand.album.name).core);
|
|
73
|
+
let duration = 0.5;
|
|
74
|
+
if (src.durationMs !== undefined) {
|
|
75
|
+
const delta = Math.abs(src.durationMs - cand.duration_ms);
|
|
76
|
+
duration = delta <= cfg.duration_tolerance_ms ? 1 : delta <= 10_000 ? 0.5 : 0;
|
|
77
|
+
}
|
|
78
|
+
const parts: ScoreParts = { title, artist, album, duration, versionTagsAgree: setsEqual(src.versionTags, candTitle.versionTags) };
|
|
79
|
+
return { score: W_TITLE * title + W_ARTIST * artist + W_ALBUM * album + W_DURATION * duration, parts };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** DESIGN.md §5.3: weighted sum of title / artist / album / duration similarities. */
|
|
83
|
+
export function scoreCandidate(src: ScoreInput, cand: SpotifyTrack, cfg: Config["matching"]): { score: number; parts: ScoreParts } {
|
|
84
|
+
return scorePrepared(prepareSource(src, cfg), cand, cfg);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Hard gate for `matched(auto)`: high overall score plus strong title/artist, exact-enough duration, and agreeing version tags. */
|
|
88
|
+
export function passesAutoGate(score: number, parts: ScoreParts, cfg: Config["matching"]): boolean {
|
|
89
|
+
return score >= cfg.auto_threshold && parts.title >= 0.9 && parts.artist >= 0.8 && parts.duration === 1 && parts.versionTagsAgree;
|
|
90
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Config } from "../config.ts";
|
|
2
|
+
import type { SpotifyApi } from "../spotify/api.ts";
|
|
3
|
+
import type { SpotifyTrack } from "../spotify/types.ts";
|
|
4
|
+
import type { Repo } from "../state/repo.ts";
|
|
5
|
+
import { sleep } from "../util/retry.ts";
|
|
6
|
+
import { splitAnnotations, versionTagsOf } from "./normalize.ts";
|
|
7
|
+
import type { ScoreInput } from "./score.ts";
|
|
8
|
+
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
const SEARCH_LIMIT = 10;
|
|
11
|
+
const QUOTES = /["'“”‘’]/g;
|
|
12
|
+
|
|
13
|
+
/** Thrown on a cache miss once `matching.max_searches_per_run` network searches have been spent. */
|
|
14
|
+
export class SearchBudgetExhaustedError extends Error {
|
|
15
|
+
constructor(readonly budget: number) {
|
|
16
|
+
super(`search budget of ${budget} requests for this run is used up`);
|
|
17
|
+
this.name = "SearchBudgetExhaustedError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Query-friendly form of a title: annotations removed, version tags kept as plain words, quotes stripped. */
|
|
22
|
+
export function queryTitle(title: string): string {
|
|
23
|
+
const { base, segments } = splitAnnotations(title);
|
|
24
|
+
const words = [base];
|
|
25
|
+
for (const seg of segments) words.push(...versionTagsOf(seg));
|
|
26
|
+
return words.join(" ").replace(QUOTES, "").replace(/\s+/g, " ").trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class TrackSearch {
|
|
30
|
+
private readonly ttlMs: number;
|
|
31
|
+
private readonly budget: number;
|
|
32
|
+
private readonly maxQueries: number;
|
|
33
|
+
private readonly minIntervalMs: number;
|
|
34
|
+
private networkSearches = 0;
|
|
35
|
+
/** Serializes the pacing gap so concurrent callers never exceed one request per `minIntervalMs`. */
|
|
36
|
+
private gate: Promise<void> = Promise.resolve();
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly api: SpotifyApi,
|
|
40
|
+
private readonly repo: Repo,
|
|
41
|
+
cfg: Config["matching"],
|
|
42
|
+
private readonly market: string,
|
|
43
|
+
) {
|
|
44
|
+
this.ttlMs = cfg.search_cache_ttl_days * DAY_MS;
|
|
45
|
+
this.budget = cfg.max_searches_per_run === 0 ? Number.POSITIVE_INFINITY : cfg.max_searches_per_run;
|
|
46
|
+
this.maxQueries = cfg.max_queries_per_track;
|
|
47
|
+
this.minIntervalMs = cfg.search_min_interval_ms;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Network searches performed so far in this process. */
|
|
51
|
+
get used(): number {
|
|
52
|
+
return this.networkSearches;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Runs a search, serving from `search_cache` when a fresh entry exists for (query, market). */
|
|
56
|
+
async search(q: string, now: number): Promise<SpotifyTrack[]> {
|
|
57
|
+
const key = new Bun.CryptoHasher("sha1").update(`${q}\u0000${this.market}`).digest("hex");
|
|
58
|
+
const cached = this.repo.cacheGet<SpotifyTrack[]>(key, now, this.ttlMs);
|
|
59
|
+
if (cached) return cached;
|
|
60
|
+
if (this.networkSearches >= this.budget) throw new SearchBudgetExhaustedError(this.budget);
|
|
61
|
+
this.networkSearches++;
|
|
62
|
+
await this.pace();
|
|
63
|
+
const tracks = await this.api.searchTracks(q, this.market, SEARCH_LIMIT);
|
|
64
|
+
this.repo.cacheSet(key, tracks, now);
|
|
65
|
+
return tracks;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private pace(): Promise<void> {
|
|
69
|
+
const turn = this.gate.then(() => sleep(this.minIntervalMs));
|
|
70
|
+
this.gate = turn.catch(() => undefined);
|
|
71
|
+
return turn;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* DESIGN.md §5.1 query sequence: isrc → fielded title+artist → free text → aliases → bare title.
|
|
76
|
+
* Deduped, in order, capped at `max_queries_per_track` while always keeping the bare-title fallback last.
|
|
77
|
+
*/
|
|
78
|
+
queriesFor(src: ScoreInput & { isrc?: string }): string[] {
|
|
79
|
+
const primary = new Set<string>();
|
|
80
|
+
if (src.isrc) primary.add(`isrc:${src.isrc.toUpperCase()}`);
|
|
81
|
+
const artist = src.artists[0]?.replace(QUOTES, "").trim();
|
|
82
|
+
const titles = [src.title, ...src.aliases].map(queryTitle).filter((t) => t.length > 0);
|
|
83
|
+
for (const title of titles) {
|
|
84
|
+
if (artist) {
|
|
85
|
+
primary.add(`track:"${title}" artist:"${artist}"`);
|
|
86
|
+
primary.add(`${title} ${artist}`);
|
|
87
|
+
} else {
|
|
88
|
+
primary.add(`track:"${title}"`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const bare = titles[0];
|
|
92
|
+
if (bare === undefined) return [...primary].slice(0, this.maxQueries);
|
|
93
|
+
primary.delete(bare);
|
|
94
|
+
const head = [...primary].slice(0, Math.max(0, this.maxQueries - 1));
|
|
95
|
+
return [...head, bare];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type MatchStatus = "pending" | "matched" | "review" | "local" | "skipped";
|
|
2
|
+
|
|
3
|
+
export type DecidedBy = "auto" | "isrc" | "fingerprint" | "user";
|
|
4
|
+
|
|
5
|
+
/** Per-component similarity in [0, 1]; see DESIGN.md §5.3 */
|
|
6
|
+
export interface ScoreParts {
|
|
7
|
+
title: number;
|
|
8
|
+
artist: number;
|
|
9
|
+
album: number;
|
|
10
|
+
duration: number;
|
|
11
|
+
versionTagsAgree: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Candidate {
|
|
15
|
+
id: string;
|
|
16
|
+
uri: string;
|
|
17
|
+
title: string;
|
|
18
|
+
artists: string[];
|
|
19
|
+
album: string;
|
|
20
|
+
durationMs: number;
|
|
21
|
+
isPlayable: boolean;
|
|
22
|
+
score: number;
|
|
23
|
+
parts: ScoreParts;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MatchRow {
|
|
27
|
+
canonicalKey: string;
|
|
28
|
+
status: MatchStatus;
|
|
29
|
+
spotifyId: string | null;
|
|
30
|
+
spotifyUri: string | null;
|
|
31
|
+
score: number | null;
|
|
32
|
+
decidedBy: DecidedBy | null;
|
|
33
|
+
candidates: Candidate[];
|
|
34
|
+
decidedAt: number | null;
|
|
35
|
+
lastSearchAt: number | null;
|
|
36
|
+
searchCount: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A user decision from the review TUI or `rematch`. */
|
|
40
|
+
export type Decision =
|
|
41
|
+
| { kind: "pick"; candidate: Candidate }
|
|
42
|
+
| { kind: "uri"; spotifyUri: string }
|
|
43
|
+
| { kind: "local" }
|
|
44
|
+
| { kind: "skip" }
|
|
45
|
+
| { kind: "reset" };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createDecipheriv } from "node:crypto";
|
|
2
|
+
import { mkdir, open, type FileHandle } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Netease `.ncm` container (layout as implemented by ncmdump):
|
|
8
|
+
*
|
|
9
|
+
* "CTENFDAM" | 2 reserved | u32le keyLen | key (XOR 0x64, AES-128-ECB[CORE_KEY], "neteasecloudmusic" + rc4Key)
|
|
10
|
+
* u32le metaLen | meta (XOR 0x63, "163 key(Don't modify):" + base64(AES-128-ECB[META_KEY]("music:" + json)))
|
|
11
|
+
* u32le crc32 | 5 reserved | u32le imageSize | image | audio (XOR stream derived from RC4 S-box of rc4Key)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MAGIC = "CTENFDAM";
|
|
15
|
+
const CORE_KEY = Buffer.from("687A4852416D736F356B496E62617857", "hex");
|
|
16
|
+
const META_KEY = Buffer.from("2331346C6A6B5F215C5D2630553C2728", "hex");
|
|
17
|
+
const KEY_PREFIX = "neteasecloudmusic".length;
|
|
18
|
+
const META_PREFIX = "163 key(Don't modify):".length;
|
|
19
|
+
/** Upper bound for any length-prefixed header field; real files stay far below this. */
|
|
20
|
+
const MAX_FIELD = 16 << 20;
|
|
21
|
+
const CHUNK = 1 << 20;
|
|
22
|
+
|
|
23
|
+
export interface NcmMeta {
|
|
24
|
+
musicId: number;
|
|
25
|
+
musicName: string;
|
|
26
|
+
artist: Array<[string, number]>;
|
|
27
|
+
album: string;
|
|
28
|
+
albumId?: number;
|
|
29
|
+
alias?: string[];
|
|
30
|
+
transNames?: string[];
|
|
31
|
+
format: string;
|
|
32
|
+
/** milliseconds */
|
|
33
|
+
duration: number;
|
|
34
|
+
albumPic?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MetaSchema = z.object({
|
|
38
|
+
musicId: z.coerce.number(),
|
|
39
|
+
musicName: z.string(),
|
|
40
|
+
artist: z.array(z.tuple([z.string(), z.coerce.number()])).default([]),
|
|
41
|
+
album: z.string().default(""),
|
|
42
|
+
albumId: z.coerce.number().optional(),
|
|
43
|
+
alias: z.array(z.string()).optional(),
|
|
44
|
+
transNames: z.array(z.string()).optional(),
|
|
45
|
+
format: z.string(),
|
|
46
|
+
duration: z.number(),
|
|
47
|
+
albumPic: z.string().optional(),
|
|
48
|
+
});
|
|
49
|
+
/** "dj:" payloads (radio programs) nest the song under mainMusic. */
|
|
50
|
+
const DjMetaSchema = z.object({ mainMusic: MetaSchema }).transform((o) => o.mainMusic);
|
|
51
|
+
|
|
52
|
+
/** Standard RC4 key scheduling; the resulting S-box drives the audio XOR stream. Exported for tests. */
|
|
53
|
+
export function keyBox(key: Uint8Array): Uint8Array {
|
|
54
|
+
const box = new Uint8Array(256);
|
|
55
|
+
for (let i = 0; i < 256; i++) box[i] = i;
|
|
56
|
+
let j = 0;
|
|
57
|
+
for (let i = 0; i < 256; i++) {
|
|
58
|
+
const s = box[i]!;
|
|
59
|
+
j = (j + s + key[i % key.length]!) & 0xff;
|
|
60
|
+
box[i] = box[j]!;
|
|
61
|
+
box[j] = s;
|
|
62
|
+
}
|
|
63
|
+
return box;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 256-byte XOR stream: audio byte at offset k is XORed with `stream[k & 0xff]`. */
|
|
67
|
+
function xorStream(box: Uint8Array): Uint8Array {
|
|
68
|
+
const stream = new Uint8Array(256);
|
|
69
|
+
for (let k = 0; k < 256; k++) {
|
|
70
|
+
const j = (k + 1) & 0xff;
|
|
71
|
+
const bj = box[j]!;
|
|
72
|
+
stream[k] = box[(bj + box[(bj + j) & 0xff]!) & 0xff]!;
|
|
73
|
+
}
|
|
74
|
+
return stream;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function aesEcbDecrypt(key: Buffer, data: Uint8Array): Buffer {
|
|
78
|
+
const d = createDecipheriv("aes-128-ecb", key, null);
|
|
79
|
+
return Buffer.concat([d.update(data), d.final()]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
class Cursor {
|
|
83
|
+
pos = 0;
|
|
84
|
+
|
|
85
|
+
constructor(
|
|
86
|
+
private readonly fh: FileHandle,
|
|
87
|
+
private readonly path: string,
|
|
88
|
+
) {}
|
|
89
|
+
|
|
90
|
+
async bytes(n: number): Promise<Buffer> {
|
|
91
|
+
if (n > MAX_FIELD) throw new Error(`${this.path}: implausible NCM field length ${n}`);
|
|
92
|
+
const buf = Buffer.allocUnsafe(n);
|
|
93
|
+
for (let done = 0; done < n; ) {
|
|
94
|
+
const { bytesRead } = await this.fh.read(buf, done, n - done, this.pos + done);
|
|
95
|
+
if (bytesRead === 0) throw new Error(`${this.path}: truncated NCM header (EOF at byte ${this.pos + done})`);
|
|
96
|
+
done += bytesRead;
|
|
97
|
+
}
|
|
98
|
+
this.pos += n;
|
|
99
|
+
return buf;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseMeta(text: string, path: string): NcmMeta {
|
|
104
|
+
const colon = text.indexOf(":");
|
|
105
|
+
if (colon < 0) throw new Error(`${path}: malformed NCM metadata (no type prefix)`);
|
|
106
|
+
const parsed = (text.startsWith("dj:") ? DjMetaSchema : MetaSchema).safeParse(JSON.parse(text.slice(colon + 1)));
|
|
107
|
+
if (!parsed.success) throw new Error(`${path}: unexpected NCM metadata shape: ${parsed.error.message}`);
|
|
108
|
+
return parsed.data;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const KEY_163 = "163 key(Don't modify):";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Plain mp3/flac downloaded by the NetEase client carry the same encrypted metadata as an NCM header in a
|
|
115
|
+
* comment tag (`163 key(Don't modify):<base64>`). Returns null when the text is not such a key or does not decode.
|
|
116
|
+
*/
|
|
117
|
+
export function decode163Key(comment: string, path: string): NcmMeta | null {
|
|
118
|
+
const text = comment.trim();
|
|
119
|
+
if (!text.startsWith(KEY_163)) return null;
|
|
120
|
+
try {
|
|
121
|
+
return parseMeta(aesEcbDecrypt(META_KEY, Buffer.from(text.slice(KEY_163.length), "base64")).toString("utf8"), path);
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface Header {
|
|
128
|
+
meta: NcmMeta;
|
|
129
|
+
stream: Uint8Array;
|
|
130
|
+
cover: Buffer | null;
|
|
131
|
+
audioOffset: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function readHeader(fh: FileHandle, path: string, withCover: boolean): Promise<Header> {
|
|
135
|
+
const r = new Cursor(fh, path);
|
|
136
|
+
const head = await r.bytes(14);
|
|
137
|
+
if (head.toString("latin1", 0, 8) !== MAGIC) throw new Error(`${path}: not an NCM file (bad magic)`);
|
|
138
|
+
|
|
139
|
+
const keyRaw = await r.bytes(head.readUInt32LE(10));
|
|
140
|
+
for (let i = 0; i < keyRaw.length; i++) keyRaw[i] = keyRaw[i]! ^ 0x64;
|
|
141
|
+
const rc4Key = aesEcbDecrypt(CORE_KEY, keyRaw).subarray(KEY_PREFIX);
|
|
142
|
+
if (rc4Key.length === 0) throw new Error(`${path}: NCM key block is empty`);
|
|
143
|
+
|
|
144
|
+
const metaLen = (await r.bytes(4)).readUInt32LE(0);
|
|
145
|
+
if (metaLen === 0) throw new Error(`${path}: NCM file carries no embedded metadata`);
|
|
146
|
+
const metaRaw = await r.bytes(metaLen);
|
|
147
|
+
for (let i = 0; i < metaRaw.length; i++) metaRaw[i] = metaRaw[i]! ^ 0x63;
|
|
148
|
+
const metaCipher = Buffer.from(metaRaw.toString("latin1", META_PREFIX), "base64");
|
|
149
|
+
const meta = parseMeta(aesEcbDecrypt(META_KEY, metaCipher).toString("utf8"), path);
|
|
150
|
+
|
|
151
|
+
r.pos += 9; // crc32 + 5 reserved bytes
|
|
152
|
+
const imageSize = (await r.bytes(4)).readUInt32LE(0);
|
|
153
|
+
let cover: Buffer | null = null;
|
|
154
|
+
if (withCover && imageSize > 0) cover = await r.bytes(imageSize);
|
|
155
|
+
else r.pos += imageSize;
|
|
156
|
+
|
|
157
|
+
return { meta, stream: xorStream(keyBox(rc4Key)), cover, audioOffset: r.pos };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Parses only the header (key + metadata); never touches the audio or cover bytes. */
|
|
161
|
+
export async function readNcmMeta(path: string): Promise<NcmMeta> {
|
|
162
|
+
const fh = await open(path, "r");
|
|
163
|
+
try {
|
|
164
|
+
return (await readHeader(fh, path, false)).meta;
|
|
165
|
+
} finally {
|
|
166
|
+
await fh.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function writeAll(fh: FileHandle, buf: Buffer, length: number): Promise<void> {
|
|
171
|
+
for (let off = 0; off < length; ) off += (await fh.write(buf, off, length - off)).bytesWritten;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Streams the decrypted audio to `outPath` (parent dir created) and returns the metadata plus embedded cover. */
|
|
175
|
+
export async function decryptNcm(path: string, outPath: string): Promise<{ meta: NcmMeta; cover: Uint8Array | null }> {
|
|
176
|
+
const fh = await open(path, "r");
|
|
177
|
+
try {
|
|
178
|
+
const { meta, stream, cover, audioOffset } = await readHeader(fh, path, true);
|
|
179
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
180
|
+
const out = await open(outPath, "w");
|
|
181
|
+
try {
|
|
182
|
+
const buf = Buffer.allocUnsafe(CHUNK);
|
|
183
|
+
for (let pos = audioOffset; ; ) {
|
|
184
|
+
const { bytesRead } = await fh.read(buf, 0, CHUNK, pos);
|
|
185
|
+
if (bytesRead === 0) break;
|
|
186
|
+
const base = pos - audioOffset;
|
|
187
|
+
for (let i = 0; i < bytesRead; i++) buf[i] = buf[i]! ^ stream[(base + i) & 0xff]!;
|
|
188
|
+
await writeAll(out, buf, bytesRead);
|
|
189
|
+
pos += bytesRead;
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
await out.close();
|
|
193
|
+
}
|
|
194
|
+
return { meta, cover };
|
|
195
|
+
} finally {
|
|
196
|
+
await fh.close();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { extname, join, resolve } from "node:path";
|
|
4
|
+
import { log } from "../../util/log.ts";
|
|
5
|
+
|
|
6
|
+
export interface ScannedFile {
|
|
7
|
+
path: string;
|
|
8
|
+
size: number;
|
|
9
|
+
/** integer milliseconds (sub-ms precision dropped so cached values compare exactly) */
|
|
10
|
+
mtimeMs: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Recursively lists files under `dirs` whose extension is in `extensions` (case-insensitive, with or without the dot).
|
|
15
|
+
* Dot-entries and symlinks are skipped; a missing/unreadable root throws, an unreadable subdirectory is logged and skipped.
|
|
16
|
+
* Result paths are absolute, de-duplicated, and sorted by ordinal comparison.
|
|
17
|
+
*/
|
|
18
|
+
export async function scanDirs(dirs: string[], extensions: string[]): Promise<ScannedFile[]> {
|
|
19
|
+
const exts = new Set(extensions.map((e) => "." + e.replace(/^\./, "").toLowerCase()));
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
const out: ScannedFile[] = [];
|
|
22
|
+
for (const dir of dirs) {
|
|
23
|
+
const root = resolve(dir);
|
|
24
|
+
await walk(root, await readdir(root, { withFileTypes: true }), exts, seen, out);
|
|
25
|
+
}
|
|
26
|
+
out.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function walk(
|
|
31
|
+
dir: string,
|
|
32
|
+
entries: Dirent[],
|
|
33
|
+
exts: Set<string>,
|
|
34
|
+
seen: Set<string>,
|
|
35
|
+
out: ScannedFile[],
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
for (const ent of entries) {
|
|
38
|
+
if (ent.name.startsWith(".")) continue;
|
|
39
|
+
const full = join(dir, ent.name);
|
|
40
|
+
if (ent.isDirectory()) {
|
|
41
|
+
let children;
|
|
42
|
+
try {
|
|
43
|
+
children = await readdir(full, { withFileTypes: true });
|
|
44
|
+
} catch (e) {
|
|
45
|
+
log.warn(`skipping unreadable directory ${full}: ${e instanceof Error ? e.message : String(e)}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
await walk(full, children, exts, seen, out);
|
|
49
|
+
} else if (ent.isFile() && exts.has(extname(ent.name).toLowerCase()) && !seen.has(full)) {
|
|
50
|
+
seen.add(full);
|
|
51
|
+
const st = await stat(full);
|
|
52
|
+
out.push({ path: full, size: st.size, mtimeMs: Math.floor(st.mtimeMs) });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|