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
package/src/config.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4
+ import { z } from "zod";
5
+ import template from "../config.example.toml" with { type: "text" };
6
+
7
+ const SpotifySchema = z.object({
8
+ client_id: z.string().default(""),
9
+ redirect_port: z.number().int().min(1024).max(65535).default(8765),
10
+ market: z.string().min(2).default("from_token"),
11
+ });
12
+
13
+ const NeteaseSchema = z.object({
14
+ enabled: z.boolean().default(true),
15
+ include_liked: z.boolean().default(true),
16
+ include_playlists: z.array(z.string()).default([]),
17
+ exclude_playlists: z.array(z.string()).default([]),
18
+ like_matched: z.boolean().default(true),
19
+ });
20
+
21
+ const LocalSchema = z.object({
22
+ enabled: z.boolean().default(true),
23
+ dirs: z.array(z.string()).default([]),
24
+ playlist_name: z.string().min(1).default("Local Library"),
25
+ /** false: local files only supply audio for tracks of other playlists (export path); no "Local Library" playlist is mirrored */
26
+ mirror_playlist: z.boolean().default(true),
27
+ extensions: z.array(z.string().min(1)).default(["mp3", "flac", "m4a", "ogg", "wav", "ncm"]),
28
+ filename_pattern: z.enum(["artist-title", "title-artist"]).default("artist-title"),
29
+ like_matched: z.boolean().default(false),
30
+ });
31
+
32
+ const ExportSchema = z.object({
33
+ dir: z.string().default(""),
34
+ ffmpeg: z.string().default("ffmpeg"),
35
+ bitrate: z.string().regex(/^\d+k$/).default("320k"),
36
+ });
37
+
38
+ const MatchingSchema = z
39
+ .object({
40
+ auto_threshold: z.number().min(0).max(1).default(0.9),
41
+ review_threshold: z.number().min(0).max(1).default(0.6),
42
+ duration_tolerance_ms: z.number().int().nonnegative().default(3000),
43
+ retry_unmatched_after_days: z.number().int().positive().default(30),
44
+ search_cache_ttl_days: z.number().int().positive().default(30),
45
+ /** network searches allowed per run (cache hits are free); 0 = unlimited. Spotify dev-mode apps get a daily quota. */
46
+ max_searches_per_run: z.number().int().nonnegative().default(400),
47
+ /** queries tried per track before giving up (isrc / fielded / free text / aliases / bare title) */
48
+ max_queries_per_track: z.number().int().min(1).default(4),
49
+ search_concurrency: z.number().int().min(1).max(8).default(2),
50
+ search_min_interval_ms: z.number().int().nonnegative().default(120),
51
+ fingerprint: z.boolean().default(false),
52
+ fpcalc: z.string().default("fpcalc"),
53
+ acoustid_key: z.string().default(""),
54
+ artist_aliases: z.record(z.string(), z.string()).default({}),
55
+ })
56
+ .refine((m) => m.review_threshold <= m.auto_threshold, {
57
+ message: "matching.review_threshold must be <= matching.auto_threshold",
58
+ });
59
+
60
+ const SyncSchema = z.object({
61
+ playlist_prefix: z.string().default(""),
62
+ });
63
+
64
+ // `prefault` (zod v4) fills a missing section with `{}` *before* parsing, so inner field defaults apply.
65
+ export const ConfigSchema = z.object({
66
+ spotify: SpotifySchema.prefault({}),
67
+ netease: NeteaseSchema.prefault({}),
68
+ local: LocalSchema.prefault({}),
69
+ export: ExportSchema.prefault({}),
70
+ matching: MatchingSchema.prefault({}),
71
+ sync: SyncSchema.prefault({}),
72
+ });
73
+
74
+ export type Config = z.infer<typeof ConfigSchema>;
75
+
76
+ export const CONFIG_TEMPLATE: string = template;
77
+
78
+ export function stateDir(override?: string): string {
79
+ return expandPath(override ?? process.env.SPOTIFIFY_STATE_DIR ?? join(homedir(), ".spotifify"));
80
+ }
81
+
82
+ export const CONFIG_FILENAME = "config.toml";
83
+
84
+ export function expandPath(p: string): string {
85
+ if (p === "~") return homedir();
86
+ if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
87
+ return resolve(p);
88
+ }
89
+
90
+ export async function loadConfig(path = join(stateDir(), CONFIG_FILENAME)): Promise<Config> {
91
+ const file = Bun.file(path);
92
+ if (!(await file.exists())) {
93
+ throw new Error(`config not found: ${path} (run \`spotifify init\`)`);
94
+ }
95
+ const raw = parseToml(await file.text());
96
+ const cfg = ConfigSchema.parse(raw);
97
+ cfg.local.dirs = cfg.local.dirs.map(expandPath);
98
+ if (cfg.export.dir) cfg.export.dir = expandPath(cfg.export.dir);
99
+ cfg.local.extensions = cfg.local.extensions.map((e) => e.replace(/^\./, "").toLowerCase());
100
+ return cfg;
101
+ }
102
+
103
+ export interface ConfigUpgrade {
104
+ text: string;
105
+ /** dotted keys that were absent from the user's file and now carry the template default */
106
+ added: string[];
107
+ }
108
+
109
+ /** Merge entries into `[matching.artist_aliases]`, re-rendering the file through the template (comments/order preserved). */
110
+ export function withArtistAliases(existing: string, aliases: Record<string, string>): string {
111
+ const current = parseToml(existing) as Record<string, unknown>;
112
+ const matching = (current.matching ??= {}) as Record<string, unknown>;
113
+ const table = (matching.artist_aliases ??= {}) as Record<string, unknown>;
114
+ Object.assign(table, aliases);
115
+ return upgradeConfig(stringifyToml(current)).text;
116
+ }
117
+
118
+ /**
119
+ * Rewrite a config file against the current template: template order and comments, the user's values
120
+ * where present, template defaults for options the file predates. Keys the template does not know are
121
+ * kept at the end of their section; unknown sections are appended verbatim.
122
+ */
123
+ export function upgradeConfig(existing: string, tmpl: string = CONFIG_TEMPLATE): ConfigUpgrade {
124
+ const current = parseToml(existing) as Record<string, unknown>;
125
+ const out: string[] = [];
126
+ const added: string[] = [];
127
+ const seenSections = new Set<string>();
128
+ let section: string[] = [];
129
+ let emitted = new Set<string>();
130
+
131
+ const table = (path: string[]): Record<string, unknown> | undefined => {
132
+ let node: unknown = current;
133
+ for (const p of path) {
134
+ if (typeof node !== "object" || node === null || !(p in node)) return undefined;
135
+ node = (node as Record<string, unknown>)[p];
136
+ }
137
+ return typeof node === "object" && node !== null && !Array.isArray(node) ? (node as Record<string, unknown>) : undefined;
138
+ };
139
+ const isTable = (v: unknown) => typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
140
+ const flushExtras = () => {
141
+ const t = table(section);
142
+ if (!t) return;
143
+ for (const [k, v] of Object.entries(t)) {
144
+ if (emitted.has(k) || isTable(v)) continue;
145
+ while (out.length > 0 && out[out.length - 1] === "") out.pop();
146
+ out.push(`${tomlKey(k)} = ${tomlValue(v)}`);
147
+ }
148
+ out.push("");
149
+ };
150
+
151
+ for (const line of tmpl.split(/\r?\n/)) {
152
+ const header = line.match(/^\[([^\]]+)\]\s*$/);
153
+ if (header) {
154
+ flushExtras();
155
+ section = header[1]!.split(".");
156
+ seenSections.add(header[1]!);
157
+ emitted = new Set();
158
+ out.push(line);
159
+ continue;
160
+ }
161
+ const kv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/);
162
+ if (kv) {
163
+ const key = kv[1]!;
164
+ const t = table(section);
165
+ emitted.add(key);
166
+ if (t && key in t && !isTable(t[key])) {
167
+ out.push(`${key} = ${tomlValue(t[key])}${trailingComment(kv[2]!)}`);
168
+ } else {
169
+ out.push(line);
170
+ added.push([...section, key].join("."));
171
+ }
172
+ continue;
173
+ }
174
+ out.push(line);
175
+ }
176
+ flushExtras();
177
+
178
+ // Sections the template does not know at all (e.g. removed or user-invented): keep them.
179
+ const walk = (node: Record<string, unknown>, path: string[]) => {
180
+ for (const [k, v] of Object.entries(node)) {
181
+ if (!isTable(v)) continue;
182
+ const full = [...path, k];
183
+ const name = full.join(".");
184
+ if (!seenSections.has(name)) {
185
+ out.push(`[${name}]`);
186
+ for (const [ik, iv] of Object.entries(v as Record<string, unknown>)) if (!isTable(iv)) out.push(`${tomlKey(ik)} = ${tomlValue(iv)}`);
187
+ out.push("");
188
+ }
189
+ walk(v as Record<string, unknown>, full);
190
+ }
191
+ };
192
+ walk(current, []);
193
+
194
+ return { text: out.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n"), added };
195
+ }
196
+
197
+ /** Options present in the template but missing from `existing` (what `init --upgrade` would add). */
198
+ export function missingConfigKeys(existing: string, tmpl: string = CONFIG_TEMPLATE): string[] {
199
+ return upgradeConfig(existing, tmpl).added;
200
+ }
201
+
202
+ function tomlValue(v: unknown): string {
203
+ const line = stringifyToml({ v });
204
+ return line.slice(line.indexOf("=") + 1).trim();
205
+ }
206
+
207
+ function tomlKey(k: string): string {
208
+ return /^[A-Za-z0-9_-]+$/.test(k) ? k : JSON.stringify(k);
209
+ }
210
+
211
+ /** Keep an inline `# comment` that follows the template value. */
212
+ function trailingComment(rest: string): string {
213
+ const m = rest.match(/\s+(#.*)$/);
214
+ return m ? ` ${m[1]}` : "";
215
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Bun text imports: `import s from "./x.sql" with { type: "text" }`
2
+ declare module "*.sql" {
3
+ const text: string;
4
+ export default text;
5
+ }
6
+ declare module "*.toml" {
7
+ const text: string;
8
+ export default text;
9
+ }
10
+
11
+ // NeteaseCloudMusicApi internals loaded by src/sources/netease/lib.ts (the package ships types only for main.js).
12
+ declare namespace NeteaseApi {
13
+ type Request = (uri: string, data: Record<string, unknown>, options: Record<string, unknown>) => Promise<unknown>;
14
+ type Module = (query: Record<string, unknown>, request: Request) => Promise<unknown>;
15
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Mine `matching.artist_aliases` candidates from matches whose identity is certain (user-confirmed,
3
+ * ISRC or fingerprint hits): when exactly one source artist and one Spotify artist are left unpaired
4
+ * after applying the current alias table, they are the same act under two names (周杰倫 → Jay Chou).
5
+ */
6
+ import type { Config } from "../config.ts";
7
+ import type { Repo } from "../state/repo.ts";
8
+ import { normalizeArtists, normalizeText, similarity, splitArtists } from "./normalize.ts";
9
+
10
+ export interface AliasSuggestion {
11
+ /** raw source artist name as it appears in the source */
12
+ from: string;
13
+ /** raw Spotify artist name */
14
+ to: string;
15
+ /** confirmed matches supporting this pairing */
16
+ count: number;
17
+ /** a few "artist - title" examples */
18
+ examples: string[];
19
+ /** other Spotify names the same source artist was paired with (ambiguous; the most frequent wins) */
20
+ conflicts: string[];
21
+ }
22
+
23
+ /** Below this similarity the two names would not be matched by fuzzy scoring, so an alias actually changes outcomes. */
24
+ const FUZZY_ENOUGH = 0.8;
25
+ const EXAMPLES = 3;
26
+
27
+ export function inferArtistAliases(repo: Repo, cfg: Config["matching"]): AliasSuggestion[] {
28
+ const rows = repo.listMatches("matched").filter((m) => m.decidedBy === "user" || m.decidedBy === "isrc" || m.decidedBy === "fingerprint");
29
+ const tracks = repo.representativeTracks(rows.map((m) => m.canonicalKey));
30
+ // normalized source name → normalized spotify name → tally
31
+ const tally = new Map<string, Map<string, { from: string; to: string; count: number; examples: string[] }>>();
32
+
33
+ for (const m of rows) {
34
+ const track = tracks.get(m.canonicalKey);
35
+ const cand = m.candidates.find((c) => c.id === m.spotifyId);
36
+ if (!track || !cand) continue;
37
+
38
+ const srcRaw = splitArtists(track.artists);
39
+ const candRaw = splitArtists(cand.artists);
40
+ const srcNorm = normalizeArtists(track.artists, cfg.artist_aliases);
41
+ const candNorm = normalizeArtists(cand.artists, cfg.artist_aliases);
42
+ const candSet = new Set(candNorm);
43
+ const srcSet = new Set(srcNorm);
44
+
45
+ const leftSrc = srcRaw.filter((r) => !candSet.has(normalizeArtists([r], cfg.artist_aliases)[0] ?? ""));
46
+ const leftCand = candRaw.filter((r) => !srcSet.has(normalizeArtists([r], cfg.artist_aliases)[0] ?? ""));
47
+ if (leftSrc.length !== 1 || leftCand.length !== 1) continue;
48
+
49
+ const from = leftSrc[0]!;
50
+ const to = leftCand[0]!;
51
+ const nf = normalizeText(from);
52
+ const nt = normalizeText(to);
53
+ if (nf === "" || nt === "" || similarity(nf, nt) >= FUZZY_ENOUGH) continue;
54
+
55
+ let byTarget = tally.get(nf);
56
+ if (!byTarget) {
57
+ byTarget = new Map();
58
+ tally.set(nf, byTarget);
59
+ }
60
+ let entry = byTarget.get(nt);
61
+ if (!entry) {
62
+ entry = { from, to, count: 0, examples: [] };
63
+ byTarget.set(nt, entry);
64
+ }
65
+ entry.count++;
66
+ if (entry.examples.length < EXAMPLES) entry.examples.push(`${track.artists.join(", ")} - ${track.title} → ${cand.artists.join(", ")} - ${cand.title}`);
67
+ }
68
+
69
+ const out: AliasSuggestion[] = [];
70
+ for (const byTarget of tally.values()) {
71
+ const ranked = [...byTarget.values()].sort((a, b) => b.count - a.count);
72
+ const best = ranked[0]!;
73
+ out.push({ from: best.from, to: best.to, count: best.count, examples: best.examples, conflicts: ranked.slice(1).map((r) => r.to) });
74
+ }
75
+ return out.sort((a, b) => b.count - a.count || a.from.localeCompare(b.from));
76
+ }
@@ -0,0 +1,104 @@
1
+ import { z } from "zod";
2
+ import type { Config } from "../config.ts";
3
+ import type { Repo } from "../state/repo.ts";
4
+ import { log } from "../util/log.ts";
5
+ import { RetryableError, sleep, withRetry } from "../util/retry.ts";
6
+
7
+ const ACOUSTID_MIN_SCORE = 0.7;
8
+ const MAX_RECORDINGS = 3;
9
+ const MUSICBRAINZ_GAP_MS = 1100;
10
+ const USER_AGENT = "Spotifify/0.1 (https://github.com/spotifify)";
11
+
12
+ const FpcalcOutput = z.object({ duration: z.number(), fingerprint: z.string().min(1) });
13
+
14
+ const AcoustIdResponse = z.object({
15
+ status: z.string(),
16
+ error: z.object({ message: z.string() }).optional(),
17
+ results: z.array(z.object({ id: z.string(), score: z.number(), recordings: z.array(z.object({ id: z.string() })).optional() })).optional(),
18
+ });
19
+
20
+ const MusicBrainzRecording = z.object({ isrcs: z.array(z.string()).optional() });
21
+
22
+ /** Set once per process when `fpcalc` cannot be spawned, so every later track skips silently. */
23
+ let fpcalcMissing = false;
24
+ /** All AcoustID / MusicBrainz traffic is serialized so the 1 req/s MusicBrainz budget holds across concurrent tracks. */
25
+ let queue: Promise<unknown> = Promise.resolve();
26
+ let lastMusicBrainzAt = 0;
27
+
28
+ async function fetchJson<T>(url: string, schema: z.ZodType<T>): Promise<T> {
29
+ return withRetry(async () => {
30
+ const res = await fetch(url, { headers: { "User-Agent": USER_AGENT, Accept: "application/json" } });
31
+ if (res.status === 429 || res.status >= 500) {
32
+ const retryAfter = Number(res.headers.get("retry-after"));
33
+ throw new RetryableError(`${res.status} from ${new URL(url).host}`, retryAfter > 0 ? retryAfter * 1000 : undefined);
34
+ }
35
+ if (!res.ok) throw new Error(`${res.status} from ${new URL(url).host}: ${(await res.text()).slice(0, 200)}`);
36
+ return schema.parse(await res.json());
37
+ });
38
+ }
39
+
40
+ /** Returns null (and disables fingerprinting for the process) when the binary is not installed. */
41
+ async function runFpcalc(fpcalc: string, path: string): Promise<z.infer<typeof FpcalcOutput> | null> {
42
+ let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
43
+ try {
44
+ proc = Bun.spawn([fpcalc, "-json", path], { stdout: "pipe", stderr: "pipe" });
45
+ } catch (e) {
46
+ if (e instanceof Error && "code" in e && e.code === "ENOENT") {
47
+ fpcalcMissing = true;
48
+ log.warn(`fpcalc not found (${fpcalc}); fingerprint lookups disabled for this run`);
49
+ return null;
50
+ }
51
+ throw e;
52
+ }
53
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
54
+ if (code !== 0) throw new Error(`fpcalc exited ${code}: ${stderr.trim().slice(0, 200)}`);
55
+ return FpcalcOutput.parse(JSON.parse(stdout));
56
+ }
57
+
58
+ async function lookup(path: string, contentHash: string, cfg: Config["matching"], repo: Repo, now: number): Promise<string[]> {
59
+ const fp = await runFpcalc(cfg.fpcalc, path);
60
+ if (!fp) return [];
61
+ const durationS = Math.round(fp.duration);
62
+ const acoustid = await fetchJson(
63
+ `https://api.acoustid.org/v2/lookup?client=${encodeURIComponent(cfg.acoustid_key)}&meta=recordings&duration=${durationS}&fingerprint=${encodeURIComponent(fp.fingerprint)}`,
64
+ AcoustIdResponse,
65
+ );
66
+ if (acoustid.status !== "ok") throw new Error(`acoustid: ${acoustid.error?.message ?? acoustid.status}`);
67
+ const mbids: string[] = [];
68
+ for (const r of acoustid.results ?? []) {
69
+ if (r.score < ACOUSTID_MIN_SCORE) continue;
70
+ for (const rec of r.recordings ?? []) {
71
+ if (mbids.length >= MAX_RECORDINGS) break;
72
+ if (!mbids.includes(rec.id)) mbids.push(rec.id);
73
+ }
74
+ }
75
+ const isrcs = new Set<string>();
76
+ for (const mbid of mbids) {
77
+ const wait = lastMusicBrainzAt + MUSICBRAINZ_GAP_MS - Date.now();
78
+ if (wait > 0) await sleep(wait);
79
+ lastMusicBrainzAt = Date.now();
80
+ const rec = await fetchJson(`https://musicbrainz.org/ws/2/recording/${mbid}?inc=isrcs&fmt=json`, MusicBrainzRecording);
81
+ for (const isrc of rec.isrcs ?? []) isrcs.add(isrc.toUpperCase());
82
+ }
83
+ const out = [...isrcs];
84
+ repo.setFingerprint({ contentHash, fp: fp.fingerprint, durationS, acoustid: acoustid.results ?? null, isrcs: out, fetchedAt: now });
85
+ return out;
86
+ }
87
+
88
+ /**
89
+ * ISRCs for a local file via Chromaprint → AcoustID → MusicBrainz, cached by content hash.
90
+ * Returns [] when fingerprinting is disabled, `fpcalc`/key are missing, or any step fails (failures are not cached).
91
+ */
92
+ export async function isrcsByFingerprint(path: string, contentHash: string, cfg: Config["matching"], repo: Repo, now: number): Promise<string[]> {
93
+ if (!cfg.fingerprint || !cfg.acoustid_key || fpcalcMissing) return [];
94
+ const cached = repo.getFingerprint(contentHash);
95
+ if (cached) return cached.isrcs;
96
+ const job = queue.then(() => lookup(path, contentHash, cfg, repo, now));
97
+ queue = job.catch(() => {});
98
+ try {
99
+ return await job;
100
+ } catch (e) {
101
+ log.warn(`fingerprint lookup failed for ${path}: ${e instanceof Error ? e.message : String(e)}`);
102
+ return [];
103
+ }
104
+ }
@@ -0,0 +1,182 @@
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, SourceTrackRow } from "../state/repo.ts";
5
+ import { mapLimit } from "../util/retry.ts";
6
+ import { isrcsByFingerprint } from "./fingerprint.ts";
7
+ import { passesAutoGate, prepareSource, scorePrepared, type PreparedSource } from "./score.ts";
8
+ import { queryTitle, TrackSearch } from "./search.ts";
9
+ import type { Candidate, DecidedBy, MatchRow, ScoreParts } from "./types.ts";
10
+
11
+ const MAX_CANDIDATES = 10;
12
+ /** DESIGN.md §5.1 step 5: a bare-title query only contributes candidates whose artist already looks right. */
13
+ const BARE_TITLE_MIN_ARTIST = 0.8;
14
+ const SEARCH_CONCURRENCY = 4;
15
+ const TRACK_REF = /^(?:spotify:track:|https?:\/\/open\.spotify\.com\/(?:intl-[a-z-]+\/)?track\/)([0-9A-Za-z]{22})(?:[?#].*)?$/;
16
+
17
+ /** Dedupes search results by id (and relinked origin id) and scores each one once. */
18
+ class CandidatePool {
19
+ private readonly seen = new Set<string>();
20
+ private readonly candidates: Candidate[] = [];
21
+
22
+ constructor(
23
+ private readonly src: PreparedSource,
24
+ private readonly cfg: Config["matching"],
25
+ ) {}
26
+
27
+ /** Scores unseen tracks; returns those admitted. Unplayable tracks are skipped when `playableOnly`. */
28
+ add(tracks: SpotifyTrack[], playableOnly: boolean, minArtist: number): Candidate[] {
29
+ const added: Candidate[] = [];
30
+ for (const t of tracks) {
31
+ if (t.id === null || t.is_local) continue;
32
+ if (this.seen.has(t.id) || (t.linked_from !== undefined && this.seen.has(t.linked_from.id))) continue;
33
+ this.seen.add(t.id);
34
+ if (t.linked_from) this.seen.add(t.linked_from.id);
35
+ if (playableOnly && t.is_playable === false) continue;
36
+ const scored = scorePrepared(this.src, t, this.cfg);
37
+ if (scored.parts.artist < minArtist) continue;
38
+ const c = toCandidate(t.id, t, scored);
39
+ this.candidates.push(c);
40
+ added.push(c);
41
+ }
42
+ return added;
43
+ }
44
+
45
+ sorted(): Candidate[] {
46
+ return [...this.candidates].sort((a, b) => b.score - a.score);
47
+ }
48
+ }
49
+
50
+ function toCandidate(id: string, t: SpotifyTrack, scored: { score: number; parts: ScoreParts }): Candidate {
51
+ return {
52
+ id,
53
+ uri: t.uri,
54
+ title: t.name,
55
+ artists: t.artists.map((a) => a.name),
56
+ album: t.album.name,
57
+ durationMs: t.duration_ms,
58
+ isPlayable: t.is_playable !== false,
59
+ score: scored.score,
60
+ parts: scored.parts,
61
+ };
62
+ }
63
+
64
+ type Hit = { by: "isrc" | "fingerprint"; candidate: Candidate } | { by: "auto" };
65
+
66
+ export class Matcher {
67
+ private readonly api: SpotifyApi;
68
+ private readonly repo: Repo;
69
+ private readonly cfg: Config;
70
+ private readonly market: string;
71
+ private readonly search: TrackSearch;
72
+
73
+ constructor(deps: { api: SpotifyApi; repo: Repo; cfg: Config; market: string }) {
74
+ this.api = deps.api;
75
+ this.repo = deps.repo;
76
+ this.cfg = deps.cfg;
77
+ this.market = deps.market;
78
+ this.search = new TrackSearch(deps.api, deps.repo, deps.cfg.matching, deps.market);
79
+ }
80
+
81
+ /** Network searches spent by this matcher (cache hits excluded). */
82
+ get searchesUsed(): number {
83
+ return this.search.used;
84
+ }
85
+
86
+ /**
87
+ * Runs queries in order, admitting playable results into the pool. Stops at the first ISRC hit
88
+ * (returned as the decision) or as soon as some candidate passes the auto gate.
89
+ */
90
+ private async runQueries(pool: CandidatePool, queries: string[], bareTitle: string | null, isrcBy: "isrc" | "fingerprint", now: number): Promise<Hit | null> {
91
+ const m = this.cfg.matching;
92
+ for (const q of queries) {
93
+ const tracks = await this.search.search(q, now);
94
+ const added = pool.add(tracks, true, q === bareTitle ? BARE_TITLE_MIN_ARTIST : 0);
95
+ if (added.length === 0) continue;
96
+ if (q.startsWith("isrc:")) {
97
+ let best = added[0]!;
98
+ for (const c of added) if (c.score > best.score) best = c;
99
+ return { by: isrcBy, candidate: best };
100
+ }
101
+ if (added.some((c) => passesAutoGate(c.score, c.parts, m))) return { by: "auto" };
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /**
107
+ * Decides `matched` / `review` / `local` for one source track. User decisions are returned untouched.
108
+ * Does not persist: the caller writes the returned row with `repo.upsertMatch`.
109
+ */
110
+ async matchOne(track: SourceTrackRow, existing: MatchRow | null, now: number): Promise<MatchRow> {
111
+ if (existing?.decidedBy === "user") return existing;
112
+ const m = this.cfg.matching;
113
+ const pool = new CandidatePool(prepareSource(track, m), m);
114
+ const bareTitle = track.artists.length > 0 ? queryTitle(track.title) : null;
115
+ let hit = await this.runQueries(pool, this.search.queriesFor(track), bareTitle, "isrc", now);
116
+ if (hit === null && track.file && m.fingerprint) {
117
+ const isrcs = await isrcsByFingerprint(track.file.path, track.file.contentHash, m, this.repo, now);
118
+ for (const isrc of isrcs) {
119
+ hit = await this.runQueries(pool, [`isrc:${isrc}`], null, "fingerprint", now);
120
+ if (hit !== null) break;
121
+ }
122
+ }
123
+
124
+ let candidates = pool.sorted();
125
+ let winner: { candidate: Candidate; decidedBy: DecidedBy; score: number } | null = null;
126
+ if (hit !== null && hit.by !== "auto") {
127
+ winner = { candidate: hit.candidate, decidedBy: hit.by, score: 1 };
128
+ } else {
129
+ const auto = candidates.find((c) => passesAutoGate(c.score, c.parts, m));
130
+ if (auto) winner = { candidate: auto, decidedBy: "auto", score: auto.score };
131
+ }
132
+ if (winner !== null && !candidates.slice(0, MAX_CANDIDATES).includes(winner.candidate)) {
133
+ candidates = [winner.candidate, ...candidates.filter((c) => c !== winner.candidate)];
134
+ }
135
+ candidates = candidates.slice(0, MAX_CANDIDATES);
136
+
137
+ const row: MatchRow = {
138
+ canonicalKey: track.canonicalKey,
139
+ status: "local",
140
+ spotifyId: null,
141
+ spotifyUri: null,
142
+ score: candidates[0]?.score ?? null,
143
+ decidedBy: "auto",
144
+ candidates,
145
+ decidedAt: now,
146
+ lastSearchAt: now,
147
+ searchCount: (existing?.searchCount ?? 0) + 1,
148
+ };
149
+ if (winner !== null) {
150
+ row.status = "matched";
151
+ row.spotifyId = winner.candidate.id;
152
+ row.spotifyUri = `spotify:track:${winner.candidate.id}`;
153
+ row.score = winner.score;
154
+ row.decidedBy = winner.decidedBy;
155
+ } else if (candidates[0] !== undefined && candidates[0].score >= m.review_threshold) {
156
+ row.status = "review";
157
+ row.decidedBy = null;
158
+ row.decidedAt = null;
159
+ }
160
+ return row;
161
+ }
162
+
163
+ /** Every scored candidate for the TUI: the full §5.1 query union, or one custom query. Unplayable tracks are kept and flagged. */
164
+ async candidatesFor(track: SourceTrackRow, query?: string): Promise<Candidate[]> {
165
+ const m = this.cfg.matching;
166
+ const pool = new CandidatePool(prepareSource(track, m), m);
167
+ const queries = query === undefined ? this.search.queriesFor(track) : [query];
168
+ const now = Date.now();
169
+ for (const tracks of await mapLimit(queries, SEARCH_CONCURRENCY, (q) => this.search.search(q, now))) pool.add(tracks, false, 0);
170
+ return pool.sorted();
171
+ }
172
+
173
+ /** Scores a pasted `spotify:track:` URI or open.spotify.com track URL against the source track. */
174
+ async candidateFromUri(track: SourceTrackRow, uriOrUrl: string): Promise<Candidate | null> {
175
+ const id = TRACK_REF.exec(uriOrUrl.trim())?.[1];
176
+ if (id === undefined) return null;
177
+ const t = await this.api.getTrack(id, this.market);
178
+ if (t === null || t.id === null) return null;
179
+ const m = this.cfg.matching;
180
+ return toCandidate(t.id, t, scorePrepared(prepareSource(track, m), t, m));
181
+ }
182
+ }