spotifify 0.1.1 → 0.1.2

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.2](https://github.com/FYWinds/Spotifify/compare/v0.1.1...v0.1.2) (2026-09-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * **doctor:** check the client's local-file index ([bdc099b](https://github.com/FYWinds/Spotifify/commit/bdc099bfa54abaae2a9f75b4d804fd9fd41f90eb))
9
+ * **sync:** prune superseded exports with --prune ([4db0559](https://github.com/FYWinds/Spotifify/commit/4db05590f9c318e57703a5f8cdcc7be96850a58e))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * **sync:** prune against the listing snapshot ([8c1634a](https://github.com/FYWinds/Spotifify/commit/8c1634a91b2228c4ff4916e42e621ddc54c91737))
15
+
3
16
  ## [0.1.1](https://github.com/FYWinds/Spotifify/compare/v0.1.0...v0.1.1) (2026-09-05)
4
17
 
5
18
 
package/README.md CHANGED
@@ -65,9 +65,9 @@ State lives in `~/.spotifify` (`config.toml`, `state.db`, logs); override with `
65
65
  | Command | What it does |
66
66
  |---|---|
67
67
  | `init [--force\|--upgrade]` | Write the config template; `--upgrade` merges options added in newer versions into your file (values kept, `.bak` written). |
68
- | `doctor` | Check config, state db, `ffmpeg`/`fpcalc`, token scopes, search-quota deadline. |
68
+ | `doctor` | Check config, state db, `ffmpeg`/`fpcalc`, token scopes, search-quota deadline, and the desktop client's local-files index (exports it never indexed or indexed with another duration — the two causes of grey rows). |
69
69
  | `auth spotify` / `auth netease [--cookie …]` | Log in. |
70
- | `sync [--dry-run] [--prune] [--source netease\|local] [--playlist NAME] [--skip-match]` | Pull → match → export → plan → apply → report. Exit code `3` = re-authenticate. |
70
+ | `sync [--dry-run] [--prune] [--source netease\|local] [--playlist NAME] [--skip-match]` | Pull → match → export → plan → apply → report. `--prune` also removes superseded local entries and exported files no longer needed. Exit code `3` = re-authenticate. |
71
71
  | `review` | Ink TUI: `j/k` move, `1-9`/`Enter` pick a candidate, `/` custom search, `p` paste a Spotify URL/URI, `o`/`O` open candidate/source in the browser, `l` keep as local file, `s` skip, `u` undo, `?` help. |
72
72
  | `status` | Match counts, playlist mappings, last run. |
73
73
  | `unmatched [--status local\|review\|all] [--tsv]` | Tracks without a Spotify match and the local file that backs them. |
package/README.zh-CN.md CHANGED
@@ -65,9 +65,9 @@ spotifify review # 处理低置信度匹配
65
65
  | 命令 | 作用 |
66
66
  |---|---|
67
67
  | `init [--force\|--upgrade]` | 写配置模板;`--upgrade` 把新版本新增的选项合并进现有文件(保留原值,写 `.bak`)。 |
68
- | `doctor` | 检查配置、状态库、`ffmpeg`/`fpcalc`、token scope、搜索配额截止时间。 |
68
+ | `doctor` | 检查配置、状态库、`ffmpeg`/`fpcalc`、token scope、搜索配额截止时间,以及桌面端的本地文件索引(哪些导出没被索引、哪些时长和我们算的不一致——歌单里灰掉的两种原因)。 |
69
69
  | `auth spotify` / `auth netease [--cookie …]` | 登录。 |
70
- | `sync [--dry-run] [--prune] [--source netease\|local] [--playlist 名称] [--skip-match]` | 拉取 → 匹配 → 导出 → 计划 → 执行 → 报告。退出码 `3` = 需要重新登录。 |
70
+ | `sync [--dry-run] [--prune] [--source netease\|local] [--playlist 名称] [--skip-match]` | 拉取 → 匹配 → 导出 → 计划 → 执行 → 报告。`--prune` 还会删掉被取代的本地条目和不再需要的导出文件。退出码 `3` = 需要重新登录。 |
71
71
  | `review` | Ink TUI:`j/k` 移动,`1-9`/`Enter` 选候选,`/` 自定义搜索,`p` 粘贴 Spotify 链接/URI,`o`/`O` 在浏览器打开候选/来源,`l` 保持为本地文件,`s` 跳过,`u` 撤销,`?` 帮助。 |
72
72
  | `status` | 匹配统计、歌单映射、上次运行。 |
73
73
  | `unmatched [--status local\|review\|all] [--tsv]` | 没有 Spotify 匹配的歌以及对应的本地文件。 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spotifify",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Idempotently sync Netease Cloud Music playlists and a local music library (incl. .ncm) to Spotify",
6
6
  "keywords": [
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { existsSync, mkdirSync, statSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
4
5
  import { Command, InvalidArgumentError } from "commander";
5
6
  import qrcode from "qrcode-terminal";
6
7
  import { CONFIG_FILENAME, CONFIG_TEMPLATE, loadConfig, missingConfigKeys, stateDir, upgradeConfig, withArtistAliases, type Config } from "./config.ts";
@@ -12,6 +13,7 @@ import { NeteaseAuthError, NeteaseClient } from "./sources/netease/client.ts";
12
13
  import { SpotifyApi } from "./spotify/api.ts";
13
14
  import { AuthExpiredError, loginPkce, type TokenStore } from "./spotify/auth.ts";
14
15
  import { SpotifyClient } from "./spotify/client.ts";
16
+ import { compareExports, findLocalFilesIndexes, parseLocalFilesIndex } from "./spotify/localIndex.ts";
15
17
  import { SCOPES, type SpotifyTokens } from "./spotify/types.ts";
16
18
  import { openDatabase, schemaVersion } from "./state/db.ts";
17
19
  import { Repo } from "./state/repo.ts";
@@ -42,7 +44,7 @@ interface Ctx {
42
44
  const program = new Command()
43
45
  .name("spotifify")
44
46
  .description("Sync Netease Cloud Music playlists and a local library to Spotify")
45
- .version("0.1.1") // x-release-please-version
47
+ .version("0.1.2") // x-release-please-version
46
48
  .option("--config <path>", "config file (default: <state-dir>/config.toml)")
47
49
  .option("--state-dir <dir>", "state directory (default: ~/.spotifify or $SPOTIFIFY_STATE_DIR)")
48
50
  .option("--log-file <path>", "append log lines to this file")
@@ -91,6 +93,38 @@ function fail(e: unknown): never {
91
93
  process.exit(EXIT_ERROR);
92
94
  }
93
95
 
96
+ /**
97
+ * Compare `local_export` with the desktop client's own local-files index. Every grey "can't play"
98
+ * row traces back to one of: the client never indexed the file, or it indexed it with another
99
+ * identity (a different duration); both are visible here without touching the network.
100
+ */
101
+ async function checkClientIndex(repo: Repo, report: (ok: boolean, label: string, detail: string) => void): Promise<void> {
102
+ const exports = repo.listExports();
103
+ if (exports.length === 0) return;
104
+ const indexes = findLocalFilesIndexes();
105
+ if (indexes.length === 0) {
106
+ report(true, "client index", "desktop client index not found; skipped");
107
+ return;
108
+ }
109
+ for (const file of indexes) {
110
+ const written = statSync(file).mtime.toLocaleString();
111
+ const entries = parseLocalFilesIndex(await readFile(file));
112
+ const user = basename(dirname(file)).replace(/-user$/, "");
113
+ if (entries.length === 0) {
114
+ report(false, "client index", `empty for user ${user} (written ${written}); restart the desktop client or toggle the folder under Settings → Local Files`);
115
+ continue;
116
+ }
117
+ const c = compareExports(entries, exports);
118
+ const examples = (xs: string[]) => xs.slice(0, 3).join(", ") + (xs.length > 3 ? ", …" : "");
119
+ if (c.mismatched.length > 0) {
120
+ report(false, "client index", `${c.mismatched.length} export(s) indexed with another duration: ${examples(c.mismatched.map((m) => `${m.file} (client ${m.client}s, ours ${m.ours}s)`))}`);
121
+ }
122
+ if (c.missing.length > 0) {
123
+ report(false, "client index", `${c.missing.length} export(s) not indexed by the desktop client (user ${user}, written ${written}): ${examples(c.missing)}; restart the client or toggle the folder`);
124
+ }
125
+ if (c.mismatched.length === 0 && c.missing.length === 0) report(true, "client index", `${c.matched} export(s) indexed with matching identity (user ${user}, written ${written})`);
126
+ }
127
+ }
94
128
  // ---- init / doctor ----------------------------------------------------------
95
129
 
96
130
  program
@@ -185,6 +219,7 @@ program
185
219
  } else {
186
220
  report(true, "auth netease", "disabled");
187
221
  }
222
+ await checkClientIndex(repo, report);
188
223
  db.close();
189
224
  } catch (e) {
190
225
  report(false, "state.db", e instanceof Error ? e.message : String(e));
@@ -242,7 +277,7 @@ program
242
277
  .command("sync")
243
278
  .description("pull sources, match, and apply the diff to Spotify")
244
279
  .option("--dry-run", "print the plan without applying")
245
- .option("--prune", "remove tool-added items that left the source (default: report only)")
280
+ .option("--prune", "remove tool-added items that left the source, superseded local entries, and exported files no longer needed (default: report only)")
246
281
  .option("--source <kind>", "only this source: netease | local", (v: string) => {
247
282
  if (v !== "netease" && v !== "local") throw new InvalidArgumentError("expected netease or local");
248
283
  return v;
@@ -289,10 +324,10 @@ function printSummary(s: SyncSummary): void {
289
324
  } else if (s.matched.budgetExhausted) {
290
325
  console.log(` search budget for this run used up (matching.max_searches_per_run); rerun later or raise the budget`);
291
326
  }
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}`);
327
+ 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}, remove export ${s.plan.exportGc}`);
293
328
  if (s.apply) {
294
329
  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)` : ""}`,
330
+ `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)` : ""}, removed exports ${s.apply.exportsRemoved}`,
296
331
  );
297
332
  }
298
333
  console.log(`match state: ${Object.entries(s.matchCounts).map(([k, v]) => `${k} ${v}`).join(", ")}`);
@@ -70,6 +70,12 @@ export class SpotifyApi {
70
70
  return this.client.paginate<SpotifyPlaylistItem>(`/v1/playlists/${id}/items`, { limit: 50, fields: ITEM_FIELDS });
71
71
  }
72
72
 
73
+ /** Current snapshot id only (used to bracket an items listing). */
74
+ async getPlaylistSnapshot(id: string): Promise<string> {
75
+ const res = await this.client.request<{ snapshot_id: string }>("GET", `/v1/playlists/${id}`, { query: { fields: "snapshot_id" } });
76
+ return res.snapshot_id;
77
+ }
78
+
73
79
  /** ≤100 per request; `position` advances by batch size so the whole run lands contiguously. Returns the last snapshot_id. */
74
80
  async addPlaylistItems(id: string, uris: string[], position?: number): Promise<string> {
75
81
  let snapshot = "";
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Reader for the desktop client's local-file index (`local-files.bnk`), the source of truth for which
3
+ * files it knows and which identity (tags + its own duration) it computed for each. `spotifify doctor`
4
+ * compares it with `local_export` so a broken index (half-written file, dropped entry, duration
5
+ * mismatch) is diagnosed instead of showing up as grey playlist rows.
6
+ *
7
+ * The format is undocumented ("SPCO" container, protobuf-like records); this parser only walks the
8
+ * repeating record shape observed in the wild and gives up quietly when it does not match:
9
+ * 09 <len> title 09 <len> artist 09 <len> album 10 <varint seconds> … 2c 01 <len> path … 08 01 78 78 04
10
+ * The file is written when the client flushes (shutdown, rescan), so it may lag the live index.
11
+ */
12
+ import { existsSync, readdirSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { basename, join } from "node:path";
15
+ import type { LocalExportRow } from "../state/repo.ts";
16
+ import { parseLocalUri } from "./localUri.ts";
17
+
18
+ export interface LocalIndexEntry {
19
+ title: string;
20
+ artist: string;
21
+ album: string;
22
+ durationSec: number;
23
+ path: string;
24
+ }
25
+
26
+ const RECORD_SEPARATOR = [0x08, 0x01, 0x78, 0x78, 0x04];
27
+
28
+ /** Every `Users/<id>-user/local-files.bnk` of every known client install location. */
29
+ export function findLocalFilesIndexes(): string[] {
30
+ const home = homedir();
31
+ const local = process.env.LOCALAPPDATA ?? join(home, "AppData", "Local");
32
+ const roots =
33
+ process.platform === "win32"
34
+ ? [join(local, "Packages", "SpotifyAB.SpotifyMusic_zpdnekdrzrea0", "LocalState", "Spotify", "Users"), join(local, "Spotify", "Users")]
35
+ : process.platform === "darwin"
36
+ ? [join(home, "Library", "Application Support", "Spotify", "Users")]
37
+ : [join(home, ".config", "spotify", "Users"), join(home, ".var", "app", "com.spotify.Client", "config", "spotify", "Users")];
38
+ const out: string[] = [];
39
+ for (const root of roots) {
40
+ if (!existsSync(root)) continue;
41
+ for (const user of readdirSync(root)) {
42
+ const file = join(root, user, "local-files.bnk");
43
+ if (user.endsWith("-user") && existsSync(file)) out.push(file);
44
+ }
45
+ }
46
+ return out;
47
+ }
48
+
49
+ function readVarint(b: Uint8Array, at: number): [value: number, next: number] | null {
50
+ let value = 0;
51
+ let shift = 0;
52
+ for (let i = at; i < b.length && shift <= 35; i++, shift += 7) {
53
+ const c = b[i]!;
54
+ value += (c & 0x7f) * 2 ** shift;
55
+ if (c < 0x80) return [value, i + 1];
56
+ }
57
+ return null;
58
+ }
59
+
60
+ const decoder = new TextDecoder("utf-8", { fatal: true });
61
+ /** paths occasionally carry stray bytes in the file (seen once in 81 records); identity fields never did */
62
+ const lenientDecoder = new TextDecoder("utf-8");
63
+
64
+ /** `09 <varint len> <utf-8>` */
65
+ function readString(b: Uint8Array, at: number): [value: string, next: number] | null {
66
+ if (b[at] !== 0x09) return null;
67
+ const len = readVarint(b, at + 1);
68
+ if (!len) return null;
69
+ const [n, start] = len;
70
+ if (start + n > b.length) return null;
71
+ try {
72
+ return [decoder.decode(b.subarray(start, start + n)), start + n];
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function indexOfSeq(b: Uint8Array, seq: readonly number[], from: number): number {
79
+ outer: for (let i = from; i + seq.length <= b.length; i++) {
80
+ for (let j = 0; j < seq.length; j++) if (b[i + j] !== seq[j]) continue outer;
81
+ return i;
82
+ }
83
+ return -1;
84
+ }
85
+
86
+ export function parseLocalFilesIndex(b: Uint8Array): LocalIndexEntry[] {
87
+ const out: LocalIndexEntry[] = [];
88
+ let at = indexOfSeq(b, RECORD_SEPARATOR, 0);
89
+ while (at !== -1) {
90
+ const next = indexOfSeq(b, RECORD_SEPARATOR, at + RECORD_SEPARATOR.length);
91
+ const end = next === -1 ? b.length : next;
92
+ const entry = parseRecord(b, at + RECORD_SEPARATOR.length, end);
93
+ if (entry) out.push(entry);
94
+ at = next;
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function parseRecord(b: Uint8Array, at: number, end: number): LocalIndexEntry | null {
100
+ const title = readString(b, at);
101
+ if (!title) return null;
102
+ const artist = readString(b, title[1]);
103
+ if (!artist) return null;
104
+ const album = readString(b, artist[1]);
105
+ if (!album) return null;
106
+ if (b[album[1]] !== 0x10) return null;
107
+ const duration = readVarint(b, album[1] + 1);
108
+ if (!duration) return null;
109
+ // the path follows further down the record: 2c 01 <varint len> <utf-8>
110
+ const p = indexOfSeq(b, [0x2c, 0x01], duration[1]);
111
+ if (p === -1 || p >= end) return null;
112
+ const len = readVarint(b, p + 2);
113
+ if (!len) return null;
114
+ const [n, start] = len;
115
+ if (n === 0 || start + n > end) return null;
116
+ return { title: title[0], artist: artist[0], album: album[0], durationSec: duration[0], path: lenientDecoder.decode(b.subarray(start, start + n)) };
117
+ }
118
+
119
+ export interface IndexComparison {
120
+ /** exports the client has not indexed at all (file names) */
121
+ missing: string[];
122
+ /** exports the client indexed with a different duration: the pasted uri will never link */
123
+ mismatched: Array<{ file: string; ours: number; client: number }>;
124
+ /** exports found with the identical identity */
125
+ matched: number;
126
+ }
127
+
128
+ /** Compare `local_export` identities with the client's index, keyed by tags (the client's own key); paths are not compared. */
129
+ export function compareExports(entries: readonly LocalIndexEntry[], exports: readonly LocalExportRow[]): IndexComparison {
130
+ const key = (title: string, artist: string, album: string) => `${title}\u0000${artist}\u0000${album}`;
131
+ const byIdentity = new Map(entries.map((e) => [key(e.title, e.artist, e.album), e]));
132
+ const out: IndexComparison = { missing: [], mismatched: [], matched: 0 };
133
+ for (const e of exports) {
134
+ const p = parseLocalUri(e.localUri);
135
+ if (!p) continue;
136
+ const file = basename(e.exportPath);
137
+ const hit = byIdentity.get(key(p.title, p.artist, p.album));
138
+ if (!hit) out.missing.push(file);
139
+ else if (hit.durationSec !== p.durationSec) out.mismatched.push({ file, ours: p.durationSec ?? -1, client: hit.durationSec });
140
+ else out.matched++;
141
+ }
142
+ return out;
143
+ }
package/src/state/repo.ts CHANGED
@@ -423,6 +423,10 @@ export class Repo {
423
423
  );
424
424
  }
425
425
 
426
+ deleteExport(key: string): void {
427
+ this.db.run("DELETE FROM local_export WHERE canonical_key = ?", [key]);
428
+ }
429
+
426
430
  // ---- caches -----------------------------------------------------------
427
431
 
428
432
  cacheGet<T>(key: string, now: number, ttlMs: number): T | null {
package/src/sync/apply.ts CHANGED
@@ -30,6 +30,8 @@ export interface ApplySummary {
30
30
  unliked: number;
31
31
  exported: number;
32
32
  exportErrors: number;
33
+ /** exports garbage-collected after the playlist prune (--prune only) */
34
+ exportsRemoved: number;
33
35
  }
34
36
 
35
37
  /** Replace the whole playlist only when it saves real calls: more than this many moves AND more than a third of the list. */
@@ -38,7 +40,7 @@ const REPLACE_MOVE_RATIO = 1 / 3;
38
40
 
39
41
  /** Applies playlist and library changes. Exports run separately (`applyExports`) before planning. */
40
42
  export async function applyPlan(plan: Plan, deps: ApplyDeps): Promise<ApplySummary> {
41
- const s: ApplySummary = { created: 0, renamed: 0, added: 0, pruned: 0, moved: 0, replaced: 0, liked: 0, unliked: 0, exported: 0, exportErrors: 0 };
43
+ const s: ApplySummary = { created: 0, renamed: 0, added: 0, pruned: 0, moved: 0, replaced: 0, liked: 0, unliked: 0, exported: 0, exportErrors: 0, exportsRemoved: 0 };
42
44
 
43
45
  for (const p of plan.playlists) await applyPlaylist(p, deps, s);
44
46
 
@@ -118,15 +120,18 @@ async function applyPlaylist(p: PlaylistPlan, deps: ApplyDeps, s: ApplySummary):
118
120
  s.added += uris.length;
119
121
  }
120
122
  if (deps.prune && p.prune.length > 0) {
121
- snapshot ??= (await api.getPlaylist(spotifyId))?.snapshot_id ?? null;
122
- if (snapshot === null) throw new Error(`playlist ${spotifyId} vanished during apply`);
123
+ // Positions come from the planning-time listing, so they are validated against that snapshot
124
+ // (Spotify checks them against the snapshot given, not the current one). Adds only append.
125
+ const base = p.snapshotId ?? snapshot ?? (await api.getPlaylist(spotifyId))?.snapshot_id ?? null;
126
+ if (base === null) throw new Error(`playlist ${spotifyId} vanished during apply`);
123
127
  const items = p.prune.map((x) => (x.uri.startsWith("spotify:local:") ? { uri: x.uri, positions: x.positions } : { uri: x.uri }));
124
- snapshot = await api.removePlaylistItems(spotifyId, items, snapshot);
128
+ snapshot = await api.removePlaylistItems(spotifyId, items, base);
125
129
  repo.removeManaged(spotifyId, p.prune.map((x) => x.uri));
126
130
  s.pruned += p.prune.length;
127
131
  }
128
132
  if (p.moves.length > 0) {
129
- snapshot ??= (await api.getPlaylist(spotifyId))?.snapshot_id ?? null;
133
+ // Moves are computed on the post-add/post-prune order, so they chain from the latest write.
134
+ snapshot ??= p.snapshotId ?? (await api.getPlaylist(spotifyId))?.snapshot_id ?? null;
130
135
  if (snapshot === null) throw new Error(`playlist ${spotifyId} vanished during apply`);
131
136
  for (const m of p.moves) {
132
137
  snapshot = await api.reorderPlaylistItems(spotifyId, m.rangeStart, m.insertBefore, snapshot);
@@ -8,7 +8,7 @@ import { tmpdir } from "node:os";
8
8
  import { extname, join } from "node:path";
9
9
  import type { Config } from "../config.ts";
10
10
  import { decryptNcm } from "../sources/local/ncm.ts";
11
- import type { SourceTrackRow } from "../state/repo.ts";
11
+ import type { LocalExportRow, Repo, SourceTrackRow } from "../state/repo.ts";
12
12
  import { buildLocalUri } from "../spotify/localUri.ts";
13
13
  import { probeMp3, probeMp4DurationSec } from "./duration.ts";
14
14
  import { log } from "../util/log.ts";
@@ -93,26 +93,47 @@ export async function exportTrack(plan: ExportPlan, track: SourceTrackRow, cfg:
93
93
  * that fails with EPERM/EBUSY while the client has it open (playing), which clears within seconds.
94
94
  */
95
95
  async function placeExport(partPath: string, exportPath: string): Promise<void> {
96
+ await removeFile(exportPath);
97
+ try {
98
+ await link(partPath, exportPath);
99
+ } catch (e) {
100
+ const code = (e as NodeJS.ErrnoException).code;
101
+ if (code !== "EXDEV" && code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP") throw e;
102
+ log.warn("hard link unavailable, renaming instead; the desktop client will only index the file after a restart", { path: exportPath });
103
+ await rename(partPath, exportPath);
104
+ }
105
+ }
106
+
107
+ /** Delete with retries: the desktop client holds an exported file open while it plays it (EPERM/EBUSY on Windows). */
108
+ async function removeFile(path: string): Promise<void> {
96
109
  await withRetry(
97
110
  async () => {
98
111
  try {
99
- await rm(exportPath, { force: true });
112
+ await rm(path, { force: true });
100
113
  } catch (e) {
101
114
  const code = (e as NodeJS.ErrnoException).code;
102
- if (code === "EPERM" || code === "EBUSY") throw new RetryableError(`${code} replacing ${exportPath}`);
115
+ if (code === "EPERM" || code === "EBUSY") throw new RetryableError(`${code} removing ${path}`);
103
116
  throw e;
104
117
  }
105
118
  },
106
119
  { attempts: 6, baseMs: 500 },
107
120
  );
108
- try {
109
- await link(partPath, exportPath);
110
- } catch (e) {
111
- const code = (e as NodeJS.ErrnoException).code;
112
- if (code !== "EXDEV" && code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP") throw e;
113
- log.warn("hard link unavailable, renaming instead; the desktop client will only index the file after a restart", { path: exportPath });
114
- await rename(partPath, exportPath);
121
+ }
122
+
123
+ /** Garbage-collect exports (file + record). A file that cannot be deleted keeps its record so the next run retries. Returns the number removed. */
124
+ export async function removeExports(rows: readonly LocalExportRow[], repo: Repo): Promise<number> {
125
+ let removed = 0;
126
+ for (const e of rows) {
127
+ try {
128
+ await removeFile(e.exportPath);
129
+ repo.deleteExport(e.canonicalKey);
130
+ removed++;
131
+ log.info("removed export", { path: e.exportPath });
132
+ } catch (err) {
133
+ log.error("export removal failed", { path: e.exportPath, error: err instanceof Error ? err.message : String(err) });
134
+ }
115
135
  }
136
+ return removed;
116
137
  }
117
138
 
118
139
  /** null: unparsable, or a VBR mp3 whose client duration is not predictable. */
package/src/sync/plan.ts CHANGED
@@ -25,6 +25,8 @@ export interface PlaylistPlan {
25
25
  sourceName: string;
26
26
  /** null when the playlist must be created first */
27
27
  spotifyId: string | null;
28
+ /** snapshot the remote listing (and therefore `prune[].positions`) belongs to; null when created */
29
+ snapshotId: string | null;
28
30
  create: { name: string } | null;
29
31
  rename: { from: string; to: string } | null;
30
32
  /** spotify:track URIs to POST, in desired order */
@@ -62,6 +64,8 @@ export interface Plan {
62
64
  playlists: PlaylistPlan[];
63
65
  likes: LikePlan;
64
66
  exports: ExportPlan[];
67
+ /** export records (and files) no longer needed; removed only with --prune, after the playlist prune */
68
+ exportGc: LocalExportRow[];
65
69
  /** canonical keys needing human review */
66
70
  reviewPending: number;
67
71
  }
@@ -70,10 +74,11 @@ export interface RemoteItem {
70
74
  uri: string;
71
75
  isLocal: boolean;
72
76
  /**
73
- * A local entry that names one of our exports but with an identity the client will never resolve
74
- * (different duration segment, tags from an earlier export). Removed with --prune so a correct paste can replace it.
77
+ * A local entry that names one of our exports exactly, or with an identity the client will never
78
+ * resolve (different duration segment, tags from an earlier export). Ours to remove with --prune
79
+ * when it is no longer desired (superseded by a Spotify match, gone from the source, wrong identity).
75
80
  */
76
- stale: boolean;
81
+ owned: boolean;
77
82
  }
78
83
 
79
84
  export interface PlaylistPlanInput {
@@ -82,6 +87,8 @@ export interface PlaylistPlanInput {
82
87
  targetName: string;
83
88
  /** existing remote playlist (already verified to exist), or null */
84
89
  spotify: { id: string; name: string } | null;
90
+ /** snapshot id the `remote` listing was taken at (null when `spotify` is null) */
91
+ snapshotId: string | null;
85
92
  /** ordered, deduped by uri */
86
93
  desired: DesiredItem[];
87
94
  /** current remote order; local uris already canonicalized via `resolveRemoteLocalUri` */
@@ -96,27 +103,27 @@ const LEGACY_TITLE_SUFFIX = / \(local\)$/;
96
103
 
97
104
  export interface ResolvedRemoteLocal {
98
105
  uri: string;
99
- stale: boolean;
106
+ owned: boolean;
100
107
  }
101
108
 
102
109
  /**
103
110
  * Map a remote local-file uri onto our export identities. Same artist/album/title/duration as an export
104
111
  * → the export's `local_uri`. Same artist/album/title but a different identity (wrong or missing
105
- * duration segment, legacy title suffix) → stale. Anything else is left untouched (a foreign local file).
112
+ * duration segment, legacy title suffix) → the verbatim uri (removal by position needs it exactly).
113
+ * Both are `owned`. Anything else is left untouched (a foreign local file).
106
114
  */
107
115
  export function resolveRemoteLocalUri(remoteUri: string, exports: readonly LocalExportRow[]): ResolvedRemoteLocal {
108
116
  const parts = parseLocalUri(remoteUri);
109
- if (!parts) return { uri: remoteUri, stale: false };
117
+ if (!parts) return { uri: remoteUri, owned: false };
110
118
  const fold = (s: string) => s.replace(LEGACY_TITLE_SUFFIX, "").trim().toLowerCase();
111
119
  for (const e of exports) {
112
120
  const p = parseLocalUri(e.localUri);
113
121
  if (!p) continue;
114
122
  if (fold(p.artist) !== fold(parts.artist) || fold(p.album) !== fold(parts.album) || fold(p.title) !== fold(parts.title)) continue;
115
123
  const exact = parts.durationSec === p.durationSec && parts.title === p.title;
116
- // stale entries keep the API's exact uri: removal of local items needs it verbatim alongside positions
117
- return exact ? { uri: e.localUri, stale: false } : { uri: remoteUri, stale: true };
124
+ return { uri: exact ? e.localUri : remoteUri, owned: true };
118
125
  }
119
- return { uri: buildLocalUri(parts), stale: false };
126
+ return { uri: buildLocalUri(parts), owned: false };
120
127
  }
121
128
 
122
129
  export function computePlaylistPlan(input: PlaylistPlanInput): PlaylistPlan {
@@ -128,6 +135,7 @@ export function computePlaylistPlan(input: PlaylistPlanInput): PlaylistPlan {
128
135
  sourcePlaylistId: input.sourcePlaylistId,
129
136
  sourceName: input.sourceName,
130
137
  spotifyId: null,
138
+ snapshotId: null,
131
139
  create: { name: input.targetName },
132
140
  rename: null,
133
141
  adds: desired.filter((d) => d.kind === "spotify").map((d) => d.uri),
@@ -149,7 +157,7 @@ export function computePlaylistPlan(input: PlaylistPlanInput): PlaylistPlan {
149
157
  const foreign: string[] = [];
150
158
  remote.forEach((r, i) => {
151
159
  if (desiredSet.has(r.uri)) return;
152
- if (managed.has(r.uri) || r.stale) {
160
+ if (managed.has(r.uri) || r.owned) {
153
161
  let positions = pruneByUri.get(r.uri);
154
162
  if (!positions) {
155
163
  positions = [];
@@ -192,6 +200,7 @@ export function computePlaylistPlan(input: PlaylistPlanInput): PlaylistPlan {
192
200
  sourcePlaylistId: input.sourcePlaylistId,
193
201
  sourceName: input.sourceName,
194
202
  spotifyId: input.spotify.id,
203
+ snapshotId: input.snapshotId,
195
204
  create: null,
196
205
  rename: input.spotify.name === input.targetName ? null : { from: input.spotify.name, to: input.targetName },
197
206
  adds,
package/src/sync/run.ts CHANGED
@@ -12,13 +12,14 @@ import { NeteaseSource } from "../sources/netease/source.ts";
12
12
  import type { SourceKind, SourceTrack } from "../sources/types.ts";
13
13
  import type { SpotifyApi } from "../spotify/api.ts";
14
14
  import { SpotifyHttpError, SpotifyRateLimitedError } from "../spotify/client.ts";
15
- import { MANAGED_DESCRIPTION } from "../spotify/types.ts";
15
+ import { MANAGED_DESCRIPTION, type SpotifyPlaylistItem } from "../spotify/types.ts";
16
16
  import { parseLocalUri } from "../spotify/localUri.ts";
17
17
  import type { LocalExportRow, Repo, SourcePlaylistRow } from "../state/repo.ts";
18
18
  import { sanitizeFilename } from "../util/fs.ts";
19
19
  import { log } from "../util/log.ts";
20
20
  import { mapLimit } from "../util/retry.ts";
21
21
  import { applyExports, applyPlan, type ApplySummary } from "./apply.ts";
22
+ import { removeExports } from "./export.ts";
22
23
  import { computePlaylistPlan, resolveRemoteLocalUri, type DesiredItem, type ExportPlan, type Plan, type PlaylistPlan, type RemoteItem } from "./plan.ts";
23
24
 
24
25
  export interface SyncOptions {
@@ -58,7 +59,7 @@ export interface MatchPhaseSummary {
58
59
  export interface SyncSummary {
59
60
  pulled: Record<SourceKind, { playlists: number; tracks: number }>;
60
61
  matched: MatchPhaseSummary;
61
- plan: { creates: number; adds: number; prune: number; moves: number; likes: number; unlikes: number; exports: number; reviewPending: number };
62
+ plan: { creates: number; adds: number; prune: number; moves: number; likes: number; unlikes: number; exports: number; exportGc: number; reviewPending: number };
62
63
  apply: ApplySummary | null;
63
64
  awaiting: AwaitingEntry[];
64
65
  matchCounts: Record<string, number>;
@@ -92,6 +93,8 @@ export async function runSync(deps: SyncDeps, opts: SyncOptions): Promise<SyncRe
92
93
  apply.exported = exported.exported;
93
94
  apply.exportErrors = exported.errors;
94
95
  }
96
+ // After apply: the playlist entries pointing at these files were pruned above, so the files can go.
97
+ if (apply && opts.prune) apply.exportsRemoved = await removeExports(plan.exportGc, repo);
95
98
  const summary: SyncSummary = {
96
99
  pulled,
97
100
  matched,
@@ -103,6 +106,7 @@ export async function runSync(deps: SyncDeps, opts: SyncOptions): Promise<SyncRe
103
106
  likes: plan.likes.add.length,
104
107
  unlikes: plan.likes.prune.length,
105
108
  exports: plan.exports.length,
109
+ exportGc: plan.exportGc.length,
106
110
  reviewPending: plan.reviewPending,
107
111
  },
108
112
  apply,
@@ -130,6 +134,19 @@ export function planExportsOnly(repo: Repo, cfg: Config, opts: Pick<SyncOptions,
130
134
  return planExports(repo, repo.listMatches("local").map((m) => m.canonicalKey).filter((k) => keys.has(k)), repo.listExports(), force);
131
135
  }
132
136
 
137
+ /**
138
+ * Exported files no longer needed: the track left every mirrored playlist, or it has a Spotify match
139
+ * now. Only meaningful for a full run — with `--playlist`/`--source` the other playlists' exports
140
+ * would look unneeded — and the remote entries pointing at these files are pruned in the same run
141
+ * (they are `owned`), so nothing in a playlist is left pointing at a deleted file.
142
+ */
143
+ export function planExportGc(repo: Repo, cfg: Config, opts: Pick<SyncOptions, "source" | "playlist">): LocalExportRow[] {
144
+ if (opts.source || opts.playlist) return [];
145
+ const needed = selectedKeys(repo, cfg, opts);
146
+ const local = new Set(repo.listMatches("local").map((m) => m.canonicalKey));
147
+ return repo.listExports().filter((e) => !needed.has(e.canonicalKey) || !local.has(e.canonicalKey));
148
+ }
149
+
133
150
  // ---- pull -------------------------------------------------------------------
134
151
 
135
152
  async function pull(deps: SyncDeps, opts: SyncOptions, now: number): Promise<SyncSummary["pulled"]> {
@@ -278,11 +295,14 @@ export async function buildPlan(deps: SyncDeps, opts: Pick<SyncOptions, "prune"
278
295
 
279
296
  const remote = await resolveRemotePlaylist(sp, targetName, remotePlaylists, deps);
280
297
  let remoteItems: RemoteItem[] = [];
298
+ let snapshotId: string | null = null;
281
299
  if (remote) {
282
- remoteItems = (await api.getPlaylistItems(remote.id)).map((it) => {
283
- if (!it.item) return { uri: "", isLocal: false, stale: false };
300
+ const listing = await listPlaylistItemsConsistently(api, remote.id);
301
+ snapshotId = listing.snapshotId;
302
+ remoteItems = listing.items.map((it) => {
303
+ if (!it.item) return { uri: "", isLocal: false, owned: false };
284
304
  if (it.is_local || it.item.is_local) return { ...resolveRemoteLocalUri(it.item.uri, exports), isLocal: true };
285
- return { uri: it.item.uri, isLocal: false, stale: false };
305
+ return { uri: it.item.uri, isLocal: false, owned: false };
286
306
  });
287
307
  }
288
308
 
@@ -292,6 +312,7 @@ export async function buildPlan(deps: SyncDeps, opts: Pick<SyncOptions, "prune"
292
312
  sourceName: sp.name,
293
313
  targetName,
294
314
  spotify: remote,
315
+ snapshotId,
295
316
  desired,
296
317
  remote: remoteItems,
297
318
  managed: remote ? repo.managedUris(remote.id) : new Set<string>(),
@@ -308,7 +329,7 @@ export async function buildPlan(deps: SyncDeps, opts: Pick<SyncOptions, "prune"
308
329
  prune: [...repo.likedIds()].filter((id) => !likeDesired.has(id)),
309
330
  };
310
331
 
311
- return { playlists, likes, exports: exportPlans, reviewPending: repo.countMatches().review };
332
+ return { playlists, likes, exports: exportPlans, exportGc: planExportGc(repo, cfg, opts), reviewPending: repo.countMatches().review };
312
333
  }
313
334
 
314
335
  /** Which of `ids` are already liked. `/me/tracks/contains` is 403 for some development-mode apps; then list the library instead. */
@@ -324,6 +345,22 @@ async function savedFlags(api: SpotifyApi, ids: string[]): Promise<boolean[]> {
324
345
  }
325
346
  }
326
347
 
348
+ /**
349
+ * Items plus the snapshot id they belong to. The listing is paginated and the snapshot endpoint is
350
+ * separate, so the listing is bracketed by two snapshot reads and retried while they differ; the
351
+ * snapshot is what position-based removals are validated against, so a wrong one must never be sent.
352
+ */
353
+ async function listPlaylistItemsConsistently(api: SpotifyApi, id: string): Promise<{ items: SpotifyPlaylistItem[]; snapshotId: string }> {
354
+ for (let attempt = 1; ; attempt++) {
355
+ const before = await api.getPlaylistSnapshot(id);
356
+ const items = await api.getPlaylistItems(id);
357
+ const after = await api.getPlaylistSnapshot(id);
358
+ if (before === after) return { items, snapshotId: after };
359
+ if (attempt === 3) throw new Error(`playlist ${id} keeps changing while it is being read; retry later`);
360
+ log.warn("playlist changed while listing, retrying", { id, attempt });
361
+ }
362
+ }
363
+
327
364
  /**
328
365
  * Find the remote playlist for a source playlist: the stored mapping if it still exists, else a
329
366
  * remote playlist with the target name carrying our description (adoption after state loss), else null.
@@ -396,9 +433,11 @@ export function formatPlan(plan: Plan, prune: boolean): string {
396
433
  for (const x of p.prune) lines.push(` ${prune ? "-" : "?"} ${x.uri}`);
397
434
  }
398
435
  lines.push(`likes: +${plan.likes.add.length}, prune ${plan.likes.prune.length}${prune ? "" : " (report only)"}`);
399
- lines.push(`exports: ${plan.exports.length}`);
436
+ lines.push(`exports: ${plan.exports.length}, remove ${plan.exportGc.length}${prune ? "" : " (report only)"}`);
400
437
  for (const e of plan.exports.slice(0, 20)) lines.push(` → ${e.baseName} (${e.sourcePath})`);
401
438
  if (plan.exports.length > 20) lines.push(` → … ${plan.exports.length - 20} more`);
439
+ for (const e of plan.exportGc.slice(0, 20)) lines.push(` ${prune ? "-" : "?"} ${e.exportPath}`);
440
+ if (plan.exportGc.length > 20) lines.push(` ${prune ? "-" : "?"} … ${plan.exportGc.length - 20} more`);
402
441
  lines.push(`review pending: ${plan.reviewPending}`);
403
442
  return lines.join("\n");
404
443
  }