upnext-adapter-spotify 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.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +224 -0
  3. package/dist/src/applescript.d.ts +100 -0
  4. package/dist/src/applescript.d.ts.map +1 -0
  5. package/dist/src/applescript.js +197 -0
  6. package/dist/src/applescript.js.map +1 -0
  7. package/dist/src/desktop.d.ts +100 -0
  8. package/dist/src/desktop.d.ts.map +1 -0
  9. package/dist/src/desktop.js +194 -0
  10. package/dist/src/desktop.js.map +1 -0
  11. package/dist/src/errors.d.ts +61 -0
  12. package/dist/src/errors.d.ts.map +1 -0
  13. package/dist/src/errors.js +93 -0
  14. package/dist/src/errors.js.map +1 -0
  15. package/dist/src/http.d.ts +50 -0
  16. package/dist/src/http.d.ts.map +1 -0
  17. package/dist/src/http.js +113 -0
  18. package/dist/src/http.js.map +1 -0
  19. package/dist/src/index.d.ts +34 -0
  20. package/dist/src/index.d.ts.map +1 -0
  21. package/dist/src/index.js +28 -0
  22. package/dist/src/index.js.map +1 -0
  23. package/dist/src/metadata.d.ts +39 -0
  24. package/dist/src/metadata.d.ts.map +1 -0
  25. package/dist/src/metadata.js +130 -0
  26. package/dist/src/metadata.js.map +1 -0
  27. package/dist/src/ref.d.ts +29 -0
  28. package/dist/src/ref.d.ts.map +1 -0
  29. package/dist/src/ref.js +150 -0
  30. package/dist/src/ref.js.map +1 -0
  31. package/dist/src/sampler.d.ts +68 -0
  32. package/dist/src/sampler.d.ts.map +1 -0
  33. package/dist/src/sampler.js +96 -0
  34. package/dist/src/sampler.js.map +1 -0
  35. package/dist/src/uri.d.ts +31 -0
  36. package/dist/src/uri.d.ts.map +1 -0
  37. package/dist/src/uri.js +92 -0
  38. package/dist/src/uri.js.map +1 -0
  39. package/dist/src/watch.d.ts +35 -0
  40. package/dist/src/watch.d.ts.map +1 -0
  41. package/dist/src/watch.js +131 -0
  42. package/dist/src/watch.js.map +1 -0
  43. package/dist/src/web.d.ts +115 -0
  44. package/dist/src/web.d.ts.map +1 -0
  45. package/dist/src/web.js +300 -0
  46. package/dist/src/web.js.map +1 -0
  47. package/package.json +42 -0
  48. package/src/applescript.ts +241 -0
  49. package/src/desktop.ts +250 -0
  50. package/src/errors.ts +119 -0
  51. package/src/http.ts +159 -0
  52. package/src/index.ts +51 -0
  53. package/src/metadata.ts +167 -0
  54. package/src/ref.ts +145 -0
  55. package/src/sampler.ts +173 -0
  56. package/src/uri.ts +104 -0
  57. package/src/watch.ts +145 -0
  58. package/src/web.ts +343 -0
@@ -0,0 +1,241 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { SpotifyError, classifyText } from './errors.js';
3
+ import { parseSpotifyUri, toSpotifyUri } from './uri.js';
4
+
5
+ /**
6
+ * Talking to the Spotify desktop app through its AppleScript dictionary.
7
+ *
8
+ * The script text and the parsing are separated on purpose, and both are
9
+ * exported. AppleScript is a string until the moment it runs, so the part most
10
+ * likely to break — a field order that drifts, a property spelled wrong — is
11
+ * exactly the part a type checker cannot see. Keeping the parser pure means the
12
+ * wire format has real tests on a machine with no Spotify, no macOS and no
13
+ * network, which is where this package's CI runs.
14
+ */
15
+
16
+ /** ASCII unit separator. Written as an escape because the character is
17
+ * invisible in an editor, and chosen over a tab or a pipe because a track title
18
+ * is free text — anything a person might plausibly type would split a title in
19
+ * half. */
20
+ export const FIELD = '\u001F';
21
+
22
+ /** How long any one script may take. A beachballing player, or a first run
23
+ * waiting on the macOS Automation consent prompt, must not hold a sample
24
+ * forever and let the next one stack behind it. */
25
+ export const SCRIPT_TIMEOUT_MS = 5_000;
26
+
27
+ /** The one dependency on the outside world, injectable so every decision this
28
+ * package makes is testable without a Mac, a Spotify account, or an app. */
29
+ export type Osascript = (script: string) => Promise<string>;
30
+
31
+ export function runOsascript(script: string): Promise<string> {
32
+ return new Promise((resolve, reject) => {
33
+ execFile(
34
+ 'osascript',
35
+ ['-e', script],
36
+ { timeout: SCRIPT_TIMEOUT_MS },
37
+ (error, stdout, stderr) => {
38
+ if (!error) return resolve(stdout);
39
+ const text = String(stderr || error.message);
40
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
41
+ return reject(
42
+ new SpotifyError('unavailable', 'osascript is not available on this system'),
43
+ );
44
+ }
45
+ reject(new SpotifyError(classifyText(text), text.trim() || 'osascript failed'));
46
+ },
47
+ );
48
+ });
49
+ }
50
+
51
+ /**
52
+ * One reading of the desktop app.
53
+ *
54
+ * `running: false` is a first-class answer, not an error. Spotify not being
55
+ * open is the ordinary state of most machines most of the time, and a backend
56
+ * that treats it as a failure would fill a host's logs with news it cannot act
57
+ * on.
58
+ */
59
+ export interface Sample {
60
+ running: boolean;
61
+ status: 'playing' | 'paused' | 'idle';
62
+ positionMs: number;
63
+ durationMs: number | null;
64
+ /** What Spotify says it is playing, as a `spotify:` URI. */
65
+ nativeUri: string | null;
66
+ /** 0..1, or null when the app would not say. */
67
+ volume: number | null;
68
+ }
69
+
70
+ /**
71
+ * Read the app's state without ever launching it.
72
+ *
73
+ * `if application "Spotify" is running` is the load-bearing line. A bare
74
+ * `tell application "Spotify"` *launches* Spotify, so a sampler running every
75
+ * second would boot a music player onto the machine of someone who never opened
76
+ * one — and would do it on a poll the user never asked for. The guard is what
77
+ * makes this safe to run on a timer.
78
+ *
79
+ * Every property sits behind its own `try` so that one that errors — and
80
+ * `current track` errors whenever nothing is loaded — costs its own field
81
+ * rather than the whole reading.
82
+ */
83
+ export function stateScript(): string {
84
+ return `on run
85
+ set AppleScript's text item delimiters to (ASCII character 31)
86
+ if application "Spotify" is running then
87
+ tell application "Spotify"
88
+ set stateField to "idle"
89
+ set positionField to "0"
90
+ set trackField to ""
91
+ set durationField to ""
92
+ set volumeField to ""
93
+ try
94
+ set stateField to (player state as text)
95
+ end try
96
+ try
97
+ set positionField to (player position as text)
98
+ end try
99
+ try
100
+ set trackField to (id of current track as text)
101
+ end try
102
+ try
103
+ set durationField to (duration of current track as text)
104
+ end try
105
+ try
106
+ set volumeField to (sound volume as text)
107
+ end try
108
+ return {"running", stateField, positionField, trackField, durationField, volumeField} as text
109
+ end tell
110
+ end if
111
+ return ""
112
+ end run`;
113
+ }
114
+
115
+ /**
116
+ * Turn a reading into a `Sample`, or `null` if the text is not one.
117
+ *
118
+ * Anything malformed answers null rather than a half-filled sample: a sample
119
+ * with a NaN playhead would be read as a real position and could end a track
120
+ * that is still playing, where a missing sample simply means this tick learned
121
+ * nothing and the next one will.
122
+ */
123
+ export function parseSample(raw: string): Sample | null {
124
+ const trimmed = raw.trim();
125
+ // Empty is the script's own word for "Spotify is not open" — a real answer.
126
+ if (!trimmed) {
127
+ return { running: false, status: 'idle', positionMs: 0, durationMs: null, nativeUri: null, volume: null };
128
+ }
129
+
130
+ const fields = trimmed.split(FIELD);
131
+ if (fields[0] !== 'running' || fields.length < 6) return null;
132
+
133
+ const parsed = parseSpotifyUri(fields[3]);
134
+ const durationMs = positiveInt(fields[4]);
135
+ const volume = positiveFloat(fields[5]);
136
+
137
+ return {
138
+ running: true,
139
+ status: readStatus(fields[1]),
140
+ // Spotify reports the playhead in seconds and the track length in
141
+ // milliseconds. Everything above this line is milliseconds.
142
+ positionMs: Math.round((positiveFloat(fields[2]) ?? 0) * 1000),
143
+ durationMs,
144
+ nativeUri: parsed ? toSpotifyUri(parsed) : null,
145
+ volume: volume === null ? null : Math.min(1, Math.max(0, volume / 100)),
146
+ };
147
+ }
148
+
149
+ /** Spotify's dictionary spells the three states exactly this way. */
150
+ function readStatus(value: string | undefined): Sample['status'] {
151
+ if (value === 'playing') return 'playing';
152
+ if (value === 'paused') return 'paused';
153
+ return 'idle';
154
+ }
155
+
156
+ function positiveFloat(value: string | undefined): number | null {
157
+ const parsed = Number.parseFloat(value ?? '');
158
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
159
+ }
160
+
161
+ function positiveInt(value: string | undefined): number | null {
162
+ const parsed = Number.parseInt(value ?? '', 10);
163
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
164
+ }
165
+
166
+ // -- commands ---------------------------------------------------------------
167
+
168
+ /**
169
+ * Start a specific track.
170
+ *
171
+ * This is the one script allowed to launch Spotify, because it only ever runs
172
+ * as the direct result of something asking for playback. `activate` is
173
+ * deliberately absent: starting a song should not take the foreground away from
174
+ * whatever the person is actually looking at.
175
+ *
176
+ * A start offset needs the pause. `play track` returns as soon as the command
177
+ * is accepted, not when the track is loaded, and moving the playhead of a track
178
+ * that has not loaded yet either errors or is silently discarded — so the seek
179
+ * is retried until it takes, which costs nothing in the common case where
180
+ * `startAtMs` is zero and none of this runs.
181
+ */
182
+ export function playTrackScript(nativeUri: string, startAtMs = 0): string {
183
+ const seconds = Math.max(0, Math.round(startAtMs / 1000));
184
+ const seek =
185
+ seconds > 0
186
+ ? `
187
+ repeat 20 times
188
+ try
189
+ if player state is playing then
190
+ set player position to ${seconds}
191
+ exit repeat
192
+ end if
193
+ end try
194
+ delay 0.1
195
+ end repeat`
196
+ : '';
197
+ return `tell application "Spotify"
198
+ play track ${quote(nativeUri)}${seek}
199
+ end tell`;
200
+ }
201
+
202
+ /**
203
+ * Any other verb, wrapped so it cannot launch the app.
204
+ *
205
+ * There is nothing to pause, seek or re-level in an app that is not open, and
206
+ * launching one to tell it to be quiet would be absurd — so unlike
207
+ * `playTrackScript`, everything here is behind the running guard.
208
+ */
209
+ export function commandScript(body: string): string {
210
+ return `if application "Spotify" is running then
211
+ tell application "Spotify"
212
+ ${body}
213
+ end tell
214
+ end if`;
215
+ }
216
+
217
+ export const commands = {
218
+ play: 'play',
219
+ /**
220
+ * Spotify's dictionary has no stop that does not quit the app, and quitting
221
+ * someone's music player because a queue moved to a different source would be
222
+ * far ruder than leaving it paused. `stop` therefore means pause here, and
223
+ * that is the honest description of what happens.
224
+ */
225
+ pause: 'pause',
226
+ seek: (positionMs: number) => `set player position to ${Math.max(0, Math.round(positionMs / 1000))}`,
227
+ volume: (volume: number) =>
228
+ `set sound volume to ${Math.min(100, Math.max(0, Math.round(volume * 100)))}`,
229
+ } as const;
230
+
231
+ /**
232
+ * A string literal AppleScript will read as one string.
233
+ *
234
+ * Only ever given a `spotify:` URI built by `toSpotifyUri` from a validated
235
+ * base-62 id, so there is nothing here to escape in practice — but this text
236
+ * becomes a program, and a quoting function that exists is cheaper than the
237
+ * argument about whether the caller can always be trusted.
238
+ */
239
+ function quote(value: string): string {
240
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
241
+ }
package/src/desktop.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { defaultCapabilities, systemScheduler, type Scheduler } from 'upnext-core';
2
+ import type { Adapter, AdapterEvent, Binding, Capabilities, MediaRef } from 'upnext-core';
3
+ import {
4
+ commandScript,
5
+ commands,
6
+ parseSample,
7
+ playTrackScript,
8
+ runOsascript,
9
+ stateScript,
10
+ type Osascript,
11
+ } from './applescript.js';
12
+ import { SpotifyError } from './errors.js';
13
+ import { embedLookup, type TrackLookup } from './metadata.js';
14
+ import { isPlayableKind, parseSpotifyUri, toSpotifyUri } from './uri.js';
15
+ import { BackendWatcher } from './watch.js';
16
+
17
+ export interface SpotifyDesktopOptions {
18
+ id?: string;
19
+ /**
20
+ * How often to read the app while something is loaded.
21
+ *
22
+ * This is the resolution of every time-dependent thing the backend can tell
23
+ * us — the playhead, a pause someone made in the app, the end of a track.
24
+ * A second is comfortably below what a person notices and cheap enough to
25
+ * run for hours; the cost of each sample is one short-lived `osascript`.
26
+ */
27
+ sampleIntervalMs?: number;
28
+ /** Injected so the whole adapter is testable off macOS. */
29
+ osascript?: Osascript;
30
+ scheduler?: Scheduler;
31
+ /**
32
+ * How a bare Spotify URI becomes a title and a cover. `null` turns it off and
33
+ * the queue simply shows URIs — nothing about playback depends on it.
34
+ */
35
+ lookup?: TrackLookup | null;
36
+ /** Overridden only by tests; a real host is on whatever it is on. */
37
+ platform?: string;
38
+ }
39
+
40
+ /**
41
+ * Drives the Spotify desktop app on macOS, with no credentials at all.
42
+ *
43
+ * There is no OAuth here, no client id, no Premium check and nothing to
44
+ * register: it talks to the copy of Spotify already running on the machine
45
+ * through the AppleScript dictionary that ships with it. If someone can play
46
+ * music in Spotify, this can play music in Spotify.
47
+ *
48
+ * It is also the first backend in this project that a **human can fight**, and
49
+ * that is the interesting part. A local file is ours alone; the Spotify app has
50
+ * its own transport bar, its own queue, and a person holding a phone who is
51
+ * fully entitled to press next. So it declares `externalControl: true`, keeps
52
+ * enough history to tell a natural track change from a deliberate one (see
53
+ * `sampler.ts`), and lets the runtime's reconciler decide who wins — which by
54
+ * default is the person.
55
+ *
56
+ * What it deliberately does *not* claim:
57
+ *
58
+ * `search: false` — the dictionary cannot search the catalogue. It could be
59
+ * faked by scraping something, and then every `resolve` of a title would be
60
+ * a guess dressed as a lookup. An adapter that says it cannot do a thing is
61
+ * correct and slightly limited; one that says it can and then does it badly
62
+ * is broken. Use `SpotifyWebAdapter` when you need search.
63
+ *
64
+ * No `poll()` — `position: 'authoritative'` would normally make the runtime's
65
+ * watcher poll this adapter on an interval. It samples itself instead,
66
+ * because only the adapter can compare a reading against the one before it,
67
+ * and that comparison is the whole rollover-versus-takeover decision.
68
+ * Offering `poll` as well would mean two timers reading the same app.
69
+ */
70
+ export class SpotifyDesktopAdapter implements Adapter {
71
+ readonly id: string;
72
+
73
+ readonly capabilities: Capabilities = {
74
+ ...defaultCapabilities,
75
+ endOfTrack: 'event',
76
+ position: 'authoritative',
77
+ externalControl: true,
78
+ seek: true,
79
+ pause: true,
80
+ volume: true,
81
+ search: false,
82
+ };
83
+
84
+ #osascript: Osascript;
85
+ #lookup: TrackLookup | null;
86
+ #platform: string;
87
+ #watcher: BackendWatcher;
88
+
89
+ #listeners = new Set<(event: AdapterEvent) => void>();
90
+ #binding: Binding | null = null;
91
+ #startAtMs = 0;
92
+ /** Whether `play track` has been issued for the loaded binding. */
93
+ #started = false;
94
+
95
+ constructor(options: SpotifyDesktopOptions = {}) {
96
+ this.id = options.id ?? 'spotify-desktop';
97
+ this.#osascript = options.osascript ?? runOsascript;
98
+ this.#lookup = options.lookup === undefined ? embedLookup : options.lookup;
99
+ this.#platform = options.platform ?? process.platform;
100
+
101
+ const intervalMs = options.sampleIntervalMs ?? 1000;
102
+ this.#watcher = new BackendWatcher({
103
+ read: async () => parseSample(await this.#osascript(stateScript())),
104
+ emit: (event) => this.#emit(event),
105
+ scheduler: options.scheduler ?? systemScheduler,
106
+ intervalMs,
107
+ // Two intervals wide: the last reading before a track change can be a
108
+ // whole interval short of the end, and a window tighter than that reads
109
+ // every natural rollover as a human taking over.
110
+ rolloverWindowMs: Math.max(2000, intervalMs * 2),
111
+ confirmWithin: Math.max(3, Math.ceil(5000 / intervalMs)),
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Fail here rather than at the first song.
117
+ *
118
+ * A backend whose `init` throws is excluded from selection and shows up in
119
+ * `getState().adapters` as `available: false` with the reason — so a host on
120
+ * Linux gets one clear "this backend is macOS-only" at startup instead of an
121
+ * agent discovering it one failed track at a time.
122
+ *
123
+ * `id of application "Spotify"` resolves the app through LaunchServices
124
+ * *without launching it*, which is the only way to ask "is Spotify even
125
+ * installed" that does not open a music player on someone's desktop.
126
+ */
127
+ async init(): Promise<void> {
128
+ if (this.#platform !== 'darwin') {
129
+ throw new SpotifyError(
130
+ 'unavailable',
131
+ `${this.id} drives the macOS Spotify app and cannot run on ${this.#platform}; ` +
132
+ 'use SpotifyWebAdapter instead',
133
+ );
134
+ }
135
+ try {
136
+ await this.#osascript('id of application "Spotify"');
137
+ } catch (err) {
138
+ throw new SpotifyError(
139
+ 'unavailable',
140
+ `${this.id} could not find the Spotify desktop app: ${
141
+ err instanceof Error ? err.message : String(err)
142
+ }`,
143
+ );
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Only things that already name a Spotify track.
149
+ *
150
+ * Without search there is no way to get from "Bad Habit by Steve Lacy" to a
151
+ * URI, so scoring anything else above zero would win the ref away from an
152
+ * adapter that could actually have played it.
153
+ */
154
+ match(ref: MediaRef): number {
155
+ const parsed = parseSpotifyUri(ref.uri);
156
+ return parsed && isPlayableKind(parsed.kind) ? 1 : 0;
157
+ }
158
+
159
+ async resolve(ref: MediaRef): Promise<Binding | null> {
160
+ const parsed = parseSpotifyUri(ref.uri);
161
+ if (!parsed || !isPlayableKind(parsed.kind)) return null;
162
+ const nativeUri = toSpotifyUri(parsed);
163
+
164
+ // Best-effort only, and skipped entirely when the ref already says enough.
165
+ // A track with no title still plays; a resolve that throws does not.
166
+ let extra: Partial<MediaRef> = {};
167
+ if (this.#lookup && !(ref.title && ref.artist)) {
168
+ try {
169
+ extra = await this.#lookup(parsed);
170
+ } catch {
171
+ extra = {};
172
+ }
173
+ }
174
+
175
+ return {
176
+ adapterId: this.id,
177
+ nativeUri,
178
+ // What the caller already knew wins: it came from whoever built the
179
+ // queue, and the lookup is only filling gaps.
180
+ ref: { ...extra, ...ref, uri: nativeUri },
181
+ };
182
+ }
183
+
184
+ async load(binding: Binding, opts?: { startAtMs?: number }): Promise<void> {
185
+ this.#watcher.stop();
186
+ this.#binding = binding;
187
+ this.#startAtMs = opts?.startAtMs ?? 0;
188
+ this.#started = false;
189
+ }
190
+
191
+ async play(): Promise<void> {
192
+ const binding = this.#binding;
193
+ if (!binding) throw new SpotifyError('failed', `${this.id}: nothing is loaded`);
194
+
195
+ if (this.#started) {
196
+ // Resuming what is already loaded. `play` on its own is the dictionary's
197
+ // resume; re-issuing `play track` would restart the song from zero.
198
+ await this.#run(commandScript(commands.play));
199
+ return;
200
+ }
201
+
202
+ await this.#run(playTrackScript(binding.nativeUri, this.#startAtMs));
203
+ this.#started = true;
204
+ this.#watcher.start(binding.nativeUri);
205
+ }
206
+
207
+ async pause(): Promise<void> {
208
+ await this.#run(commandScript(commands.pause));
209
+ }
210
+
211
+ /**
212
+ * Spotify's dictionary has no stop that does not quit the app, so this
213
+ * pauses. Quitting somebody's music player because the queue moved on to a
214
+ * podcast would be a much bigger thing to do than the runtime is asking for.
215
+ */
216
+ async stop(): Promise<void> {
217
+ this.#watcher.stop();
218
+ const wasStarted = this.#started;
219
+ this.#binding = null;
220
+ this.#started = false;
221
+ if (wasStarted) await this.#run(commandScript(commands.pause));
222
+ }
223
+
224
+ async seek(positionMs: number): Promise<void> {
225
+ await this.#run(commandScript(commands.seek(positionMs)));
226
+ }
227
+
228
+ async setVolume(volume: number): Promise<void> {
229
+ await this.#run(commandScript(commands.volume(volume)));
230
+ }
231
+
232
+ subscribe(listener: (event: AdapterEvent) => void): () => void {
233
+ this.#listeners.add(listener);
234
+ return () => this.#listeners.delete(listener);
235
+ }
236
+
237
+ async dispose(): Promise<void> {
238
+ this.#watcher.stop();
239
+ this.#listeners.clear();
240
+ this.#binding = null;
241
+ }
242
+
243
+ async #run(script: string): Promise<void> {
244
+ await this.#osascript(script);
245
+ }
246
+
247
+ #emit(event: AdapterEvent): void {
248
+ for (const listener of [...this.#listeners]) listener(event);
249
+ }
250
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Why a Spotify call did not work, in the few shapes a caller can act on.
3
+ *
4
+ * The distinction that matters is not the status code — it is what the person
5
+ * holding the machine would have to *do*. "Your token expired" and "you have no
6
+ * speaker selected" are both a failed play, and an agent that cannot tell them
7
+ * apart either nags for a re-login when the real problem is a sleeping phone,
8
+ * or silently retries forever when the real problem is a login.
9
+ *
10
+ * Deliberately coarse: the exact wording of an upstream error is not a contract
11
+ * and will drift, so this keys off the few stable signals and buckets everything
12
+ * else as `failed`.
13
+ */
14
+ export type SpotifyFailure =
15
+ /** No usable session. The host must get a fresh token, or the user must log in. */
16
+ | 'unauthorized'
17
+ /** Authenticated, but Spotify will not let this account control playback. */
18
+ | 'premium-required'
19
+ /** Nothing to play *on*: no active device, or the desktop app is not running. */
20
+ | 'no-device'
21
+ /** Too many calls. Back off; `retryAfterMs` says how long if Spotify said. */
22
+ | 'rate-limited'
23
+ /** Spotify does not have this track, or not in this market. */
24
+ | 'not-found'
25
+ /** This backend cannot run here at all — wrong platform, app not installed. */
26
+ | 'unavailable'
27
+ /** Something else. Renders as "try again", the safe default when unsure. */
28
+ | 'failed';
29
+
30
+ export class SpotifyError extends Error {
31
+ readonly reason: SpotifyFailure;
32
+ readonly status?: number;
33
+ readonly retryAfterMs?: number;
34
+
35
+ constructor(
36
+ reason: SpotifyFailure,
37
+ message: string,
38
+ extra: { status?: number; retryAfterMs?: number } = {},
39
+ ) {
40
+ super(message);
41
+ this.name = 'SpotifyError';
42
+ this.reason = reason;
43
+ if (extra.status !== undefined) this.status = extra.status;
44
+ if (extra.retryAfterMs !== undefined) this.retryAfterMs = extra.retryAfterMs;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Map an HTTP status, plus Spotify's own `reason` when it sends one, onto a
50
+ * bucket.
51
+ *
52
+ * Two statuses are genuinely ambiguous and the body is what settles them:
53
+ * a 403 is a scope problem *or* a free account, and a 404 from the player
54
+ * endpoints is a missing track *or* the well-known `NO_ACTIVE_DEVICE`. Both
55
+ * distinctions change what a host should tell the user, so both are read.
56
+ */
57
+ export function classifyStatus(status: number, body?: unknown): SpotifyFailure {
58
+ const reason = errorReason(body);
59
+ if (status === 429) return 'rate-limited';
60
+ if (status === 401) return 'unauthorized';
61
+ if (status === 403) {
62
+ return reason === 'PREMIUM_REQUIRED' ? 'premium-required' : 'unauthorized';
63
+ }
64
+ if (status === 404) {
65
+ return reason === 'NO_ACTIVE_DEVICE' ? 'no-device' : 'not-found';
66
+ }
67
+ return 'failed';
68
+ }
69
+
70
+ /** Spotify's player errors carry `{ error: { status, message, reason } }`. */
71
+ function errorReason(body: unknown): string | null {
72
+ if (!body || typeof body !== 'object') return null;
73
+ const error = (body as { error?: unknown }).error;
74
+ if (!error || typeof error !== 'object') return null;
75
+ const reason = (error as { reason?: unknown }).reason;
76
+ return typeof reason === 'string' ? reason : null;
77
+ }
78
+
79
+ /** Spotify's error body carries a human message worth passing through. */
80
+ export function errorMessage(body: unknown, fallback: string): string {
81
+ if (!body || typeof body !== 'object') return fallback;
82
+ const error = (body as { error?: unknown }).error;
83
+ if (typeof error === 'string') return error;
84
+ if (error && typeof error === 'object') {
85
+ const message = (error as { message?: unknown }).message;
86
+ if (typeof message === 'string' && message.trim()) return message.trim();
87
+ }
88
+ return fallback;
89
+ }
90
+
91
+ /**
92
+ * The same classification against free text, for the backend that has no status
93
+ * codes — osascript writes prose to stderr and nothing else.
94
+ *
95
+ * The ordering matters: rate limiting is tested first because a throttling
96
+ * message can contain the word "user" and must not be mistaken for the auth
97
+ * case below it.
98
+ */
99
+ export function classifyText(text: string): SpotifyFailure {
100
+ const lower = text.toLowerCase();
101
+ if (/rate limit|too many|429/.test(lower)) return 'rate-limited';
102
+ if (/not authorized|not authorised|-1743|assistive access/.test(lower)) {
103
+ // macOS Automation consent was declined. Not a Spotify login problem, but
104
+ // it is the same shape of problem: a permission the user has to grant.
105
+ return 'unauthorized';
106
+ }
107
+ if (/(-600|-609)|isn't running|is not running|application isn't/.test(lower)) {
108
+ return 'no-device';
109
+ }
110
+ if (/can't get|cant get|-1728/.test(lower)) return 'not-found';
111
+ return 'failed';
112
+ }
113
+
114
+ /** Seconds in a `Retry-After` header, in milliseconds. */
115
+ export function retryAfterMs(header: string | null): number | undefined {
116
+ if (!header) return undefined;
117
+ const seconds = Number.parseInt(header, 10);
118
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined;
119
+ }