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 +13 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/package.json +1 -1
- package/src/cli.ts +40 -5
- package/src/spotify/api.ts +6 -0
- package/src/spotify/localIndex.ts +143 -0
- package/src/state/repo.ts +4 -0
- package/src/sync/apply.ts +10 -5
- package/src/sync/export.ts +31 -10
- package/src/sync/plan.ts +19 -10
- package/src/sync/run.ts +46 -7
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]` | 拉取 → 匹配 → 导出 → 计划 → 执行 →
|
|
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
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 {
|
|
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.
|
|
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(", ")}`);
|
package/src/spotify/api.ts
CHANGED
|
@@ -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
|
-
|
|
122
|
-
|
|
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,
|
|
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
|
-
|
|
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);
|
package/src/sync/export.ts
CHANGED
|
@@ -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(
|
|
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}
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
74
|
-
* (different duration segment, tags from an earlier export).
|
|
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
|
-
|
|
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
|
-
|
|
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) →
|
|
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,
|
|
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
|
-
|
|
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),
|
|
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.
|
|
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
|
-
|
|
283
|
-
|
|
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,
|
|
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
|
}
|