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/cli.ts ADDED
@@ -0,0 +1,504 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync, mkdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { Command, InvalidArgumentError } from "commander";
5
+ import qrcode from "qrcode-terminal";
6
+ import { CONFIG_FILENAME, CONFIG_TEMPLATE, loadConfig, missingConfigKeys, stateDir, upgradeConfig, withArtistAliases, type Config } from "./config.ts";
7
+ import { inferArtistAliases } from "./match/aliases.ts";
8
+ import { Matcher } from "./match/matcher.ts";
9
+ import type { MatchStatus } from "./match/types.ts";
10
+ import { loginByQr, normalizeCookie } from "./sources/netease/auth.ts";
11
+ import { NeteaseAuthError, NeteaseClient } from "./sources/netease/client.ts";
12
+ import { SpotifyApi } from "./spotify/api.ts";
13
+ import { AuthExpiredError, loginPkce, type TokenStore } from "./spotify/auth.ts";
14
+ import { SpotifyClient } from "./spotify/client.ts";
15
+ import { SCOPES, type SpotifyTokens } from "./spotify/types.ts";
16
+ import { openDatabase, schemaVersion } from "./state/db.ts";
17
+ import { Repo } from "./state/repo.ts";
18
+ import { applyExports } from "./sync/apply.ts";
19
+ import { formatPlan, planExportsOnly, runSync, selectedKeys, type AwaitingEntry, type SyncSummary } from "./sync/run.ts";
20
+ import { runReviewTui } from "./tui/index.ts";
21
+ import { probeBinary } from "./util/bin.ts";
22
+ import { copyToClipboard } from "./util/clipboard.ts";
23
+ import { acquireLock } from "./util/lock.ts";
24
+ import { configureLog, log } from "./util/log.ts";
25
+
26
+ const EXIT_ERROR = 1;
27
+ const EXIT_AUTH = 3;
28
+
29
+ interface GlobalOpts {
30
+ config?: string;
31
+ stateDir?: string;
32
+ logFile?: string;
33
+ verbose?: boolean;
34
+ }
35
+
36
+ interface Ctx {
37
+ dir: string;
38
+ cfg: Config;
39
+ repo: Repo;
40
+ }
41
+
42
+ const program = new Command()
43
+ .name("spotifify")
44
+ .description("Sync Netease Cloud Music playlists and a local library to Spotify")
45
+ .version("0.1.1") // x-release-please-version
46
+ .option("--config <path>", "config file (default: <state-dir>/config.toml)")
47
+ .option("--state-dir <dir>", "state directory (default: ~/.spotifify or $SPOTIFIFY_STATE_DIR)")
48
+ .option("--log-file <path>", "append log lines to this file")
49
+ .option("--verbose", "debug logging")
50
+ .hook("preAction", () => {
51
+ const o = program.opts<GlobalOpts>();
52
+ configureLog({ level: o.verbose ? "debug" : "info", file: o.logFile });
53
+ });
54
+
55
+ function paths(): { dir: string; configPath: string } {
56
+ const opts = program.opts<GlobalOpts>();
57
+ const dir = stateDir(opts.stateDir);
58
+ return { dir, configPath: opts.config ?? join(dir, CONFIG_FILENAME) };
59
+ }
60
+
61
+ async function ctx(): Promise<Ctx> {
62
+ const { dir, configPath } = paths();
63
+ const cfg = await loadConfig(configPath);
64
+ return { dir, cfg, repo: new Repo(openDatabase(dir)) };
65
+ }
66
+
67
+ function tokenStore(repo: Repo): TokenStore {
68
+ return {
69
+ load: () => repo.getAuth<SpotifyTokens>("spotify"),
70
+ save: (t) => repo.setAuth("spotify", t, Date.now()),
71
+ };
72
+ }
73
+
74
+ function spotifyApi(c: Ctx): SpotifyApi {
75
+ if (!c.cfg.spotify.client_id) throw new Error("spotify.client_id is empty in config");
76
+ const tokens = c.repo.getAuth<SpotifyTokens>("spotify");
77
+ if (tokens) {
78
+ const granted = new Set(tokens.scope.split(/\s+/));
79
+ const missing = SCOPES.filter((s) => !granted.has(s));
80
+ if (missing.length > 0) throw new AuthExpiredError(`Spotify token lacks scope(s) ${missing.join(", ")}; run \`spotifify auth spotify\` again`);
81
+ }
82
+ return new SpotifyApi(new SpotifyClient({ clientId: c.cfg.spotify.client_id, store: tokenStore(c.repo) }));
83
+ }
84
+
85
+ function fail(e: unknown): never {
86
+ if (e instanceof AuthExpiredError || e instanceof NeteaseAuthError) {
87
+ log.error(e.message);
88
+ process.exit(EXIT_AUTH);
89
+ }
90
+ log.error(e instanceof Error ? (program.opts<GlobalOpts>().verbose ? (e.stack ?? e.message) : e.message) : String(e));
91
+ process.exit(EXIT_ERROR);
92
+ }
93
+
94
+ // ---- init / doctor ----------------------------------------------------------
95
+
96
+ program
97
+ .command("init")
98
+ .description("write a config template; --upgrade adds options introduced since the file was written")
99
+ .option("--force", "overwrite an existing config with the template")
100
+ .option("--upgrade", "merge new template options into the existing config (values kept, backup written)")
101
+ .action(async (opts: { force?: boolean; upgrade?: boolean }) => {
102
+ const { dir, configPath } = paths();
103
+ mkdirSync(dir, { recursive: true });
104
+ const exists = existsSync(configPath);
105
+ if (exists && opts.upgrade) {
106
+ const existing = await Bun.file(configPath).text();
107
+ const { text, added } = upgradeConfig(existing);
108
+ if (added.length === 0) {
109
+ console.log("config already has every option; nothing to do");
110
+ return;
111
+ }
112
+ await Bun.write(`${configPath}.bak`, existing);
113
+ await Bun.write(configPath, text);
114
+ console.log(`added ${added.length} option(s) with defaults (backup: ${configPath}.bak):`);
115
+ for (const k of added) console.log(` ${k}`);
116
+ return;
117
+ }
118
+ if (exists && !opts.force) {
119
+ console.log(`config already exists: ${configPath} (--upgrade to add new options, --force to overwrite)`);
120
+ return;
121
+ }
122
+ await Bun.write(configPath, CONFIG_TEMPLATE);
123
+ console.log(`wrote ${configPath}`);
124
+ console.log("next: fill spotify.client_id, local.dirs, export.dir; then `spotifify doctor`");
125
+ });
126
+
127
+ program
128
+ .command("doctor")
129
+ .description("check config, state db, external binaries, and auth state")
130
+ .action(async () => {
131
+ const { dir, configPath } = paths();
132
+ let failures = 0;
133
+ const report = (ok: boolean, label: string, detail: string) => {
134
+ if (!ok) failures++;
135
+ console.log(`${ok ? "ok " : "FAIL"} ${label.padEnd(18)} ${detail}`);
136
+ };
137
+
138
+ let cfg: Config;
139
+ try {
140
+ cfg = await loadConfig(configPath);
141
+ report(true, "config", configPath);
142
+ const missing = missingConfigKeys(await Bun.file(configPath).text());
143
+ report(true, "config options", missing.length === 0 ? "up to date" : `${missing.length} new option(s) using defaults (${missing.slice(0, 4).join(", ")}${missing.length > 4 ? ", …" : ""}); run \`spotifify init --upgrade\``);
144
+ } catch (e) {
145
+ report(false, "config", e instanceof Error ? e.message : String(e));
146
+ process.exit(EXIT_ERROR);
147
+ }
148
+
149
+ report(cfg.spotify.client_id.length > 0, "spotify.client_id", cfg.spotify.client_id ? "set" : "empty");
150
+
151
+ for (const d of cfg.local.dirs) {
152
+ const isDir = existsSync(d) && statSync(d).isDirectory();
153
+ report(isDir || !cfg.local.enabled, "local.dirs", isDir ? d : `missing: ${d}`);
154
+ }
155
+ if (cfg.local.enabled && cfg.local.dirs.length === 0) report(false, "local.dirs", "empty while local.enabled = true");
156
+
157
+ const exportOk = cfg.export.dir.length > 0 && existsSync(cfg.export.dir) && statSync(cfg.export.dir).isDirectory();
158
+ report(exportOk, "export.dir", exportOk ? cfg.export.dir : `missing: ${cfg.export.dir || "(unset)"}`);
159
+
160
+ const ffmpeg = await probeBinary(cfg.export.ffmpeg, ["-version"]);
161
+ report(ffmpeg !== null, "ffmpeg", ffmpeg ?? `not found: ${cfg.export.ffmpeg}`);
162
+
163
+ if (cfg.matching.fingerprint) {
164
+ const fpcalc = await probeBinary(cfg.matching.fpcalc, ["-version"]);
165
+ report(fpcalc !== null, "fpcalc", fpcalc ?? `not found: ${cfg.matching.fpcalc}`);
166
+ report(cfg.matching.acoustid_key.length > 0, "acoustid_key", cfg.matching.acoustid_key ? "set" : "empty");
167
+ } else {
168
+ report(true, "fingerprint", "disabled");
169
+ }
170
+
171
+ try {
172
+ const db = openDatabase(dir);
173
+ report(true, "state.db", `${join(dir, "state.db")} (schema v${schemaVersion(db)})`);
174
+ const repo = new Repo(db);
175
+ const spotify = repo.getAuth<SpotifyTokens>("spotify");
176
+ report(spotify !== null, "auth spotify", spotify ? `present (refresh ok until re-auth needed)` : "run `spotifify auth spotify`");
177
+ const netease = repo.getAuth<{ cookie: string }>("netease");
178
+ if (cfg.netease.enabled) {
179
+ if (netease) {
180
+ const status = await new NeteaseClient(netease.cookie).loginStatus().catch(() => null);
181
+ report(status !== null, "auth netease", status ? `logged in as ${status.nickname} (${status.uid})` : "cookie invalid: run `spotifify auth netease`");
182
+ } else {
183
+ report(false, "auth netease", "run `spotifify auth netease`");
184
+ }
185
+ } else {
186
+ report(true, "auth netease", "disabled");
187
+ }
188
+ db.close();
189
+ } catch (e) {
190
+ report(false, "state.db", e instanceof Error ? e.message : String(e));
191
+ }
192
+
193
+ console.log(failures === 0 ? "\nall checks passed" : `\n${failures} check(s) failed`);
194
+ process.exit(failures === 0 ? 0 : EXIT_ERROR);
195
+ });
196
+
197
+ // ---- auth -------------------------------------------------------------------
198
+
199
+ const auth = program.command("auth").description("log in to a provider");
200
+
201
+ auth
202
+ .command("spotify")
203
+ .description("Authorization Code + PKCE login via the browser")
204
+ .action(async () => {
205
+ try {
206
+ const c = await ctx();
207
+ if (!c.cfg.spotify.client_id) throw new Error("spotify.client_id is empty in config");
208
+ await loginPkce({ clientId: c.cfg.spotify.client_id, port: c.cfg.spotify.redirect_port, store: tokenStore(c.repo) });
209
+ const me = await spotifyApi(c).me();
210
+ console.log(`logged in to Spotify as ${me.id} (${me.country})`);
211
+ } catch (e) {
212
+ fail(e);
213
+ }
214
+ });
215
+
216
+ auth
217
+ .command("netease")
218
+ .description("QR login (default) or paste a cookie with --cookie")
219
+ .option("--cookie <cookie>", "MUSIC_U=... or a full Cookie header")
220
+ .action(async (opts: { cookie?: string }) => {
221
+ try {
222
+ const c = await ctx();
223
+ const cookie = opts.cookie
224
+ ? normalizeCookie(opts.cookie)
225
+ : await loginByQr((url) => {
226
+ console.log("scan with the NetEase Cloud Music app:\n");
227
+ qrcode.generate(url, { small: true });
228
+ console.log(`\n(${url})`);
229
+ });
230
+ const status = await new NeteaseClient(cookie).loginStatus();
231
+ if (!status) throw new NeteaseAuthError("cookie rejected by netease");
232
+ c.repo.setAuth("netease", { cookie }, Date.now());
233
+ console.log(`logged in to NetEase as ${status.nickname} (${status.uid})`);
234
+ } catch (e) {
235
+ fail(e);
236
+ }
237
+ });
238
+
239
+ // ---- sync -------------------------------------------------------------------
240
+
241
+ program
242
+ .command("sync")
243
+ .description("pull sources, match, and apply the diff to Spotify")
244
+ .option("--dry-run", "print the plan without applying")
245
+ .option("--prune", "remove tool-added items that left the source (default: report only)")
246
+ .option("--source <kind>", "only this source: netease | local", (v: string) => {
247
+ if (v !== "netease" && v !== "local") throw new InvalidArgumentError("expected netease or local");
248
+ return v;
249
+ })
250
+ .option("--playlist <name>", "only source playlists with this exact name")
251
+ .option("--skip-match", "do not search Spotify for pending tracks")
252
+ .action(async (opts: { dryRun?: boolean; prune?: boolean; source?: "netease" | "local"; playlist?: string; skipMatch?: boolean }) => {
253
+ let release: (() => void) | null = null;
254
+ const onInterrupt = () => {
255
+ // Match decisions are persisted per track, so aborting loses at most the in-flight searches.
256
+ release?.();
257
+ console.error("\ninterrupted; progress so far is saved — rerun `spotifify sync` to continue");
258
+ process.exit(130);
259
+ };
260
+ process.once("SIGINT", onInterrupt);
261
+ try {
262
+ const c = await ctx();
263
+ const api = spotifyApi(c);
264
+ release = acquireLock(join(c.dir, "sync.lock"));
265
+ const { summary, plan } = await runSync(
266
+ { cfg: c.cfg, repo: c.repo, api },
267
+ { dryRun: opts.dryRun ?? false, prune: opts.prune ?? false, source: opts.source, playlist: opts.playlist, skipMatch: opts.skipMatch ?? false },
268
+ );
269
+ if (opts.dryRun) {
270
+ console.log("\n== plan (dry run) ==");
271
+ console.log(formatPlan(plan, opts.prune ?? false));
272
+ }
273
+ printSummary(summary);
274
+ } catch (e) {
275
+ release?.(); // fail() exits the process, so `finally` would never run
276
+ fail(e);
277
+ } finally {
278
+ process.off("SIGINT", onInterrupt);
279
+ release?.();
280
+ }
281
+ });
282
+
283
+ function printSummary(s: SyncSummary): void {
284
+ console.log("\n== summary ==");
285
+ console.log(`pulled: netease ${s.pulled.netease.playlists} playlists / ${s.pulled.netease.tracks} tracks; local ${s.pulled.local.tracks} tracks`);
286
+ console.log(`matched: searched ${s.matched.searched} → matched ${s.matched.matched}, review ${s.matched.review}, local ${s.matched.local}${s.matched.remaining ? `; ${s.matched.remaining} still pending` : ""}`);
287
+ if (s.matched.blockedUntil !== null) {
288
+ console.log(` Spotify search is rate-limited until ${new Date(s.matched.blockedUntil).toLocaleString()}; rerun after that (matching resumes where it stopped)`);
289
+ } else if (s.matched.budgetExhausted) {
290
+ console.log(` search budget for this run used up (matching.max_searches_per_run); rerun later or raise the budget`);
291
+ }
292
+ console.log(`plan: create ${s.plan.creates}, add ${s.plan.adds}, move ${s.plan.moves}, prune ${s.plan.prune}, like ${s.plan.likes}, unlike ${s.plan.unlikes}, export ${s.plan.exports}`);
293
+ if (s.apply) {
294
+ console.log(
295
+ `applied: created ${s.apply.created}, added ${s.apply.added}, moved ${s.apply.moved}, replaced ${s.apply.replaced}, pruned ${s.apply.pruned}, liked ${s.apply.liked}, unliked ${s.apply.unliked}, exported ${s.apply.exported}${s.apply.exportErrors ? ` (${s.apply.exportErrors} export errors)` : ""}`,
296
+ );
297
+ }
298
+ console.log(`match state: ${Object.entries(s.matchCounts).map(([k, v]) => `${k} ${v}`).join(", ")}`);
299
+ if (s.plan.reviewPending > 0) console.log(`\n${s.plan.reviewPending} track(s) need review: run \`spotifify review\``);
300
+ printAwaiting(s.awaiting);
301
+ }
302
+
303
+ function printAwaiting(entries: AwaitingEntry[]): void {
304
+ const total = entries.reduce((n, e) => n + e.uris.length, 0);
305
+ if (total === 0) return;
306
+ console.log(`\n${total} local file(s) await pasting into the desktop client (run \`spotifify pending --copy\`):`);
307
+ for (const e of entries) console.log(` ${e.playlist}: ${e.uris.length}`);
308
+ }
309
+
310
+ // ---- review / status / pending / rematch / export ---------------------------
311
+
312
+ program
313
+ .command("review")
314
+ .description("interactive TUI to resolve review-queue and unmatched tracks")
315
+ .action(async () => {
316
+ try {
317
+ const c = await ctx();
318
+ const api = spotifyApi(c);
319
+ const market = await api.resolveMarket(c.cfg.spotify.market);
320
+ const matcher = new Matcher({ api, repo: c.repo, cfg: c.cfg, market });
321
+ const { decided } = await runReviewTui({ repo: c.repo, matcher, market });
322
+ console.log(`${decided} decision(s) saved; run \`spotifify sync\` to apply`);
323
+ } catch (e) {
324
+ fail(e);
325
+ }
326
+ });
327
+
328
+ program
329
+ .command("status")
330
+ .description("match counts, playlist mappings, last run")
331
+ .action(async () => {
332
+ try {
333
+ const c = await ctx();
334
+ const counts = c.repo.countMatches();
335
+ console.log(`match state: ${Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", ")}`);
336
+ console.log("\nplaylists:");
337
+ for (const p of c.repo.listSourcePlaylists()) {
338
+ const m = c.repo.getSpotifyPlaylist(p.id);
339
+ const n = c.repo.playlistTracks(p.id).length;
340
+ console.log(` [${p.kind}] ${p.name} (${n}) → ${m ? `${m.name} <${m.spotifyId}>` : "(not created yet)"}`);
341
+ }
342
+ const last = c.repo.lastRun();
343
+ if (last) {
344
+ console.log(`\nlast run: ${new Date(last.startedAt).toISOString()} ${last.ok === null ? "(running/aborted)" : last.ok ? "ok" : "FAILED"}`);
345
+ if (last.ok && last.summary) printAwaiting((last.summary as SyncSummary).awaiting ?? []);
346
+ if (last.ok === false && last.summary) console.log(` ${JSON.stringify(last.summary)}`);
347
+ }
348
+ } catch (e) {
349
+ fail(e);
350
+ }
351
+ });
352
+
353
+ program
354
+ .command("unmatched")
355
+ .description("list tracks with no Spotify match (status local/review) in mirrored playlists, with the file that would back them")
356
+ .option("--status <s>", "local | review | all", "local")
357
+ .option("--tsv", "tab-separated output for spreadsheets")
358
+ .action(async (opts: { status: string; tsv?: boolean }) => {
359
+ try {
360
+ const c = await ctx();
361
+ const statuses: MatchStatus[] = opts.status === "all" ? ["local", "review"] : opts.status === "review" ? ["review"] : ["local"];
362
+ const keys = selectedKeys(c.repo, c.cfg, {});
363
+ const rows = statuses.flatMap((s) => c.repo.listMatches(s)).filter((m) => keys.has(m.canonicalKey));
364
+ const tracks = c.repo.representativeTracks(rows.map((m) => m.canonicalKey));
365
+ const exports = new Map(c.repo.listExports().map((e) => [e.canonicalKey, e] as const));
366
+ if (opts.tsv) console.log(["status", "key", "title", "artists", "album", "playlists", "file", "exported"].join("\t"));
367
+ let withFile = 0;
368
+ for (const m of rows) {
369
+ const t = tracks.get(m.canonicalKey);
370
+ if (!t) continue;
371
+ if (t.file) withFile++;
372
+ const playlists = c.repo.playlistNamesForKey(m.canonicalKey).join(", ");
373
+ const file = t.file?.path ?? "";
374
+ const exported = exports.get(m.canonicalKey)?.exportPath ?? "";
375
+ if (opts.tsv) console.log([m.status, m.canonicalKey, t.title, t.artists.join("/"), t.album ?? "", playlists, file, exported].join("\t"));
376
+ else {
377
+ const link = t.neteaseId !== undefined ? `https://music.163.com/#/song?id=${t.neteaseId}` : "";
378
+ console.log(`[${m.status}] ${t.artists.join(", ")} - ${t.title}${t.album ? ` (${t.album})` : ""} ${link}`);
379
+ console.log(` in: ${playlists}${file ? `\n file: ${file}` : ""}${exported ? `\n exported: ${exported}` : ""}`);
380
+ }
381
+ }
382
+ if (!opts.tsv) {
383
+ console.log(`\n${rows.length} track(s); ${withFile} backed by a local file, ${rows.length - withFile} need one (download them into local.dirs, then sync)`);
384
+ }
385
+ } catch (e) {
386
+ fail(e);
387
+ }
388
+ });
389
+
390
+ program
391
+ .command("aliases")
392
+ .description("suggest matching.artist_aliases from confirmed matches (user / ISRC / fingerprint); --apply writes them to the config")
393
+ .option("--apply", "merge the suggestions into the config (backup written)")
394
+ .option("--min <n>", "only pairs seen at least n times", (v: string) => Number.parseInt(v, 10), 1)
395
+ .action(async (opts: { apply?: boolean; min: number }) => {
396
+ try {
397
+ const c = await ctx();
398
+ const suggestions = inferArtistAliases(c.repo, c.cfg.matching).filter((s) => s.count >= opts.min);
399
+ if (suggestions.length === 0) {
400
+ console.log("no new alias pairs found (need confirmed matches whose artist names differ from Spotify's)");
401
+ return;
402
+ }
403
+ for (const s of suggestions) {
404
+ console.log(`"${s.from}" = "${s.to}" # ${s.count}×${s.conflicts.length ? `, also seen as: ${s.conflicts.join(" / ")}` : ""}`);
405
+ for (const e of s.examples) console.log(` ${e}`);
406
+ }
407
+ if (!opts.apply) {
408
+ console.log(`\n${suggestions.length} suggestion(s); rerun with --apply to add them to [matching.artist_aliases]`);
409
+ return;
410
+ }
411
+ const { configPath } = paths();
412
+ const existing = await Bun.file(configPath).text();
413
+ await Bun.write(`${configPath}.bak`, existing);
414
+ await Bun.write(configPath, withArtistAliases(existing, Object.fromEntries(suggestions.map((s) => [s.from, s.to]))));
415
+ console.log(`\nwrote ${suggestions.length} alias(es) to ${configPath} (backup: ${configPath}.bak)`);
416
+ console.log("next: `spotifify rematch --all-local` then `spotifify sync` — cached search results are re-scored without new requests");
417
+ } catch (e) {
418
+ fail(e);
419
+ }
420
+ });
421
+
422
+ program
423
+ .command("pending")
424
+ .description("list local-file URIs that must be pasted into the desktop client (from the last sync)")
425
+ .option("--copy", "copy the URIs to the clipboard")
426
+ .option("--playlist <name>", "only this Spotify playlist")
427
+ .action(async (opts: { copy?: boolean; playlist?: string }) => {
428
+ try {
429
+ const c = await ctx();
430
+ const last = c.repo.lastRun();
431
+ const entries = (last?.ok && last.summary ? ((last.summary as SyncSummary).awaiting ?? []) : []).filter(
432
+ (e) => opts.playlist === undefined || e.playlist === opts.playlist,
433
+ );
434
+ if (entries.length === 0) {
435
+ console.log("nothing pending" + (last ? "" : " (no successful sync yet)"));
436
+ return;
437
+ }
438
+ for (const e of entries) {
439
+ console.log(`# ${e.playlist}`);
440
+ for (const u of e.uris) console.log(u);
441
+ }
442
+ if (opts.copy) {
443
+ const text = entries.flatMap((e) => e.uris).join("\n");
444
+ const ok = await copyToClipboard(text);
445
+ console.log(ok ? `\ncopied ${entries.reduce((n, e) => n + e.uris.length, 0)} URI(s); open the playlist in Spotify desktop and paste` : "\nclipboard unavailable");
446
+ }
447
+ } catch (e) {
448
+ fail(e);
449
+ }
450
+ });
451
+
452
+ program
453
+ .command("rematch")
454
+ .description("reset match decisions so the next sync searches again")
455
+ .argument("[key...]", "canonical keys (netease:123, isrc:XXX, local:hash)")
456
+ .option("--all-local", "reset every auto-decided unmatched track")
457
+ .action(async (keys: string[], opts: { allLocal?: boolean }) => {
458
+ try {
459
+ const c = await ctx();
460
+ const targets = opts.allLocal ? c.repo.listMatches("local").filter((m) => m.decidedBy === "auto") : keys.map((k) => c.repo.getMatch(k)).filter((m) => m !== null);
461
+ for (const m of targets) c.repo.upsertMatch({ ...m, status: "pending", spotifyId: null, spotifyUri: null, score: null, decidedBy: null, decidedAt: null });
462
+ console.log(`reset ${targets.length} match(es) to pending`);
463
+ } catch (e) {
464
+ fail(e);
465
+ }
466
+ });
467
+
468
+ program
469
+ .command("export")
470
+ .description("run only the export step (decrypt/transcode unmatched local files into export.dir)")
471
+ .option("--force", "re-export files that already exist (use after changing the bitrate, or to refresh tags/URIs)")
472
+ .action(async (opts: { force?: boolean }) => {
473
+ try {
474
+ const c = await ctx();
475
+ const plans = planExportsOnly(c.repo, c.cfg, {}, opts.force ?? false);
476
+ const r = await applyExports(plans, { repo: c.repo, cfg: c.cfg, now: Date.now() });
477
+ console.log(`exported ${r.exported} file(s)${r.errors ? `, ${r.errors} error(s)` : ""}; run \`spotifify sync\` to refresh the paste list`);
478
+ if (r.uriChanged > 0) console.log(`${r.uriChanged} file(s) changed identity: the old entries in the desktop client are now stale (\`spotifify sync --prune\` removes them), then \`spotifify pending --copy\` and paste again`);
479
+ } catch (e) {
480
+ fail(e);
481
+ }
482
+ });
483
+
484
+ // ---- task -------------------------------------------------------------------
485
+
486
+ const task = program.command("task").description("Windows Task Scheduler registration");
487
+
488
+ task
489
+ .command("install")
490
+ .option("--time <HH:mm>", "daily run time", "03:00")
491
+ .option("--exe <path>", "compiled spotifify.exe (default: bun run src/cli.ts)")
492
+ .action(async (opts: { time: string; exe?: string }) => {
493
+ const script = join(import.meta.dir, "..", "scripts", "register-task.ps1");
494
+ const args = ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-Time", opts.time];
495
+ if (opts.exe) args.push("-Exe", opts.exe);
496
+ process.exit(await Bun.spawn(["powershell", ...args], { stdout: "inherit", stderr: "inherit" }).exited);
497
+ });
498
+
499
+ task.command("uninstall").action(async () => {
500
+ const script = join(import.meta.dir, "..", "scripts", "register-task.ps1");
501
+ process.exit(await Bun.spawn(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script, "-Uninstall"], { stdout: "inherit", stderr: "inherit" }).exited);
502
+ });
503
+
504
+ await program.parseAsync(process.argv);