spotifify 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -0
  4. package/README.zh-CN.md +131 -0
  5. package/config.example.toml +61 -0
  6. package/package.json +66 -0
  7. package/scripts/register-task.ps1 +43 -0
  8. package/src/cli.ts +504 -0
  9. package/src/config.ts +215 -0
  10. package/src/env.d.ts +15 -0
  11. package/src/match/aliases.ts +76 -0
  12. package/src/match/fingerprint.ts +104 -0
  13. package/src/match/matcher.ts +182 -0
  14. package/src/match/normalize.ts +107 -0
  15. package/src/match/score.ts +90 -0
  16. package/src/match/search.ts +97 -0
  17. package/src/match/types.ts +45 -0
  18. package/src/sources/local/ncm.ts +198 -0
  19. package/src/sources/local/scan.ts +55 -0
  20. package/src/sources/local/source.ts +105 -0
  21. package/src/sources/local/tags.ts +67 -0
  22. package/src/sources/netease/auth.ts +91 -0
  23. package/src/sources/netease/client.ts +188 -0
  24. package/src/sources/netease/lib.ts +38 -0
  25. package/src/sources/netease/source.ts +91 -0
  26. package/src/sources/types.ts +49 -0
  27. package/src/spotify/api.ts +155 -0
  28. package/src/spotify/auth.ts +121 -0
  29. package/src/spotify/client.ts +120 -0
  30. package/src/spotify/localUri.ts +48 -0
  31. package/src/spotify/types.ts +61 -0
  32. package/src/state/db.ts +42 -0
  33. package/src/state/repo.ts +480 -0
  34. package/src/state/schema.sql +115 -0
  35. package/src/sync/apply.ts +142 -0
  36. package/src/sync/duration.ts +115 -0
  37. package/src/sync/export.ts +159 -0
  38. package/src/sync/plan.ts +205 -0
  39. package/src/sync/reorder.ts +72 -0
  40. package/src/sync/run.ts +404 -0
  41. package/src/tui/App.tsx +420 -0
  42. package/src/tui/CandidatePane.tsx +158 -0
  43. package/src/tui/ReviewList.tsx +56 -0
  44. package/src/tui/SearchInput.tsx +37 -0
  45. package/src/tui/index.ts +32 -0
  46. package/src/tui/model.ts +54 -0
  47. package/src/util/bin.ts +12 -0
  48. package/src/util/clipboard.ts +13 -0
  49. package/src/util/fs.ts +18 -0
  50. package/src/util/lock.ts +38 -0
  51. package/src/util/log.ts +31 -0
  52. package/src/util/open.ts +22 -0
  53. package/src/util/retry.ts +49 -0
@@ -0,0 +1,72 @@
1
+ import type { Move } from "./plan.ts";
2
+
3
+ /**
4
+ * Spotify `PUT /playlists/{id}/tracks` semantics for a single item: remove the item at `rangeStart`,
5
+ * then insert it before the element that sat at `insertBefore` in the pre-move array.
6
+ */
7
+ export function applyMove(arr: readonly string[], m: Move): string[] {
8
+ const out = arr.slice();
9
+ const [item] = out.splice(m.rangeStart, 1);
10
+ out.splice(m.insertBefore > m.rangeStart ? m.insertBefore - 1 : m.insertBefore, 0, item!);
11
+ return out;
12
+ }
13
+
14
+ /** Indices (into `seq`) of one longest strictly increasing subsequence. */
15
+ function longestIncreasingSubsequence(seq: number[]): number[] {
16
+ const tails: number[] = []; // tails[k] = index in seq of the smallest tail of an increasing subsequence of length k+1
17
+ const prev = new Array<number>(seq.length).fill(-1);
18
+ for (let i = 0; i < seq.length; i++) {
19
+ const v = seq[i]!;
20
+ let lo = 0;
21
+ let hi = tails.length;
22
+ while (lo < hi) {
23
+ const mid = (lo + hi) >>> 1;
24
+ if (seq[tails[mid]!]! < v) lo = mid + 1;
25
+ else hi = mid;
26
+ }
27
+ if (lo > 0) prev[i] = tails[lo - 1]!;
28
+ tails[lo] = i;
29
+ }
30
+ const out = new Array<number>(tails.length);
31
+ for (let k = tails.length - 1, i = tails[k] ?? -1; k >= 0; k--, i = prev[i]!) out[k] = i;
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * Minimal single-item move sequence turning `current` into `target` (a permutation of `current`; duplicates are
37
+ * paired by occurrence). Elements on a longest increasing subsequence stay put; every other element is moved into
38
+ * place once, so `moves.length === n - |LIS|`. Moves are expressed against the array as it is at application time.
39
+ */
40
+ export function planMoves(current: string[], target: string[]): Move[] {
41
+ if (current.length !== target.length) throw new Error(`reorder: length mismatch (${current.length} vs ${target.length})`);
42
+ const slots = new Map<string, number[]>();
43
+ for (let j = target.length - 1; j >= 0; j--) {
44
+ const v = target[j]!;
45
+ const list = slots.get(v);
46
+ if (list) list.push(j);
47
+ else slots.set(v, [j]);
48
+ }
49
+ const n = current.length;
50
+ // Working order expressed as target indices; the goal state is the identity.
51
+ const w = new Array<number>(n);
52
+ for (let i = 0; i < n; i++) {
53
+ const j = slots.get(current[i]!)?.pop();
54
+ if (j === undefined) throw new Error(`reorder: target is not a permutation of current (extra ${JSON.stringify(current[i])})`);
55
+ w[i] = j;
56
+ }
57
+ const kept = new Uint8Array(n);
58
+ for (const i of longestIncreasingSubsequence(w)) kept[w[i]!] = 1;
59
+
60
+ const moves: Move[] = [];
61
+ for (let t = 0; t < n; t++) {
62
+ if (kept[t]) continue;
63
+ // Every element with target index < t is already placed and in final relative order, so t belongs right after t-1.
64
+ const rangeStart = w.indexOf(t);
65
+ const insertBefore = t === 0 ? 0 : w.indexOf(t - 1) + 1;
66
+ moves.push({ rangeStart, insertBefore });
67
+ w.splice(rangeStart, 1);
68
+ w.splice(insertBefore > rangeStart ? insertBefore - 1 : insertBefore, 0, t);
69
+ }
70
+ for (let i = 0; i < n; i++) if (w[i] !== i) throw new Error("reorder: internal error, planned moves do not reach target");
71
+ return moves;
72
+ }
@@ -0,0 +1,404 @@
1
+ /**
2
+ * The five sync phases (pull → match → plan → apply → report). Each phase is idempotent and
3
+ * re-runnable; see DESIGN.md §2.
4
+ */
5
+ import type { Config } from "../config.ts";
6
+ import { Matcher } from "../match/matcher.ts";
7
+ import { SearchBudgetExhaustedError } from "../match/search.ts";
8
+ import type { MatchRow } from "../match/types.ts";
9
+ import { LocalSource } from "../sources/local/source.ts";
10
+ import { NeteaseAuthError, NeteaseClient } from "../sources/netease/client.ts";
11
+ import { NeteaseSource } from "../sources/netease/source.ts";
12
+ import type { SourceKind, SourceTrack } from "../sources/types.ts";
13
+ import type { SpotifyApi } from "../spotify/api.ts";
14
+ import { SpotifyHttpError, SpotifyRateLimitedError } from "../spotify/client.ts";
15
+ import { MANAGED_DESCRIPTION } from "../spotify/types.ts";
16
+ import { parseLocalUri } from "../spotify/localUri.ts";
17
+ import type { LocalExportRow, Repo, SourcePlaylistRow } from "../state/repo.ts";
18
+ import { sanitizeFilename } from "../util/fs.ts";
19
+ import { log } from "../util/log.ts";
20
+ import { mapLimit } from "../util/retry.ts";
21
+ import { applyExports, applyPlan, type ApplySummary } from "./apply.ts";
22
+ import { computePlaylistPlan, resolveRemoteLocalUri, type DesiredItem, type ExportPlan, type Plan, type PlaylistPlan, type RemoteItem } from "./plan.ts";
23
+
24
+ export interface SyncOptions {
25
+ dryRun: boolean;
26
+ prune: boolean;
27
+ /** pull/plan only this source kind */
28
+ source?: SourceKind;
29
+ /** plan/apply only source playlists whose name equals this (pull is never filtered: it would prune the others) */
30
+ playlist?: string;
31
+ skipMatch: boolean;
32
+ onProgress?: (phase: string, done: number, total: number) => void;
33
+ }
34
+
35
+ export interface SyncDeps {
36
+ cfg: Config;
37
+ repo: Repo;
38
+ api: SpotifyApi;
39
+ }
40
+
41
+ export interface AwaitingEntry {
42
+ playlist: string;
43
+ uris: string[];
44
+ }
45
+
46
+ export interface MatchPhaseSummary {
47
+ searched: number;
48
+ matched: number;
49
+ review: number;
50
+ local: number;
51
+ /** tracks still due after this run (budget / rate limit stopped the phase early) */
52
+ remaining: number;
53
+ /** epoch ms until which Spotify refuses searches (429 with a long Retry-After), or null */
54
+ blockedUntil: number | null;
55
+ budgetExhausted: boolean;
56
+ }
57
+
58
+ export interface SyncSummary {
59
+ pulled: Record<SourceKind, { playlists: number; tracks: number }>;
60
+ matched: MatchPhaseSummary;
61
+ plan: { creates: number; adds: number; prune: number; moves: number; likes: number; unlikes: number; exports: number; reviewPending: number };
62
+ apply: ApplySummary | null;
63
+ awaiting: AwaitingEntry[];
64
+ matchCounts: Record<string, number>;
65
+ }
66
+
67
+ const META_SEARCH_BLOCKED_UNTIL = "spotify_search_blocked_until";
68
+
69
+ const DAY_MS = 86_400_000;
70
+
71
+ export interface SyncResult {
72
+ summary: SyncSummary;
73
+ plan: Plan;
74
+ }
75
+
76
+ export async function runSync(deps: SyncDeps, opts: SyncOptions): Promise<SyncResult> {
77
+ const { cfg, repo, api } = deps;
78
+ const now = Date.now();
79
+ const runId = repo.startRun(now);
80
+ try {
81
+ const pulled = await pull(deps, opts, now);
82
+ const matched = opts.skipMatch
83
+ ? { searched: 0, matched: 0, review: 0, local: 0, remaining: 0, blockedUntil: null, budgetExhausted: false }
84
+ : await match(deps, opts, now);
85
+ // Export before planning: it depends only on match state, and the plan must see fresh export records
86
+ // to list them as awaiting paste in the same run.
87
+ const exports = planExportsOnly(repo, cfg, opts);
88
+ const exported = opts.dryRun ? null : await applyExports(exports, { repo, cfg, now });
89
+ const plan = await buildPlan(deps, opts, exports);
90
+ const apply = opts.dryRun ? null : await applyPlan(plan, { api, repo, cfg, prune: opts.prune, now });
91
+ if (apply && exported) {
92
+ apply.exported = exported.exported;
93
+ apply.exportErrors = exported.errors;
94
+ }
95
+ const summary: SyncSummary = {
96
+ pulled,
97
+ matched,
98
+ plan: {
99
+ creates: plan.playlists.filter((p) => p.create).length,
100
+ adds: plan.playlists.reduce((n, p) => n + p.adds.length, 0),
101
+ prune: plan.playlists.reduce((n, p) => n + p.prune.length, 0),
102
+ moves: plan.playlists.reduce((n, p) => n + p.moves.length, 0),
103
+ likes: plan.likes.add.length,
104
+ unlikes: plan.likes.prune.length,
105
+ exports: plan.exports.length,
106
+ reviewPending: plan.reviewPending,
107
+ },
108
+ apply,
109
+ awaiting: awaitingEntries(plan, repo),
110
+ matchCounts: repo.countMatches(),
111
+ };
112
+ repo.finishRun(runId, true, summary, Date.now());
113
+ return { summary, plan };
114
+ } catch (e) {
115
+ repo.finishRun(runId, false, { error: e instanceof Error ? e.message : String(e) }, Date.now());
116
+ throw e;
117
+ }
118
+ }
119
+
120
+ /** Canonical keys referenced by the playlists this run mirrors; match and export never spend effort outside it. */
121
+ export function selectedKeys(repo: Repo, cfg: Config, opts: Pick<SyncOptions, "source" | "playlist">): Set<string> {
122
+ const keys = new Set<string>();
123
+ for (const sp of selectedSourcePlaylists(repo, cfg, opts)) for (const t of repo.playlistTracks(sp.id)) keys.add(t.canonicalKey);
124
+ return keys;
125
+ }
126
+
127
+ /** Export plan for every unmatched track that has a local file and belongs to a mirrored playlist (no network). `force` re-exports existing ones. */
128
+ export function planExportsOnly(repo: Repo, cfg: Config, opts: Pick<SyncOptions, "source" | "playlist"> = {}, force = false): ExportPlan[] {
129
+ const keys = selectedKeys(repo, cfg, opts);
130
+ return planExports(repo, repo.listMatches("local").map((m) => m.canonicalKey).filter((k) => keys.has(k)), repo.listExports(), force);
131
+ }
132
+
133
+ // ---- pull -------------------------------------------------------------------
134
+
135
+ async function pull(deps: SyncDeps, opts: SyncOptions, now: number): Promise<SyncSummary["pulled"]> {
136
+ const { cfg, repo } = deps;
137
+ const out: SyncSummary["pulled"] = { netease: { playlists: 0, tracks: 0 }, local: { playlists: 0, tracks: 0 } };
138
+ const wanted = (k: SourceKind) => opts.source === undefined || opts.source === k;
139
+
140
+ if (cfg.netease.enabled && wanted("netease")) {
141
+ const auth = repo.getAuth<{ cookie: string }>("netease");
142
+ if (!auth) throw new NeteaseAuthError("netease not logged in: run `spotifify auth netease`");
143
+ const byExternal = new Map(repo.listSourcePlaylists("netease").map((p) => [p.externalId, p] as const));
144
+ const source = new NeteaseSource(new NeteaseClient(auth.cookie), cfg.netease, {
145
+ playlistUpdatedAt: (id) => byExternal.get(id)?.sourceUpdatedAt ?? undefined,
146
+ playlistTracks: (id) => {
147
+ const p = byExternal.get(id);
148
+ return p ? repo.playlistTracks(p.id) : [];
149
+ },
150
+ knownSongs: (ids) => repo.sourceTracksByExternalIds("netease", ids) as Map<string, SourceTrack>,
151
+ });
152
+ const { playlists } = await source.pull();
153
+ repo.savePull("netease", playlists, now);
154
+ out.netease = { playlists: playlists.length, tracks: playlists.reduce((n, p) => n + p.tracks.length, 0) };
155
+ log.info("pulled netease", out.netease);
156
+ }
157
+
158
+ if (cfg.local.enabled && wanted("local")) {
159
+ const source = new LocalSource(cfg.local, repo.localTracksByPath(), (done, total) => opts.onProgress?.("scan", done, total));
160
+ const { playlists } = await source.pull();
161
+ repo.savePull("local", playlists, now);
162
+ out.local = { playlists: playlists.length, tracks: playlists.reduce((n, p) => n + p.tracks.length, 0) };
163
+ log.info("pulled local", out.local);
164
+ }
165
+ return out;
166
+ }
167
+
168
+ // ---- match ------------------------------------------------------------------
169
+
170
+ async function match(deps: SyncDeps, opts: SyncOptions, now: number): Promise<MatchPhaseSummary> {
171
+ const { cfg, repo, api } = deps;
172
+ const wanted = selectedKeys(repo, cfg, opts);
173
+ const due = repo.matchesDue(now, cfg.matching.retry_unmatched_after_days * DAY_MS).filter((m) => wanted.has(m.canonicalKey));
174
+ const result: MatchPhaseSummary = { searched: 0, matched: 0, review: 0, local: 0, remaining: due.length, blockedUntil: null, budgetExhausted: false };
175
+
176
+ const storedBlock = Number(repo.metaGet(META_SEARCH_BLOCKED_UNTIL) ?? 0);
177
+ if (storedBlock > now) {
178
+ result.blockedUntil = storedBlock;
179
+ log.warn("Spotify search still rate-limited; skipping match phase", { until: new Date(storedBlock).toISOString(), due: due.length });
180
+ return result;
181
+ }
182
+ repo.metaSet(META_SEARCH_BLOCKED_UNTIL, null);
183
+ if (due.length === 0) return result;
184
+
185
+ const tracks = repo.representativeTracks(due.map((m) => m.canonicalKey));
186
+ const matcher = new Matcher({ api, repo, cfg, market: await api.resolveMarket(cfg.spotify.market) });
187
+ let done = 0;
188
+ let stop = false;
189
+ await mapLimit(due, cfg.matching.search_concurrency, async (existing) => {
190
+ if (stop) return;
191
+ const track = tracks.get(existing.canonicalKey);
192
+ if (!track) return;
193
+ let row: MatchRow;
194
+ try {
195
+ row = await matcher.matchOne(track, existing, now);
196
+ } catch (e) {
197
+ if (e instanceof SpotifyRateLimitedError) {
198
+ if (!stop) {
199
+ stop = true;
200
+ result.blockedUntil = e.untilMs;
201
+ repo.metaSet(META_SEARCH_BLOCKED_UNTIL, String(e.untilMs));
202
+ log.warn("Spotify search quota exhausted; stopping match phase", { until: new Date(e.untilMs).toISOString() });
203
+ }
204
+ return;
205
+ }
206
+ if (e instanceof SearchBudgetExhaustedError) {
207
+ if (!stop) {
208
+ stop = true;
209
+ result.budgetExhausted = true;
210
+ log.warn("search budget for this run used up; stopping match phase", { budget: e.budget });
211
+ }
212
+ return;
213
+ }
214
+ throw e;
215
+ }
216
+ repo.upsertMatch(row);
217
+ result.searched++;
218
+ result.remaining--;
219
+ if (row.status === "matched") result.matched++;
220
+ else if (row.status === "review") result.review++;
221
+ else if (row.status === "local") result.local++;
222
+ done++;
223
+ opts.onProgress?.("match", done, due.length);
224
+ if (done % 50 === 0) log.info("matching", { done, total: due.length, requests: matcher.searchesUsed });
225
+ });
226
+ log.info("matched", { ...result, requests: matcher.searchesUsed });
227
+ return result;
228
+ }
229
+
230
+ /**
231
+ * Source playlists this run acts on: `--source` / `--playlist` filters, and the local library only when
232
+ * `local.mirror_playlist` is on (otherwise local files serve purely as audio for tracks of other playlists).
233
+ */
234
+ export function selectedSourcePlaylists(repo: Repo, cfg: Config, opts: Pick<SyncOptions, "source" | "playlist">): SourcePlaylistRow[] {
235
+ return repo
236
+ .listSourcePlaylists(opts.source)
237
+ .filter((p) => (opts.playlist === undefined || p.name === opts.playlist) && (p.kind !== "local" || cfg.local.mirror_playlist));
238
+ }
239
+
240
+ // ---- plan -------------------------------------------------------------------
241
+
242
+ export async function buildPlan(deps: SyncDeps, opts: Pick<SyncOptions, "prune" | "source" | "playlist">, exportPlans: ExportPlan[]): Promise<Plan> {
243
+ const { cfg, repo, api } = deps;
244
+ const me = await api.me();
245
+ const remotePlaylists = (await api.listMyPlaylists()).filter((p) => p.owner.id === me.id);
246
+ const exports = repo.listExports();
247
+
248
+ const sourcePlaylists = selectedSourcePlaylists(repo, cfg, opts);
249
+
250
+ const playlists: PlaylistPlan[] = [];
251
+ const likeDesired = new Set<string>();
252
+
253
+ for (const sp of sourcePlaylists) {
254
+ const targetName = cfg.sync.playlist_prefix + sp.name;
255
+ const tracks = repo.playlistTracks(sp.id);
256
+ const matches = repo.matchesForKeys(tracks.map((t) => t.canonicalKey));
257
+ const exportByKey = new Map(exports.map((e) => [e.canonicalKey, e] as const));
258
+ const likeThis = sp.kind === "netease" ? cfg.netease.like_matched : cfg.local.like_matched;
259
+
260
+ const desired: DesiredItem[] = [];
261
+ const seen = new Set<string>();
262
+ for (const t of tracks) {
263
+ const m = matches.get(t.canonicalKey);
264
+ if (!m) continue;
265
+ let item: DesiredItem | null = null;
266
+ if (m.status === "matched" && m.spotifyUri && m.spotifyId) {
267
+ item = { uri: m.spotifyUri, kind: "spotify", canonicalKey: t.canonicalKey };
268
+ if (likeThis) likeDesired.add(m.spotifyId);
269
+ } else if (m.status === "local") {
270
+ const e = exportByKey.get(t.canonicalKey);
271
+ if (e) item = { uri: e.localUri, kind: "local", canonicalKey: t.canonicalKey };
272
+ }
273
+ if (item && !seen.has(item.uri)) {
274
+ seen.add(item.uri);
275
+ desired.push(item);
276
+ }
277
+ }
278
+
279
+ const remote = await resolveRemotePlaylist(sp, targetName, remotePlaylists, deps);
280
+ let remoteItems: RemoteItem[] = [];
281
+ if (remote) {
282
+ remoteItems = (await api.getPlaylistItems(remote.id)).map((it) => {
283
+ if (!it.item) return { uri: "", isLocal: false, stale: false };
284
+ 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 };
286
+ });
287
+ }
288
+
289
+ playlists.push(
290
+ computePlaylistPlan({
291
+ sourcePlaylistId: sp.id,
292
+ sourceName: sp.name,
293
+ targetName,
294
+ spotify: remote,
295
+ desired,
296
+ remote: remoteItems,
297
+ managed: remote ? repo.managedUris(remote.id) : new Set<string>(),
298
+ pruneEnabled: opts.prune,
299
+ }),
300
+ );
301
+ }
302
+
303
+ // Likes: everything desired that is not already saved; prune tool-liked ids no longer desired.
304
+ const likeIds = [...likeDesired];
305
+ const saved = await savedFlags(api, likeIds);
306
+ const likes = {
307
+ add: likeIds.filter((_, i) => !saved[i]),
308
+ prune: [...repo.likedIds()].filter((id) => !likeDesired.has(id)),
309
+ };
310
+
311
+ return { playlists, likes, exports: exportPlans, reviewPending: repo.countMatches().review };
312
+ }
313
+
314
+ /** Which of `ids` are already liked. `/me/tracks/contains` is 403 for some development-mode apps; then list the library instead. */
315
+ async function savedFlags(api: SpotifyApi, ids: string[]): Promise<boolean[]> {
316
+ if (ids.length === 0) return [];
317
+ try {
318
+ return await api.checkSaved(ids);
319
+ } catch (e) {
320
+ if (!(e instanceof SpotifyHttpError) || e.status !== 403) throw e;
321
+ log.warn("/me/tracks/contains is forbidden for this app; listing the whole library instead");
322
+ const saved = await api.listSavedTrackIds();
323
+ return ids.map((id) => saved.has(id));
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Find the remote playlist for a source playlist: the stored mapping if it still exists, else a
329
+ * remote playlist with the target name carrying our description (adoption after state loss), else null.
330
+ */
331
+ async function resolveRemotePlaylist(
332
+ sp: SourcePlaylistRow,
333
+ targetName: string,
334
+ remotePlaylists: Array<{ id: string; name: string; description: string | null }>,
335
+ deps: SyncDeps,
336
+ ): Promise<{ id: string; name: string } | null> {
337
+ const mapping = deps.repo.getSpotifyPlaylist(sp.id);
338
+ if (mapping) {
339
+ const live = remotePlaylists.find((p) => p.id === mapping.spotifyId);
340
+ if (live) return { id: live.id, name: live.name };
341
+ log.warn("mapped spotify playlist no longer exists; will recreate", { source: sp.name, spotifyId: mapping.spotifyId });
342
+ deps.repo.deleteSpotifyPlaylist(sp.id);
343
+ }
344
+ const adopt = remotePlaylists.find((p) => p.name === targetName && p.description === MANAGED_DESCRIPTION);
345
+ if (adopt) {
346
+ log.info("adopting existing spotify playlist", { name: adopt.name, id: adopt.id });
347
+ deps.repo.setSpotifyPlaylist({ sourcePlaylistId: sp.id, spotifyId: adopt.id, name: adopt.name, snapshotId: null, lastSyncedAt: null });
348
+ return { id: adopt.id, name: adopt.name };
349
+ }
350
+ return null;
351
+ }
352
+
353
+ function planExports(repo: Repo, localKeys: string[], exports: LocalExportRow[], force = false): ExportPlan[] {
354
+ const usedNames = new Set(exports.map((e) => e.exportPath.replace(/\.[^.\\/]+$/, "").replace(/^.*[\\/]/, "").toLowerCase()));
355
+ const exportByKey = new Map(exports.map((e) => [e.canonicalKey, e] as const));
356
+ const tracks = repo.representativeTracks(localKeys);
357
+ const plans: ExportPlan[] = [];
358
+ for (const key of localKeys) {
359
+ const t = tracks.get(key);
360
+ if (!t?.file) continue;
361
+ const existing = exportByKey.get(key);
362
+ // An export is current when the source is unchanged and its recorded identity is complete
363
+ // (rows written before the duration segment was known cannot match anything the client indexes).
364
+ if (!force && existing && existing.contentHash === t.file.contentHash && parseLocalUri(existing.localUri)?.durationSec !== null) continue;
365
+ let base = sanitizeFilename(`${t.artists.join(", ") || "Unknown Artist"} - ${t.title}`);
366
+ if (!existing) {
367
+ for (let n = 2; usedNames.has(base.toLowerCase()); n++) base = sanitizeFilename(`${t.artists.join(", ") || "Unknown Artist"} - ${t.title} (${n})`);
368
+ } else {
369
+ base = existing.exportPath.replace(/\.[^.\\/]+$/, "").replace(/^.*[\\/]/, "");
370
+ }
371
+ usedNames.add(base.toLowerCase());
372
+ plans.push({ canonicalKey: key, sourcePath: t.file.path, baseName: base, decryptNcm: t.file.path.toLowerCase().endsWith(".ncm") });
373
+ }
374
+ return plans;
375
+ }
376
+
377
+ function awaitingEntries(plan: Plan, repo: Repo): AwaitingEntry[] {
378
+ const out: AwaitingEntry[] = [];
379
+ for (const p of plan.playlists) {
380
+ if (p.awaiting.length === 0) continue;
381
+ const mapping = repo.getSpotifyPlaylist(p.sourcePlaylistId);
382
+ out.push({ playlist: mapping?.name ?? p.create?.name ?? p.sourceName, uris: p.awaiting.map((a) => a.uri) });
383
+ }
384
+ return out;
385
+ }
386
+
387
+ // ---- report -----------------------------------------------------------------
388
+
389
+ export function formatPlan(plan: Plan, prune: boolean): string {
390
+ const lines: string[] = [];
391
+ for (const p of plan.playlists) {
392
+ const head = p.create ? `+ create "${p.create.name}"` : `~ "${p.rename ? `${p.rename.from}" → "${p.rename.to}` : p.sourceName}"`;
393
+ lines.push(`${head}: add ${p.adds.length}, move ${p.moves.length}, awaiting paste ${p.awaiting.length}, foreign ${p.foreign.length}, prune ${p.prune.length}${prune ? "" : " (report only)"}`);
394
+ for (const u of p.adds.slice(0, 20)) lines.push(` + ${u}`);
395
+ if (p.adds.length > 20) lines.push(` + … ${p.adds.length - 20} more`);
396
+ for (const x of p.prune) lines.push(` ${prune ? "-" : "?"} ${x.uri}`);
397
+ }
398
+ lines.push(`likes: +${plan.likes.add.length}, prune ${plan.likes.prune.length}${prune ? "" : " (report only)"}`);
399
+ lines.push(`exports: ${plan.exports.length}`);
400
+ for (const e of plan.exports.slice(0, 20)) lines.push(` → ${e.baseName} (${e.sourcePath})`);
401
+ if (plan.exports.length > 20) lines.push(` → … ${plan.exports.length - 20} more`);
402
+ lines.push(`review pending: ${plan.reviewPending}`);
403
+ return lines.join("\n");
404
+ }