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 +10 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/match/matcher.ts +24 -5
- package/src/sources/local/source.ts +14 -5
- package/src/sources/netease/client.ts +15 -15
- package/src/sources/netease/source.ts +8 -1
- package/src/spotify/api.ts +10 -11
- package/src/spotify/auth.ts +5 -9
- package/src/spotify/client.ts +4 -2
- package/src/state/repo.ts +23 -0
- package/src/sync/apply.ts +27 -5
- package/src/sync/plan.ts +13 -2
- package/src/sync/run.ts +81 -40
- package/src/util/fs.ts +4 -1
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`
|
|
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
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.
|
|
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")
|
package/src/match/matcher.ts
CHANGED
|
@@ -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
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
.
|
|
42
|
-
z.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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() }))
|
|
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
|
|
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 });
|
package/src/spotify/api.ts
CHANGED
|
@@ -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
|
|
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).
|
|
96
|
-
*
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
127
|
-
|
|
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. */
|
package/src/spotify/auth.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
114
|
-
export async function refreshTokens(clientId: string,
|
|
115
|
-
return postToken({ client_id: clientId, grant_type: "refresh_token", refresh_token:
|
|
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
|
}
|
package/src/spotify/client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
104
|
-
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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
|
|
141
|
-
*
|
|
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
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
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
|
|
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
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
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
|
|
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) => !
|
|
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
|
-
|
|
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
|
-
|
|
438
|
+
const raw = `${t.artists.join(", ") || "Unknown Artist"} - ${t.title}`;
|
|
439
|
+
let base = sanitizeFilename(raw);
|
|
403
440
|
if (!existing) {
|
|
404
|
-
|
|
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 =
|
|
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, " ")
|