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,480 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { Candidate, DecidedBy, MatchRow, MatchStatus } from "../match/types.ts";
3
+ import { canonicalKey, type SourceKind, type SourcePlaylist, type SourceTrack } from "../sources/types.ts";
4
+
5
+ export interface SourcePlaylistRow {
6
+ id: number;
7
+ kind: SourceKind;
8
+ externalId: string;
9
+ name: string;
10
+ sourceUpdatedAt: number | null;
11
+ lastSeenAt: number;
12
+ }
13
+
14
+ export interface SourceTrackRow extends SourceTrack {
15
+ id: number;
16
+ canonicalKey: string;
17
+ lastSeenAt: number;
18
+ }
19
+
20
+ export interface SpotifyPlaylistRow {
21
+ sourcePlaylistId: number;
22
+ spotifyId: string;
23
+ name: string;
24
+ snapshotId: string | null;
25
+ lastSyncedAt: number | null;
26
+ }
27
+
28
+ export interface LocalExportRow {
29
+ canonicalKey: string;
30
+ exportPath: string;
31
+ localUri: string;
32
+ contentHash: string;
33
+ exportedAt: number;
34
+ }
35
+
36
+ export interface FingerprintRow {
37
+ contentHash: string;
38
+ fp: string;
39
+ durationS: number;
40
+ acoustid: unknown;
41
+ isrcs: string[];
42
+ fetchedAt: number;
43
+ }
44
+
45
+ export interface PulledPlaylist {
46
+ playlist: SourcePlaylist;
47
+ tracks: SourceTrack[];
48
+ }
49
+
50
+ interface DbSourceTrack {
51
+ id: number;
52
+ kind: SourceKind;
53
+ external_id: string;
54
+ canonical_key: string;
55
+ title: string;
56
+ artists: string;
57
+ album: string | null;
58
+ duration_ms: number | null;
59
+ isrc: string | null;
60
+ netease_id: number | null;
61
+ aliases: string;
62
+ file_path: string | null;
63
+ content_hash: string | null;
64
+ file_size: number | null;
65
+ file_mtime: number | null;
66
+ last_seen_at: number;
67
+ }
68
+
69
+ interface DbMatch {
70
+ canonical_key: string;
71
+ status: MatchStatus;
72
+ spotify_id: string | null;
73
+ spotify_uri: string | null;
74
+ score: number | null;
75
+ decided_by: DecidedBy | null;
76
+ candidates: string;
77
+ decided_at: number | null;
78
+ last_search_at: number | null;
79
+ search_count: number;
80
+ }
81
+
82
+ function toTrackRow(r: DbSourceTrack): SourceTrackRow {
83
+ return {
84
+ id: r.id,
85
+ kind: r.kind,
86
+ externalId: r.external_id,
87
+ canonicalKey: r.canonical_key,
88
+ title: r.title,
89
+ artists: JSON.parse(r.artists) as string[],
90
+ album: r.album ?? undefined,
91
+ durationMs: r.duration_ms ?? undefined,
92
+ isrc: r.isrc ?? undefined,
93
+ neteaseId: r.netease_id ?? undefined,
94
+ aliases: JSON.parse(r.aliases) as string[],
95
+ file:
96
+ r.file_path !== null && r.content_hash !== null
97
+ ? { path: r.file_path, contentHash: r.content_hash, size: r.file_size ?? 0, mtimeMs: r.file_mtime ?? 0 }
98
+ : undefined,
99
+ lastSeenAt: r.last_seen_at,
100
+ };
101
+ }
102
+
103
+ function toMatchRow(r: DbMatch): MatchRow {
104
+ return {
105
+ canonicalKey: r.canonical_key,
106
+ status: r.status,
107
+ spotifyId: r.spotify_id,
108
+ spotifyUri: r.spotify_uri,
109
+ score: r.score,
110
+ decidedBy: r.decided_by,
111
+ candidates: JSON.parse(r.candidates) as Candidate[],
112
+ decidedAt: r.decided_at,
113
+ lastSearchAt: r.last_search_at,
114
+ searchCount: r.search_count,
115
+ };
116
+ }
117
+
118
+ const TRACK_COLS =
119
+ "id, kind, external_id, canonical_key, title, artists, album, duration_ms, isrc, netease_id, aliases, file_path, content_hash, file_size, file_mtime, last_seen_at";
120
+
121
+ export class Repo {
122
+ constructor(readonly db: Database) {}
123
+
124
+ // ---- meta -------------------------------------------------------------
125
+
126
+ metaGet(key: string): string | null {
127
+ return this.db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?").get(key)?.value ?? null;
128
+ }
129
+
130
+ metaSet(key: string, value: string | null): void {
131
+ if (value === null) this.db.run("DELETE FROM meta WHERE key = ?", [key]);
132
+ else this.db.run("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", [key, value]);
133
+ }
134
+
135
+ // ---- runs -------------------------------------------------------------
136
+
137
+ startRun(now: number): number {
138
+ return this.db.query<{ id: number }, [number]>("INSERT INTO run (started_at) VALUES (?) RETURNING id").get(now)!.id;
139
+ }
140
+
141
+ finishRun(id: number, ok: boolean, summary: unknown, now: number): void {
142
+ this.db.run("UPDATE run SET finished_at = ?, ok = ?, summary = ? WHERE id = ?", [now, ok ? 1 : 0, JSON.stringify(summary), id]);
143
+ }
144
+
145
+ lastRun(): { id: number; startedAt: number; finishedAt: number | null; ok: boolean | null; summary: unknown } | null {
146
+ const r = this.db
147
+ .query<{ id: number; started_at: number; finished_at: number | null; ok: number | null; summary: string | null }, []>(
148
+ "SELECT id, started_at, finished_at, ok, summary FROM run ORDER BY id DESC LIMIT 1",
149
+ )
150
+ .get();
151
+ if (!r) return null;
152
+ return { id: r.id, startedAt: r.started_at, finishedAt: r.finished_at, ok: r.ok === null ? null : r.ok === 1, summary: r.summary ? JSON.parse(r.summary) : null };
153
+ }
154
+
155
+ // ---- auth -------------------------------------------------------------
156
+
157
+ getAuth<T>(provider: "spotify" | "netease"): T | null {
158
+ const r = this.db.query<{ payload: string }, [string]>("SELECT payload FROM auth WHERE provider = ?").get(provider);
159
+ return r ? (JSON.parse(r.payload) as T) : null;
160
+ }
161
+
162
+ setAuth(provider: "spotify" | "netease", payload: unknown, now: number): void {
163
+ this.db.run(
164
+ "INSERT INTO auth (provider, payload, updated_at) VALUES (?, ?, ?) ON CONFLICT (provider) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at",
165
+ [provider, JSON.stringify(payload), now],
166
+ );
167
+ }
168
+
169
+ deleteAuth(provider: "spotify" | "netease"): void {
170
+ this.db.run("DELETE FROM auth WHERE provider = ?", [provider]);
171
+ }
172
+
173
+ // ---- sources ----------------------------------------------------------
174
+
175
+ /**
176
+ * Persist one source's full pull: upsert playlists/tracks, rewrite playlist membership,
177
+ * drop rows of this kind not seen in this pull, and register pending match rows.
178
+ */
179
+ savePull(kind: SourceKind, pulled: PulledPlaylist[], now: number): void {
180
+ const upsertPlaylist = this.db.query<{ id: number }, [SourceKind, string, string, number | null, number]>(
181
+ `INSERT INTO source_playlist (kind, external_id, name, source_updated_at, last_seen_at) VALUES (?, ?, ?, ?, ?)
182
+ ON CONFLICT (kind, external_id) DO UPDATE SET name = excluded.name, source_updated_at = excluded.source_updated_at, last_seen_at = excluded.last_seen_at
183
+ RETURNING id`,
184
+ );
185
+ const upsertTrack = this.db.query<{ id: number }, (string | number | null)[]>(
186
+ `INSERT INTO source_track (kind, external_id, canonical_key, title, artists, album, duration_ms, isrc, netease_id, aliases,
187
+ file_path, content_hash, file_size, file_mtime, first_seen_at, last_seen_at)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
189
+ ON CONFLICT (kind, external_id) DO UPDATE SET
190
+ canonical_key = excluded.canonical_key, title = excluded.title, artists = excluded.artists, album = excluded.album,
191
+ duration_ms = excluded.duration_ms, isrc = excluded.isrc, netease_id = excluded.netease_id, aliases = excluded.aliases,
192
+ file_path = excluded.file_path, content_hash = excluded.content_hash, file_size = excluded.file_size,
193
+ file_mtime = excluded.file_mtime, last_seen_at = excluded.last_seen_at
194
+ RETURNING id`,
195
+ );
196
+ const clearMembers = this.db.query("DELETE FROM playlist_track WHERE source_playlist_id = ?");
197
+ const addMember = this.db.query("INSERT INTO playlist_track (source_playlist_id, source_track_id, position) VALUES (?, ?, ?)");
198
+ const ensureMatch = this.db.query("INSERT OR IGNORE INTO match (canonical_key, status) VALUES (?, 'pending')");
199
+
200
+ this.db.transaction(() => {
201
+ for (const { playlist, tracks } of pulled) {
202
+ const pid = upsertPlaylist.get(kind, playlist.externalId, playlist.name, playlist.sourceUpdatedAt ?? null, now)!.id;
203
+ clearMembers.run(pid);
204
+ const seen = new Set<number>();
205
+ let position = 0;
206
+ for (const t of tracks) {
207
+ const key = canonicalKey(t);
208
+ const tid = upsertTrack.get(
209
+ kind, t.externalId, key, t.title, JSON.stringify(t.artists), t.album ?? null, t.durationMs ?? null,
210
+ t.isrc ?? null, t.neteaseId ?? null, JSON.stringify(t.aliases),
211
+ t.file?.path ?? null, t.file?.contentHash ?? null, t.file?.size ?? null, t.file?.mtimeMs ?? null, now, now,
212
+ )!.id;
213
+ if (seen.has(tid)) continue;
214
+ seen.add(tid);
215
+ addMember.run(pid, tid, position++);
216
+ ensureMatch.run(key);
217
+ }
218
+ }
219
+ this.db.run("DELETE FROM source_playlist WHERE kind = ? AND last_seen_at < ?", [kind, now]);
220
+ this.db.run("DELETE FROM source_track WHERE kind = ? AND last_seen_at < ?", [kind, now]);
221
+ })();
222
+ }
223
+
224
+ listSourcePlaylists(kind?: SourceKind): SourcePlaylistRow[] {
225
+ const sql = "SELECT id, kind, external_id, name, source_updated_at, last_seen_at FROM source_playlist" + (kind ? " WHERE kind = ?" : "") + " ORDER BY kind, name";
226
+ const rows = kind ? this.db.query<DbPlaylist, [SourceKind]>(sql).all(kind) : this.db.query<DbPlaylist, []>(sql).all();
227
+ return rows.map((r) => ({ id: r.id, kind: r.kind, externalId: r.external_id, name: r.name, sourceUpdatedAt: r.source_updated_at, lastSeenAt: r.last_seen_at }));
228
+ }
229
+
230
+ /** Ordered tracks of one source playlist. */
231
+ playlistTracks(sourcePlaylistId: number): SourceTrackRow[] {
232
+ return this.db
233
+ .query<DbSourceTrack, [number]>(
234
+ `SELECT ${TRACK_COLS.replace(/(^|, )/g, "$1t.")} FROM playlist_track pt JOIN source_track t ON t.id = pt.source_track_id
235
+ WHERE pt.source_playlist_id = ? ORDER BY pt.position`,
236
+ )
237
+ .all(sourcePlaylistId)
238
+ .map(toTrackRow);
239
+ }
240
+
241
+ sourceTracksByExternalIds(kind: SourceKind, externalIds: string[]): Map<string, SourceTrackRow> {
242
+ const out = new Map<string, SourceTrackRow>();
243
+ const q = this.db.query<DbSourceTrack, [SourceKind, string]>(`SELECT ${TRACK_COLS} FROM source_track WHERE kind = ? AND external_id = ?`);
244
+ for (const id of externalIds) {
245
+ const r = q.get(kind, id);
246
+ if (r) out.set(id, toTrackRow(r));
247
+ }
248
+ return out;
249
+ }
250
+
251
+ /** All local tracks keyed by absolute path (for change detection / tag cache). */
252
+ localTracksByPath(): Map<string, SourceTrackRow> {
253
+ const out = new Map<string, SourceTrackRow>();
254
+ for (const r of this.db.query<DbSourceTrack, []>(`SELECT ${TRACK_COLS} FROM source_track WHERE kind = 'local' AND file_path IS NOT NULL`).all()) {
255
+ out.set(r.file_path!, toTrackRow(r));
256
+ }
257
+ return out;
258
+ }
259
+
260
+ tracksByCanonicalKey(key: string): SourceTrackRow[] {
261
+ return this.db.query<DbSourceTrack, [string]>(`SELECT ${TRACK_COLS} FROM source_track WHERE canonical_key = ?`).all(key).map(toTrackRow);
262
+ }
263
+
264
+ /** Names of source playlists containing any track with this canonical key. */
265
+ playlistNamesForKey(key: string): string[] {
266
+ return this.db
267
+ .query<{ name: string }, [string]>(
268
+ `SELECT DISTINCT p.name FROM source_playlist p JOIN playlist_track pt ON pt.source_playlist_id = p.id
269
+ JOIN source_track t ON t.id = pt.source_track_id WHERE t.canonical_key = ? ORDER BY p.name`,
270
+ )
271
+ .all(key)
272
+ .map((r) => r.name);
273
+ }
274
+
275
+ /** One representative source track per canonical key (prefers rows with a local file). */
276
+ representativeTracks(keys: string[]): Map<string, SourceTrackRow> {
277
+ const out = new Map<string, SourceTrackRow>();
278
+ const q = this.db.query<DbSourceTrack, [string]>(
279
+ `SELECT ${TRACK_COLS} FROM source_track WHERE canonical_key = ? ORDER BY (file_path IS NULL), id LIMIT 1`,
280
+ );
281
+ for (const k of keys) {
282
+ const r = q.get(k);
283
+ if (r) out.set(k, toTrackRow(r));
284
+ }
285
+ return out;
286
+ }
287
+
288
+ // ---- match ------------------------------------------------------------
289
+
290
+ getMatch(key: string): MatchRow | null {
291
+ const r = this.db.query<DbMatch, [string]>("SELECT * FROM match WHERE canonical_key = ?").get(key);
292
+ return r ? toMatchRow(r) : null;
293
+ }
294
+
295
+ upsertMatch(m: MatchRow): void {
296
+ this.db.run(
297
+ `INSERT INTO match (canonical_key, status, spotify_id, spotify_uri, score, decided_by, candidates, decided_at, last_search_at, search_count)
298
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
299
+ ON CONFLICT (canonical_key) DO UPDATE SET status = excluded.status, spotify_id = excluded.spotify_id, spotify_uri = excluded.spotify_uri,
300
+ score = excluded.score, decided_by = excluded.decided_by, candidates = excluded.candidates, decided_at = excluded.decided_at,
301
+ last_search_at = excluded.last_search_at, search_count = excluded.search_count`,
302
+ [m.canonicalKey, m.status, m.spotifyId, m.spotifyUri, m.score, m.decidedBy, JSON.stringify(m.candidates), m.decidedAt, m.lastSearchAt, m.searchCount],
303
+ );
304
+ }
305
+
306
+ listMatches(status?: MatchStatus): MatchRow[] {
307
+ const sql = "SELECT * FROM match" + (status ? " WHERE status = ?" : "");
308
+ const rows = status ? this.db.query<DbMatch, [MatchStatus]>(sql).all(status) : this.db.query<DbMatch, []>(sql).all();
309
+ return rows.map(toMatchRow);
310
+ }
311
+
312
+ /** Match rows for canonical keys that are still referenced by some source track. */
313
+ matchesForKeys(keys: string[]): Map<string, MatchRow> {
314
+ const out = new Map<string, MatchRow>();
315
+ const q = this.db.query<DbMatch, [string]>("SELECT * FROM match WHERE canonical_key = ?");
316
+ for (const k of keys) {
317
+ const r = q.get(k);
318
+ if (r) out.set(k, toMatchRow(r));
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /** Keys needing a search: pending, or auto-decided `local` older than the retry window. Only keys still referenced by a source. */
324
+ matchesDue(now: number, retryAfterMs: number): MatchRow[] {
325
+ return this.db
326
+ .query<DbMatch, [number]>(
327
+ `SELECT m.* FROM match m WHERE EXISTS (SELECT 1 FROM source_track t WHERE t.canonical_key = m.canonical_key)
328
+ AND (m.status = 'pending' OR (m.status = 'local' AND m.decided_by = 'auto' AND COALESCE(m.last_search_at, 0) < ?))`,
329
+ )
330
+ .all(now - retryAfterMs)
331
+ .map(toMatchRow);
332
+ }
333
+
334
+ countMatches(): Record<MatchStatus, number> {
335
+ const counts: Record<MatchStatus, number> = { pending: 0, matched: 0, review: 0, local: 0, skipped: 0 };
336
+ for (const r of this.db
337
+ .query<{ status: MatchStatus; n: number }, []>(
338
+ "SELECT m.status, COUNT(*) AS n FROM match m WHERE EXISTS (SELECT 1 FROM source_track t WHERE t.canonical_key = m.canonical_key) GROUP BY m.status",
339
+ )
340
+ .all()) {
341
+ counts[r.status] = r.n;
342
+ }
343
+ return counts;
344
+ }
345
+
346
+ // ---- spotify playlists ------------------------------------------------
347
+
348
+ getSpotifyPlaylist(sourcePlaylistId: number): SpotifyPlaylistRow | null {
349
+ const r = this.db
350
+ .query<{ source_playlist_id: number; spotify_id: string; name: string; snapshot_id: string | null; last_synced_at: number | null }, [number]>(
351
+ "SELECT * FROM spotify_playlist WHERE source_playlist_id = ?",
352
+ )
353
+ .get(sourcePlaylistId);
354
+ return r ? { sourcePlaylistId: r.source_playlist_id, spotifyId: r.spotify_id, name: r.name, snapshotId: r.snapshot_id, lastSyncedAt: r.last_synced_at } : null;
355
+ }
356
+
357
+ setSpotifyPlaylist(row: SpotifyPlaylistRow): void {
358
+ this.db.run(
359
+ `INSERT INTO spotify_playlist (source_playlist_id, spotify_id, name, snapshot_id, last_synced_at) VALUES (?, ?, ?, ?, ?)
360
+ ON CONFLICT (source_playlist_id) DO UPDATE SET spotify_id = excluded.spotify_id, name = excluded.name, snapshot_id = excluded.snapshot_id, last_synced_at = excluded.last_synced_at`,
361
+ [row.sourcePlaylistId, row.spotifyId, row.name, row.snapshotId, row.lastSyncedAt],
362
+ );
363
+ }
364
+
365
+ deleteSpotifyPlaylist(sourcePlaylistId: number): void {
366
+ this.db.run("DELETE FROM spotify_playlist WHERE source_playlist_id = ?", [sourcePlaylistId]);
367
+ }
368
+
369
+ // ---- managed items / liked -------------------------------------------
370
+
371
+ managedUris(spotifyPlaylistId: string): Set<string> {
372
+ return new Set(this.db.query<{ uri: string }, [string]>("SELECT uri FROM managed_item WHERE spotify_playlist_id = ?").all(spotifyPlaylistId).map((r) => r.uri));
373
+ }
374
+
375
+ addManaged(spotifyPlaylistId: string, uris: string[], now: number): void {
376
+ const q = this.db.query("INSERT OR IGNORE INTO managed_item (spotify_playlist_id, uri, added_at) VALUES (?, ?, ?)");
377
+ this.db.transaction(() => {
378
+ for (const u of uris) q.run(spotifyPlaylistId, u, now);
379
+ })();
380
+ }
381
+
382
+ removeManaged(spotifyPlaylistId: string, uris: string[]): void {
383
+ const q = this.db.query("DELETE FROM managed_item WHERE spotify_playlist_id = ? AND uri = ?");
384
+ this.db.transaction(() => {
385
+ for (const u of uris) q.run(spotifyPlaylistId, u);
386
+ })();
387
+ }
388
+
389
+ likedIds(): Set<string> {
390
+ return new Set(this.db.query<{ spotify_id: string }, []>("SELECT spotify_id FROM liked").all().map((r) => r.spotify_id));
391
+ }
392
+
393
+ addLiked(ids: string[], now: number): void {
394
+ const q = this.db.query("INSERT OR IGNORE INTO liked (spotify_id, added_at) VALUES (?, ?)");
395
+ this.db.transaction(() => {
396
+ for (const id of ids) q.run(id, now);
397
+ })();
398
+ }
399
+
400
+ removeLiked(ids: string[]): void {
401
+ const q = this.db.query("DELETE FROM liked WHERE spotify_id = ?");
402
+ this.db.transaction(() => {
403
+ for (const id of ids) q.run(id);
404
+ })();
405
+ }
406
+
407
+ // ---- local export -----------------------------------------------------
408
+
409
+ getExport(key: string): LocalExportRow | null {
410
+ const r = this.db.query<DbExport, [string]>("SELECT * FROM local_export WHERE canonical_key = ?").get(key);
411
+ return r ? toExportRow(r) : null;
412
+ }
413
+
414
+ listExports(): LocalExportRow[] {
415
+ return this.db.query<DbExport, []>("SELECT * FROM local_export").all().map(toExportRow);
416
+ }
417
+
418
+ setExport(row: LocalExportRow): void {
419
+ this.db.run(
420
+ `INSERT INTO local_export (canonical_key, export_path, local_uri, content_hash, exported_at) VALUES (?, ?, ?, ?, ?)
421
+ ON CONFLICT (canonical_key) DO UPDATE SET export_path = excluded.export_path, local_uri = excluded.local_uri, content_hash = excluded.content_hash, exported_at = excluded.exported_at`,
422
+ [row.canonicalKey, row.exportPath, row.localUri, row.contentHash, row.exportedAt],
423
+ );
424
+ }
425
+
426
+ // ---- caches -----------------------------------------------------------
427
+
428
+ cacheGet<T>(key: string, now: number, ttlMs: number): T | null {
429
+ const r = this.db.query<{ response: string; fetched_at: number }, [string]>("SELECT response, fetched_at FROM search_cache WHERE key = ?").get(key);
430
+ if (!r || r.fetched_at < now - ttlMs) return null;
431
+ return JSON.parse(r.response) as T;
432
+ }
433
+
434
+ cacheSet(key: string, value: unknown, now: number): void {
435
+ this.db.run(
436
+ "INSERT INTO search_cache (key, response, fetched_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET response = excluded.response, fetched_at = excluded.fetched_at",
437
+ [key, JSON.stringify(value), now],
438
+ );
439
+ }
440
+
441
+ getFingerprint(contentHash: string): FingerprintRow | null {
442
+ const r = this.db
443
+ .query<{ content_hash: string; fp: string; duration_s: number; acoustid: string | null; isrcs: string; fetched_at: number }, [string]>(
444
+ "SELECT * FROM fingerprint WHERE content_hash = ?",
445
+ )
446
+ .get(contentHash);
447
+ return r
448
+ ? { contentHash: r.content_hash, fp: r.fp, durationS: r.duration_s, acoustid: r.acoustid ? JSON.parse(r.acoustid) : null, isrcs: JSON.parse(r.isrcs) as string[], fetchedAt: r.fetched_at }
449
+ : null;
450
+ }
451
+
452
+ setFingerprint(row: FingerprintRow): void {
453
+ this.db.run(
454
+ `INSERT INTO fingerprint (content_hash, fp, duration_s, acoustid, isrcs, fetched_at) VALUES (?, ?, ?, ?, ?, ?)
455
+ ON CONFLICT (content_hash) DO UPDATE SET fp = excluded.fp, duration_s = excluded.duration_s, acoustid = excluded.acoustid, isrcs = excluded.isrcs, fetched_at = excluded.fetched_at`,
456
+ [row.contentHash, row.fp, row.durationS, row.acoustid === null ? null : JSON.stringify(row.acoustid), JSON.stringify(row.isrcs), row.fetchedAt],
457
+ );
458
+ }
459
+ }
460
+
461
+ interface DbPlaylist {
462
+ id: number;
463
+ kind: SourceKind;
464
+ external_id: string;
465
+ name: string;
466
+ source_updated_at: number | null;
467
+ last_seen_at: number;
468
+ }
469
+
470
+ interface DbExport {
471
+ canonical_key: string;
472
+ export_path: string;
473
+ local_uri: string;
474
+ content_hash: string;
475
+ exported_at: number;
476
+ }
477
+
478
+ function toExportRow(r: DbExport): LocalExportRow {
479
+ return { canonicalKey: r.canonical_key, exportPath: r.export_path, localUri: r.local_uri, contentHash: r.content_hash, exportedAt: r.exported_at };
480
+ }
@@ -0,0 +1,115 @@
1
+ -- Spotifify state schema v1. Timestamps are unix epoch milliseconds.
2
+
3
+ CREATE TABLE IF NOT EXISTS source_playlist (
4
+ id INTEGER PRIMARY KEY,
5
+ kind TEXT NOT NULL CHECK (kind IN ('netease', 'local')),
6
+ external_id TEXT NOT NULL,
7
+ name TEXT NOT NULL,
8
+ source_updated_at INTEGER,
9
+ last_seen_at INTEGER NOT NULL,
10
+ UNIQUE (kind, external_id)
11
+ );
12
+
13
+ CREATE TABLE IF NOT EXISTS source_track (
14
+ id INTEGER PRIMARY KEY,
15
+ kind TEXT NOT NULL CHECK (kind IN ('netease', 'local')),
16
+ external_id TEXT NOT NULL, -- netease song id | local relative path key
17
+ canonical_key TEXT NOT NULL, -- netease:{id} | isrc:{ISRC} | local:{blake2b256}
18
+ title TEXT NOT NULL,
19
+ artists TEXT NOT NULL, -- JSON string[]
20
+ album TEXT,
21
+ duration_ms INTEGER,
22
+ isrc TEXT,
23
+ netease_id INTEGER,
24
+ aliases TEXT NOT NULL DEFAULT '[]', -- JSON string[]
25
+ file_path TEXT,
26
+ content_hash TEXT,
27
+ file_size INTEGER,
28
+ file_mtime INTEGER,
29
+ first_seen_at INTEGER NOT NULL,
30
+ last_seen_at INTEGER NOT NULL,
31
+ UNIQUE (kind, external_id)
32
+ );
33
+ CREATE INDEX IF NOT EXISTS source_track_canonical ON source_track (canonical_key);
34
+ CREATE INDEX IF NOT EXISTS source_track_hash ON source_track (content_hash);
35
+
36
+ CREATE TABLE IF NOT EXISTS playlist_track (
37
+ source_playlist_id INTEGER NOT NULL REFERENCES source_playlist (id) ON DELETE CASCADE,
38
+ source_track_id INTEGER NOT NULL REFERENCES source_track (id) ON DELETE CASCADE,
39
+ position INTEGER NOT NULL,
40
+ PRIMARY KEY (source_playlist_id, source_track_id)
41
+ );
42
+ CREATE INDEX IF NOT EXISTS playlist_track_order ON playlist_track (source_playlist_id, position);
43
+
44
+ CREATE TABLE IF NOT EXISTS match (
45
+ canonical_key TEXT PRIMARY KEY,
46
+ status TEXT NOT NULL CHECK (status IN ('pending', 'matched', 'review', 'local', 'skipped')),
47
+ spotify_id TEXT,
48
+ spotify_uri TEXT,
49
+ score REAL,
50
+ decided_by TEXT CHECK (decided_by IN ('auto', 'isrc', 'fingerprint', 'user')),
51
+ candidates TEXT NOT NULL DEFAULT '[]', -- JSON Candidate[]
52
+ decided_at INTEGER,
53
+ last_search_at INTEGER,
54
+ search_count INTEGER NOT NULL DEFAULT 0
55
+ );
56
+ CREATE INDEX IF NOT EXISTS match_status ON match (status);
57
+
58
+ CREATE TABLE IF NOT EXISTS spotify_playlist (
59
+ source_playlist_id INTEGER PRIMARY KEY REFERENCES source_playlist (id) ON DELETE CASCADE,
60
+ spotify_id TEXT NOT NULL UNIQUE,
61
+ name TEXT NOT NULL,
62
+ snapshot_id TEXT,
63
+ last_synced_at INTEGER
64
+ );
65
+
66
+ -- Items this tool added to a Spotify playlist; anything else in the remote playlist is "foreign".
67
+ CREATE TABLE IF NOT EXISTS managed_item (
68
+ spotify_playlist_id TEXT NOT NULL,
69
+ uri TEXT NOT NULL,
70
+ added_at INTEGER NOT NULL,
71
+ PRIMARY KEY (spotify_playlist_id, uri)
72
+ );
73
+
74
+ -- Tracks this tool liked. Tracks already liked before we touched them are never recorded, hence never unliked.
75
+ CREATE TABLE IF NOT EXISTS liked (
76
+ spotify_id TEXT PRIMARY KEY,
77
+ added_at INTEGER NOT NULL
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS local_export (
81
+ canonical_key TEXT PRIMARY KEY,
82
+ export_path TEXT NOT NULL,
83
+ local_uri TEXT NOT NULL, -- canonical spotify:local:... (see spotify/localUri.ts)
84
+ content_hash TEXT NOT NULL,
85
+ exported_at INTEGER NOT NULL
86
+ );
87
+
88
+ CREATE TABLE IF NOT EXISTS search_cache (
89
+ key TEXT PRIMARY KEY, -- sha1(query + market)
90
+ response TEXT NOT NULL, -- JSON
91
+ fetched_at INTEGER NOT NULL
92
+ );
93
+
94
+ CREATE TABLE IF NOT EXISTS fingerprint (
95
+ content_hash TEXT PRIMARY KEY,
96
+ fp TEXT NOT NULL,
97
+ duration_s INTEGER NOT NULL,
98
+ acoustid TEXT, -- JSON
99
+ isrcs TEXT NOT NULL DEFAULT '[]',-- JSON string[]
100
+ fetched_at INTEGER NOT NULL
101
+ );
102
+
103
+ CREATE TABLE IF NOT EXISTS auth (
104
+ provider TEXT PRIMARY KEY CHECK (provider IN ('spotify', 'netease')),
105
+ payload TEXT NOT NULL, -- JSON
106
+ updated_at INTEGER NOT NULL
107
+ );
108
+
109
+ CREATE TABLE IF NOT EXISTS run (
110
+ id INTEGER PRIMARY KEY,
111
+ started_at INTEGER NOT NULL,
112
+ finished_at INTEGER,
113
+ ok INTEGER,
114
+ summary TEXT -- JSON
115
+ );