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
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);
|