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,155 @@
1
+ import { chunk } from "../util/retry.ts";
2
+ import { SpotifyHttpError, type SpotifyClient } from "./client.ts";
3
+ import type { SpotifyPlaylist, SpotifyPlaylistItem, SpotifyTrack } from "./types.ts";
4
+
5
+ // Spotify moved playlist entries to `/playlists/{id}/items` (entry field `item`) and the library to
6
+ // `/me/library?uris=`; the legacy `/tracks` and `/me/tracks/contains` paths answer 403 for newer apps.
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
+
9
+ /** Playlist item batch limit for add/remove/replace. */
10
+ const ITEMS_BATCH = 100;
11
+ /** `/me/library` batch limit (probed: 41+ uris → 400 "Too many uris requested"). */
12
+ const LIBRARY_BATCH = 40;
13
+
14
+ /** Typed façade over the ~15 endpoints this tool needs. Every write returns the resulting snapshot_id. */
15
+ export class SpotifyApi {
16
+ constructor(readonly client: SpotifyClient) {}
17
+
18
+ async me(): Promise<{ id: string; country: string }> {
19
+ const { id, country } = await this.client.request<{ id: string; country?: string }>("GET", "/v1/me");
20
+ if (!country) throw new Error("Spotify profile has no country: the token lacks user-read-private; run `spotifify auth spotify`");
21
+ return { id, country };
22
+ }
23
+
24
+ /** Concrete ISO market for search: config value, or the account country when configured as "from_token". */
25
+ async resolveMarket(configured: string): Promise<string> {
26
+ return configured === "from_token" ? (await this.me()).country : configured;
27
+ }
28
+
29
+ async searchTracks(q: string, market: string, limit = 10): Promise<SpotifyTrack[]> {
30
+ const res = await this.client.request<{ tracks: { items: SpotifyTrack[] } }>("GET", "/v1/search", {
31
+ query: { q, type: "track", market, limit },
32
+ });
33
+ return res.tracks.items;
34
+ }
35
+
36
+ /** null on 404 (deleted / never existed). */
37
+ async getTrack(id: string, market: string): Promise<SpotifyTrack | null> {
38
+ try {
39
+ return await this.client.request<SpotifyTrack>("GET", `/v1/tracks/${id}`, { query: { market } });
40
+ } catch (e) {
41
+ if (e instanceof SpotifyHttpError && e.status === 404) return null;
42
+ throw e;
43
+ }
44
+ }
45
+
46
+ listMyPlaylists(): Promise<SpotifyPlaylist[]> {
47
+ return this.client.paginate<SpotifyPlaylist>("/v1/me/playlists", { limit: 50 });
48
+ }
49
+
50
+ /** null on 404 (deleted playlist). */
51
+ async getPlaylist(id: string): Promise<SpotifyPlaylist | null> {
52
+ try {
53
+ return await this.client.request<SpotifyPlaylist>("GET", `/v1/playlists/${id}`);
54
+ } catch (e) {
55
+ if (e instanceof SpotifyHttpError && e.status === 404) return null;
56
+ throw e;
57
+ }
58
+ }
59
+
60
+ /** Always private. Uses `/me/playlists`: the legacy `/users/{id}/playlists` path answers 403 for development-mode apps. */
61
+ createPlaylist(name: string, description: string): Promise<SpotifyPlaylist> {
62
+ return this.client.request<SpotifyPlaylist>("POST", "/v1/me/playlists", { body: { name, public: false, description } });
63
+ }
64
+
65
+ async renamePlaylist(id: string, name: string): Promise<void> {
66
+ await this.client.request<void>("PUT", `/v1/playlists/${id}`, { body: { name } });
67
+ }
68
+
69
+ getPlaylistItems(id: string): Promise<SpotifyPlaylistItem[]> {
70
+ return this.client.paginate<SpotifyPlaylistItem>(`/v1/playlists/${id}/items`, { limit: 50, fields: ITEM_FIELDS });
71
+ }
72
+
73
+ /** ≤100 per request; `position` advances by batch size so the whole run lands contiguously. Returns the last snapshot_id. */
74
+ async addPlaylistItems(id: string, uris: string[], position?: number): Promise<string> {
75
+ let snapshot = "";
76
+ let at = position;
77
+ for (const batch of chunk(uris, ITEMS_BATCH)) {
78
+ const res = await this.client.request<{ snapshot_id: string }>("POST", `/v1/playlists/${id}/items`, {
79
+ body: at === undefined ? { uris: batch } : { uris: batch, position: at },
80
+ });
81
+ snapshot = res.snapshot_id;
82
+ if (at !== undefined) at += batch.length;
83
+ }
84
+ return snapshot;
85
+ }
86
+
87
+ /**
88
+ * ≤100 per request. Catalog tracks are removed by URI; local files must be removed by position only
89
+ * (`/items` answers 400 "Invalid base62 id" for a `spotify:local:` URI). Positions are removed highest
90
+ * first with chained snapshot ids, so earlier indexes never shift underneath later batches.
91
+ */
92
+ async removePlaylistItems(id: string, items: Array<{ uri: string; positions?: number[] }>, snapshotId: string): Promise<string> {
93
+ let snapshot = snapshotId;
94
+ const positions = items.flatMap((x) => x.positions ?? []).sort((a, b) => b - a);
95
+ const uris = items.filter((x) => x.positions === undefined).map((x) => ({ uri: x.uri }));
96
+ for (const batch of chunk(positions, ITEMS_BATCH)) {
97
+ const res = await this.client.request<{ snapshot_id: string }>("DELETE", `/v1/playlists/${id}/items`, {
98
+ body: { positions: batch, snapshot_id: snapshot },
99
+ });
100
+ snapshot = res.snapshot_id;
101
+ }
102
+ for (const batch of chunk(uris, ITEMS_BATCH)) {
103
+ const res = await this.client.request<{ snapshot_id: string }>("DELETE", `/v1/playlists/${id}/items`, {
104
+ body: { items: batch, snapshot_id: snapshot },
105
+ });
106
+ snapshot = res.snapshot_id;
107
+ }
108
+ return snapshot;
109
+ }
110
+
111
+ async reorderPlaylistItems(id: string, rangeStart: number, insertBefore: number, snapshotId: string, rangeLength = 1): Promise<string> {
112
+ const res = await this.client.request<{ snapshot_id: string }>("PUT", `/v1/playlists/${id}/items`, {
113
+ body: { range_start: rangeStart, insert_before: insertBefore, range_length: rangeLength, snapshot_id: snapshotId },
114
+ });
115
+ return res.snapshot_id;
116
+ }
117
+
118
+ /** PUT the first ≤100 (which also clears the playlist), then append the rest. */
119
+ async replacePlaylistItems(id: string, uris: string[]): Promise<string> {
120
+ const res = await this.client.request<{ snapshot_id: string }>("PUT", `/v1/playlists/${id}/items`, {
121
+ body: { uris: uris.slice(0, ITEMS_BATCH) },
122
+ });
123
+ const rest = uris.slice(ITEMS_BATCH);
124
+ return rest.length === 0 ? res.snapshot_id : this.addPlaylistItems(id, rest);
125
+ }
126
+
127
+ /** ≤50 per request; result aligns with `ids` order. */
128
+ async checkSaved(ids: string[]): Promise<boolean[]> {
129
+ const out: boolean[] = [];
130
+ for (const batch of chunk(ids, LIBRARY_BATCH)) {
131
+ out.push(...(await this.client.request<boolean[]>("GET", "/v1/me/library/contains", { query: { uris: batch.map(trackUri).join(",") } })));
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /** Every saved track id, paginated 50 at a time. Fallback for accounts where `contains` is unavailable. */
137
+ async listSavedTrackIds(): Promise<Set<string>> {
138
+ const items = await this.client.paginate<{ track: { id: string | null } | null }>("/v1/me/tracks", { limit: LIBRARY_BATCH });
139
+ const ids = new Set<string>();
140
+ for (const it of items) if (it.track?.id) ids.add(it.track.id);
141
+ return ids;
142
+ }
143
+
144
+ async saveTracks(ids: string[]): Promise<void> {
145
+ for (const batch of chunk(ids, LIBRARY_BATCH)) await this.client.request<void>("PUT", "/v1/me/library", { query: { uris: batch.map(trackUri).join(",") } });
146
+ }
147
+
148
+ async removeSavedTracks(ids: string[]): Promise<void> {
149
+ for (const batch of chunk(ids, LIBRARY_BATCH)) await this.client.request<void>("DELETE", "/v1/me/library", { query: { uris: batch.map(trackUri).join(",") } });
150
+ }
151
+ }
152
+
153
+ function trackUri(id: string): string {
154
+ return id.startsWith("spotify:") ? id : `spotify:track:${id}`;
155
+ }
@@ -0,0 +1,121 @@
1
+ import { log } from "../util/log.ts";
2
+ import { openExternal } from "../util/open.ts";
3
+ import { SCOPES, type SpotifyTokens } from "./types.ts";
4
+
5
+ const ACCOUNTS = "https://accounts.spotify.com";
6
+ /** Refresh this long before the token actually expires so in-flight requests never race the deadline. */
7
+ const EXPIRY_MARGIN_MS = 30_000;
8
+
9
+ export interface TokenStore {
10
+ load(): SpotifyTokens | null;
11
+ save(t: SpotifyTokens): void;
12
+ }
13
+
14
+ /** Refresh rejected (invalid_grant) or no stored tokens: the user must run `auth spotify` again. */
15
+ export class AuthExpiredError extends Error {
16
+ constructor(message = "Spotify authorization expired; run `spotifify auth spotify`") {
17
+ super(message);
18
+ this.name = "AuthExpiredError";
19
+ }
20
+ }
21
+
22
+ interface TokenResponse {
23
+ access_token: string;
24
+ refresh_token?: string;
25
+ expires_in: number;
26
+ scope?: string;
27
+ }
28
+
29
+ function base64url(bytes: Uint8Array): string {
30
+ return Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
31
+ }
32
+
33
+ async function postToken(form: Record<string, string>, previous?: SpotifyTokens): Promise<SpotifyTokens> {
34
+ const res = await fetch(`${ACCOUNTS}/api/token`, {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
37
+ body: new URLSearchParams(form).toString(),
38
+ });
39
+ const text = await res.text();
40
+ if (!res.ok) {
41
+ if (res.status === 400 && text.includes("invalid_grant")) throw new AuthExpiredError(`Spotify token refresh rejected: ${text}`);
42
+ throw new Error(`Spotify token endpoint ${res.status}: ${text}`);
43
+ }
44
+ const body = JSON.parse(text) as TokenResponse;
45
+ const refresh = body.refresh_token ?? previous?.refresh_token;
46
+ if (!refresh) throw new Error("Spotify token response carried no refresh_token");
47
+ return {
48
+ access_token: body.access_token,
49
+ refresh_token: refresh,
50
+ expires_at: Date.now() + body.expires_in * 1000 - EXPIRY_MARGIN_MS,
51
+ scope: body.scope ?? previous?.scope ?? SCOPES.join(" "),
52
+ };
53
+ }
54
+
55
+ /** Authorization Code + PKCE via a one-shot loopback server; resolves once tokens are exchanged and saved. */
56
+ export async function loginPkce(opts: { clientId: string; port: number; store: TokenStore }): Promise<SpotifyTokens> {
57
+ const verifier = base64url(crypto.getRandomValues(new Uint8Array(64)));
58
+ const challenge = base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
59
+ const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
60
+ const redirectUri = `http://127.0.0.1:${opts.port}/callback`;
61
+
62
+ const { promise: code, resolve, reject } = Promise.withResolvers<string>();
63
+ const server = Bun.serve({
64
+ hostname: "127.0.0.1",
65
+ port: opts.port,
66
+ fetch(req) {
67
+ const url = new URL(req.url);
68
+ if (url.pathname !== "/callback") return new Response("Not found", { status: 404 });
69
+ const error = url.searchParams.get("error");
70
+ const got = url.searchParams.get("code");
71
+ if (url.searchParams.get("state") !== state) {
72
+ reject(new Error("Spotify callback state mismatch"));
73
+ return new Response("State mismatch. You can close this tab.", { status: 400 });
74
+ }
75
+ if (error || !got) {
76
+ reject(new Error(`Spotify authorization failed: ${error ?? "no code"}`));
77
+ return new Response(`Authorization failed: ${error ?? "no code"}. You can close this tab.`, { status: 400 });
78
+ }
79
+ resolve(got);
80
+ return new Response("Spotifify: authorization complete. You can close this tab.", { headers: { "Content-Type": "text/plain" } });
81
+ },
82
+ });
83
+
84
+ const authorize = new URL(`${ACCOUNTS}/authorize`);
85
+ authorize.search = new URLSearchParams({
86
+ client_id: opts.clientId,
87
+ response_type: "code",
88
+ redirect_uri: redirectUri,
89
+ scope: SCOPES.join(" "),
90
+ state,
91
+ code_challenge_method: "S256",
92
+ code_challenge: challenge,
93
+ }).toString();
94
+
95
+ log.info(`Open this URL to authorize Spotify:\n${authorize.href}`);
96
+ openExternal(authorize.href);
97
+
98
+ try {
99
+ const tokens = await postToken({
100
+ client_id: opts.clientId,
101
+ grant_type: "authorization_code",
102
+ code: await code,
103
+ redirect_uri: redirectUri,
104
+ code_verifier: verifier,
105
+ });
106
+ opts.store.save(tokens);
107
+ return tokens;
108
+ } finally {
109
+ server.stop(true);
110
+ }
111
+ }
112
+
113
+ /** PKCE refresh responses may omit `refresh_token`; the previous one is kept in that case. */
114
+ export async function refreshTokens(clientId: string, refreshToken: string): Promise<SpotifyTokens> {
115
+ return postToken({ client_id: clientId, grant_type: "refresh_token", refresh_token: refreshToken }, {
116
+ access_token: "",
117
+ refresh_token: refreshToken,
118
+ expires_at: 0,
119
+ scope: "",
120
+ });
121
+ }
@@ -0,0 +1,120 @@
1
+ import { log } from "../util/log.ts";
2
+ import { RetryableError, withRetry } from "../util/retry.ts";
3
+ import { AuthExpiredError, refreshTokens, type TokenStore } from "./auth.ts";
4
+ import type { Paging, SpotifyTokens } from "./types.ts";
5
+
6
+ const API = "https://api.spotify.com";
7
+ const ATTEMPTS = 5;
8
+
9
+ export type Query = Record<string, string | number | undefined>;
10
+
11
+ export class SpotifyHttpError extends Error {
12
+ constructor(
13
+ readonly status: number,
14
+ readonly body: string,
15
+ method: string,
16
+ url: string,
17
+ ) {
18
+ super(`Spotify ${method} ${url} → ${status}: ${body}`);
19
+ this.name = "SpotifyHttpError";
20
+ }
21
+ }
22
+
23
+ /** Longest 429 back-off we sleep through in-process; anything longer (Spotify hands out ~24 h for quota exhaustion) aborts the phase. */
24
+ const MAX_RETRY_AFTER_MS = 120_000;
25
+
26
+ /** 429 with a Retry-After beyond MAX_RETRY_AFTER_MS: callers should stop, persist `untilMs`, and continue next run. */
27
+ export class SpotifyRateLimitedError extends Error {
28
+ constructor(
29
+ readonly untilMs: number,
30
+ path: string,
31
+ ) {
32
+ super(`Spotify rate limited on ${path} until ${new Date(untilMs).toISOString()}`);
33
+ this.name = "SpotifyRateLimitedError";
34
+ }
35
+ }
36
+
37
+ /** Thin fetch wrapper: bearer injection, proactive + reactive refresh, 429/5xx retry, JSON decoding. */
38
+ export class SpotifyClient {
39
+ private readonly clientId: string;
40
+ private readonly store: TokenStore;
41
+ private refreshing: Promise<SpotifyTokens> | null = null;
42
+
43
+ constructor(opts: { clientId: string; store: TokenStore }) {
44
+ this.clientId = opts.clientId;
45
+ this.store = opts.store;
46
+ }
47
+
48
+ async request<T>(method: "GET" | "POST" | "PUT" | "DELETE", path: string, opts: { query?: Query; body?: unknown } = {}): Promise<T> {
49
+ // SPOTIFIFY_SPOTIFY_API lets an in-process fake stand in for the Web API in end-to-end tests.
50
+ const url = new URL(path.startsWith("http") ? path : (process.env.SPOTIFIFY_SPOTIFY_API ?? API) + path);
51
+ if (opts.query) {
52
+ for (const [k, v] of Object.entries(opts.query)) if (v !== undefined) url.searchParams.set(k, String(v));
53
+ }
54
+ const body = opts.body === undefined ? undefined : JSON.stringify(opts.body);
55
+
56
+ let retriedAuth = false;
57
+ return withRetry(
58
+ async () => {
59
+ const token = await this.accessToken();
60
+ const res = await fetch(url, {
61
+ method,
62
+ headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
63
+ body,
64
+ });
65
+ const text = await res.text();
66
+ if (res.ok) return (text === "" ? undefined : JSON.parse(text)) as T;
67
+
68
+ if (res.status === 401 && !retriedAuth) {
69
+ retriedAuth = true;
70
+ await this.refresh();
71
+ throw new RetryableError("401; refreshed token", 0);
72
+ }
73
+ if (res.status === 429) {
74
+ const secs = Number(res.headers.get("Retry-After") ?? "1");
75
+ const waitMs = (Number.isFinite(secs) ? secs : 1) * 1000;
76
+ if (waitMs > MAX_RETRY_AFTER_MS) throw new SpotifyRateLimitedError(Date.now() + waitMs, url.pathname);
77
+ log.warn(`Spotify rate limited; waiting ${secs}s`, { method, path: url.pathname });
78
+ throw new RetryableError(`429 on ${method} ${url.pathname}`, waitMs);
79
+ }
80
+ if (res.status >= 500) throw new RetryableError(`${res.status} on ${method} ${url.pathname}`);
81
+ throw new SpotifyHttpError(res.status, text, method, url.pathname);
82
+ },
83
+ { attempts: ATTEMPTS },
84
+ );
85
+ }
86
+
87
+ /** Follows `Paging.next` until exhausted. */
88
+ async paginate<T>(path: string, query?: Query): Promise<T[]> {
89
+ const out: T[] = [];
90
+ let page = await this.request<Paging<T>>("GET", path, { query });
91
+ for (;;) {
92
+ out.push(...page.items);
93
+ if (page.next === null) return out;
94
+ page = await this.request<Paging<T>>("GET", page.next);
95
+ }
96
+ }
97
+
98
+ private async accessToken(): Promise<string> {
99
+ const tokens = this.store.load();
100
+ if (tokens === null) throw new AuthExpiredError();
101
+ if (tokens.expires_at > Date.now()) return tokens.access_token;
102
+ return (await this.refresh()).access_token;
103
+ }
104
+
105
+ /** Concurrent callers share one refresh so a burst of expired requests does not spam the token endpoint. */
106
+ private refresh(): Promise<SpotifyTokens> {
107
+ if (this.refreshing) return this.refreshing;
108
+ const tokens = this.store.load();
109
+ if (tokens === null) throw new AuthExpiredError();
110
+ this.refreshing = refreshTokens(this.clientId, tokens.refresh_token)
111
+ .then((fresh) => {
112
+ this.store.save(fresh);
113
+ return fresh;
114
+ })
115
+ .finally(() => {
116
+ this.refreshing = null;
117
+ });
118
+ return this.refreshing;
119
+ }
120
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Spotify desktop derives a local file's identity from its tags and its own duration computation:
3
+ * spotify:local:{artist}:{album}:{title}:{durationSec}
4
+ * Segments are percent-encoded with spaces as "+". The client normalizes whatever is pasted into this
5
+ * form (a three-segment paste comes back from the API as `…:0`) and only links an entry to a file when
6
+ * every segment — the whole-second duration included — equals what its index computed (see
7
+ * `sync/duration.ts`). Encoding details drift across client versions (it writes "(" as "%28"), so
8
+ * remote URIs are compared via `canonicalLocalUri`.
9
+ */
10
+
11
+ export interface LocalUriParts {
12
+ artist: string;
13
+ album: string;
14
+ title: string;
15
+ /** null only for a bare three-segment uri, which the client never produces itself */
16
+ durationSec: number | null;
17
+ }
18
+
19
+ const PREFIX = "spotify:local:";
20
+
21
+ export function buildLocalUri(p: LocalUriParts): string {
22
+ const seg = (s: string) => encodeURIComponent(s).replace(/%20/g, "+");
23
+ const base = `${PREFIX}${seg(p.artist)}:${seg(p.album)}:${seg(p.title)}`;
24
+ return p.durationSec === null ? base : `${base}:${p.durationSec}`;
25
+ }
26
+
27
+ export function parseLocalUri(uri: string): LocalUriParts | null {
28
+ if (!uri.startsWith(PREFIX)) return null;
29
+ const segs = uri.slice(PREFIX.length).split(":");
30
+ if (segs.length !== 3 && segs.length !== 4) return null;
31
+ let durationSec: number | null = null;
32
+ if (segs.length === 4) {
33
+ durationSec = Number(segs[3]);
34
+ if (!Number.isInteger(durationSec) || durationSec < 0) return null;
35
+ }
36
+ try {
37
+ const dec = (s: string) => decodeURIComponent(s.replace(/\+/g, " "));
38
+ return { artist: dec(segs[0]!), album: dec(segs[1]!), title: dec(segs[2]!), durationSec };
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /** Normal form used for equality between `local_export.local_uri` and remote playlist items. */
45
+ export function canonicalLocalUri(uri: string): string | null {
46
+ const parts = parseLocalUri(uri);
47
+ return parts === null ? null : buildLocalUri(parts);
48
+ }
@@ -0,0 +1,61 @@
1
+ /** Minimal Web API shapes this tool reads. Field names follow the API verbatim. */
2
+
3
+ export interface SpotifyArtist {
4
+ id: string | null;
5
+ name: string;
6
+ }
7
+
8
+ export interface SpotifyTrack {
9
+ id: string | null;
10
+ uri: string;
11
+ name: string;
12
+ artists: SpotifyArtist[];
13
+ album: { id: string | null; name: string };
14
+ duration_ms: number;
15
+ is_local: boolean;
16
+ /** present only when the request carried `market` */
17
+ is_playable?: boolean;
18
+ external_ids?: { isrc?: string };
19
+ linked_from?: { id: string; uri: string };
20
+ }
21
+
22
+ /** Entry of `GET /playlists/{id}/items`; the payload lives under `item` (`track` in the retired `/tracks` API). */
23
+ export interface SpotifyPlaylistItem {
24
+ added_at: string;
25
+ is_local: boolean;
26
+ item: SpotifyTrack | null;
27
+ }
28
+
29
+ export interface SpotifyPlaylist {
30
+ id: string;
31
+ name: string;
32
+ description: string | null;
33
+ snapshot_id: string;
34
+ owner: { id: string };
35
+ }
36
+
37
+ export interface Paging<T> {
38
+ items: T[];
39
+ next: string | null;
40
+ total: number;
41
+ }
42
+
43
+ export interface SpotifyTokens {
44
+ access_token: string;
45
+ refresh_token: string;
46
+ /** unix ms */
47
+ expires_at: number;
48
+ scope: string;
49
+ }
50
+
51
+ export const SCOPES = [
52
+ "playlist-read-private",
53
+ "playlist-modify-private",
54
+ "playlist-modify-public",
55
+ "user-library-read",
56
+ "user-library-modify",
57
+ // `/v1/me.country` and market-restricted search need this; without it search answers 403 "Insufficient client scope".
58
+ "user-read-private",
59
+ ] as const;
60
+
61
+ export const MANAGED_DESCRIPTION = "Managed by Spotifify";
@@ -0,0 +1,42 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import schemaV1 from "./schema.sql" with { type: "text" };
5
+
6
+ // Append-only. Each entry runs once, in order, inside a transaction; user_version tracks progress.
7
+ const MIGRATIONS: readonly string[] = [
8
+ schemaV1,
9
+ // v2: small key/value store (e.g. Spotify rate-limit deadline carried across runs)
10
+ "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
11
+ ];
12
+
13
+ export const SCHEMA_VERSION = MIGRATIONS.length;
14
+
15
+ export function openDatabase(dir: string): Database {
16
+ mkdirSync(dir, { recursive: true });
17
+ const db = new Database(join(dir, "state.db"), { create: true, strict: true });
18
+ db.exec("PRAGMA journal_mode = WAL");
19
+ db.exec("PRAGMA foreign_keys = ON");
20
+ db.exec("PRAGMA busy_timeout = 5000");
21
+ migrate(db);
22
+ return db;
23
+ }
24
+
25
+ export function schemaVersion(db: Database): number {
26
+ const row = db.query<{ user_version: number }, []>("PRAGMA user_version").get();
27
+ return row?.user_version ?? 0;
28
+ }
29
+
30
+ function migrate(db: Database): void {
31
+ const current = schemaVersion(db);
32
+ if (current > SCHEMA_VERSION) {
33
+ throw new Error(`state.db schema v${current} is newer than this build (v${SCHEMA_VERSION})`);
34
+ }
35
+ for (let v = current; v < SCHEMA_VERSION; v++) {
36
+ const sql = MIGRATIONS[v]!;
37
+ db.transaction(() => {
38
+ db.exec(sql);
39
+ db.exec(`PRAGMA user_version = ${v + 1}`);
40
+ })();
41
+ }
42
+ }