@mulmoclaude/spotify-plugin 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 @@
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../../node_modules/gui-chat-protocol/dist/index.js","../src/oauth.ts","../src/time.ts","../src/tokens.ts","../src/client.ts","../src/normalize.ts","../src/listening.ts","../src/search.ts","../src/searchSummary.ts","../src/profile.ts","../src/playback.ts","../src/index.ts"],"sourcesContent":["function definePlugin(setup) {\n return setup;\n}\nfunction isPluginFactory(value) {\n return typeof value === \"function\";\n}\nexport {\n definePlugin,\n isPluginFactory\n};\n//# sourceMappingURL=index.js.map\n","// PKCE primitives + in-memory pending-authorization store.\n//\n// PKCE (RFC 7636) flow:\n// 1. Plugin generates a high-entropy `code_verifier` per connect.\n// 2. Derives `code_challenge` = base64url(SHA-256(code_verifier)).\n// 3. Sends the challenge to Spotify's authorize endpoint plus a\n// single-use `state`.\n// 4. Browser comes back with `code` + `state`. Plugin looks up the\n// pending record by state, presents `code_verifier` to the\n// token endpoint.\n// 5. Verifier never leaves the host process.\n//\n// `state` is the CSRF defense — a third-party site can't trick the\n// user's browser into completing an OAuth dance the user never\n// started here, because the matching state isn't in the store.\n//\n// Crypto via the global WebCrypto (`globalThis.crypto.{subtle,\n// getRandomValues}`). Node 20+ ships WebCrypto on the global, so\n// the same code paths work in both runtimes. Importing\n// `node:crypto` would force vite to externalise that specifier and\n// drag a platform-detect branch into the bundle.\n//\n// `deriveCodeChallenge` is async because `crypto.subtle.digest` is\n// async; the call sites are in async handlers anyway.\n\nimport type { PendingAuthorization } from \"./types\";\n\n/** Maximum age before a pending authorization is considered stale.\n * Spotify's authorize page typically redirects back within a\n * minute; 10 minutes covers slow users without leaking entries\n * forever. The runtime is sandboxed (no `runtime.now`), so this\n * uses `Date.now()` directly — pure number, not external state. */\nconst PENDING_TTL_MS = 10 * 60 * 1000;\n\nconst _pendingAuthorizations = new Map<string, PendingAuthorization>();\n\n/** Generate 32 bytes of random entropy as a base64url string.\n * Used both for `code_verifier` (PKCE) and `state` (CSRF). */\nexport function generateRandomToken(): string {\n const bytes = new Uint8Array(32);\n globalThis.crypto.getRandomValues(bytes);\n return base64UrlEncode(bytes);\n}\n\n/** Derive `code_challenge` from `code_verifier`: SHA-256 then\n * base64url. Spotify's authorize URL carries this; the token\n * endpoint receives the verifier and re-derives. */\nexport async function deriveCodeChallenge(codeVerifier: string): Promise<string> {\n const buffer = await globalThis.crypto.subtle.digest(\"SHA-256\", new TextEncoder().encode(codeVerifier));\n return base64UrlEncode(new Uint8Array(buffer));\n}\n\n/** Encode bytes as RFC 4648 base64url (no padding). The standard\n * `btoa` produces base64; we replace the URL-unsafe characters and\n * strip padding to match what Spotify's authorize / token\n * endpoints expect. */\nfunction base64UrlEncode(bytes: Uint8Array): string {\n let binary = \"\";\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replaceAll(\"+\", \"-\").replaceAll(\"/\", \"_\").replace(/=+$/, \"\");\n}\n\n/** Register a fresh pending authorization. Returns the `state` the\n * caller embeds on the authorize URL. Sweeps stale entries on\n * every call so the map can't grow unbounded across abandoned\n * attempts. */\nexport function registerPendingAuthorization(codeVerifier: string, redirectUri: string, now: Date = new Date()): string {\n sweepStaleAuthorizations(now);\n const state = generateRandomToken();\n _pendingAuthorizations.set(state, {\n codeVerifier,\n redirectUri,\n createdAtMs: now.getTime(),\n });\n return state;\n}\n\n/** Look up + consume a pending authorization by `state`. Single-\n * use: a successful lookup deletes the record so the same state\n * can't be replayed. Returns null when the state is unknown\n * (CSRF / stale / replayed). */\nexport function consumePendingAuthorization(state: string, now: Date = new Date()): PendingAuthorization | null {\n sweepStaleAuthorizations(now);\n const entry = _pendingAuthorizations.get(state);\n if (!entry) return null;\n _pendingAuthorizations.delete(state);\n return entry;\n}\n\nfunction sweepStaleAuthorizations(now: Date): void {\n const cutoff = now.getTime() - PENDING_TTL_MS;\n for (const [state, entry] of _pendingAuthorizations) {\n if (entry.createdAtMs < cutoff) _pendingAuthorizations.delete(state);\n }\n}\n\n/** Build the Spotify authorize URL. Pure — no side effects, no\n * randomness; `state` and `codeChallenge` are passed in so the\n * caller controls them (for replay registration and tests). */\nexport function buildAuthorizeUrl(params: { clientId: string; redirectUri: string; scopes: readonly string[]; state: string; codeChallenge: string }): string {\n const search = new URLSearchParams({\n response_type: \"code\",\n client_id: params.clientId,\n redirect_uri: params.redirectUri,\n scope: params.scopes.join(\" \"),\n state: params.state,\n code_challenge_method: \"S256\",\n code_challenge: params.codeChallenge,\n });\n return `https://accounts.spotify.com/authorize?${search.toString()}`;\n}\n\n/** Test-only access to the in-memory store. */\nexport const _pendingAuthorizationsForTests = _pendingAuthorizations;\n\n/** Test-only reset — wipe the store between cases. */\nexport function _resetPendingAuthorizationsForTests(): void {\n _pendingAuthorizations.clear();\n}\n","// Local time-constants. The plugin can't import host's\n// `server/utils/time.ts` (the runtime is sandboxed), so we mirror\n// the small constants we need. Keeping them in one module preserves\n// the \"no raw 1000 / 60000\" lint convention plugin-side too.\n\nexport const ONE_SECOND_MS = 1000;\nexport const ONE_MINUTE_MS = 60 * ONE_SECOND_MS;\n","// Token + client-config persistence on top of `runtime.files.config`.\n// Lives at:\n// tokens.json — accessToken / refreshToken / expiresAt / scopes\n// client.json — { clientId } (user pastes their Spotify Developer\n// Dashboard Client ID here; PKCE flow needs no secret)\n//\n// Both are per-machine secrets — `files.config` is the right scope\n// (`files.data` is described in the protocol as a backup target, so\n// putting tokens / Client IDs there would invite cross-machine sync,\n// which is wrong for these values).\n\nimport type { FileOps } from \"gui-chat-protocol\";\n\nimport { ClientConfigSchema, TokensSchema } from \"./schemas\";\nimport { ONE_SECOND_MS } from \"./time\";\nimport type { RefreshResponseFields, SpotifyClientConfig, SpotifyTokens } from \"./types\";\n\nconst TOKENS_FILE = \"tokens.json\";\nconst CLIENT_CONFIG_FILE = \"client.json\";\n\n/** Read persisted tokens. Returns null on absent / malformed (=\n * caller treats as \"not_connected\" and walks the user back to the\n * connect button). Throws only on the read I/O itself. */\nexport async function readTokens(files: FileOps): Promise<SpotifyTokens | null> {\n if (!(await files.exists(TOKENS_FILE))) return null;\n try {\n const raw = await files.read(TOKENS_FILE);\n const parsed = TokensSchema.safeParse(JSON.parse(raw));\n return parsed.success ? parsed.data : null;\n } catch {\n return null;\n }\n}\n\n/** Write the full token record. */\nexport async function writeTokens(files: FileOps, tokens: SpotifyTokens): Promise<void> {\n await files.write(TOKENS_FILE, JSON.stringify(tokens, null, 2));\n}\n\n/** Read the user-provided Client ID. Returns null when the file is\n * absent / malformed (caller treats as \"client_id_missing\" and\n * surfaces the setup guide). */\nexport async function readClientConfig(files: FileOps): Promise<SpotifyClientConfig | null> {\n if (!(await files.exists(CLIENT_CONFIG_FILE))) return null;\n try {\n const raw = await files.read(CLIENT_CONFIG_FILE);\n const parsed = ClientConfigSchema.safeParse(JSON.parse(raw));\n return parsed.success ? parsed.data : null;\n } catch {\n return null;\n }\n}\n\n/** Write the Client ID. The View's \"Configure\" form posts here\n * via `runtime.dispatch({ kind: \"configure\", clientId })` (PR 2). */\nexport async function writeClientConfig(files: FileOps, config: SpotifyClientConfig): Promise<void> {\n await files.write(CLIENT_CONFIG_FILE, JSON.stringify(config, null, 2));\n}\n\n/** Apply a refresh response to the persisted tokens, preserving the\n * prior `refreshToken` when Spotify omits a fresh one (the common\n * case). Pure — caller persists. */\nexport function mergeRefreshResponse(prior: SpotifyTokens, response: RefreshResponseFields, now: Date = new Date()): SpotifyTokens {\n return {\n accessToken: response.accessToken,\n refreshToken: response.refreshToken ?? prior.refreshToken,\n expiresAt: new Date(now.getTime() + response.expiresInSec * ONE_SECOND_MS).toISOString(),\n scopes: response.scopes !== undefined ? [...response.scopes] : prior.scopes,\n };\n}\n","// Spotify Web API client — wraps `runtime.fetch` with proactive\n// refresh near expiry + a single 401 → refresh → retry-once loop.\n//\n// Why retry only once: a second 401 after refresh means the\n// refresh token is revoked / rotated, and looping would just\n// hammer Spotify's token endpoint. Surface `auth_expired` and let\n// the user reconnect.\n//\n// Refresh-path failures are split into two classes (CodeRabbit\n// review on PR #1166): `auth_expired` only when Spotify actually\n// rejected the refresh token (4xx, or protocol-malformed response),\n// and `transient_error` for network / 5xx / parse failures so the\n// user isn't told to reconnect during a Spotify outage.\n\nimport type { PluginRuntime } from \"gui-chat-protocol\";\n\nimport { mergeRefreshResponse, writeTokens } from \"./tokens\";\nimport { ONE_SECOND_MS } from \"./time\";\nimport type { RefreshResponseFields, SpotifyTokens } from \"./types\";\n\nconst SPOTIFY_API_BASE = \"https://api.spotify.com\";\nconst SPOTIFY_TOKEN_URL = \"https://accounts.spotify.com/api/token\";\nconst SPOTIFY_API_HOST = \"api.spotify.com\";\nconst SPOTIFY_TOKEN_HOST = \"accounts.spotify.com\";\n\nconst FETCH_TIMEOUT_MS = 15 * ONE_SECOND_MS;\n\n/** Treat tokens within this window of expiry as already expired so\n * a request that races the boundary refreshes proactively instead\n * of waiting for the 401. */\nconst EXPIRY_LEEWAY_MS = 30 * ONE_SECOND_MS;\n\nconst RETRY_AFTER_FALLBACK_SEC = 60;\n\n/** Reasons the client returns instead of throwing. The dispatch\n * layer (`index.ts`) maps these to the user-facing `instructions`\n * field of the SpotifyError union.\n *\n * `auth_expired` means the credential is unusable — refresh token\n * was rejected by Spotify, or the protocol response was malformed\n * in a way that's indistinguishable from rejection. The user has to\n * reconnect.\n *\n * `transient_error` means the refresh path failed in a way that\n * doesn't imply the credential is bad — network timeout, 5xx from\n * Spotify, JSON parse failure (likely proxy / middleware). Caller\n * should retry later, NOT prompt the user to reconnect. */\nexport type SpotifyClientError =\n | { kind: \"not_connected\" }\n | { kind: \"auth_expired\"; detail: string }\n | { kind: \"transient_error\"; detail: string }\n | { kind: \"rate_limited\"; retryAfterSec: number }\n | { kind: \"spotify_api_error\"; status: number; body: string };\n\nexport type SpotifyClientResult<T> = { ok: true; data: T } | { ok: false; error: SpotifyClientError };\n\ninterface RawTokenResponse {\n access_token?: unknown;\n refresh_token?: unknown;\n expires_in?: unknown;\n scope?: unknown;\n}\n\n/** Make an authenticated Spotify API call. Path is relative to\n * `https://api.spotify.com` (e.g. `/v1/me/player/recently-played`). */\nexport async function spotifyApi<T = unknown>(\n runtime: PluginRuntime,\n clientId: string,\n initialTokens: SpotifyTokens,\n method: \"GET\" | \"POST\" | \"PUT\" | \"DELETE\",\n apiPath: string,\n init: { body?: unknown } = {},\n now: () => Date = () => new Date(),\n): Promise<SpotifyClientResult<T>> {\n let tokens = initialTokens;\n if (needsProactiveRefresh(tokens, now())) {\n const refreshed = await refreshTokens(runtime, clientId, tokens, now);\n if (!refreshed.ok) return { ok: false, error: refreshed.error };\n tokens = refreshed.tokens;\n }\n const firstAttempt = await callOnce<T>(runtime, method, apiPath, init, tokens);\n if (firstAttempt.ok || firstAttempt.error.kind !== \"auth_expired\") return firstAttempt;\n\n // 401 reactive refresh. Only one retry — a second 401 after\n // refresh signals a revoked refresh token; reconnect is the only\n // recovery.\n const refreshed = await refreshTokens(runtime, clientId, tokens, now);\n if (!refreshed.ok) return { ok: false, error: refreshed.error };\n return callOnce<T>(runtime, method, apiPath, init, refreshed.tokens);\n}\n\nfunction needsProactiveRefresh(tokens: SpotifyTokens, now: Date): boolean {\n const expiresAtMs = Date.parse(tokens.expiresAt);\n if (Number.isNaN(expiresAtMs)) return true; // corrupt / unknown — refresh defensively\n return expiresAtMs - now.getTime() <= EXPIRY_LEEWAY_MS;\n}\n\nasync function callOnce<T>(\n runtime: PluginRuntime,\n method: \"GET\" | \"POST\" | \"PUT\" | \"DELETE\",\n apiPath: string,\n init: { body?: unknown },\n tokens: SpotifyTokens,\n): Promise<SpotifyClientResult<T>> {\n let response: Response;\n try {\n response = await runtime.fetch(`${SPOTIFY_API_BASE}${apiPath}`, {\n method,\n headers: {\n Authorization: `Bearer ${tokens.accessToken}`,\n ...(init.body !== undefined ? { \"Content-Type\": \"application/json\" } : {}),\n },\n body: init.body !== undefined ? JSON.stringify(init.body) : undefined,\n timeoutMs: FETCH_TIMEOUT_MS,\n allowedHosts: [SPOTIFY_API_HOST],\n });\n } catch (err) {\n return { ok: false, error: { kind: \"spotify_api_error\", status: 0, body: errorMessage(err) } };\n }\n if (response.status === 401) {\n return { ok: false, error: { kind: \"auth_expired\", detail: \"Spotify returned 401\" } };\n }\n if (response.status === 429) {\n return { ok: false, error: { kind: \"rate_limited\", retryAfterSec: parseRetryAfterSec(response.headers.get(\"Retry-After\")) } };\n }\n if (!response.ok) {\n const body = await response.text().catch(() => \"\");\n return { ok: false, error: { kind: \"spotify_api_error\", status: response.status, body: body.slice(0, 500) } };\n }\n if (response.status === 204) return { ok: true, data: null as T };\n try {\n const data = (await response.json()) as T;\n return { ok: true, data };\n } catch (err) {\n return { ok: false, error: { kind: \"spotify_api_error\", status: response.status, body: errorMessage(err) } };\n }\n}\n\n/** Refresh the access token using the persisted refreshToken and\n * persist the merged result. */\nasync function refreshTokens(\n runtime: PluginRuntime,\n clientId: string,\n tokens: SpotifyTokens,\n now: () => Date,\n): Promise<{ ok: true; tokens: SpotifyTokens } | { ok: false; error: SpotifyClientError }> {\n let response: Response;\n try {\n response = await runtime.fetch(SPOTIFY_TOKEN_URL, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"refresh_token\",\n refresh_token: tokens.refreshToken,\n client_id: clientId,\n }).toString(),\n timeoutMs: FETCH_TIMEOUT_MS,\n allowedHosts: [SPOTIFY_TOKEN_HOST],\n });\n } catch (err) {\n return { ok: false, error: { kind: \"transient_error\", detail: `refresh fetch failed: ${errorMessage(err)}` } };\n }\n if (!response.ok) {\n const body = await response.text().catch(() => \"\");\n runtime.log.warn(\"refresh failed\", { status: response.status, body: body.slice(0, 200) });\n // 4xx ⇒ Spotify rejected the refresh token (revoked, malformed,\n // client_id mismatch). 5xx ⇒ Spotify is having an outage; the\n // credential may still be valid. Don't conflate the two — but\n // 408 (Request Timeout) and 429 (Too Many Requests) are also\n // transient: the credential wasn't actually checked, so forcing\n // a re-auth would be wrong.\n const status = response.status;\n const isTransient = status >= 500 || status === 408 || status === 429;\n const kind = isTransient ? \"transient_error\" : \"auth_expired\";\n return { ok: false, error: { kind, detail: `refresh returned ${status}` } };\n }\n let parsed: RawTokenResponse;\n try {\n parsed = (await response.json()) as RawTokenResponse;\n } catch (err) {\n // Non-JSON response on a 2xx is almost certainly a proxy /\n // middleware error page — transient, not credential-related.\n return { ok: false, error: { kind: \"transient_error\", detail: `refresh response parse failed: ${errorMessage(err)}` } };\n }\n const refreshFields = parseRefreshResponse(parsed);\n if (!refreshFields) {\n // Spotify returned 2xx JSON without access_token / expires_in.\n // Indistinguishable from a refresh-token rejection at the protocol\n // level — surface as auth_expired so the user reconnects.\n return { ok: false, error: { kind: \"auth_expired\", detail: \"refresh response missing access_token / expires_in\" } };\n }\n const merged = mergeRefreshResponse(tokens, refreshFields, now());\n await writeTokens(runtime.files.config, merged);\n return { ok: true, tokens: merged };\n}\n\nfunction parseRefreshResponse(raw: RawTokenResponse): RefreshResponseFields | null {\n if (typeof raw.access_token !== \"string\" || raw.access_token.length === 0) return null;\n if (typeof raw.expires_in !== \"number\" || !Number.isFinite(raw.expires_in)) return null;\n return {\n accessToken: raw.access_token,\n refreshToken: typeof raw.refresh_token === \"string\" && raw.refresh_token.length > 0 ? raw.refresh_token : undefined,\n expiresInSec: raw.expires_in,\n scopes: typeof raw.scope === \"string\" ? raw.scope.split(\" \").filter(Boolean) : undefined,\n };\n}\n\n/** Parse a `Retry-After` header. Spotify normally returns delta-\n * seconds (an integer) but the RFC also allows HTTP-date format.\n * Anything non-finite or non-positive collapses to a safe 60s\n * fallback so callers never propagate `NaN` (Codex review on\n * PR #1164 caught this). */\nexport function parseRetryAfterSec(headerValue: string | null): number {\n if (headerValue === null) return RETRY_AFTER_FALLBACK_SEC;\n const trimmed = headerValue.trim();\n if (trimmed === \"\") return RETRY_AFTER_FALLBACK_SEC;\n // delta-seconds path — pure integer.\n const asInt = Number.parseInt(trimmed, 10);\n if (Number.isFinite(asInt) && asInt > 0 && String(asInt) === trimmed) return asInt;\n // HTTP-date path — parse and diff against now.\n const asDateMs = Date.parse(trimmed);\n if (Number.isFinite(asDateMs)) {\n const deltaSec = Math.ceil((asDateMs - Date.now()) / ONE_SECOND_MS);\n if (deltaSec > 0) return deltaSec;\n }\n return RETRY_AFTER_FALLBACK_SEC;\n}\n\nfunction errorMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n","// Normalisers: shrink raw Spotify responses to the\n// `Normalised{Track,Playlist}` shapes the View renders. Pure —\n// no runtime / fetch / I/O — so unit tests run without mocks.\n\nimport type { NormalisedAlbum, NormalisedArtist, NormalisedPlaylist, NormalisedTrack, RecentlyPlayedItem } from \"./types\";\n\ninterface SpotifyArtist {\n name?: unknown;\n}\n\ninterface SpotifyImage {\n url?: unknown;\n}\n\ninterface SpotifyAlbum {\n name?: unknown;\n images?: unknown;\n}\n\ninterface SpotifyExternalUrls {\n spotify?: unknown;\n}\n\ninterface SpotifyTrack {\n id?: unknown;\n name?: unknown;\n artists?: unknown;\n album?: unknown;\n duration_ms?: unknown;\n external_urls?: unknown;\n}\n\ninterface SpotifyPlaylist {\n id?: unknown;\n name?: unknown;\n description?: unknown;\n /** Legacy field — Spotify's older `SimplifiedPlaylistObject`\n * carried `tracks: { href, total }` here. Still present on\n * individual-playlist endpoints. */\n tracks?: unknown;\n /** Newer field — `/v1/me/playlists` now returns\n * `items: { href, total }` on each playlist (Spotify renamed\n * the field 2024-2025 to align with the new\n * `/v1/playlists/{id}/items` endpoint that handles tracks\n * AND episodes). Read both for forward-compat (#1162 PR 2). */\n items?: unknown;\n external_urls?: unknown;\n images?: unknown;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null;\n\nfunction smallestImageUrl(images: unknown): string | undefined {\n if (!Array.isArray(images) || images.length === 0) return undefined;\n // Spotify orders `images` largest-first; pick the last one with a\n // valid URL so we don't hammer mobile data plans on cover-art-heavy\n // playlists.\n for (let i = images.length - 1; i >= 0; i -= 1) {\n const candidate = images[i] as SpotifyImage;\n if (typeof candidate?.url === \"string\" && candidate.url.length > 0) return candidate.url;\n }\n return undefined;\n}\n\nfunction spotifyUrl(externalUrls: unknown): string | undefined {\n if (!isRecord(externalUrls)) return undefined;\n const candidate = (externalUrls as SpotifyExternalUrls).spotify;\n return typeof candidate === \"string\" && candidate.length > 0 ? candidate : undefined;\n}\n\nfunction artistNames(artists: unknown): string[] {\n if (!Array.isArray(artists)) return [];\n return artists\n .map((a) => (isRecord(a) && typeof (a as SpotifyArtist).name === \"string\" ? ((a as SpotifyArtist).name as string) : \"\"))\n .filter((n) => n.length > 0);\n}\n\n/** Normalise one Spotify track. Returns null when the response is\n * missing required scalar fields — caller should drop it from the\n * list rather than render a half-broken row. */\nexport function normaliseTrack(raw: unknown): NormalisedTrack | null {\n if (!isRecord(raw)) return null;\n const track = raw as SpotifyTrack;\n if (typeof track.id !== \"string\" || track.id.length === 0) return null;\n if (typeof track.name !== \"string\") return null;\n const album = isRecord(track.album) ? (track.album as SpotifyAlbum) : null;\n const url = spotifyUrl(track.external_urls);\n const imageUrl = smallestImageUrl(album?.images);\n return {\n id: track.id,\n name: track.name,\n artists: artistNames(track.artists),\n album: typeof album?.name === \"string\" ? album.name : \"\",\n durationMs: typeof track.duration_ms === \"number\" && Number.isFinite(track.duration_ms) ? track.duration_ms : 0,\n ...(url !== undefined ? { url } : {}),\n ...(imageUrl !== undefined ? { imageUrl } : {}),\n };\n}\n\n/** Walk a paginated `items[]` response, normalise each entry's\n * nested track, and drop entries that fail validation. */\nexport function normaliseTrackList(raw: unknown, trackPath: \"track\" | \"self\"): NormalisedTrack[] {\n if (!isRecord(raw)) return [];\n const items = (raw as { items?: unknown }).items;\n if (!Array.isArray(items)) return [];\n const out: NormalisedTrack[] = [];\n for (const item of items) {\n if (!isRecord(item)) continue;\n const candidate = trackPath === \"track\" ? item.track : item;\n const normalised = normaliseTrack(candidate);\n if (normalised) out.push(normalised);\n }\n return out;\n}\n\n/** `recently-played` items wrap the track in an object that carries\n * the `played_at` timestamp. The View renders timestamps so we\n * preserve them at this layer. */\nexport function normaliseRecentlyPlayed(raw: unknown): RecentlyPlayedItem[] {\n if (!isRecord(raw)) return [];\n const items = (raw as { items?: unknown }).items;\n if (!Array.isArray(items)) return [];\n const out: RecentlyPlayedItem[] = [];\n for (const item of items) {\n if (!isRecord(item)) continue;\n const track = normaliseTrack(item.track);\n if (!track) continue;\n const playedAt = typeof item.played_at === \"string\" ? item.played_at : \"\";\n out.push({ track, playedAt });\n }\n return out;\n}\n\nfunction readPlaylistTotal(playlist: SpotifyPlaylist): number {\n // Read the new `items.total` first (current Spotify response\n // shape), fall back to `tracks.total` for the legacy shape.\n const candidates = [playlist.items, playlist.tracks];\n for (const candidate of candidates) {\n if (!isRecord(candidate)) continue;\n const total = (candidate as { total?: unknown }).total;\n if (typeof total === \"number\" && Number.isFinite(total)) return total;\n }\n return 0;\n}\n\nexport function normalisePlaylist(raw: unknown): NormalisedPlaylist | null {\n if (!isRecord(raw)) return null;\n const playlist = raw as SpotifyPlaylist;\n if (typeof playlist.id !== \"string\" || playlist.id.length === 0) return null;\n if (typeof playlist.name !== \"string\") return null;\n const url = spotifyUrl(playlist.external_urls);\n const imageUrl = smallestImageUrl(playlist.images);\n return {\n id: playlist.id,\n name: playlist.name,\n description: typeof playlist.description === \"string\" ? playlist.description : \"\",\n trackCount: readPlaylistTotal(playlist),\n ...(url !== undefined ? { url } : {}),\n ...(imageUrl !== undefined ? { imageUrl } : {}),\n };\n}\n\nexport function normalisePlaylistList(raw: unknown): NormalisedPlaylist[] {\n if (!isRecord(raw)) return [];\n const items = (raw as { items?: unknown }).items;\n if (!Array.isArray(items)) return [];\n const out: NormalisedPlaylist[] = [];\n for (const item of items) {\n const normalised = normalisePlaylist(item);\n if (normalised) out.push(normalised);\n }\n return out;\n}\n\ninterface SpotifyArtistFull {\n id?: unknown;\n name?: unknown;\n genres?: unknown;\n popularity?: unknown;\n external_urls?: unknown;\n images?: unknown;\n}\n\nexport function normaliseArtist(raw: unknown): NormalisedArtist | null {\n if (!isRecord(raw)) return null;\n const artist = raw as SpotifyArtistFull;\n if (typeof artist.id !== \"string\" || artist.id.length === 0) return null;\n if (typeof artist.name !== \"string\") return null;\n const url = spotifyUrl(artist.external_urls);\n const imageUrl = smallestImageUrl(artist.images);\n const popularity = typeof artist.popularity === \"number\" && Number.isFinite(artist.popularity) ? artist.popularity : undefined;\n return {\n id: artist.id,\n name: artist.name,\n genres: Array.isArray(artist.genres) ? artist.genres.filter((g): g is string => typeof g === \"string\") : [],\n ...(popularity !== undefined ? { popularity } : {}),\n ...(url !== undefined ? { url } : {}),\n ...(imageUrl !== undefined ? { imageUrl } : {}),\n };\n}\n\nexport function normaliseArtistList(raw: unknown): NormalisedArtist[] {\n if (!isRecord(raw)) return [];\n const items = (raw as { items?: unknown }).items;\n if (!Array.isArray(items)) return [];\n const out: NormalisedArtist[] = [];\n for (const item of items) {\n const normalised = normaliseArtist(item);\n if (normalised) out.push(normalised);\n }\n return out;\n}\n\ninterface SpotifyAlbumFull {\n id?: unknown;\n name?: unknown;\n artists?: unknown;\n release_date?: unknown;\n total_tracks?: unknown;\n external_urls?: unknown;\n images?: unknown;\n}\n\nexport function normaliseAlbum(raw: unknown): NormalisedAlbum | null {\n if (!isRecord(raw)) return null;\n const album = raw as SpotifyAlbumFull;\n if (typeof album.id !== \"string\" || album.id.length === 0) return null;\n if (typeof album.name !== \"string\") return null;\n const url = spotifyUrl(album.external_urls);\n const imageUrl = smallestImageUrl(album.images);\n return {\n id: album.id,\n name: album.name,\n artists: artistNames(album.artists),\n releaseDate: typeof album.release_date === \"string\" ? album.release_date : \"\",\n totalTracks: typeof album.total_tracks === \"number\" && Number.isFinite(album.total_tracks) ? album.total_tracks : 0,\n ...(url !== undefined ? { url } : {}),\n ...(imageUrl !== undefined ? { imageUrl } : {}),\n };\n}\n\nexport function normaliseAlbumList(raw: unknown): NormalisedAlbum[] {\n if (!isRecord(raw)) return [];\n const items = (raw as { items?: unknown }).items;\n if (!Array.isArray(items)) return [];\n const out: NormalisedAlbum[] = [];\n for (const item of items) {\n const normalised = normaliseAlbum(item);\n if (normalised) out.push(normalised);\n }\n return out;\n}\n","// Listening-data handlers (PR 2). Each one:\n// 1. Reads clientConfig + tokens; returns a structured error\n// result if either is missing.\n// 2. Calls `spotifyApi(...)` for the matching Spotify endpoint.\n// 3. Normalises the response into the View-friendly shape.\n//\n// Kept separate from `index.ts` so the dispatcher stays small and\n// each handler is independently testable. Pure delegation — the\n// runtime + clientId + tokens are passed in by the dispatcher.\n\nimport type { PluginRuntime } from \"gui-chat-protocol\";\n\nimport { spotifyApi } from \"./client\";\nimport type { SpotifyClientError } from \"./client\";\nimport { normalisePlaylist, normalisePlaylistList, normaliseRecentlyPlayed, normaliseTrack, normaliseTrackList } from \"./normalize\";\nimport type { NormalisedPlaylist, NormalisedTrack, RecentlyPlayedItem, SpotifyTokens } from \"./types\";\n\nexport interface ListeningDeps {\n runtime: PluginRuntime;\n clientId: string;\n tokens: SpotifyTokens;\n /** Injectable clock — primarily for tests, where the default\n * `() => new Date()` would race the proactive-refresh window\n * whenever the fixture's `expiresAt` is close to wall-clock time.\n * Production callers omit it. */\n now?: () => Date;\n}\n\ntype Result<T> = { ok: true; data: T } | { ok: false; error: SpotifyClientError };\n\nexport async function fetchLiked(deps: ListeningDeps, limit: number): Promise<Result<NormalisedTrack[]>> {\n const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"GET\", `/v1/me/tracks?limit=${limit}`, {}, deps.now);\n if (!result.ok) return result;\n return { ok: true, data: normaliseTrackList(result.data, \"track\") };\n}\n\n/** Spotify's `/v1/me/playlists` caps at 50 items per page. Walk\n * pages until exhausted (`next === null`) or a hard cap is hit, so\n * users with a large library don't silently lose playlists\n * (CodeRabbit review on PR #1166). Cap at 500 so a runaway\n * account-with-thousands-of-playlists doesn't blow the LLM context\n * window or hammer the API. */\nconst PLAYLISTS_PAGE_SIZE = 50;\nconst PLAYLISTS_HARD_CAP = 500;\n\nexport async function fetchPlaylists(deps: ListeningDeps): Promise<Result<NormalisedPlaylist[]>> {\n const collected: NormalisedPlaylist[] = [];\n let offset = 0;\n while (collected.length < PLAYLISTS_HARD_CAP) {\n const result = await spotifyApi(\n deps.runtime,\n deps.clientId,\n deps.tokens,\n \"GET\",\n `/v1/me/playlists?limit=${PLAYLISTS_PAGE_SIZE}&offset=${offset}`,\n {},\n deps.now,\n );\n if (!result.ok) return result;\n logPlaylistsPageDebug(deps, result.data, offset);\n collected.push(...normalisePlaylistList(result.data));\n if (!hasNextPage(result.data)) break;\n offset += PLAYLISTS_PAGE_SIZE;\n }\n return { ok: true, data: collected };\n}\n\nfunction hasNextPage(raw: unknown): boolean {\n return typeof raw === \"object\" && raw !== null && typeof (raw as { next?: unknown }).next === \"string\";\n}\n\nfunction logPlaylistsPageDebug(deps: ListeningDeps, raw: unknown, offset: number): void {\n // Dump first item's `tracks` shape on debug log so \"all playlists\n // show 0 tracks\" reports can be triaged from the server log\n // without re-running curl.\n if (typeof raw !== \"object\" || raw === null) return;\n const items = (raw as { items?: unknown }).items;\n if (!Array.isArray(items) || items.length === 0) return;\n if (typeof items[0] !== \"object\" || items[0] === null) return;\n const sample = items[0] as { id?: unknown; name?: unknown; tracks?: unknown };\n deps.runtime.log.debug(\"playlists page\", { offset, count: items.length, sample: { id: sample.id, name: sample.name, tracks: sample.tracks } });\n}\n\nexport async function fetchPlaylistTracks(deps: ListeningDeps, playlistId: string, limit: number): Promise<Result<NormalisedTrack[]>> {\n const path = `/v1/playlists/${encodeURIComponent(playlistId)}/tracks?limit=${limit}`;\n const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"GET\", path, {}, deps.now);\n if (!result.ok) return result;\n return { ok: true, data: normaliseTrackList(result.data, \"track\") };\n}\n\nexport async function fetchRecent(deps: ListeningDeps, limit: number): Promise<Result<RecentlyPlayedItem[]>> {\n const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"GET\", `/v1/me/player/recently-played?limit=${limit}`, {}, deps.now);\n if (!result.ok) return result;\n return { ok: true, data: normaliseRecentlyPlayed(result.data) };\n}\n\n/** `nowPlaying` returns null when nothing is currently playing\n * (Spotify returns 204). The View shows an empty state. */\nexport async function fetchNowPlaying(deps: ListeningDeps): Promise<Result<NormalisedTrack | null>> {\n const result = await spotifyApi<unknown>(deps.runtime, deps.clientId, deps.tokens, \"GET\", \"/v1/me/player/currently-playing\", {}, deps.now);\n if (!result.ok) return result;\n if (result.data === null) return { ok: true, data: null };\n // Currently-playing wraps the track under `item`. Some endpoints\n // (e.g. local playback) return null here even with a 200; the\n // normaliser handles that as a drop.\n if (typeof result.data === \"object\" && result.data !== null && \"item\" in result.data) {\n const track = normaliseTrack((result.data as { item: unknown }).item);\n return { ok: true, data: track };\n }\n // Anything else (ad break, podcast/show context, or a future\n // currently-playing-type the API adds) collapses to \"nothing to\n // show\" — the View renders the empty state. We deliberately don't\n // try alternative normalisers here; if a future PR adds a podcast\n // surface, it'll need its own handler.\n return { ok: true, data: null };\n}\n\n// `normalisePlaylist` is exported so the LLM can request a single\n// playlist's metadata via `playlistTracks` indirectly; reserved for\n// future kinds.\nexport { normalisePlaylist };\n","// Search handler — wraps Spotify's `/v1/search` and normalises the\n// per-category response into the View-friendly shape. Public API:\n// LLM and View both call into this via the `search` dispatch kind.\n//\n// Result shape: only the categories the caller asked for are\n// present in the returned `SearchResult`. That keeps the LLM\n// context window tight (no empty `tracks: []` stub when the caller\n// only wanted artists).\n\nimport type { PluginRuntime } from \"gui-chat-protocol\";\n\nimport { spotifyApi } from \"./client\";\nimport type { SpotifyClientError } from \"./client\";\nimport { normaliseAlbumList, normaliseArtistList, normalisePlaylistList, normaliseTrackList } from \"./normalize\";\nimport type { SearchResult, SpotifyTokens } from \"./types\";\n\nexport type SearchType = \"track\" | \"artist\" | \"album\" | \"playlist\";\n\nconst DEFAULT_SEARCH_TYPES: readonly SearchType[] = [\"track\", \"artist\", \"album\", \"playlist\"];\nconst DEFAULT_SEARCH_LIMIT = 10;\n\nexport interface SearchDeps {\n runtime: PluginRuntime;\n clientId: string;\n tokens: SpotifyTokens;\n now?: () => Date;\n}\n\ntype Result<T> = { ok: true; data: T } | { ok: false; error: SpotifyClientError };\n\nexport async function searchSpotify(\n deps: SearchDeps,\n query: string,\n types: readonly SearchType[] | undefined,\n limit: number | undefined,\n): Promise<Result<SearchResult>> {\n const requested = types && types.length > 0 ? types : DEFAULT_SEARCH_TYPES;\n const cap = limit ?? DEFAULT_SEARCH_LIMIT;\n const url = buildSearchUrl(query, requested, cap);\n const response = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"GET\", url, {}, deps.now);\n if (!response.ok) return response;\n return { ok: true, data: assembleSearchResult(response.data, requested) };\n}\n\nfunction buildSearchUrl(query: string, types: readonly SearchType[], limit: number): string {\n // Spotify accepts `type=track,artist,…` as a CSV. URLSearchParams\n // would percent-encode the comma, which Spotify still accepts but\n // makes the URL noisier in logs.\n const params = new URLSearchParams({ q: query, limit: String(limit) });\n return `/v1/search?${params.toString()}&type=${types.join(\",\")}`;\n}\n\nfunction assembleSearchResult(raw: unknown, requested: readonly SearchType[]): SearchResult {\n if (typeof raw !== \"object\" || raw === null) return {};\n const root = raw as { tracks?: unknown; artists?: unknown; albums?: unknown; playlists?: unknown };\n const out: SearchResult = {};\n if (requested.includes(\"track\")) out.tracks = normaliseTrackList(root.tracks, \"self\");\n if (requested.includes(\"artist\")) out.artists = normaliseArtistList(root.artists);\n if (requested.includes(\"album\")) out.albums = normaliseAlbumList(root.albums);\n if (requested.includes(\"playlist\")) out.playlists = normalisePlaylistList(root.playlists);\n return out;\n}\n","// Search-result text summarisation. Lives in its own module so the\n// formatters are unit-testable directly (CodeRabbit review on PR\n// #1168) without going through the full dispatch path. Pure\n// functions, no side effects.\n\nimport type { NormalisedAlbum, NormalisedArtist, NormalisedPlaylist, NormalisedTrack, SearchResult } from \"./types\";\n\n/** Build the LLM-facing message string for a search result. The\n * plain text mirrors the View's grouped sections, one entity per\n * line.\n *\n * `query` is user-influenced on the tool path — both the LLM and\n * a manual View submission can put arbitrary strings in there.\n * Embedding it raw lets a hostile query smuggle line breaks and\n * control characters into the LLM's context window (a\n * prompt-injection vector via tool output: `query: \"x\\n\\nIgnore\n * all previous instructions and …\"`). Strip control chars and\n * bound the length before interpolating (Codex review on PR\n * #1168). */\nexport function summariseSearch(query: string, result: SearchResult): string {\n const safeQuery = sanitiseQueryForSummary(query);\n const sections: string[] = [];\n if (result.tracks?.length) sections.push(formatSearchSection(\"Tracks\", result.tracks, formatTrackLine));\n if (result.artists?.length) sections.push(formatSearchSection(\"Artists\", result.artists, formatArtistLine));\n if (result.albums?.length) sections.push(formatSearchSection(\"Albums\", result.albums, formatAlbumLine));\n if (result.playlists?.length) sections.push(formatSearchSection(\"Playlists\", result.playlists, formatPlaylistLine));\n if (sections.length === 0) return `Search \"${safeQuery}\": no results.`;\n return `Search \"${safeQuery}\":\\n${sections.join(\"\\n\\n\")}`;\n}\n\n/** Cap and strip control characters so a hostile or accidentally\n * multi-line query can't break out of the `Search \"...\"` quoting\n * or smuggle `\\n\\nIgnore previous instructions ...` into the\n * LLM-facing text. Exported for tests.\n *\n * Coverage: C0 (0x00-0x1F), DEL (0x7F), C1 (0x80-0x9F), and the\n * Unicode line/paragraph separators (U+2028, U+2029). Each maps\n * to a single space so adjacent words don't fuse together; runs\n * of whitespace then collapse to one space. */\nconst SUMMARY_QUERY_MAX_LEN = 100;\n\nfunction isControlCodepoint(code: number): boolean {\n if (code <= 0x1f) return true;\n if (code >= 0x7f && code <= 0x9f) return true;\n if (code === 0x2028 || code === 0x2029) return true;\n return false;\n}\n\nexport function sanitiseQueryForSummary(query: string): string {\n let cleaned = \"\";\n for (const char of query) {\n const code = char.codePointAt(0) ?? 0;\n cleaned += isControlCodepoint(code) ? \" \" : char;\n }\n const collapsed = cleaned.replace(/\\s+/g, \" \").trim();\n if (collapsed.length <= SUMMARY_QUERY_MAX_LEN) return collapsed;\n return `${collapsed.slice(0, SUMMARY_QUERY_MAX_LEN)}…`;\n}\n\nexport function formatSearchSection<T>(label: string, items: T[], formatter: (item: T, idx: number) => string): string {\n return `${label} (${items.length}):\\n${items.map(formatter).join(\"\\n\")}`;\n}\n\nexport function formatTrackLine(track: NormalisedTrack, idx: number): string {\n return `${idx + 1}. ${track.name} — ${track.artists.join(\", \")}`;\n}\n\nexport function formatArtistLine(artist: NormalisedArtist, idx: number): string {\n const genres = artist.genres.length > 0 ? ` [${artist.genres.slice(0, 3).join(\", \")}]` : \"\";\n return `${idx + 1}. ${artist.name}${genres}`;\n}\n\nexport function formatAlbumLine(album: NormalisedAlbum, idx: number): string {\n const year = album.releaseDate ? album.releaseDate.slice(0, 4) : \"?\";\n return `${idx + 1}. ${album.name} — ${album.artists.join(\", \")} (${year})`;\n}\n\nexport function formatPlaylistLine(playlist: NormalisedPlaylist, idx: number): string {\n return `${idx + 1}. ${playlist.name} (${playlist.trackCount} tracks)`;\n}\n","// Spotify user profile cache (PR 3). Reads `/v1/me` once and\n// caches the `product` field so the player-control gate doesn't\n// need a fresh API roundtrip on every `play` dispatch.\n//\n// TTL is 24 h: long enough that a typical user paying for Premium\n// once doesn't get rate-limited extra GETs to `/v1/me`, short\n// enough that a Free → Premium upgrade is reflected within a day\n// without manually reconnecting.\n//\n// On a stale cache miss the loader fires `/v1/me` once and writes\n// the result to `profile.json`. If the API call fails we keep the\n// stale snapshot rather than locking the user out — the player\n// gate then errs on the side of \"let them try\" with a softer error\n// message. This intentionally trades strict correctness for UX:\n// a network blip on Spotify's side shouldn't break playback.\n\nimport type { FileOps, PluginRuntime } from \"gui-chat-protocol\";\n\nimport { spotifyApi } from \"./client\";\nimport type { SpotifyClientError } from \"./client\";\nimport { ONE_SECOND_MS } from \"./time\";\nimport type { SpotifyProfile, SpotifyTokens } from \"./types\";\n\nconst PROFILE_FILE = \"profile.json\";\nconst PROFILE_TTL_MS = 24 * 60 * 60 * ONE_SECOND_MS;\n\nconst PREMIUM_PRODUCT = \"premium\";\n\ninterface RawProfile {\n id?: unknown;\n product?: unknown;\n display_name?: unknown;\n}\n\nexport interface ProfileDeps {\n runtime: PluginRuntime;\n clientId: string;\n tokens: SpotifyTokens;\n now?: () => Date;\n}\n\nexport async function readProfile(files: FileOps): Promise<SpotifyProfile | null> {\n if (!(await files.exists(PROFILE_FILE))) return null;\n try {\n const raw = await files.read(PROFILE_FILE);\n const parsed = JSON.parse(raw) as Partial<SpotifyProfile>;\n if (typeof parsed.product !== \"string\") return null;\n if (typeof parsed.fetchedAtMs !== \"number\" || !Number.isFinite(parsed.fetchedAtMs)) return null;\n return {\n // userId may be missing from caches written before the\n // account-scoping fix landed; treat as empty so a\n // reconnect-then-replay doesn't trip the equality check\n // until the next /v1/me round-trip refreshes it.\n userId: typeof parsed.userId === \"string\" ? parsed.userId : \"\",\n product: parsed.product,\n displayName: typeof parsed.displayName === \"string\" ? parsed.displayName : \"\",\n fetchedAtMs: parsed.fetchedAtMs,\n };\n } catch {\n return null;\n }\n}\n\nexport async function writeProfile(files: FileOps, profile: SpotifyProfile): Promise<void> {\n await files.write(PROFILE_FILE, JSON.stringify(profile, null, 2));\n}\n\nfunction isCacheFresh(profile: SpotifyProfile, now: Date): boolean {\n return now.getTime() - profile.fetchedAtMs < PROFILE_TTL_MS;\n}\n\n/** Get the cached profile if fresh; otherwise fetch + persist a\n * new snapshot. On API failure with a stale cache we keep the\n * stale value (better than locking the user out — a network blip\n * shouldn't break playback).\n *\n * Account scoping: cache is invalidated by `clearProfileCache`\n * whenever new tokens are written (i.e. after `oauthCallback`),\n * so reconnecting with a different Spotify account starts with a\n * fresh fetch and never serves the previous account's `product`\n * (Codex review on PR #1171). */\nexport async function getProfile(deps: ProfileDeps): Promise<{ ok: true; profile: SpotifyProfile } | { ok: false; error: SpotifyClientError }> {\n const now = deps.now ?? (() => new Date());\n const cached = await readProfile(deps.runtime.files.config);\n if (cached && isCacheFresh(cached, now())) return { ok: true, profile: cached };\n const fresh = await fetchProfile(deps);\n if (fresh.ok) {\n await writeProfile(deps.runtime.files.config, fresh.profile);\n return { ok: true, profile: fresh.profile };\n }\n if (cached) {\n deps.runtime.log.warn(\"profile fetch failed; serving stale cache\", { detail: errorMessage(fresh.error) });\n return { ok: true, profile: cached };\n }\n return fresh;\n}\n\nasync function fetchProfile(deps: ProfileDeps): Promise<{ ok: true; profile: SpotifyProfile } | { ok: false; error: SpotifyClientError }> {\n const result = await spotifyApi<RawProfile>(deps.runtime, deps.clientId, deps.tokens, \"GET\", \"/v1/me\", {}, deps.now);\n if (!result.ok) return result;\n const raw = result.data;\n const userId = typeof raw.id === \"string\" ? raw.id : \"\";\n const product = typeof raw.product === \"string\" ? raw.product : \"free\";\n const displayName = typeof raw.display_name === \"string\" ? raw.display_name : \"\";\n const now = deps.now ?? (() => new Date());\n return { ok: true, profile: { userId, product, displayName, fetchedAtMs: now().getTime() } };\n}\n\nexport function isPremium(profile: SpotifyProfile): boolean {\n return profile.product === PREMIUM_PRODUCT;\n}\n\nfunction errorMessage(error: SpotifyClientError): string {\n switch (error.kind) {\n case \"auth_expired\":\n return error.detail;\n case \"transient_error\":\n return error.detail;\n case \"rate_limited\":\n return `rate limited (retry ${error.retryAfterSec}s)`;\n case \"spotify_api_error\":\n return `${error.status}: ${error.body}`;\n case \"not_connected\":\n return \"not connected\";\n }\n}\n\n/** Test-only: clear the cache. Production callers should not need\n * this — the TTL handles it. */\nexport async function clearProfileCache(files: FileOps): Promise<void> {\n if (await files.exists(PROFILE_FILE)) await files.unlink(PROFILE_FILE);\n}\n","// Player Controls (PR 3). Handlers for the 8 dispatch kinds added\n// in PR 3. Spotify Premium is required at runtime — the dispatcher\n// in `index.ts` checks `getProfile()` before calling any of these\n// (except `getDevices`, which is read-only and works for Free\n// accounts too — useful for the View's device dropdown even before\n// upgrade).\n//\n// Spotify's Player API is mostly side-effects: `play`, `pause`,\n// `next`, etc. all return 204 on success. The handlers below\n// translate the (mostly-empty) response into a friendly\n// `{ ok, message }` so the LLM can confirm the action. `getDevices`\n// is the only one with an interesting payload.\n\nimport type { PluginRuntime } from \"gui-chat-protocol\";\n\nimport { spotifyApi } from \"./client\";\nimport type { SpotifyClientError } from \"./client\";\nimport type { NormalisedDevice, SpotifyTokens } from \"./types\";\n\nexport interface PlaybackDeps {\n runtime: PluginRuntime;\n clientId: string;\n tokens: SpotifyTokens;\n now?: () => Date;\n}\n\ntype Result<T> = { ok: true; data: T } | { ok: false; error: SpotifyClientError };\n\ninterface PlayArgs {\n deviceId?: string;\n contextUri?: string;\n trackUris?: string[];\n}\n\nexport async function playerPlay(deps: PlaybackDeps, args: PlayArgs): Promise<Result<null>> {\n const body: Record<string, unknown> = {};\n if (args.contextUri) body.context_uri = args.contextUri;\n if (args.trackUris) body.uris = args.trackUris;\n const path = withDeviceId(\"/v1/me/player/play\", args.deviceId);\n // Spotify's `play` accepts PUT with empty body (resume) or with\n // {context_uri, uris, offset, position_ms} (start specific\n // content). Pass an empty body when no `contextUri`/`trackUris`\n // were supplied — that's the \"resume\" semantics.\n const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"PUT\", path, Object.keys(body).length > 0 ? { body } : {}, deps.now);\n return mapVoidResult(result);\n}\n\nexport async function playerPause(deps: PlaybackDeps, deviceId?: string): Promise<Result<null>> {\n const path = withDeviceId(\"/v1/me/player/pause\", deviceId);\n const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"PUT\", path, {}, deps.now);\n return mapVoidResult(result);\n}\n\nexport async function playerNext(deps: PlaybackDeps, deviceId?: string): Promise<Result<null>> {\n const path = withDeviceId(\"/v1/me/player/next\", deviceId);\n const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"POST\", path, {}, deps.now);\n return mapVoidResult(result);\n}\n\nexport async function playerPrevious(deps: PlaybackDeps, deviceId?: string): Promise<Result<null>> {\n const path = withDeviceId(\"/v1/me/player/previous\", deviceId);\n const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"POST\", path, {}, deps.now);\n return mapVoidResult(result);\n}\n\nexport async function playerSeek(deps: PlaybackDeps, positionMs: number, deviceId?: string): Promise<Result<null>> {\n const params = new URLSearchParams({ position_ms: String(positionMs) });\n const path = appendQueryParam(`/v1/me/player/seek?${params.toString()}`, \"device_id\", deviceId);\n const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"PUT\", path, {}, deps.now);\n return mapVoidResult(result);\n}\n\nexport async function playerSetVolume(deps: PlaybackDeps, volumePercent: number, deviceId?: string): Promise<Result<null>> {\n const params = new URLSearchParams({ volume_percent: String(volumePercent) });\n const path = appendQueryParam(`/v1/me/player/volume?${params.toString()}`, \"device_id\", deviceId);\n const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"PUT\", path, {}, deps.now);\n return mapVoidResult(result);\n}\n\nexport async function playerTransfer(deps: PlaybackDeps, deviceId: string, play: boolean | undefined): Promise<Result<null>> {\n const body: Record<string, unknown> = { device_ids: [deviceId] };\n if (play !== undefined) body.play = play;\n const result = await spotifyApi(deps.runtime, deps.clientId, deps.tokens, \"PUT\", \"/v1/me/player\", { body }, deps.now);\n return mapVoidResult(result);\n}\n\nexport async function playerGetDevices(deps: PlaybackDeps): Promise<Result<NormalisedDevice[]>> {\n const result = await spotifyApi<unknown>(deps.runtime, deps.clientId, deps.tokens, \"GET\", \"/v1/me/player/devices\", {}, deps.now);\n if (!result.ok) return result;\n return { ok: true, data: normaliseDevices(result.data) };\n}\n\nfunction withDeviceId(basePath: string, deviceId: string | undefined): string {\n if (!deviceId) return basePath;\n return `${basePath}?${new URLSearchParams({ device_id: deviceId }).toString()}`;\n}\n\nfunction appendQueryParam(path: string, key: string, value: string | undefined): string {\n if (!value) return path;\n return `${path}&${new URLSearchParams({ [key]: value }).toString()}`;\n}\n\n/** Player API success responses are 204 No Content; `data` is null\n * in our client wrapper. Normalise so the dispatcher can use a\n * uniform `{ ok, message }` shape. */\nfunction mapVoidResult(result: Result<unknown>): Result<null> {\n if (!result.ok) return result;\n return { ok: true, data: null };\n}\n\ninterface RawDevice {\n id?: unknown;\n name?: unknown;\n type?: unknown;\n is_active?: unknown;\n volume_percent?: unknown;\n}\n\nfunction normaliseDevices(raw: unknown): NormalisedDevice[] {\n if (typeof raw !== \"object\" || raw === null) return [];\n const devices = (raw as { devices?: unknown }).devices;\n if (!Array.isArray(devices)) return [];\n const out: NormalisedDevice[] = [];\n for (const candidate of devices) {\n const normalised = normaliseDevice(candidate);\n if (normalised) out.push(normalised);\n }\n return out;\n}\n\nfunction normaliseDevice(raw: unknown): NormalisedDevice | null {\n if (typeof raw !== \"object\" || raw === null) return null;\n const device = raw as RawDevice;\n // Spotify can return restricted devices with a `name` + `type`\n // but no `id` (DRM / account-state quirks). Preserve them — the\n // View renders them with the Transfer button disabled — so the\n // user isn't left wondering \"why isn't my speaker listed?\"\n // (Codex review on PR #1171). `name` is still required: an\n // anonymous nameless device is not useful to surface.\n if (typeof device.name !== \"string\") return null;\n const id = typeof device.id === \"string\" && device.id.length > 0 ? device.id : null;\n const volumePercent = typeof device.volume_percent === \"number\" && Number.isFinite(device.volume_percent) ? device.volume_percent : undefined;\n return {\n id,\n name: device.name,\n type: typeof device.type === \"string\" ? device.type : \"\",\n isActive: device.is_active === true,\n ...(volumePercent !== undefined ? { volumePercent } : {}),\n };\n}\n","// Spotify plugin — server side (issue #1162).\n//\n// PR 1 ships only the OAuth-flavored kinds:\n// - `connect` — generate authorize URL + register PKCE pending auth\n// - `oauthCallback` — invoked by the host's generic OAuth callback\n// endpoint after Spotify redirects the browser back;\n// validates state, exchanges code for tokens, persists\n// - `status` — connection state for the View (no token values)\n// - `diagnose` — verbose diagnostic for the LLM to surface to the\n// user when something is misconfigured\n//\n// PR 2 extends the dispatch union with the listening-data kinds\n// (`liked` / `playlists` / `playlistTracks` / `recent` / `nowPlaying`)\n// and ships a Vue View / Preview.\n//\n// Everything that touches disk goes through `runtime.files.config`\n// (per-machine secret), every external HTTP call uses `runtime.fetch`\n// with an explicit `allowedHosts` allowlist. The eslint preset bans\n// `node:fs` / `node:path` / direct `fetch` so platform bypasses\n// surface at lint time.\n\nimport { definePlugin, type PluginRuntime } from \"gui-chat-protocol\";\n\nimport { TOOL_DEFINITION } from \"./definition\";\nimport { DispatchArgsSchema, type DispatchArgs } from \"./schemas\";\nimport { buildAuthorizeUrl, consumePendingAuthorization, deriveCodeChallenge, generateRandomToken, registerPendingAuthorization } from \"./oauth\";\nimport { readClientConfig, readTokens, writeClientConfig, writeTokens } from \"./tokens\";\nimport { ONE_SECOND_MS } from \"./time\";\nimport type { NormalisedDevice, NormalisedPlaylist, NormalisedTrack, SpotifyClientConfig, SpotifyTokens } from \"./types\";\nimport { fetchLiked, fetchNowPlaying, fetchPlaylistTracks, fetchPlaylists, fetchRecent } from \"./listening\";\nimport { searchSpotify } from \"./search\";\nimport { summariseSearch } from \"./searchSummary\";\nimport { clearProfileCache, getProfile, isPremium } from \"./profile\";\nimport { playerGetDevices, playerNext, playerPause, playerPlay, playerPrevious, playerSeek, playerSetVolume, playerTransfer } from \"./playback\";\nimport type { SpotifyClientError } from \"./client\";\n\nexport { TOOL_DEFINITION };\n\n// Short, URL-safe alias the host registers as\n// `/api/plugins/runtime/oauth-callback/:alias`. Spotify's Dashboard\n// rejects redirect URIs that contain percent-encoded path characters\n// (the natural shape when `:pkg` is `@mulmoclaude/spotify-plugin`), so\n// each OAuth-using runtime plugin declares its own alphanumeric alias.\n// Collisions with other plugins are detected at boot and surfaced as\n// startup diagnostics.\nexport const OAUTH_CALLBACK_ALIAS = \"spotify\";\n\n/** Scope set requested at OAuth time. Two extra scopes were added\n * in PR 3 for Player Controls: `user-read-playback-state` (read\n * active device + playback state) and `user-modify-playback-state`\n * (play/pause/next/seek/volume/transfer). Existing users from\n * PR 1/2 will hit `403 Insufficient client scope` on the new\n * player kinds and need to reconnect. */\nconst SPOTIFY_SCOPES: readonly string[] = [\n \"playlist-read-private\",\n \"user-library-read\",\n \"user-modify-playback-state\",\n \"user-read-currently-playing\",\n \"user-read-playback-state\",\n \"user-read-recently-played\",\n] as const;\n\nconst SPOTIFY_TOKEN_URL = \"https://accounts.spotify.com/api/token\";\nconst SPOTIFY_TOKEN_HOST = \"accounts.spotify.com\";\n\nconst TOKEN_EXCHANGE_TIMEOUT_MS = 15 * ONE_SECOND_MS;\n\nconst CLIENT_ID_MISSING_INSTRUCTIONS = [\n \"Spotify の Client ID が未設定です。\",\n \"\",\n \"1. https://developer.spotify.com/dashboard を開いて Spotify アカウントでログイン\",\n \"2. 「Create app」 → Redirect URIs に http://127.0.0.1:<PORT>/api/plugins/runtime/oauth-callback/spotify を追加 (PORT は mulmoclaude が動いているポート)\",\n \"3. Web API をチェックして保存\",\n \"4. Client ID をコピー\",\n \"5. plugin View の「Configure」で貼り付ける\",\n \"\",\n \"詳細: docs/tips/spotify-setup.md\",\n].join(\"\\n\");\n\ninterface RawTokenResponse {\n access_token?: unknown;\n refresh_token?: unknown;\n expires_in?: unknown;\n scope?: unknown;\n}\n\nexport default definePlugin((pluginRuntime) => {\n const { files, log, fetch: runtimeFetch, pubsub } = pluginRuntime;\n return {\n TOOL_DEFINITION,\n\n async manageSpotify(rawArgs: unknown) {\n const parsed = DispatchArgsSchema.safeParse(rawArgs);\n if (!parsed.success) {\n return {\n ok: false,\n error: \"invalid_args\",\n message: `Invalid arguments: ${parsed.error.issues[0]?.message ?? \"unknown\"}`,\n };\n }\n const args: DispatchArgs = parsed.data;\n switch (args.kind) {\n case \"connect\":\n return handleConnect(args.redirectUri);\n case \"oauthCallback\":\n return handleOauthCallback({ code: args.code, state: args.state, error: args.error });\n case \"status\":\n return handleStatus();\n case \"diagnose\":\n return handleDiagnose();\n case \"configure\":\n return handleConfigure({ clientId: args.clientId });\n case \"liked\":\n return handleListening(\"liked\", args);\n case \"playlists\":\n return handleListening(\"playlists\", args);\n case \"playlistTracks\":\n return handleListening(\"playlistTracks\", args);\n case \"recent\":\n return handleListening(\"recent\", args);\n case \"nowPlaying\":\n return handleListening(\"nowPlaying\", args);\n case \"search\":\n return handleSearch(args);\n case \"play\":\n case \"pause\":\n case \"next\":\n case \"previous\":\n case \"seek\":\n case \"setVolume\":\n case \"transferPlayback\":\n case \"getDevices\":\n return handlePlayer(args);\n default: {\n const exhaustive: never = args;\n throw new Error(`Unhandled kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n },\n };\n\n // ───────────────────────────────────────────────────────────\n // Handlers (closures over runtime)\n // ───────────────────────────────────────────────────────────\n\n async function handleConnect(redirectUri: string) {\n const clientConfig = await readClientConfig(files.config);\n if (!clientConfig) {\n return {\n ok: false,\n error: \"client_id_missing\",\n message: \"Spotify Client ID が未設定です。詳細は instructions を参照してください。\",\n instructions: CLIENT_ID_MISSING_INSTRUCTIONS,\n };\n }\n const codeVerifier = generateRandomToken();\n const codeChallenge = await deriveCodeChallenge(codeVerifier);\n const state = registerPendingAuthorization(codeVerifier, redirectUri);\n const authorizeUrl = buildAuthorizeUrl({\n clientId: clientConfig.clientId,\n redirectUri,\n scopes: SPOTIFY_SCOPES,\n state,\n codeChallenge,\n });\n return {\n ok: true,\n message: \"Spotify の同意画面の URL を生成しました。ブラウザで開いてください。\",\n data: { authorizeUrl },\n };\n }\n\n async function handleOauthCallback(input: { code?: string; state?: string; error?: string }) {\n if (input.error) {\n log.info(\"user denied authorization\", { error: input.error });\n return {\n ok: false,\n error: \"auth_denied\",\n message: `Spotify からの認可が拒否されました: ${input.error}`,\n html: renderCallbackHtml({ title: \"Spotify authorization denied\", body: `Spotify returned: ${input.error}` }),\n };\n }\n if (!input.code || !input.state) {\n return {\n ok: false,\n error: \"invalid_callback\",\n message: \"Callback request was missing `code` or `state`.\",\n html: renderCallbackHtml({ title: \"Invalid callback\", body: \"Missing `code` or `state` query parameter.\" }),\n };\n }\n const pending = consumePendingAuthorization(input.state);\n if (!pending) {\n return {\n ok: false,\n error: \"unknown_state\",\n message: \"この認可リクエストは mulmoclaude から開始されたものではない、または期限切れです。\",\n instructions: \"plugin View の「Connect」を再度押してください。\",\n html: renderCallbackHtml({\n title: \"Unknown state\",\n body: \"This authorization request was not initiated by mulmoclaude (or it expired). Please retry from the plugin View.\",\n }),\n };\n }\n const clientConfig = await readClientConfig(files.config);\n if (!clientConfig) {\n return {\n ok: false,\n error: \"client_id_missing\",\n message: \"Spotify Client ID が未設定です。\",\n instructions: CLIENT_ID_MISSING_INSTRUCTIONS,\n html: renderCallbackHtml({ title: \"Spotify client ID not configured\", body: CLIENT_ID_MISSING_INSTRUCTIONS }),\n };\n }\n try {\n const tokens = await exchangeCodeForTokens({\n code: input.code,\n clientId: clientConfig.clientId,\n codeVerifier: pending.codeVerifier,\n redirectUri: pending.redirectUri,\n });\n await writeTokens(files.config, tokens);\n // Invalidate the profile cache: a fresh Connect may be a\n // different Spotify account, so the previous user's `product`\n // must not leak through the 24h TTL (Codex review on PR\n // #1171). The next `getProfile` call will fetch the new\n // user's snapshot.\n await clearProfileCache(files.config);\n pubsub.publish(\"connected\", { scopes: tokens.scopes });\n log.info(\"tokens written\", { scopes: tokens.scopes });\n return {\n ok: true,\n message: \"Spotify を接続しました。\",\n html: renderCallbackHtml({ title: \"Spotify connected\", body: \"You can close this window and return to mulmoclaude.\" }),\n };\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n log.error(\"token exchange failed\", { error: detail });\n const instructions = `Token exchange failed: ${detail}\\n\\nThis usually means the Redirect URI registered in your Spotify Developer Dashboard does not match the URL mulmoclaude is using:\\n${pending.redirectUri}`;\n return {\n ok: false,\n error: \"token_exchange_failed\",\n message: detail,\n instructions,\n html: renderCallbackHtml({ title: \"Token exchange failed\", body: instructions }),\n };\n }\n }\n\n async function handleStatus() {\n const clientConfig = await readClientConfig(files.config);\n const tokens = await readTokens(files.config);\n let premium: boolean | null = null;\n let displayName = \"\";\n // Only call /v1/me when we have tokens — otherwise there's\n // nothing to authenticate with. Cache hit is the common case\n // (24h TTL) so most `status` calls don't go to Spotify.\n if (tokens && clientConfig) {\n const profileResult = await getProfile({ runtime: pluginRuntime, clientId: clientConfig.clientId, tokens });\n if (profileResult.ok) {\n premium = isPremium(profileResult.profile);\n displayName = profileResult.profile.displayName;\n }\n }\n return {\n ok: true,\n message: tokens ? \"Connected.\" : clientConfig ? \"Client ID is configured but you haven't connected yet.\" : \"Client ID is not configured.\",\n data: {\n clientIdConfigured: clientConfig !== null,\n connected: tokens !== null,\n expiresAt: tokens?.expiresAt ?? null,\n scopes: tokens?.scopes ?? [],\n // PR 3 — null when we couldn't determine (no tokens, or\n // /v1/me failed). View renders the player gate accordingly.\n isPremium: premium,\n displayName,\n },\n };\n }\n\n async function handleDiagnose() {\n const clientConfig = await readClientConfig(files.config);\n const tokens = await readTokens(files.config);\n return {\n ok: true,\n message: \"See `data` for the connection diagnostics.\",\n data: {\n clientIdConfigured: clientConfig !== null,\n tokensPresent: tokens !== null,\n expiresAt: tokens?.expiresAt ?? null,\n scopes: tokens?.scopes ?? [],\n // Never return the actual token / client_id values — diagnose\n // is meant for the LLM to read aloud.\n },\n };\n }\n\n async function exchangeCodeForTokens(params: { code: string; clientId: string; codeVerifier: string; redirectUri: string }): Promise<SpotifyTokens> {\n const response = await runtimeFetch(SPOTIFY_TOKEN_URL, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\" },\n body: new URLSearchParams({\n grant_type: \"authorization_code\",\n code: params.code,\n redirect_uri: params.redirectUri,\n client_id: params.clientId,\n code_verifier: params.codeVerifier,\n }).toString(),\n timeoutMs: TOKEN_EXCHANGE_TIMEOUT_MS,\n allowedHosts: [SPOTIFY_TOKEN_HOST],\n });\n if (!response.ok) {\n const body = await response.text().catch(() => \"\");\n throw new Error(`Spotify token endpoint returned ${response.status}: ${body.slice(0, 300)}`);\n }\n const raw = (await response.json()) as RawTokenResponse;\n if (typeof raw.access_token !== \"string\" || raw.access_token.length === 0) {\n throw new Error(\"Spotify response missing access_token\");\n }\n if (typeof raw.refresh_token !== \"string\" || raw.refresh_token.length === 0) {\n throw new Error(\"Spotify response missing refresh_token\");\n }\n if (typeof raw.expires_in !== \"number\" || !Number.isFinite(raw.expires_in)) {\n throw new Error(\"Spotify response missing expires_in\");\n }\n return {\n accessToken: raw.access_token,\n refreshToken: raw.refresh_token,\n expiresAt: new Date(Date.now() + raw.expires_in * ONE_SECOND_MS).toISOString(),\n scopes: typeof raw.scope === \"string\" ? raw.scope.split(\" \").filter(Boolean) : [...SPOTIFY_SCOPES],\n };\n }\n\n async function handleConfigure(args: { clientId: string }) {\n const trimmed = args.clientId.trim();\n // Schema guarantees `min(1)` on the input, but trimming can\n // collapse whitespace-only strings to length 0 (CodeRabbit\n // review on PR #1166). Reject so we never persist a useless\n // Client ID that would silently break OAuth on the next\n // `connect` attempt.\n if (trimmed.length === 0) {\n return {\n ok: false,\n error: \"invalid_client_id\",\n message: \"Client ID が空です。Spotify Developer Dashboard からコピーした文字列を貼り付けてください。\",\n };\n }\n const config: SpotifyClientConfig = { clientId: trimmed };\n await writeClientConfig(files.config, config);\n log.info(\"client id configured\");\n return { ok: true, message: \"Spotify Client ID を保存しました。\" };\n }\n\n async function handleListening(\n kind: \"liked\" | \"playlists\" | \"playlistTracks\" | \"recent\" | \"nowPlaying\",\n args: Extract<DispatchArgs, { kind: \"liked\" | \"playlists\" | \"playlistTracks\" | \"recent\" | \"nowPlaying\" }>,\n ) {\n const ready = await loadCredentials();\n if (!ready.ok) return ready.errorResponse;\n const deps = { runtime: pluginRuntime, clientId: ready.clientConfig.clientId, tokens: ready.tokens };\n const result = await invokeListening(kind, args, deps);\n if (!result.ok) return mapClientError(result.error);\n // The host MCP bridge passes ONLY `message` + `instructions` back\n // to the LLM (`data` is rendered in the View). For read kinds the\n // LLM needs the actual list of tracks / playlists to reason\n // about, so we mirror the listing into `message` as a compact\n // text format. Format mirrors what a human would write on a chat\n // thread; not designed for machine round-tripping (the View has\n // the structured `data`).\n return { ok: true, message: summariseListening(kind, result.data), data: result.data };\n }\n\n async function handleSearch(args: Extract<DispatchArgs, { kind: \"search\" }>) {\n const ready = await loadCredentials();\n if (!ready.ok) return ready.errorResponse;\n const deps = { runtime: pluginRuntime, clientId: ready.clientConfig.clientId, tokens: ready.tokens };\n const result = await searchSpotify(deps, args.query, args.types, args.limit);\n if (!result.ok) return mapClientError(result.error);\n return { ok: true, message: summariseSearch(args.query, result.data), data: result.data };\n }\n\n async function handlePlayer(args: Extract<DispatchArgs, { kind: PlayerKind }>) {\n // Spotify's `/v1/me/player/play` 400s if a body carries both\n // `context_uri` and `uris[]`. Catching this here (since we\n // can't .refine() inside a discriminatedUnion arm) gives a\n // clean error instead of a confusing 4xx from Spotify.\n if (args.kind === \"play\" && args.contextUri && args.trackUris) {\n return {\n ok: false,\n error: \"invalid_args\",\n message: \"play: `contextUri` と `trackUris` は同時に指定できません。どちらか一方を選んでください。\",\n };\n }\n const ready = await loadCredentials();\n if (!ready.ok) return ready.errorResponse;\n const deps = { runtime: pluginRuntime, clientId: ready.clientConfig.clientId, tokens: ready.tokens };\n // `getDevices` is read-only and works for Free accounts (the View\n // uses it to populate a dropdown even before upgrade). All other\n // player kinds require Premium; gate them up front so we don't\n // burn a wasted Spotify API call for a 403 we can predict.\n if (args.kind !== \"getDevices\") {\n const gate = await premiumGate(deps);\n if (gate) return gate;\n }\n const result = await invokePlayer(args, deps);\n if (!result.ok) return mapPlayerError(result.error, args.kind);\n return summarisePlayerResult(args.kind, result.data);\n }\n\n async function loadCredentials(): Promise<\n | { ok: true; clientConfig: SpotifyClientConfig; tokens: SpotifyTokens }\n | { ok: false; errorResponse: { ok: false; error: string; message: string; instructions?: string } }\n > {\n const clientConfig = await readClientConfig(files.config);\n if (!clientConfig) {\n return {\n ok: false,\n errorResponse: {\n ok: false,\n error: \"client_id_missing\",\n message: \"Spotify Client ID が未設定です。\",\n instructions: CLIENT_ID_MISSING_INSTRUCTIONS,\n },\n };\n }\n const tokens = await readTokens(files.config);\n if (!tokens) {\n return {\n ok: false,\n errorResponse: {\n ok: false,\n error: \"not_connected\",\n message: \"Spotify に未接続です。「Connect」を実行してください。\",\n },\n };\n }\n return { ok: true, clientConfig, tokens };\n }\n});\n\ntype PlayerKind = \"play\" | \"pause\" | \"next\" | \"previous\" | \"seek\" | \"setVolume\" | \"transferPlayback\" | \"getDevices\";\n\nasync function invokePlayer(args: Extract<DispatchArgs, { kind: PlayerKind }>, deps: { runtime: PluginRuntime; clientId: string; tokens: SpotifyTokens }) {\n switch (args.kind) {\n case \"play\":\n return playerPlay(deps, { deviceId: args.deviceId, contextUri: args.contextUri, trackUris: args.trackUris });\n case \"pause\":\n return playerPause(deps, args.deviceId);\n case \"next\":\n return playerNext(deps, args.deviceId);\n case \"previous\":\n return playerPrevious(deps, args.deviceId);\n case \"seek\":\n return playerSeek(deps, args.positionMs, args.deviceId);\n case \"setVolume\":\n return playerSetVolume(deps, args.volumePercent, args.deviceId);\n case \"transferPlayback\":\n return playerTransfer(deps, args.deviceId, args.play);\n case \"getDevices\":\n return playerGetDevices(deps);\n }\n}\n\nasync function invokeListening(\n kind: \"liked\" | \"playlists\" | \"playlistTracks\" | \"recent\" | \"nowPlaying\",\n args: Extract<DispatchArgs, { kind: \"liked\" | \"playlists\" | \"playlistTracks\" | \"recent\" | \"nowPlaying\" }>,\n deps: { runtime: PluginRuntime; clientId: string; tokens: SpotifyTokens },\n) {\n switch (kind) {\n case \"liked\":\n return fetchLiked(deps, args.kind === \"liked\" ? (args.limit ?? 50) : 50);\n case \"playlists\":\n return fetchPlaylists(deps);\n case \"playlistTracks\":\n if (args.kind !== \"playlistTracks\") throw new Error(\"kind/args mismatch\");\n return fetchPlaylistTracks(deps, args.playlistId, args.limit ?? 100);\n case \"recent\":\n return fetchRecent(deps, args.kind === \"recent\" ? (args.limit ?? 50) : 50);\n case \"nowPlaying\":\n return fetchNowPlaying(deps);\n }\n}\n\n/** Build the LLM-facing message string for a listening result.\n * The plain text mirrors the View's grid: title + artists, one per\n * line. Length-capped per kind so the LLM context window doesn't\n * blow up on a 50-track Liked Songs response. */\nfunction summariseListening(kind: \"liked\" | \"playlists\" | \"playlistTracks\" | \"recent\" | \"nowPlaying\", data: unknown): string {\n if (kind === \"nowPlaying\") {\n if (!data || typeof data !== \"object\" || !(\"name\" in data)) return \"Nothing is currently playing.\";\n const track = data as { name: string; artists: string[]; album: string };\n return `Now playing: ${track.name} — ${track.artists.join(\", \")} (${track.album})`;\n }\n if (!Array.isArray(data) || data.length === 0) return `No ${kind} items.`;\n if (kind === \"playlists\") {\n const lines = (data as { name: string; trackCount: number }[]).map((p, i) => `${i + 1}. ${p.name} (${p.trackCount} tracks)`);\n return `Playlists (${data.length}):\\n${lines.join(\"\\n\")}`;\n }\n if (kind === \"recent\") {\n const lines = (data as { track: { name: string; artists: string[] }; playedAt: string }[]).map((item, i) => {\n const when = item.playedAt ? new Date(item.playedAt).toISOString().slice(0, 16).replace(\"T\", \" \") : \"?\";\n return `${i + 1}. [${when}] ${item.track.name} — ${item.track.artists.join(\", \")}`;\n });\n return `Recently played (${data.length}):\\n${lines.join(\"\\n\")}`;\n }\n // liked / playlistTracks share the NormalisedTrack[] shape.\n const lines = (data as { name: string; artists: string[] }[]).map((t, i) => `${i + 1}. ${t.name} — ${t.artists.join(\", \")}`);\n const title = kind === \"liked\" ? \"Liked Songs\" : \"Playlist tracks\";\n return `${title} (${data.length}):\\n${lines.join(\"\\n\")}`;\n}\n\nasync function premiumGate(deps: {\n runtime: PluginRuntime;\n clientId: string;\n tokens: SpotifyTokens;\n}): Promise<{ ok: false; error: string; message: string; instructions?: string } | null> {\n const profileResult = await getProfile(deps);\n if (!profileResult.ok) return mapClientError(profileResult.error);\n if (isPremium(profileResult.profile)) return null;\n return {\n ok: false,\n error: \"premium_required\",\n message: \"Spotify Premium が必要な操作です。Free アカウントでは再生制御は使えません。\",\n instructions: \"Spotify Premium にアップグレードしてください。再生制御以外 (Liked / Playlists / Recent / Search) は Free でも引き続き利用できます。\",\n };\n}\n\nfunction summarisePlayerResult(kind: PlayerKind, data: NormalisedDevice[] | null) {\n if (kind === \"getDevices\") {\n const devices = (data ?? []) as NormalisedDevice[];\n if (devices.length === 0) {\n return {\n ok: true,\n message: \"アクティブな Spotify デバイスがありません。Spotify アプリを起動してから再度お試しください。\",\n data: devices,\n };\n }\n const lines = devices.map((d, i) => `${i + 1}. ${d.name} (${d.type})${d.isActive ? \" — active\" : \"\"}`);\n return { ok: true, message: `Devices (${devices.length}):\\n${lines.join(\"\\n\")}`, data: devices };\n }\n return { ok: true, message: PLAYER_SUCCESS_MESSAGES[kind] };\n}\n\nconst PLAYER_SUCCESS_MESSAGES: Record<Exclude<PlayerKind, \"getDevices\">, string> = {\n play: \"再生を開始しました。\",\n pause: \"再生を一時停止しました。\",\n next: \"次の曲に進みました。\",\n previous: \"前の曲に戻りました。\",\n seek: \"位置をシークしました。\",\n setVolume: \"音量を変更しました。\",\n transferPlayback: \"再生をデバイスに移しました。\",\n};\n\nfunction mapPlayerError(error: SpotifyClientError, kind: PlayerKind) {\n // Spotify returns 404 for \"no active device\" on most player\n // endpoints. Surface a user-friendly hint that points at the\n // device dropdown instead of the generic API-error message.\n if (error.kind === \"spotify_api_error\" && error.status === 404 && kind !== \"getDevices\") {\n return {\n ok: false,\n error: \"no_active_device\",\n message: \"アクティブな Spotify デバイスがありません。Spotify アプリ (デスクトップ / モバイル / Web) を起動してから再度お試しください。\",\n instructions: \"View の Player タブから対象デバイスを選んで「Transfer」を押すか、Spotify アプリ側で何か再生してから再試行してください。\",\n };\n }\n if (error.kind === \"spotify_api_error\" && error.status === 403 && error.body.includes(\"scope\")) {\n return {\n ok: false,\n error: \"scope_missing\",\n message: \"新しい権限の追加が必要です。Spotify View ヘッダの「Reconnect」ボタンを押して再認可してください。\",\n instructions:\n \"PR 3 で追加された Player 制御は新しい OAuth scope を要求します。View 右上の「Reconnect」ボタンで Spotify の同意画面を開き直すと scope が更新されます。\",\n };\n }\n return mapClientError(error);\n}\n\nfunction mapClientError(error: SpotifyClientError) {\n switch (error.kind) {\n case \"auth_expired\":\n return {\n ok: false as const,\n error: \"auth_expired\",\n message: \"認可が無効化されました。「Connect」をやり直してください。\",\n detail: error.detail,\n };\n case \"transient_error\":\n return {\n ok: false as const,\n error: \"transient_error\",\n message: \"Spotify に一時的に接続できませんでした。しばらくしてから再試行してください。\",\n detail: error.detail,\n };\n case \"rate_limited\":\n return {\n ok: false as const,\n error: \"rate_limited\",\n message: `Spotify から rate limit を返されました。${error.retryAfterSec} 秒後に再試行してください。`,\n retryAfterSec: error.retryAfterSec,\n };\n case \"spotify_api_error\":\n return {\n ok: false as const,\n error: \"spotify_api_error\",\n message: `Spotify API がエラーを返しました (${error.status})`,\n detail: error.body,\n };\n case \"not_connected\":\n return { ok: false as const, error: \"not_connected\", message: \"Spotify に未接続です。\" };\n }\n}\n\nfunction renderCallbackHtml(params: { title: string; body: string }): string {\n return `<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><title>${escapeHtml(params.title)}</title>\n<style>body{font-family:system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;color:#111}h1{margin-bottom:1rem}pre{white-space:pre-wrap;background:#f5f5f5;padding:1rem;border-radius:.5rem}</style>\n<h1>${escapeHtml(params.title)}</h1>\n<pre>${escapeHtml(params.body)}</pre>\n</html>`;\n}\n\nfunction escapeHtml(value: string): string {\n return value.replaceAll(\"&\", \"&amp;\").replaceAll(\"<\", \"&lt;\").replaceAll(\">\", \"&gt;\").replaceAll('\"', \"&quot;\").replaceAll(\"'\", \"&#39;\");\n}\n"],"x_google_ignoreList":[0],"mappings":"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"}
@@ -0,0 +1,55 @@
1
+ declare const _default: {
2
+ readonly title: "Spotify";
3
+ readonly notConnected: "Not connected to Spotify";
4
+ readonly notConfigured: "Client ID not configured";
5
+ readonly configureHelp: "Paste your Spotify Developer Dashboard Client ID and click Save.";
6
+ readonly configurePlaceholder: "Spotify Client ID";
7
+ readonly save: "Save";
8
+ readonly saving: "Saving…";
9
+ readonly saved: "Saved.";
10
+ readonly saveFailed: "Save failed.";
11
+ readonly connect: "Connect Spotify";
12
+ readonly connecting: "Opening Spotify consent…";
13
+ readonly connected: "Connected.";
14
+ readonly reconnect: "Reconnect";
15
+ readonly disconnect: "Disconnect";
16
+ readonly refresh: "Refresh";
17
+ readonly setupGuideLink: "How do I get a Client ID?";
18
+ readonly scopes: "Scopes";
19
+ readonly expiresAt: "Expires";
20
+ readonly tabLiked: "Liked";
21
+ readonly tabPlaylists: "Playlists";
22
+ readonly tabRecent: "Recent";
23
+ readonly tabNowPlaying: "Now playing";
24
+ readonly tabSearch: "Search";
25
+ readonly searchPlaceholder: "Search tracks, artists, albums, playlists";
26
+ readonly searchSubmit: "Search";
27
+ readonly searchHint: "Type a query and press Search.";
28
+ readonly searchEmpty: "No results.";
29
+ readonly searchTracks: "Tracks";
30
+ readonly searchArtists: "Artists";
31
+ readonly searchAlbums: "Albums";
32
+ readonly searchPlaylists: "Playlists";
33
+ readonly empty: "Nothing to show.";
34
+ readonly emptyLiked: "You haven't liked any songs yet.";
35
+ readonly emptyPlaylists: "No playlists found.";
36
+ readonly emptyRecent: "No recently played tracks.";
37
+ readonly emptyNowPlaying: "Nothing is playing right now.";
38
+ readonly loading: "Loading…";
39
+ readonly loadFailed: "Failed to load.";
40
+ readonly retry: "Retry";
41
+ readonly trackBy: "by";
42
+ readonly tracksCount: "tracks";
43
+ readonly previewSummary: "Spotify";
44
+ readonly playerControls: "Playback";
45
+ readonly premiumRequired: "Spotify Premium is required to control playback. Free / open accounts cannot use these controls; the rest of the plugin still works.";
46
+ readonly volume: "Volume";
47
+ readonly devices: "Devices";
48
+ readonly deviceActive: "active";
49
+ readonly transferToDevice: "Transfer here";
50
+ readonly btnPrevious: "Previous track";
51
+ readonly btnPause: "Pause";
52
+ readonly btnPlay: "Play";
53
+ readonly btnNext: "Next track";
54
+ };
55
+ export default _default;
@@ -0,0 +1,107 @@
1
+ export declare function useT(): import('vue').ComputedRef<{
2
+ readonly title: "Spotify";
3
+ readonly notConnected: "Not connected to Spotify";
4
+ readonly notConfigured: "Client ID not configured";
5
+ readonly configureHelp: "Paste your Spotify Developer Dashboard Client ID and click Save.";
6
+ readonly configurePlaceholder: "Spotify Client ID";
7
+ readonly save: "Save";
8
+ readonly saving: "Saving…";
9
+ readonly saved: "Saved.";
10
+ readonly saveFailed: "Save failed.";
11
+ readonly connect: "Connect Spotify";
12
+ readonly connecting: "Opening Spotify consent…";
13
+ readonly connected: "Connected.";
14
+ readonly reconnect: "Reconnect";
15
+ readonly disconnect: "Disconnect";
16
+ readonly refresh: "Refresh";
17
+ readonly setupGuideLink: "How do I get a Client ID?";
18
+ readonly scopes: "Scopes";
19
+ readonly expiresAt: "Expires";
20
+ readonly tabLiked: "Liked";
21
+ readonly tabPlaylists: "Playlists";
22
+ readonly tabRecent: "Recent";
23
+ readonly tabNowPlaying: "Now playing";
24
+ readonly tabSearch: "Search";
25
+ readonly searchPlaceholder: "Search tracks, artists, albums, playlists";
26
+ readonly searchSubmit: "Search";
27
+ readonly searchHint: "Type a query and press Search.";
28
+ readonly searchEmpty: "No results.";
29
+ readonly searchTracks: "Tracks";
30
+ readonly searchArtists: "Artists";
31
+ readonly searchAlbums: "Albums";
32
+ readonly searchPlaylists: "Playlists";
33
+ readonly empty: "Nothing to show.";
34
+ readonly emptyLiked: "You haven't liked any songs yet.";
35
+ readonly emptyPlaylists: "No playlists found.";
36
+ readonly emptyRecent: "No recently played tracks.";
37
+ readonly emptyNowPlaying: "Nothing is playing right now.";
38
+ readonly loading: "Loading…";
39
+ readonly loadFailed: "Failed to load.";
40
+ readonly retry: "Retry";
41
+ readonly trackBy: "by";
42
+ readonly tracksCount: "tracks";
43
+ readonly previewSummary: "Spotify";
44
+ readonly playerControls: "Playback";
45
+ readonly premiumRequired: "Spotify Premium is required to control playback. Free / open accounts cannot use these controls; the rest of the plugin still works.";
46
+ readonly volume: "Volume";
47
+ readonly devices: "Devices";
48
+ readonly deviceActive: "active";
49
+ readonly transferToDevice: "Transfer here";
50
+ readonly btnPrevious: "Previous track";
51
+ readonly btnPause: "Pause";
52
+ readonly btnPlay: "Play";
53
+ readonly btnNext: "Next track";
54
+ } | {
55
+ readonly title: "Spotify";
56
+ readonly notConnected: "Spotify に未接続です";
57
+ readonly notConfigured: "Client ID が未設定です";
58
+ readonly configureHelp: "Spotify Developer Dashboard で発行した Client ID を貼り付けて Save を押してください。";
59
+ readonly configurePlaceholder: "Spotify Client ID";
60
+ readonly save: "保存";
61
+ readonly saving: "保存中…";
62
+ readonly saved: "保存しました。";
63
+ readonly saveFailed: "保存に失敗しました。";
64
+ readonly connect: "Spotify に接続";
65
+ readonly connecting: "Spotify の同意画面を開きます…";
66
+ readonly connected: "接続済み";
67
+ readonly reconnect: "再接続";
68
+ readonly disconnect: "切断";
69
+ readonly refresh: "更新";
70
+ readonly setupGuideLink: "Client ID の取得方法";
71
+ readonly scopes: "Scope";
72
+ readonly expiresAt: "有効期限";
73
+ readonly tabLiked: "Liked";
74
+ readonly tabPlaylists: "Playlists";
75
+ readonly tabRecent: "Recent";
76
+ readonly tabNowPlaying: "Now playing";
77
+ readonly tabSearch: "検索";
78
+ readonly searchPlaceholder: "曲・アーティスト・アルバム・プレイリストを検索";
79
+ readonly searchSubmit: "検索";
80
+ readonly searchHint: "クエリを入力して検索を押してください。";
81
+ readonly searchEmpty: "ヒットなし。";
82
+ readonly searchTracks: "曲";
83
+ readonly searchArtists: "アーティスト";
84
+ readonly searchAlbums: "アルバム";
85
+ readonly searchPlaylists: "プレイリスト";
86
+ readonly empty: "表示する項目がありません。";
87
+ readonly emptyLiked: "Liked Songs がありません。";
88
+ readonly emptyPlaylists: "Playlist が見つかりませんでした。";
89
+ readonly emptyRecent: "最近聞いた曲はありません。";
90
+ readonly emptyNowPlaying: "現在再生中の曲はありません。";
91
+ readonly loading: "読み込み中…";
92
+ readonly loadFailed: "読み込みに失敗しました。";
93
+ readonly retry: "再試行";
94
+ readonly trackBy: "—";
95
+ readonly tracksCount: "曲";
96
+ readonly previewSummary: "Spotify";
97
+ readonly playerControls: "再生制御";
98
+ readonly premiumRequired: "再生制御には Spotify Premium が必要です。Free / Open アカウントでは利用できません。それ以外の機能はそのまま使えます。";
99
+ readonly volume: "音量";
100
+ readonly devices: "デバイス";
101
+ readonly deviceActive: "アクティブ";
102
+ readonly transferToDevice: "ここに移す";
103
+ readonly btnPrevious: "前の曲";
104
+ readonly btnPause: "一時停止";
105
+ readonly btnPlay: "再生";
106
+ readonly btnNext: "次の曲";
107
+ }>;
@@ -0,0 +1,55 @@
1
+ declare const _default: {
2
+ readonly title: "Spotify";
3
+ readonly notConnected: "Spotify に未接続です";
4
+ readonly notConfigured: "Client ID が未設定です";
5
+ readonly configureHelp: "Spotify Developer Dashboard で発行した Client ID を貼り付けて Save を押してください。";
6
+ readonly configurePlaceholder: "Spotify Client ID";
7
+ readonly save: "保存";
8
+ readonly saving: "保存中…";
9
+ readonly saved: "保存しました。";
10
+ readonly saveFailed: "保存に失敗しました。";
11
+ readonly connect: "Spotify に接続";
12
+ readonly connecting: "Spotify の同意画面を開きます…";
13
+ readonly connected: "接続済み";
14
+ readonly reconnect: "再接続";
15
+ readonly disconnect: "切断";
16
+ readonly refresh: "更新";
17
+ readonly setupGuideLink: "Client ID の取得方法";
18
+ readonly scopes: "Scope";
19
+ readonly expiresAt: "有効期限";
20
+ readonly tabLiked: "Liked";
21
+ readonly tabPlaylists: "Playlists";
22
+ readonly tabRecent: "Recent";
23
+ readonly tabNowPlaying: "Now playing";
24
+ readonly tabSearch: "検索";
25
+ readonly searchPlaceholder: "曲・アーティスト・アルバム・プレイリストを検索";
26
+ readonly searchSubmit: "検索";
27
+ readonly searchHint: "クエリを入力して検索を押してください。";
28
+ readonly searchEmpty: "ヒットなし。";
29
+ readonly searchTracks: "曲";
30
+ readonly searchArtists: "アーティスト";
31
+ readonly searchAlbums: "アルバム";
32
+ readonly searchPlaylists: "プレイリスト";
33
+ readonly empty: "表示する項目がありません。";
34
+ readonly emptyLiked: "Liked Songs がありません。";
35
+ readonly emptyPlaylists: "Playlist が見つかりませんでした。";
36
+ readonly emptyRecent: "最近聞いた曲はありません。";
37
+ readonly emptyNowPlaying: "現在再生中の曲はありません。";
38
+ readonly loading: "読み込み中…";
39
+ readonly loadFailed: "読み込みに失敗しました。";
40
+ readonly retry: "再試行";
41
+ readonly trackBy: "—";
42
+ readonly tracksCount: "曲";
43
+ readonly previewSummary: "Spotify";
44
+ readonly playerControls: "再生制御";
45
+ readonly premiumRequired: "再生制御には Spotify Premium が必要です。Free / Open アカウントでは利用できません。それ以外の機能はそのまま使えます。";
46
+ readonly volume: "音量";
47
+ readonly devices: "デバイス";
48
+ readonly deviceActive: "アクティブ";
49
+ readonly transferToDevice: "ここに移す";
50
+ readonly btnPrevious: "前の曲";
51
+ readonly btnPause: "一時停止";
52
+ readonly btnPlay: "再生";
53
+ readonly btnNext: "次の曲";
54
+ };
55
+ export default _default;
@@ -0,0 +1,29 @@
1
+ import { PluginRuntime } from 'gui-chat-protocol';
2
+ import { SpotifyClientError } from './client';
3
+ import { normalisePlaylist } from './normalize';
4
+ import { NormalisedPlaylist, NormalisedTrack, RecentlyPlayedItem, SpotifyTokens } from './types';
5
+ export interface ListeningDeps {
6
+ runtime: PluginRuntime;
7
+ clientId: string;
8
+ tokens: SpotifyTokens;
9
+ /** Injectable clock — primarily for tests, where the default
10
+ * `() => new Date()` would race the proactive-refresh window
11
+ * whenever the fixture's `expiresAt` is close to wall-clock time.
12
+ * Production callers omit it. */
13
+ now?: () => Date;
14
+ }
15
+ type Result<T> = {
16
+ ok: true;
17
+ data: T;
18
+ } | {
19
+ ok: false;
20
+ error: SpotifyClientError;
21
+ };
22
+ export declare function fetchLiked(deps: ListeningDeps, limit: number): Promise<Result<NormalisedTrack[]>>;
23
+ export declare function fetchPlaylists(deps: ListeningDeps): Promise<Result<NormalisedPlaylist[]>>;
24
+ export declare function fetchPlaylistTracks(deps: ListeningDeps, playlistId: string, limit: number): Promise<Result<NormalisedTrack[]>>;
25
+ export declare function fetchRecent(deps: ListeningDeps, limit: number): Promise<Result<RecentlyPlayedItem[]>>;
26
+ /** `nowPlaying` returns null when nothing is currently playing
27
+ * (Spotify returns 204). The View shows an empty state. */
28
+ export declare function fetchNowPlaying(deps: ListeningDeps): Promise<Result<NormalisedTrack | null>>;
29
+ export { normalisePlaylist };
@@ -0,0 +1,18 @@
1
+ import { NormalisedAlbum, NormalisedArtist, NormalisedPlaylist, NormalisedTrack, RecentlyPlayedItem } from './types';
2
+ /** Normalise one Spotify track. Returns null when the response is
3
+ * missing required scalar fields — caller should drop it from the
4
+ * list rather than render a half-broken row. */
5
+ export declare function normaliseTrack(raw: unknown): NormalisedTrack | null;
6
+ /** Walk a paginated `items[]` response, normalise each entry's
7
+ * nested track, and drop entries that fail validation. */
8
+ export declare function normaliseTrackList(raw: unknown, trackPath: "track" | "self"): NormalisedTrack[];
9
+ /** `recently-played` items wrap the track in an object that carries
10
+ * the `played_at` timestamp. The View renders timestamps so we
11
+ * preserve them at this layer. */
12
+ export declare function normaliseRecentlyPlayed(raw: unknown): RecentlyPlayedItem[];
13
+ export declare function normalisePlaylist(raw: unknown): NormalisedPlaylist | null;
14
+ export declare function normalisePlaylistList(raw: unknown): NormalisedPlaylist[];
15
+ export declare function normaliseArtist(raw: unknown): NormalisedArtist | null;
16
+ export declare function normaliseArtistList(raw: unknown): NormalisedArtist[];
17
+ export declare function normaliseAlbum(raw: unknown): NormalisedAlbum | null;
18
+ export declare function normaliseAlbumList(raw: unknown): NormalisedAlbum[];
@@ -0,0 +1,36 @@
1
+ import { PendingAuthorization } from './types';
2
+ /** Generate 32 bytes of random entropy as a base64url string.
3
+ * Used both for `code_verifier` (PKCE) and `state` (CSRF). */
4
+ export declare function generateRandomToken(): string;
5
+ /** Derive `code_challenge` from `code_verifier`: SHA-256 then
6
+ * base64url. Spotify's authorize URL carries this; the token
7
+ * endpoint receives the verifier and re-derives. */
8
+ export declare function deriveCodeChallenge(codeVerifier: string): Promise<string>;
9
+ /** Register a fresh pending authorization. Returns the `state` the
10
+ * caller embeds on the authorize URL. Sweeps stale entries on
11
+ * every call so the map can't grow unbounded across abandoned
12
+ * attempts. */
13
+ export declare function registerPendingAuthorization(codeVerifier: string, redirectUri: string, now?: Date): string;
14
+ /** Look up + consume a pending authorization by `state`. Single-
15
+ * use: a successful lookup deletes the record so the same state
16
+ * can't be replayed. Returns null when the state is unknown
17
+ * (CSRF / stale / replayed). */
18
+ export declare function consumePendingAuthorization(state: string, now?: Date): PendingAuthorization | null;
19
+ /** Build the Spotify authorize URL. Pure — no side effects, no
20
+ * randomness; `state` and `codeChallenge` are passed in so the
21
+ * caller controls them (for replay registration and tests). */
22
+ export declare function buildAuthorizeUrl(params: {
23
+ clientId: string;
24
+ redirectUri: string;
25
+ scopes: readonly string[];
26
+ state: string;
27
+ codeChallenge: string;
28
+ }): string;
29
+ /** Test-only access to the in-memory store. */
30
+ export declare const _pendingAuthorizationsForTests: Map<string, {
31
+ codeVerifier: string;
32
+ redirectUri: string;
33
+ createdAtMs: number;
34
+ }>;
35
+ /** Test-only reset — wipe the store between cases. */
36
+ export declare function _resetPendingAuthorizationsForTests(): void;
@@ -0,0 +1,30 @@
1
+ import { PluginRuntime } from 'gui-chat-protocol';
2
+ import { SpotifyClientError } from './client';
3
+ import { NormalisedDevice, SpotifyTokens } from './types';
4
+ export interface PlaybackDeps {
5
+ runtime: PluginRuntime;
6
+ clientId: string;
7
+ tokens: SpotifyTokens;
8
+ now?: () => Date;
9
+ }
10
+ type Result<T> = {
11
+ ok: true;
12
+ data: T;
13
+ } | {
14
+ ok: false;
15
+ error: SpotifyClientError;
16
+ };
17
+ interface PlayArgs {
18
+ deviceId?: string;
19
+ contextUri?: string;
20
+ trackUris?: string[];
21
+ }
22
+ export declare function playerPlay(deps: PlaybackDeps, args: PlayArgs): Promise<Result<null>>;
23
+ export declare function playerPause(deps: PlaybackDeps, deviceId?: string): Promise<Result<null>>;
24
+ export declare function playerNext(deps: PlaybackDeps, deviceId?: string): Promise<Result<null>>;
25
+ export declare function playerPrevious(deps: PlaybackDeps, deviceId?: string): Promise<Result<null>>;
26
+ export declare function playerSeek(deps: PlaybackDeps, positionMs: number, deviceId?: string): Promise<Result<null>>;
27
+ export declare function playerSetVolume(deps: PlaybackDeps, volumePercent: number, deviceId?: string): Promise<Result<null>>;
28
+ export declare function playerTransfer(deps: PlaybackDeps, deviceId: string, play: boolean | undefined): Promise<Result<null>>;
29
+ export declare function playerGetDevices(deps: PlaybackDeps): Promise<Result<NormalisedDevice[]>>;
30
+ export {};