spotifify 0.1.2 → 0.1.3

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.3](https://github.com/FYWinds/Spotifify/compare/v0.1.2...v0.1.3) (2026-09-05)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **match:** let a fingerprint hit decide for a pooled track ([75c2717](https://github.com/FYWinds/Spotifify/commit/75c27178f16b277a5062ff3aff5c396bbc36ec4b))
9
+ * **sources:** unreadable files and partial pulls are not deletions ([0fb80a7](https://github.com/FYWinds/Spotifify/commit/0fb80a7a3af3a03d06543e118be1cb7171fd5880))
10
+ * **spotify:** make playlist writes safe to interrupt and retry ([5882336](https://github.com/FYWinds/Spotifify/commit/5882336ec47f4c8ededef8712312864f7d1d7f16))
11
+ * **sync:** never prune beyond what the run reconciles ([fd1d732](https://github.com/FYWinds/Spotifify/commit/fd1d732fa06cfcff5b855f60ff90c68414d41bb9))
12
+
3
13
  ## [0.1.2](https://github.com/FYWinds/Spotifify/compare/v0.1.1...v0.1.2) (2026-09-05)
4
14
 
5
15
 
package/README.md CHANGED
@@ -67,7 +67,7 @@ State lives in `~/.spotifify` (`config.toml`, `state.db`, logs); override with `
67
67
  | `init [--force\|--upgrade]` | Write the config template; `--upgrade` merges options added in newer versions into your file (values kept, `.bak` written). |
68
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. `--prune` also removes superseded local entries and exported files no longer needed. 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 — never beyond what the run can account for: with `--playlist`/`--source` nothing is unliked that another mirrored playlist wants, exports still referenced from a playlist outside the run are kept, and a run that mirrors no playlist at all prunes nothing. 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
@@ -67,7 +67,7 @@ spotifify review # 处理低置信度匹配
67
67
  | `init [--force\|--upgrade]` | 写配置模板;`--upgrade` 把新版本新增的选项合并进现有文件(保留原值,写 `.bak`)。 |
68
68
  | `doctor` | 检查配置、状态库、`ffmpeg`/`fpcalc`、token scope、搜索配额截止时间,以及桌面端的本地文件索引(哪些导出没被索引、哪些时长和我们算的不一致——歌单里灰掉的两种原因)。 |
69
69
  | `auth spotify` / `auth netease [--cookie …]` | 登录。 |
70
- | `sync [--dry-run] [--prune] [--source netease\|local] [--playlist 名称] [--skip-match]` | 拉取 → 匹配 → 导出 → 计划 → 执行 → 报告。`--prune` 还会删掉被取代的本地条目和不再需要的导出文件。退出码 `3` = 需要重新登录。 |
70
+ | `sync [--dry-run] [--prune] [--source netease\|local] [--playlist 名称] [--skip-match]` | 拉取 → 匹配 → 导出 → 计划 → 执行 → 报告。`--prune` 还会删掉被取代的本地条目和不再需要的导出文件——但只在本次能对账的范围内:带 `--playlist`/`--source` 时不会取消其他镜像歌单想要的喜欢,本次计划外的歌单仍引用的导出文件保留,没有任何镜像歌单的运行什么都不删。退出码 `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.2",
3
+ "version": "0.1.3",
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
@@ -44,7 +44,7 @@ interface Ctx {
44
44
  const program = new Command()
45
45
  .name("spotifify")
46
46
  .description("Sync Netease Cloud Music playlists and a local library to Spotify")
47
- .version("0.1.2") // x-release-please-version
47
+ .version("0.1.3") // x-release-please-version
48
48
  .option("--config <path>", "config file (default: <state-dir>/config.toml)")
49
49
  .option("--state-dir <dir>", "state directory (default: ~/.spotifify or $SPOTIFIFY_STATE_DIR)")
50
50
  .option("--log-file <path>", "append log lines to this file")
@@ -17,6 +17,7 @@ const TRACK_REF = /^(?:spotify:track:|https?:\/\/open\.spotify\.com\/(?:intl-[a-
17
17
  /** Dedupes search results by id (and relinked origin id) and scores each one once. */
18
18
  class CandidatePool {
19
19
  private readonly seen = new Set<string>();
20
+ private readonly byId = new Map<string, Candidate>();
20
21
  private readonly candidates: Candidate[] = [];
21
22
 
22
23
  constructor(
@@ -37,11 +38,23 @@ class CandidatePool {
37
38
  if (scored.parts.artist < minArtist) continue;
38
39
  const c = toCandidate(t.id, t, scored);
39
40
  this.candidates.push(c);
41
+ this.byId.set(t.id, c);
42
+ if (t.linked_from) this.byId.set(t.linked_from.id, c);
40
43
  added.push(c);
41
44
  }
42
45
  return added;
43
46
  }
44
47
 
48
+ /** Pooled candidates for these tracks, whichever query admitted them (an identity hit must count even for a track a text query saw first). */
49
+ known(tracks: SpotifyTrack[]): Candidate[] {
50
+ const out = new Set<Candidate>();
51
+ for (const t of tracks) {
52
+ const c = (t.id !== null ? this.byId.get(t.id) : undefined) ?? (t.linked_from !== undefined ? this.byId.get(t.linked_from.id) : undefined);
53
+ if (c) out.add(c);
54
+ }
55
+ return [...out];
56
+ }
57
+
45
58
  sorted(): Candidate[] {
46
59
  return [...this.candidates].sort((a, b) => b.score - a.score);
47
60
  }
@@ -63,19 +76,24 @@ function toCandidate(id: string, t: SpotifyTrack, scored: { score: number; parts
63
76
 
64
77
  type Hit = { by: "isrc" | "fingerprint"; candidate: Candidate } | { by: "auto" };
65
78
 
79
+ /** ISRCs for a local file (Chromaprint → AcoustID → MusicBrainz); replaceable so the decision path can be exercised without `fpcalc`. */
80
+ export type IsrcLookup = (path: string, contentHash: string, cfg: Config["matching"], repo: Repo, now: number) => Promise<string[]>;
81
+
66
82
  export class Matcher {
67
83
  private readonly api: SpotifyApi;
68
84
  private readonly repo: Repo;
69
85
  private readonly cfg: Config;
70
86
  private readonly market: string;
71
87
  private readonly search: TrackSearch;
88
+ private readonly isrcLookup: IsrcLookup;
72
89
 
73
- constructor(deps: { api: SpotifyApi; repo: Repo; cfg: Config; market: string }) {
90
+ constructor(deps: { api: SpotifyApi; repo: Repo; cfg: Config; market: string; isrcLookup?: IsrcLookup }) {
74
91
  this.api = deps.api;
75
92
  this.repo = deps.repo;
76
93
  this.cfg = deps.cfg;
77
94
  this.market = deps.market;
78
95
  this.search = new TrackSearch(deps.api, deps.repo, deps.cfg.matching, deps.market);
96
+ this.isrcLookup = deps.isrcLookup ?? isrcsByFingerprint;
79
97
  }
80
98
 
81
99
  /** Network searches spent by this matcher (cache hits excluded). */
@@ -92,10 +110,11 @@ export class Matcher {
92
110
  for (const q of queries) {
93
111
  const tracks = await this.search.search(q, now);
94
112
  const added = pool.add(tracks, true, q === bareTitle ? BARE_TITLE_MIN_ARTIST : 0);
95
- if (added.length === 0) continue;
96
113
  if (q.startsWith("isrc:")) {
97
- let best = added[0]!;
98
- for (const c of added) if (c.score > best.score) best = c;
114
+ const hits = pool.known(tracks);
115
+ if (hits.length === 0) continue;
116
+ let best = hits[0]!;
117
+ for (const c of hits) if (c.score > best.score) best = c;
99
118
  return { by: isrcBy, candidate: best };
100
119
  }
101
120
  if (added.some((c) => passesAutoGate(c.score, c.parts, m))) return { by: "auto" };
@@ -114,7 +133,7 @@ export class Matcher {
114
133
  const bareTitle = track.artists.length > 0 ? queryTitle(track.title) : null;
115
134
  let hit = await this.runQueries(pool, this.search.queriesFor(track), bareTitle, "isrc", now);
116
135
  if (hit === null && track.file && m.fingerprint) {
117
- const isrcs = await isrcsByFingerprint(track.file.path, track.file.contentHash, m, this.repo, now);
136
+ const isrcs = await this.isrcLookup(track.file.path, track.file.contentHash, m, this.repo, now);
118
137
  for (const isrc of isrcs) {
119
138
  hit = await this.runQueries(pool, [`isrc:${isrc}`], null, "fingerprint", now);
120
139
  if (hit !== null) break;
@@ -89,17 +89,26 @@ export class LocalSource implements Source {
89
89
 
90
90
  private async track(file: ScannedFile): Promise<SourceTrack | null> {
91
91
  const cached = this.cache.get(file.path);
92
- if (cached?.file !== undefined && cached.file.size === file.size && cached.file.mtimeMs === file.mtimeMs) {
93
- const { id: _id, canonicalKey: _key, lastSeenAt: _seen, ...track } = cached;
94
- return track;
95
- }
92
+ if (cached?.file !== undefined && cached.file.size === file.size && cached.file.mtimeMs === file.mtimeMs) return stripRow(cached);
96
93
  try {
97
94
  const contentHash = await hashFile(file.path);
98
95
  const meta = await describe(file.path, this.cfg.filename_pattern);
99
96
  return { kind: "local", externalId: file.path, ...meta, file: { path: file.path, contentHash, size: file.size, mtimeMs: file.mtimeMs } };
100
97
  } catch (e) {
101
- log.warn(`skipping unreadable file ${file.path}: ${e instanceof Error ? e.message : String(e)}`);
98
+ // A file that cannot be read right now (half-downloaded, locked, damaged) is not a file that left the
99
+ // library: the previous row stands until it can be read again, so the track is never treated as removed.
100
+ const error = e instanceof Error ? e.message : String(e);
101
+ if (cached?.file !== undefined) {
102
+ log.warn(`cannot read ${file.path}; keeping its previous metadata: ${error}`);
103
+ return stripRow(cached);
104
+ }
105
+ log.warn(`skipping unreadable file ${file.path}: ${error}`);
102
106
  return null;
103
107
  }
104
108
  }
105
109
  }
110
+
111
+ function stripRow(row: SourceTrackRow): SourceTrack {
112
+ const { id: _id, canonicalKey: _key, lastSeenAt: _seen, ...track } = row;
113
+ return track;
114
+ }
@@ -35,26 +35,26 @@ export class NeteaseAuthError extends Error {
35
35
  const LoginStatusBody = z.object({
36
36
  data: z.object({ profile: z.object({ userId: z.number(), nickname: z.string().default("") }).nullish() }).optional(),
37
37
  });
38
+ // Fields a pull cannot do without (`playlist`, `trackIds`) are required: a shape change must fail the run, not
39
+ // read as "the user has no playlists / the playlist is empty" and delete everything downstream.
38
40
  const UserPlaylistBody = z.object({
39
41
  more: z.boolean().default(false),
40
- playlist: z
41
- .array(
42
- z.object({
43
- id: z.number(),
44
- name: z.string(),
45
- creator: z.object({ userId: z.number() }).nullish(),
46
- userId: z.number().optional(),
47
- specialType: z.number().default(0),
48
- trackCount: z.number().default(0),
49
- updateTime: z.number().default(0),
50
- trackUpdateTime: z.number().optional(),
51
- }),
52
- )
53
- .default([]),
42
+ playlist: z.array(
43
+ z.object({
44
+ id: z.number(),
45
+ name: z.string(),
46
+ creator: z.object({ userId: z.number() }).nullish(),
47
+ userId: z.number().optional(),
48
+ specialType: z.number().default(0),
49
+ trackCount: z.number().default(0),
50
+ updateTime: z.number().default(0),
51
+ trackUpdateTime: z.number().optional(),
52
+ }),
53
+ ),
54
54
  });
55
55
  const PlaylistDetailBody = z.object({
56
56
  playlist: z.object({
57
- trackIds: z.array(z.object({ id: z.number() })).default([]),
57
+ trackIds: z.array(z.object({ id: z.number() })),
58
58
  updateTime: z.number().default(0),
59
59
  trackUpdateTime: z.number().optional(),
60
60
  }),
@@ -78,10 +78,17 @@ export class NeteaseSource implements Source {
78
78
  }
79
79
 
80
80
  const tracks: SourceTrack[] = [];
81
+ const omitted: string[] = [];
81
82
  for (const id of ids) {
82
83
  const t = known.get(id) ?? fetched.get(id);
83
84
  if (t) tracks.push(t);
84
- else log.warn("netease song_detail omitted a track", { playlist: summary.name, id });
85
+ else omitted.push(id);
86
+ }
87
+ if (omitted.length > 0) {
88
+ // Songs the detail endpoint left out were never known, so nothing is lost yet; but the playlist must not be
89
+ // recorded as pulled at this version, or the cache would serve the short list until the playlist changes.
90
+ log.warn("netease song_detail omitted tracks; the playlist will be pulled again next run", { playlist: summary.name, omitted: omitted.length, ids: omitted.slice(0, 5) });
91
+ playlist.sourceUpdatedAt = undefined;
85
92
  }
86
93
  log.info("netease playlist pulled", { name: summary.name, tracks: tracks.length, fetched: fetched.size, cached: known.size });
87
94
  playlists.push({ playlist, tracks });
@@ -6,8 +6,8 @@ import type { SpotifyPlaylist, SpotifyPlaylistItem, SpotifyTrack } from "./types
6
6
  // `/me/library?uris=`; the legacy `/tracks` and `/me/tracks/contains` paths answer 403 for newer apps.
7
7
  const ITEM_FIELDS = "next,total,items(added_at,is_local,item(id,uri,name,is_local,duration_ms,artists(id,name),album(id,name)))";
8
8
 
9
- /** Playlist item batch limit for add/remove/replace. */
10
- const ITEMS_BATCH = 100;
9
+ /** Playlist item batch limit for add/remove; also the most a single replace can hold. */
10
+ export const ITEMS_BATCH = 100;
11
11
  /** `/me/library` batch limit (probed: 41+ uris → 400 "Too many uris requested"). */
12
12
  const LIBRARY_BATCH = 40;
13
13
 
@@ -92,8 +92,9 @@ export class SpotifyApi {
92
92
 
93
93
  /**
94
94
  * ≤100 per request. Catalog tracks are removed by URI; local files must be removed by position only
95
- * (`/items` answers 400 "Invalid base62 id" for a `spotify:local:` URI). Positions are removed highest
96
- * first with chained snapshot ids, so earlier indexes never shift underneath later batches.
95
+ * (`/items` answers 400 "Invalid base62 id" for a `spotify:local:` URI). Every position batch names the
96
+ * snapshot the positions were read from: Spotify resolves them against that snapshot, so batches never
97
+ * see each other's shifts (or a concurrent edit). Positions are still sent highest first.
97
98
  */
98
99
  async removePlaylistItems(id: string, items: Array<{ uri: string; positions?: number[] }>, snapshotId: string): Promise<string> {
99
100
  let snapshot = snapshotId;
@@ -101,7 +102,7 @@ export class SpotifyApi {
101
102
  const uris = items.filter((x) => x.positions === undefined).map((x) => ({ uri: x.uri }));
102
103
  for (const batch of chunk(positions, ITEMS_BATCH)) {
103
104
  const res = await this.client.request<{ snapshot_id: string }>("DELETE", `/v1/playlists/${id}/items`, {
104
- body: { positions: batch, snapshot_id: snapshot },
105
+ body: { positions: batch, snapshot_id: snapshotId },
105
106
  });
106
107
  snapshot = res.snapshot_id;
107
108
  }
@@ -121,13 +122,11 @@ export class SpotifyApi {
121
122
  return res.snapshot_id;
122
123
  }
123
124
 
124
- /** PUT the first ≤100 (which also clears the playlist), then append the rest. */
125
+ /** One atomic PUT (≤100 items: a longer list would need a follow-up POST that can fail half-way and lose the tail). */
125
126
  async replacePlaylistItems(id: string, uris: string[]): Promise<string> {
126
- const res = await this.client.request<{ snapshot_id: string }>("PUT", `/v1/playlists/${id}/items`, {
127
- body: { uris: uris.slice(0, ITEMS_BATCH) },
128
- });
129
- const rest = uris.slice(ITEMS_BATCH);
130
- return rest.length === 0 ? res.snapshot_id : this.addPlaylistItems(id, rest);
127
+ if (uris.length > ITEMS_BATCH) throw new Error(`replacePlaylistItems: ${uris.length} items exceed the single-request limit of ${ITEMS_BATCH}`);
128
+ const res = await this.client.request<{ snapshot_id: string }>("PUT", `/v1/playlists/${id}/items`, { body: { uris } });
129
+ return res.snapshot_id;
131
130
  }
132
131
 
133
132
  /** ≤50 per request; result aligns with `ids` order. */
@@ -48,7 +48,8 @@ async function postToken(form: Record<string, string>, previous?: SpotifyTokens)
48
48
  access_token: body.access_token,
49
49
  refresh_token: refresh,
50
50
  expires_at: Date.now() + body.expires_in * 1000 - EXPIRY_MARGIN_MS,
51
- scope: body.scope ?? previous?.scope ?? SCOPES.join(" "),
51
+ // A refresh response may omit `scope` (or an older store may hold ""): the grant did not shrink, so keep what was known.
52
+ scope: body.scope || previous?.scope || SCOPES.join(" "),
52
53
  };
53
54
  }
54
55
 
@@ -110,12 +111,7 @@ export async function loginPkce(opts: { clientId: string; port: number; store: T
110
111
  }
111
112
  }
112
113
 
113
- /** PKCE refresh responses may omit `refresh_token`; the previous one is kept in that case. */
114
- export async function refreshTokens(clientId: string, refreshToken: string): Promise<SpotifyTokens> {
115
- return postToken({ client_id: clientId, grant_type: "refresh_token", refresh_token: refreshToken }, {
116
- access_token: "",
117
- refresh_token: refreshToken,
118
- expires_at: 0,
119
- scope: "",
120
- });
114
+ /** PKCE refresh responses may omit `refresh_token` and `scope`; the previous values are kept in that case. */
115
+ export async function refreshTokens(clientId: string, previous: SpotifyTokens): Promise<SpotifyTokens> {
116
+ return postToken({ client_id: clientId, grant_type: "refresh_token", refresh_token: previous.refresh_token }, previous);
121
117
  }
@@ -77,7 +77,9 @@ export class SpotifyClient {
77
77
  log.warn(`Spotify rate limited; waiting ${secs}s`, { method, path: url.pathname });
78
78
  throw new RetryableError(`429 on ${method} ${url.pathname}`, waitMs);
79
79
  }
80
- if (res.status >= 500) throw new RetryableError(`${res.status} on ${method} ${url.pathname}`);
80
+ // A 5xx on a write may arrive after Spotify applied it (a re-sent POST would add twice, a re-sent
81
+ // reorder move twice), so only reads are retried; a failed write ends the run and the next one re-plans.
82
+ if (res.status >= 500 && method === "GET") throw new RetryableError(`${res.status} on ${method} ${url.pathname}`);
81
83
  throw new SpotifyHttpError(res.status, text, method, url.pathname);
82
84
  },
83
85
  { attempts: ATTEMPTS },
@@ -107,7 +109,7 @@ export class SpotifyClient {
107
109
  if (this.refreshing) return this.refreshing;
108
110
  const tokens = this.store.load();
109
111
  if (tokens === null) throw new AuthExpiredError();
110
- this.refreshing = refreshTokens(this.clientId, tokens.refresh_token)
112
+ this.refreshing = refreshTokens(this.clientId, tokens)
111
113
  .then((fresh) => {
112
114
  this.store.save(fresh);
113
115
  return fresh;
package/src/state/repo.ts CHANGED
@@ -386,6 +386,29 @@ export class Repo {
386
386
  })();
387
387
  }
388
388
 
389
+ /** `spotify:local:` uris recorded as present in a managed playlist → ids of the playlists referencing each. */
390
+ localUriReferences(): Map<string, string[]> {
391
+ const out = new Map<string, string[]>();
392
+ for (const r of this.db.query<{ uri: string; spotify_playlist_id: string }, []>("SELECT uri, spotify_playlist_id FROM managed_item WHERE uri LIKE 'spotify:local:%'").all()) {
393
+ let ids = out.get(r.uri);
394
+ if (!ids) {
395
+ ids = [];
396
+ out.set(r.uri, ids);
397
+ }
398
+ ids.push(r.spotify_playlist_id);
399
+ }
400
+ return out;
401
+ }
402
+
403
+ /** Record exactly which of our local entries a playlist holds after a sync (rows for entries the user removed by hand go away). */
404
+ replaceManagedLocal(spotifyPlaylistId: string, uris: string[], now: number): void {
405
+ const q = this.db.query("INSERT OR IGNORE INTO managed_item (spotify_playlist_id, uri, added_at) VALUES (?, ?, ?)");
406
+ this.db.transaction(() => {
407
+ this.db.run("DELETE FROM managed_item WHERE spotify_playlist_id = ? AND uri LIKE 'spotify:local:%'", [spotifyPlaylistId]);
408
+ for (const u of uris) q.run(spotifyPlaylistId, u, now);
409
+ })();
410
+ }
411
+
389
412
  likedIds(): Set<string> {
390
413
  return new Set(this.db.query<{ spotify_id: string }, []>("SELECT spotify_id FROM liked").all().map((r) => r.spotify_id));
391
414
  }
package/src/sync/apply.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * remote operation so a crash mid-way leaves a consistent (re-plannable) state. See DESIGN.md §6.
4
4
  */
5
5
  import type { Config } from "../config.ts";
6
- import type { SpotifyApi } from "../spotify/api.ts";
6
+ import { ITEMS_BATCH, type SpotifyApi } from "../spotify/api.ts";
7
7
  import { MANAGED_DESCRIPTION } from "../spotify/types.ts";
8
8
  import type { Repo } from "../state/repo.ts";
9
9
  import { chunk } from "../util/retry.ts";
@@ -38,6 +38,13 @@ export interface ApplySummary {
38
38
  const REPLACE_MIN_MOVES = 5;
39
39
  const REPLACE_MOVE_RATIO = 1 / 3;
40
40
 
41
+ export class PlaylistDriftError extends Error {
42
+ constructor(name: string) {
43
+ super(`playlist "${name}" changed since it was read; nothing was applied to it — rerun \`spotifify sync\``);
44
+ this.name = "PlaylistDriftError";
45
+ }
46
+ }
47
+
41
48
  /** Applies playlist and library changes. Exports run separately (`applyExports`) before planning. */
42
49
  export async function applyPlan(plan: Plan, deps: ApplyDeps): Promise<ApplySummary> {
43
50
  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 };
@@ -93,6 +100,14 @@ async function applyPlaylist(p: PlaylistPlan, deps: ApplyDeps, s: ApplySummary):
93
100
  }
94
101
  if (spotifyId === null) throw new Error(`playlist plan for ${p.sourceName} has neither spotifyId nor create`);
95
102
 
103
+ // Moves and the replace order describe the playlist as it was listed and carry no snapshot Spotify could
104
+ // validate them against (unlike position removals), so an edit since the listing would reorder the wrong
105
+ // rows: such a playlist is checked before anything is written to it.
106
+ const willPrune = deps.prune && p.prune.length > 0;
107
+ if (p.snapshotId !== null && p.moves.length > 0 && (await api.getPlaylistSnapshot(spotifyId)) !== p.snapshotId) {
108
+ throw new PlaylistDriftError(name);
109
+ }
110
+
96
111
  if (p.rename) {
97
112
  await api.renamePlaylist(spotifyId, p.rename.to);
98
113
  name = p.rename.to;
@@ -100,8 +115,10 @@ async function applyPlaylist(p: PlaylistPlan, deps: ApplyDeps, s: ApplySummary):
100
115
  log.info("renamed playlist", p.rename);
101
116
  }
102
117
 
103
- // Replace-all fast path: only when no local items exist and moving would cost noticeably more calls.
104
- const useReplace = p.replaceAllowed && p.moves.length > REPLACE_MIN_MOVES && p.moves.length > p.targetOrder.length * REPLACE_MOVE_RATIO;
118
+ // Replace-all fast path: one atomic PUT, only for lists it can hold whole, with no local items, and when
119
+ // moving would cost noticeably more calls.
120
+ const useReplace =
121
+ p.replaceAllowed && p.targetOrder.length <= ITEMS_BATCH && p.moves.length > REPLACE_MIN_MOVES && p.moves.length > p.targetOrder.length * REPLACE_MOVE_RATIO;
105
122
  let snapshot: string | null = null;
106
123
 
107
124
  if (useReplace) {
@@ -114,12 +131,12 @@ async function applyPlaylist(p: PlaylistPlan, deps: ApplyDeps, s: ApplySummary):
114
131
  s.replaced++;
115
132
  log.info("replaced playlist contents", { name, items: p.targetOrder.length });
116
133
  } else {
117
- for (const uris of chunk(p.adds, 100)) {
134
+ for (const uris of chunk(p.adds, ITEMS_BATCH)) {
118
135
  snapshot = await api.addPlaylistItems(spotifyId, uris);
119
136
  repo.addManaged(spotifyId, uris, now);
120
137
  s.added += uris.length;
121
138
  }
122
- if (deps.prune && p.prune.length > 0) {
139
+ if (willPrune) {
123
140
  // Positions come from the planning-time listing, so they are validated against that snapshot
124
141
  // (Spotify checks them against the snapshot given, not the current one). Adds only append.
125
142
  const base = p.snapshotId ?? snapshot ?? (await api.getPlaylist(spotifyId))?.snapshot_id ?? null;
@@ -139,6 +156,11 @@ async function applyPlaylist(p: PlaylistPlan, deps: ApplyDeps, s: ApplySummary):
139
156
  }
140
157
  }
141
158
  }
159
+ // Local entries present after this run reference their export from this playlist; an export is only
160
+ // garbage-collected once no playlist references it any more (see planExportGc). Without --prune the
161
+ // stale ones are still there.
162
+ const localPresent = deps.prune ? p.linked : [...p.linked, ...p.prune.map((x) => x.uri).filter((u) => u.startsWith("spotify:local:"))];
163
+ repo.replaceManagedLocal(spotifyId, localPresent, now);
142
164
 
143
165
  if (p.adds.length > 0 || p.moves.length > 0 || p.prune.length > 0) {
144
166
  log.info("synced playlist", { name, added: p.adds.length, pruned: deps.prune ? p.prune.length : 0, moves: p.moves.length, awaiting: p.awaiting.length });
package/src/sync/plan.ts CHANGED
@@ -37,11 +37,13 @@ export interface PlaylistPlan {
37
37
  prune: Array<{ uri: string; positions: number[] }>;
38
38
  /** remote items not managed by this tool; never removed, kept at the tail */
39
39
  foreign: string[];
40
+ /** owned local entries that resolve to a desired export: the user's paste landed, so the export is referenced from this playlist */
41
+ linked: string[];
40
42
  /** minimal move sequence to reach desired order (after adds, and prune when enabled) */
41
43
  moves: Move[];
42
44
  /** full target order (after adds/prune) — used by the replace-all fast path */
43
45
  targetOrder: string[];
44
- /** when true, apply may replace the whole playlist instead of moving (no local items involved) */
46
+ /** when true, apply may replace the whole playlist instead of moving (no local or unresolved items involved) */
45
47
  replaceAllowed: boolean;
46
48
  }
47
49
 
@@ -71,6 +73,7 @@ export interface Plan {
71
73
  }
72
74
 
73
75
  export interface RemoteItem {
76
+ /** empty when Spotify returned the entry without an item (unresolvable): it keeps its position but is never touched */
74
77
  uri: string;
75
78
  isLocal: boolean;
76
79
  /**
@@ -142,6 +145,7 @@ export function computePlaylistPlan(input: PlaylistPlanInput): PlaylistPlan {
142
145
  awaiting: desired.filter((d) => d.kind === "local"),
143
146
  prune: [],
144
147
  foreign: [],
148
+ linked: [],
145
149
  moves: [],
146
150
  targetOrder: desired.filter((d) => d.kind === "spotify").map((d) => d.uri),
147
151
  replaceAllowed: false,
@@ -151,11 +155,17 @@ export function computePlaylistPlan(input: PlaylistPlanInput): PlaylistPlan {
151
155
  const remoteSet = new Set(remote.map((r) => r.uri));
152
156
  const adds = desired.filter((d) => d.kind === "spotify" && !remoteSet.has(d.uri)).map((d) => d.uri);
153
157
  const awaiting = desired.filter((d) => d.kind === "local" && !remoteSet.has(d.uri));
158
+ const linked = desired.filter((d) => d.kind === "local" && remoteSet.has(d.uri)).map((d) => d.uri);
154
159
 
155
160
  const prune: PlaylistPlan["prune"] = [];
156
161
  const pruneByUri = new Map<string, number[]>();
157
162
  const foreign: string[] = [];
163
+ let unresolved = false;
158
164
  remote.forEach((r, i) => {
165
+ if (r.uri === "") {
166
+ unresolved = true;
167
+ return;
168
+ }
159
169
  if (desiredSet.has(r.uri)) return;
160
170
  if (managed.has(r.uri) || r.owned) {
161
171
  let positions = pruneByUri.get(r.uri);
@@ -207,8 +217,9 @@ export function computePlaylistPlan(input: PlaylistPlanInput): PlaylistPlan {
207
217
  awaiting,
208
218
  prune,
209
219
  foreign,
220
+ linked,
210
221
  moves,
211
222
  targetOrder,
212
- replaceAllowed: !anyLocal,
223
+ replaceAllowed: !anyLocal && !unresolved,
213
224
  };
214
225
  }
package/src/sync/run.ts CHANGED
@@ -15,7 +15,7 @@ import { SpotifyHttpError, SpotifyRateLimitedError } from "../spotify/client.ts"
15
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
- import { sanitizeFilename } from "../util/fs.ts";
18
+ import { MAX_FILENAME, 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";
@@ -93,8 +93,12 @@ export async function runSync(deps: SyncDeps, opts: SyncOptions): Promise<SyncRe
93
93
  apply.exported = exported.exported;
94
94
  apply.exportErrors = exported.errors;
95
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);
96
+ // After apply: the playlist entries pointing at these files were pruned above, so the files can go
97
+ // unless some playlist outside this plan still holds an entry for them.
98
+ if (apply && opts.prune) {
99
+ const referenced = repo.localUriReferences();
100
+ apply.exportsRemoved = await removeExports(plan.exportGc.filter((e) => !referenced.has(e.localUri)), repo);
101
+ }
98
102
  const summary: SyncSummary = {
99
103
  pulled,
100
104
  matched,
@@ -137,14 +141,22 @@ export function planExportsOnly(repo: Repo, cfg: Config, opts: Pick<SyncOptions,
137
141
  /**
138
142
  * Exported files no longer needed: the track left every mirrored playlist, or it has a Spotify match
139
143
  * 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.
144
+ * would look unneeded — and never when nothing is mirrored (a config slip must not delete everything).
145
+ * An export whose entry sits in a playlist outside `planned` (retired from mirroring, or filtered out)
146
+ * is kept: only the playlists in this plan prune their entries in the same run.
142
147
  */
143
- export function planExportGc(repo: Repo, cfg: Config, opts: Pick<SyncOptions, "source" | "playlist">): LocalExportRow[] {
148
+ export function planExportGc(repo: Repo, cfg: Config, opts: Pick<SyncOptions, "source" | "playlist">, planned: ReadonlySet<string>): LocalExportRow[] {
144
149
  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));
150
+ if (selectedSourcePlaylists(repo, cfg, {}).length === 0) return [];
151
+ const needed = selectedKeys(repo, cfg, {});
152
+ // `review` keeps its export: a candidate that still needs a human must not undo a paste (see desiredItems)
153
+ const keep = new Set([...repo.listMatches("local"), ...repo.listMatches("review")].map((m) => m.canonicalKey));
154
+ const referenced = repo.localUriReferences();
155
+ return repo.listExports().filter((e) => {
156
+ if (needed.has(e.canonicalKey) && keep.has(e.canonicalKey)) return false;
157
+ const refs = referenced.get(e.localUri);
158
+ return refs === undefined || refs.every((id) => planned.has(id));
159
+ });
148
160
  }
149
161
 
150
162
  // ---- pull -------------------------------------------------------------------
@@ -261,38 +273,24 @@ export async function buildPlan(deps: SyncDeps, opts: Pick<SyncOptions, "prune"
261
273
  const me = await api.me();
262
274
  const remotePlaylists = (await api.listMyPlaylists()).filter((p) => p.owner.id === me.id);
263
275
  const exports = repo.listExports();
276
+ const exportByKey = new Map(exports.map((e) => [e.canonicalKey, e] as const));
264
277
 
265
- const sourcePlaylists = selectedSourcePlaylists(repo, cfg, opts);
278
+ const mirrored = selectedSourcePlaylists(repo, cfg, {});
279
+ const selected = new Set(selectedSourcePlaylists(repo, cfg, opts).map((p) => p.id));
266
280
 
267
281
  const playlists: PlaylistPlan[] = [];
282
+ /** ids the playlists this run acts on want liked → saved if not yet */
268
283
  const likeDesired = new Set<string>();
284
+ /** ids any mirrored playlist wants liked → never pruned, whatever `--playlist`/`--source` selected */
285
+ const likeNeeded = new Set<string>();
269
286
 
270
- for (const sp of sourcePlaylists) {
271
- const targetName = cfg.sync.playlist_prefix + sp.name;
272
- const tracks = repo.playlistTracks(sp.id);
273
- const matches = repo.matchesForKeys(tracks.map((t) => t.canonicalKey));
274
- const exportByKey = new Map(exports.map((e) => [e.canonicalKey, e] as const));
275
- const likeThis = sp.kind === "netease" ? cfg.netease.like_matched : cfg.local.like_matched;
276
-
277
- const desired: DesiredItem[] = [];
278
- const seen = new Set<string>();
279
- for (const t of tracks) {
280
- const m = matches.get(t.canonicalKey);
281
- if (!m) continue;
282
- let item: DesiredItem | null = null;
283
- if (m.status === "matched" && m.spotifyUri && m.spotifyId) {
284
- item = { uri: m.spotifyUri, kind: "spotify", canonicalKey: t.canonicalKey };
285
- if (likeThis) likeDesired.add(m.spotifyId);
286
- } else if (m.status === "local") {
287
- const e = exportByKey.get(t.canonicalKey);
288
- if (e) item = { uri: e.localUri, kind: "local", canonicalKey: t.canonicalKey };
289
- }
290
- if (item && !seen.has(item.uri)) {
291
- seen.add(item.uri);
292
- desired.push(item);
293
- }
294
- }
287
+ for (const sp of mirrored) {
288
+ const { desired, likes } = desiredItems(repo, cfg, sp, exportByKey);
289
+ for (const id of likes) likeNeeded.add(id);
290
+ if (!selected.has(sp.id)) continue;
291
+ for (const id of likes) likeDesired.add(id);
295
292
 
293
+ const targetName = cfg.sync.playlist_prefix + sp.name;
296
294
  const remote = await resolveRemotePlaylist(sp, targetName, remotePlaylists, deps);
297
295
  let remoteItems: RemoteItem[] = [];
298
296
  let snapshotId: string | null = null;
@@ -321,15 +319,53 @@ export async function buildPlan(deps: SyncDeps, opts: Pick<SyncOptions, "prune"
321
319
  );
322
320
  }
323
321
 
324
- // Likes: everything desired that is not already saved; prune tool-liked ids no longer desired.
322
+ // Likes: everything desired that is not already saved; prune tool-liked ids no mirrored playlist wants any
323
+ // more. With nothing mirrored there is nothing to reconcile against, so nothing is pruned.
325
324
  const likeIds = [...likeDesired];
326
325
  const saved = await savedFlags(api, likeIds);
327
326
  const likes = {
328
327
  add: likeIds.filter((_, i) => !saved[i]),
329
- prune: [...repo.likedIds()].filter((id) => !likeDesired.has(id)),
328
+ prune: mirrored.length === 0 ? [] : [...repo.likedIds()].filter((id) => !likeNeeded.has(id)),
330
329
  };
330
+ if (mirrored.length === 0 && (repo.likedIds().size > 0 || exports.length > 0)) {
331
+ log.warn("no playlist is mirrored (check netease.include_playlists / local.mirror_playlist); keeping every tool-liked track and export", {
332
+ liked: repo.likedIds().size,
333
+ exports: exports.length,
334
+ });
335
+ }
336
+
337
+ const planned = new Set<string>();
338
+ for (const p of playlists) if (p.spotifyId !== null) planned.add(p.spotifyId);
339
+ return { playlists, likes, exports: exportPlans, exportGc: planExportGc(repo, cfg, opts, planned), reviewPending: repo.countMatches().review };
340
+ }
331
341
 
332
- return { playlists, likes, exports: exportPlans, exportGc: planExportGc(repo, cfg, opts), reviewPending: repo.countMatches().review };
342
+ /** What one source playlist wants on Spotify, in source order and deduped by uri, plus the ids it wants liked. */
343
+ function desiredItems(repo: Repo, cfg: Config, sp: SourcePlaylistRow, exportByKey: ReadonlyMap<string, LocalExportRow>): { desired: DesiredItem[]; likes: string[] } {
344
+ const tracks = repo.playlistTracks(sp.id);
345
+ const matches = repo.matchesForKeys(tracks.map((t) => t.canonicalKey));
346
+ const likeThis = sp.kind === "netease" ? cfg.netease.like_matched : cfg.local.like_matched;
347
+ const desired: DesiredItem[] = [];
348
+ const likes: string[] = [];
349
+ const seen = new Set<string>();
350
+ for (const t of tracks) {
351
+ const m = matches.get(t.canonicalKey);
352
+ if (!m) continue;
353
+ let item: DesiredItem | null = null;
354
+ if (m.status === "matched" && m.spotifyUri && m.spotifyId) {
355
+ item = { uri: m.spotifyUri, kind: "spotify", canonicalKey: t.canonicalKey };
356
+ if (likeThis) likes.push(m.spotifyId);
357
+ } else if (m.status === "local" || m.status === "review") {
358
+ // `review` keeps the export already in place: a candidate that still needs a human (a re-search after
359
+ // retry_unmatched_after_days, say) must not undo the user's paste. Only `local` gets a new export.
360
+ const e = exportByKey.get(t.canonicalKey);
361
+ if (e) item = { uri: e.localUri, kind: "local", canonicalKey: t.canonicalKey };
362
+ }
363
+ if (item && !seen.has(item.uri)) {
364
+ seen.add(item.uri);
365
+ desired.push(item);
366
+ }
367
+ }
368
+ return { desired, likes };
333
369
  }
334
370
 
335
371
  /** Which of `ids` are already liked. `/me/tracks/contains` is 403 for some development-mode apps; then list the library instead. */
@@ -399,9 +435,14 @@ function planExports(repo: Repo, localKeys: string[], exports: LocalExportRow[],
399
435
  // An export is current when the source is unchanged and its recorded identity is complete
400
436
  // (rows written before the duration segment was known cannot match anything the client indexes).
401
437
  if (!force && existing && existing.contentHash === t.file.contentHash && parseLocalUri(existing.localUri)?.durationSec !== null) continue;
402
- let base = sanitizeFilename(`${t.artists.join(", ") || "Unknown Artist"} - ${t.title}`);
438
+ const raw = `${t.artists.join(", ") || "Unknown Artist"} - ${t.title}`;
439
+ let base = sanitizeFilename(raw);
403
440
  if (!existing) {
404
- for (let n = 2; usedNames.has(base.toLowerCase()); n++) base = sanitizeFilename(`${t.artists.join(", ") || "Unknown Artist"} - ${t.title} (${n})`);
441
+ // The suffix is appended after truncation, so a name at the length limit still gets a distinct one.
442
+ for (let n = 2; usedNames.has(base.toLowerCase()); n++) {
443
+ const suffix = ` (${n})`;
444
+ base = sanitizeFilename(raw, MAX_FILENAME - suffix.length) + suffix;
445
+ }
405
446
  } else {
406
447
  base = existing.exportPath.replace(/\.[^.\\/]+$/, "").replace(/^.*[\\/]/, "");
407
448
  }
package/src/util/fs.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { createReadStream } from "node:fs";
2
2
 
3
+ /** Longest base name (without extension) an export file gets, in UTF-16 units (NTFS's limit is 255). */
4
+ export const MAX_FILENAME = 150;
5
+
3
6
  /** Replace characters Windows/NTFS rejects and trim trailing dots/spaces. */
4
- export function sanitizeFilename(name: string, max = 150): string {
7
+ export function sanitizeFilename(name: string, max = MAX_FILENAME): string {
5
8
  const cleaned = name
6
9
  .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
7
10
  .replace(/\s+/g, " ")