pi-spotify-widget 0.1.0

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.
@@ -0,0 +1,46 @@
1
+ import type { PlaybackSnapshot } from "./playback-snapshot.ts";
2
+ import { effectiveProgressMs } from "./widget-render.ts";
3
+
4
+ export const POLL_IDLE_MS = 10_000;
5
+ export const POLL_PAUSED_MS = 15_000;
6
+ export const POLL_PLAYING_MS = 5_000;
7
+ export const POLL_NEAR_END_MS = 1_500;
8
+ export const NEAR_END_THRESHOLD_MS = 5_000;
9
+ export const TRACK_END_GRACE_MS = 500;
10
+
11
+ export function remainingMs(snapshot: PlaybackSnapshot, now = Date.now()): number | null {
12
+ if (!snapshot.isPlaying) return null;
13
+ const progress = effectiveProgressMs(snapshot, now);
14
+ if (progress === undefined) return null;
15
+ if (typeof snapshot.durationMs !== "number" || snapshot.durationMs <= 0) return null;
16
+ return snapshot.durationMs - progress;
17
+ }
18
+
19
+ export function computeRefreshDelayMs(snapshot: PlaybackSnapshot | null, now = Date.now()): number {
20
+ if (!snapshot?.track) return POLL_IDLE_MS;
21
+ if (!snapshot.isPlaying) return POLL_PAUSED_MS;
22
+
23
+ const remaining = remainingMs(snapshot, now);
24
+ if (remaining === null) return POLL_PLAYING_MS;
25
+ if (remaining <= NEAR_END_THRESHOLD_MS) return POLL_NEAR_END_MS;
26
+ return POLL_PLAYING_MS;
27
+ }
28
+
29
+ export function shouldRefreshAtTrackEnd(snapshot: PlaybackSnapshot, now = Date.now()): boolean {
30
+ if (!snapshot.isPlaying || !snapshot.track) return false;
31
+ const remaining = remainingMs(snapshot, now);
32
+ if (remaining === null) return false;
33
+ return remaining <= TRACK_END_GRACE_MS;
34
+ }
35
+
36
+ export function trackIdentity(snapshot: PlaybackSnapshot | null): string {
37
+ if (!snapshot?.track) return "";
38
+ return `${snapshot.track}::${snapshot.artist}`;
39
+ }
40
+
41
+ export const __testing = {
42
+ remainingMs,
43
+ computeRefreshDelayMs,
44
+ shouldRefreshAtTrackEnd,
45
+ trackIdentity,
46
+ };
@@ -0,0 +1,147 @@
1
+ import { getValidAccessToken, refreshAccessToken, setLastFetchAt } from "./spotify-auth.ts";
2
+ import { loadAuth } from "./auth-store.ts";
3
+ import { SPOTIFY_API_BASE } from "./spotify-config.ts";
4
+ import type { SpotifyCurrentlyPlayingRaw } from "./playback-snapshot.ts";
5
+
6
+ export interface FetchOptions {
7
+ fetchFn?: typeof fetch;
8
+ }
9
+
10
+ async function authorizedRequest(
11
+ method: string,
12
+ path: string,
13
+ accessToken: string,
14
+ fetchFn: typeof fetch,
15
+ body?: unknown,
16
+ ): Promise<Response> {
17
+ const init: RequestInit = {
18
+ method,
19
+ headers: {
20
+ Authorization: `Bearer ${accessToken}`,
21
+ },
22
+ };
23
+ if (body !== undefined) {
24
+ init.headers = {
25
+ ...init.headers,
26
+ "Content-Type": "application/json",
27
+ };
28
+ init.body = JSON.stringify(body);
29
+ }
30
+ return fetchFn(`${SPOTIFY_API_BASE}${path}`, init);
31
+ }
32
+
33
+ async function withAuthorizedRetry(
34
+ method: string,
35
+ path: string,
36
+ options: FetchOptions = {},
37
+ body?: unknown,
38
+ ): Promise<Response> {
39
+ const fetchFn = options.fetchFn ?? fetch;
40
+ let accessToken = await getValidAccessToken({ fetchFn });
41
+ if (!accessToken) {
42
+ throw new Error("Spotify is not authenticated. Run /spotify:login.");
43
+ }
44
+
45
+ let res = await authorizedRequest(method, path, accessToken, fetchFn, body);
46
+
47
+ if (res.status === 401) {
48
+ const auth = loadAuth();
49
+ if (!auth) {
50
+ throw new Error("Spotify is not authenticated. Run /spotify:login.");
51
+ }
52
+ try {
53
+ const refreshed = await refreshAccessToken(auth, { fetchFn });
54
+ accessToken = refreshed.accessToken;
55
+ res = await authorizedRequest(method, path, accessToken, fetchFn, body);
56
+ } catch {
57
+ throw new Error("Spotify token refresh failed. Run /spotify:login again.");
58
+ }
59
+ }
60
+
61
+ return res;
62
+ }
63
+
64
+ function assertPlaybackControlOk(res: Response, action: string): void {
65
+ if (res.status === 204 || res.status === 200) return;
66
+ if (res.status === 403) {
67
+ throw new Error(`Spotify ${action} failed: no active device. Open Spotify on a device and try again.`);
68
+ }
69
+ throw new Error(`Spotify ${action} failed (HTTP ${res.status}).`);
70
+ }
71
+
72
+ export async function fetchCurrentlyPlaying(
73
+ options: FetchOptions = {},
74
+ ): Promise<SpotifyCurrentlyPlayingRaw | null> {
75
+ const fetchFn = options.fetchFn ?? fetch;
76
+ const accessToken = await getValidAccessToken({ fetchFn });
77
+ if (!accessToken) return null;
78
+
79
+ let res = await authorizedRequest("GET", "/me/player/currently-playing", accessToken, fetchFn);
80
+
81
+ if (res.status === 401) {
82
+ const auth = loadAuth();
83
+ if (!auth) return null;
84
+ try {
85
+ const refreshed = await refreshAccessToken(auth, { fetchFn });
86
+ res = await authorizedRequest("GET", "/me/player/currently-playing", refreshed.accessToken, fetchFn);
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ if (res.status === 204 || res.status === 404) {
93
+ setLastFetchAt(Date.now());
94
+ return null;
95
+ }
96
+
97
+ if (!res.ok) {
98
+ throw new Error(`Spotify currently-playing request failed (HTTP ${res.status})`);
99
+ }
100
+
101
+ const raw = await res.json() as SpotifyCurrentlyPlayingRaw;
102
+ setLastFetchAt(Date.now());
103
+ return raw;
104
+ }
105
+
106
+ export async function skipToNext(options: FetchOptions = {}): Promise<void> {
107
+ const res = await withAuthorizedRetry("POST", "/me/player/next", options);
108
+ assertPlaybackControlOk(res, "skip to next track");
109
+ }
110
+
111
+ export async function skipToPrevious(options: FetchOptions = {}): Promise<void> {
112
+ const res = await withAuthorizedRetry("POST", "/me/player/previous", options);
113
+ assertPlaybackControlOk(res, "skip to previous track");
114
+ }
115
+
116
+ export async function pausePlayback(options: FetchOptions = {}): Promise<void> {
117
+ const res = await withAuthorizedRetry("PUT", "/me/player/pause", options);
118
+ assertPlaybackControlOk(res, "pause");
119
+ }
120
+
121
+ export async function resumePlayback(options: FetchOptions = {}): Promise<void> {
122
+ const res = await withAuthorizedRetry("PUT", "/me/player/play", options, {});
123
+ assertPlaybackControlOk(res, "resume");
124
+ }
125
+
126
+ export async function playTrackUri(trackUri: string, options: FetchOptions = {}): Promise<void> {
127
+ const uri = trackUri.trim();
128
+ if (!uri.startsWith("spotify:track:")) {
129
+ throw new Error("Expected a Spotify track URI like spotify:track:... or pass a track URL via /spotify:play-uri.");
130
+ }
131
+ const res = await withAuthorizedRetry("PUT", "/me/player/play", options, { uris: [uri] });
132
+ assertPlaybackControlOk(res, "play track");
133
+ }
134
+
135
+ export function spotifyUrlToTrackUri(urlOrUri: string): string {
136
+ const value = urlOrUri.trim();
137
+ if (value.startsWith("spotify:track:")) return value;
138
+ const match = value.match(/open\.spotify\.com\/track\/([A-Za-z0-9]+)/);
139
+ if (match?.[1]) return `spotify:track:${match[1]}`;
140
+ throw new Error("Could not parse Spotify track URL or URI.");
141
+ }
142
+
143
+ export const __testing = {
144
+ authorizedRequest,
145
+ assertPlaybackControlOk,
146
+ withAuthorizedRetry,
147
+ };
@@ -0,0 +1,352 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import { spawn } from "node:child_process";
4
+ import { URL } from "node:url";
5
+ import {
6
+ clearAuth,
7
+ authFileExists,
8
+ getAuthFilePath,
9
+ isConfigured,
10
+ loadAuth,
11
+ saveAuth,
12
+ type SpotifyAuthRecord,
13
+ } from "./auth-store.ts";
14
+ import {
15
+ CALLBACK_HOST,
16
+ CALLBACK_PATH,
17
+ CALLBACK_PORT,
18
+ LOGIN_TIMEOUT_MS,
19
+ REDIRECT_URI,
20
+ clientIdStatusText,
21
+ requireClientId,
22
+ resolveClientId,
23
+ scopeString,
24
+ SPOTIFY_AUTH_URL,
25
+ SPOTIFY_TOKEN_URL,
26
+ TOKEN_REFRESH_BUFFER_MS,
27
+ } from "./spotify-config.ts";
28
+
29
+ export interface SecretSafeStatus {
30
+ configured: boolean;
31
+ expired: boolean;
32
+ expiresInText: string;
33
+ authFilePath: string;
34
+ source: "spotify-web-api";
35
+ lastFetchText: string;
36
+ }
37
+
38
+ let lastFetchAt: number | null = null;
39
+
40
+ export function setLastFetchAt(timestamp: number | null): void {
41
+ lastFetchAt = timestamp;
42
+ }
43
+
44
+ export function getLastFetchAt(): number | null {
45
+ return lastFetchAt;
46
+ }
47
+
48
+ export function generateCodeVerifier(): string {
49
+ return randomBytes(32).toString("base64url");
50
+ }
51
+
52
+ export function generateCodeChallenge(verifier: string): string {
53
+ return createHash("sha256").update(verifier).digest("base64url");
54
+ }
55
+
56
+ export function isExpired(auth: SpotifyAuthRecord, now = Date.now()): boolean {
57
+ return auth.expiresAt <= now;
58
+ }
59
+
60
+ export function needsRefresh(auth: SpotifyAuthRecord, now = Date.now()): boolean {
61
+ return auth.expiresAt - now <= TOKEN_REFRESH_BUFFER_MS;
62
+ }
63
+
64
+ function nowIso(): string {
65
+ return new Date().toISOString();
66
+ }
67
+
68
+ function ageText(timestamp: number | null): string {
69
+ if (!timestamp) return "never";
70
+ const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
71
+ if (seconds < 60) return `${seconds}s ago`;
72
+ const minutes = Math.floor(seconds / 60);
73
+ if (minutes < 60) return `${minutes}m ago`;
74
+ const hours = Math.floor(minutes / 60);
75
+ return `${hours}h ago`;
76
+ }
77
+
78
+ function expiresInText(auth: SpotifyAuthRecord | null, now = Date.now()): string {
79
+ if (!auth) return "n/a";
80
+ const diffMs = auth.expiresAt - now;
81
+ if (diffMs <= 0) return "expired";
82
+ const minutes = Math.ceil(diffMs / 60_000);
83
+ if (minutes < 60) return `in ${minutes}m`;
84
+ const hours = Math.floor(minutes / 60);
85
+ const remMinutes = minutes % 60;
86
+ return remMinutes > 0 ? `in ${hours}h ${remMinutes}m` : `in ${hours}h`;
87
+ }
88
+
89
+ export function formatSecretSafeStatus(now = Date.now()): string {
90
+ const auth = loadAuth();
91
+ const status: SecretSafeStatus = {
92
+ configured: isConfigured(),
93
+ expired: auth ? isExpired(auth, now) : false,
94
+ expiresInText: expiresInText(auth, now),
95
+ authFilePath: getAuthFilePath(),
96
+ source: "spotify-web-api",
97
+ lastFetchText: ageText(lastFetchAt),
98
+ };
99
+
100
+ const authFileState = authFileExists()
101
+ ? (status.configured ? "present" : "present but invalid or incomplete")
102
+ : "missing";
103
+ const lines = [
104
+ clientIdStatusText(),
105
+ `oauth: ${status.configured ? "connected" : "not connected"}`,
106
+ `configured: ${status.configured ? "yes" : "no"}`,
107
+ `expires: ${status.expiresInText}`,
108
+ `lastFetch: ${status.lastFetchText}`,
109
+ `source: ${status.source}`,
110
+ `auth file: ${status.authFilePath} (${authFileState})`,
111
+ ];
112
+ if (!status.configured && resolveClientId()) {
113
+ lines.push("next: run /spotify:login and approve access in the browser");
114
+ }
115
+ return lines.join("\n");
116
+ }
117
+
118
+ export function openExternalUrl(url: string): void {
119
+ if (process.env.PI_SPOTIFY_DISABLE_OPEN === "1") return;
120
+
121
+ // Windows `cmd start` truncates URLs at `&`, which breaks Spotify OAuth query strings.
122
+ if (process.platform === "win32") {
123
+ const child = spawn(
124
+ "powershell",
125
+ ["-NoProfile", "-Command", `Start-Process ${JSON.stringify(url)}`],
126
+ { detached: true, stdio: "ignore", shell: false },
127
+ );
128
+ child.unref?.();
129
+ return;
130
+ }
131
+
132
+ const command = process.platform === "darwin" ? "open" : "xdg-open";
133
+ const child = spawn(command, [url], { detached: true, stdio: "ignore", shell: false });
134
+ child.unref?.();
135
+ }
136
+
137
+ function buildAuthorizeUrl(clientId: string, codeChallenge: string, state: string): string {
138
+ const params = new URLSearchParams({
139
+ client_id: clientId,
140
+ response_type: "code",
141
+ redirect_uri: REDIRECT_URI,
142
+ scope: scopeString(),
143
+ code_challenge_method: "S256",
144
+ code_challenge: codeChallenge,
145
+ state,
146
+ });
147
+ return `${SPOTIFY_AUTH_URL}?${params.toString()}`;
148
+ }
149
+
150
+ interface TokenResponse {
151
+ access_token: string;
152
+ refresh_token?: string;
153
+ expires_in: number;
154
+ scope?: string;
155
+ }
156
+
157
+ async function exchangeToken(
158
+ body: URLSearchParams,
159
+ fetchFn: typeof fetch = fetch,
160
+ ): Promise<TokenResponse> {
161
+ const res = await fetchFn(SPOTIFY_TOKEN_URL, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
164
+ body,
165
+ });
166
+ if (!res.ok) {
167
+ const text = await res.text().catch(() => "");
168
+ throw new Error(`Spotify token exchange failed (HTTP ${res.status})${text ? `: ${text.slice(0, 120)}` : ""}`);
169
+ }
170
+ return res.json() as Promise<TokenResponse>;
171
+ }
172
+
173
+ function authFromTokenResponse(
174
+ token: TokenResponse,
175
+ previous?: SpotifyAuthRecord | null,
176
+ ): SpotifyAuthRecord {
177
+ const refreshToken = token.refresh_token ?? previous?.refreshToken;
178
+ if (!refreshToken) {
179
+ throw new Error("Spotify token response did not include a refresh token.");
180
+ }
181
+ return {
182
+ accessToken: token.access_token,
183
+ refreshToken,
184
+ expiresAt: Date.now() + token.expires_in * 1000,
185
+ scope: token.scope ?? previous?.scope ?? scopeString(),
186
+ updatedAt: nowIso(),
187
+ };
188
+ }
189
+
190
+ function parseCallbackRequest(req: IncomingMessage): { code?: string; error?: string; state?: string } {
191
+ const host = req.headers.host ?? `${CALLBACK_HOST}:${CALLBACK_PORT}`;
192
+ const url = new URL(req.url ?? "/", `http://${host}`);
193
+ return {
194
+ code: url.searchParams.get("code") ?? undefined,
195
+ error: url.searchParams.get("error") ?? undefined,
196
+ state: url.searchParams.get("state") ?? undefined,
197
+ };
198
+ }
199
+
200
+ function sendCallbackPage(res: ServerResponse, title: string, message: string): void {
201
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body><p>${message}</p><p>You can close this tab and return to Pi.</p></body></html>`;
202
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
203
+ res.end(html);
204
+ }
205
+
206
+ export interface LoginOptions {
207
+ clientId?: string;
208
+ fetchFn?: typeof fetch;
209
+ openUrl?: (url: string) => void;
210
+ onAuthorizeUrl?: (url: string) => void;
211
+ }
212
+
213
+ export async function login(options: LoginOptions = {}): Promise<void> {
214
+ const clientId = options.clientId?.trim() || requireClientId();
215
+ const fetchFn = options.fetchFn ?? fetch;
216
+ const openUrl = options.openUrl ?? openExternalUrl;
217
+ const verifier = generateCodeVerifier();
218
+ const challenge = generateCodeChallenge(verifier);
219
+ const state = randomBytes(16).toString("hex");
220
+
221
+ const code = await new Promise<string>((resolve, reject) => {
222
+ const server = createServer((req, res) => {
223
+ if ((req.url ?? "").split("?")[0] !== CALLBACK_PATH) {
224
+ res.writeHead(404);
225
+ res.end();
226
+ return;
227
+ }
228
+
229
+ const parsed = parseCallbackRequest(req);
230
+ if (parsed.state && parsed.state !== state) {
231
+ sendCallbackPage(res, "Spotify login failed", "State mismatch. Return to Pi and run /spotify:login again.");
232
+ server.close();
233
+ reject(new Error("OAuth state mismatch. Run /spotify:login again."));
234
+ return;
235
+ }
236
+ if (parsed.error) {
237
+ sendCallbackPage(res, "Spotify login failed", "Authorization was denied or failed.");
238
+ server.close();
239
+ reject(new Error(`Spotify authorization failed: ${parsed.error}`));
240
+ return;
241
+ }
242
+ if (!parsed.code) {
243
+ sendCallbackPage(res, "Spotify login failed", "Missing authorization code.");
244
+ server.close();
245
+ reject(new Error("Spotify callback did not include an authorization code."));
246
+ return;
247
+ }
248
+
249
+ sendCallbackPage(res, "Spotify login successful", "Authorization complete.");
250
+ server.close();
251
+ resolve(parsed.code);
252
+ });
253
+
254
+ server.on("error", (error: NodeJS.ErrnoException) => {
255
+ if (error.code === "EADDRINUSE") {
256
+ reject(new Error(
257
+ `Port ${CALLBACK_PORT} is already in use. Close the other app using it and run /spotify:login again.`,
258
+ ));
259
+ return;
260
+ }
261
+ reject(error);
262
+ });
263
+
264
+ server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
265
+ const authorizeUrl = buildAuthorizeUrl(clientId, challenge, state);
266
+ options.onAuthorizeUrl?.(authorizeUrl);
267
+ openUrl(authorizeUrl);
268
+ });
269
+
270
+ setTimeout(() => {
271
+ server.close();
272
+ reject(new Error("Spotify login timed out after 5 minutes. Run /spotify:login to try again."));
273
+ }, LOGIN_TIMEOUT_MS).unref?.();
274
+ });
275
+
276
+ const body = new URLSearchParams({
277
+ grant_type: "authorization_code",
278
+ code,
279
+ redirect_uri: REDIRECT_URI,
280
+ client_id: clientId,
281
+ code_verifier: verifier,
282
+ });
283
+ const token = await exchangeToken(body, fetchFn);
284
+ saveAuth(authFromTokenResponse(token));
285
+ }
286
+
287
+ export interface RefreshOptions {
288
+ fetchFn?: typeof fetch;
289
+ }
290
+
291
+ export async function refreshAccessToken(
292
+ auth: SpotifyAuthRecord,
293
+ options: RefreshOptions = {},
294
+ ): Promise<SpotifyAuthRecord> {
295
+ const fetchFn = options.fetchFn ?? fetch;
296
+ const clientId = requireClientId();
297
+ const body = new URLSearchParams({
298
+ grant_type: "refresh_token",
299
+ refresh_token: auth.refreshToken,
300
+ client_id: clientId,
301
+ });
302
+ const token = await exchangeToken(body, fetchFn);
303
+ const next = authFromTokenResponse(token, auth);
304
+ saveAuth(next);
305
+ return next;
306
+ }
307
+
308
+ export async function refreshIfNeeded(options: RefreshOptions = {}): Promise<SpotifyAuthRecord | null> {
309
+ const auth = loadAuth();
310
+ if (!auth) return null;
311
+ if (!needsRefresh(auth)) return auth;
312
+ try {
313
+ return await refreshAccessToken(auth, options);
314
+ } catch {
315
+ clearAuth();
316
+ return null;
317
+ }
318
+ }
319
+
320
+ export async function getValidAccessToken(options: RefreshOptions = {}): Promise<string | null> {
321
+ const auth = await refreshIfNeeded(options);
322
+ if (!auth) return null;
323
+ if (isExpired(auth)) {
324
+ try {
325
+ const refreshed = await refreshAccessToken(auth, options);
326
+ return refreshed.accessToken;
327
+ } catch {
328
+ clearAuth();
329
+ return null;
330
+ }
331
+ }
332
+ return auth.accessToken;
333
+ }
334
+
335
+ export function logout(): void {
336
+ clearAuth();
337
+ lastFetchAt = null;
338
+ }
339
+
340
+ export const __testing = {
341
+ ageText,
342
+ authFromTokenResponse,
343
+ buildAuthorizeUrl,
344
+ exchangeToken,
345
+ expiresInText,
346
+ generateCodeChallenge,
347
+ generateCodeVerifier,
348
+ isExpired,
349
+ needsRefresh,
350
+ parseCallbackRequest,
351
+ resolveClientId,
352
+ };
@@ -0,0 +1,128 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "./auth-store.ts";
4
+
5
+ export const BUNDLED_CLIENT_ID = "";
6
+ export const SPOTIFY_DASHBOARD_URL = "https://developer.spotify.com/dashboard";
7
+
8
+ export const REDIRECT_URI = "http://127.0.0.1:8888/callback";
9
+ export const CALLBACK_HOST = "127.0.0.1";
10
+ export const CALLBACK_PORT = 8888;
11
+ export const CALLBACK_PATH = "/callback";
12
+
13
+ export const SCOPES = [
14
+ "user-read-playback-state",
15
+ "user-read-currently-playing",
16
+ "user-modify-playback-state",
17
+ ] as const;
18
+
19
+ export const SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize";
20
+ export const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
21
+ export const SPOTIFY_API_BASE = "https://api.spotify.com/v1";
22
+
23
+ export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
24
+ export const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
25
+ /** @deprecated use refresh-schedule constants */
26
+ export const POLL_INTERVAL_MS = 5 * 1000;
27
+
28
+ export const AUTH_FILE_NAME = "pi-spotify-widget-auth.json";
29
+ export const CONFIG_FILE_NAME = "pi-spotify-widget-config.json";
30
+ export const WIDGET_ID = "pi-spotify-widget";
31
+
32
+ export type ClientIdSource = "environment" | "stored" | "bundled" | "missing";
33
+
34
+ export interface SpotifyClientConfigRecord {
35
+ clientId: string;
36
+ updatedAt: string;
37
+ }
38
+
39
+ export function getConfigFilePath(): string {
40
+ return join(getAgentDir(), CONFIG_FILE_NAME);
41
+ }
42
+
43
+ function ensureAgentDir(): void {
44
+ try {
45
+ mkdirSync(getAgentDir(), { recursive: true });
46
+ } catch {
47
+ // ignore
48
+ }
49
+ }
50
+
51
+ export function loadStoredClientId(): string | undefined {
52
+ const file = getConfigFilePath();
53
+ try {
54
+ if (!existsSync(file)) return undefined;
55
+ const parsed = JSON.parse(readFileSync(file, "utf-8")) as Partial<SpotifyClientConfigRecord>;
56
+ const trimmed = typeof parsed.clientId === "string" ? parsed.clientId.trim() : "";
57
+ return trimmed || undefined;
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ export function saveStoredClientId(clientId: string): void {
64
+ ensureAgentDir();
65
+ const file = getConfigFilePath();
66
+ const record: SpotifyClientConfigRecord = {
67
+ clientId: clientId.trim(),
68
+ updatedAt: new Date().toISOString(),
69
+ };
70
+ writeFileSync(file, JSON.stringify(record, null, 2), "utf-8");
71
+ try {
72
+ chmodSync(file, 0o600);
73
+ } catch {
74
+ // ignore chmod errors on platforms without POSIX modes
75
+ }
76
+ }
77
+
78
+ export function clearStoredClientId(): void {
79
+ const file = getConfigFilePath();
80
+ try {
81
+ if (existsSync(file)) rmSync(file);
82
+ } catch {
83
+ // ignore
84
+ }
85
+ }
86
+
87
+ export function resolveClientIdSource(env: NodeJS.ProcessEnv = process.env): ClientIdSource {
88
+ if (env.PI_SPOTIFY_CLIENT_ID?.trim()) return "environment";
89
+ if (loadStoredClientId()) return "stored";
90
+ if (BUNDLED_CLIENT_ID) return "bundled";
91
+ return "missing";
92
+ }
93
+
94
+ export function resolveClientId(env: NodeJS.ProcessEnv = process.env): string {
95
+ const override = env.PI_SPOTIFY_CLIENT_ID?.trim();
96
+ if (override) return override;
97
+ const stored = loadStoredClientId();
98
+ if (stored) return stored;
99
+ return BUNDLED_CLIENT_ID;
100
+ }
101
+
102
+ export function clientIdStatusText(env: NodeJS.ProcessEnv = process.env): string {
103
+ const source = resolveClientIdSource(env);
104
+ if (source === "environment") {
105
+ return "Spotify Client ID: configured via PI_SPOTIFY_CLIENT_ID environment variable.";
106
+ }
107
+ if (source === "stored") {
108
+ return "Spotify Client ID: configured via /spotify:login.";
109
+ }
110
+ if (source === "bundled") {
111
+ return "Spotify Client ID: configured via bundled app.";
112
+ }
113
+ return "Spotify Client ID: not configured. Run /spotify:login.";
114
+ }
115
+
116
+ export function requireClientId(env: NodeJS.ProcessEnv = process.env): string {
117
+ const clientId = resolveClientId(env);
118
+ if (!clientId) {
119
+ throw new Error(
120
+ "Spotify Client ID is not configured. Run /spotify:login to open the Spotify Developer Dashboard and enter your Client ID.",
121
+ );
122
+ }
123
+ return clientId;
124
+ }
125
+
126
+ export function scopeString(): string {
127
+ return SCOPES.join(" ");
128
+ }