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,197 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { SpotifyError, classifyText } from './errors.js';
3
+ import { parseSpotifyUri, toSpotifyUri } from './uri.js';
4
+ /**
5
+ * Talking to the Spotify desktop app through its AppleScript dictionary.
6
+ *
7
+ * The script text and the parsing are separated on purpose, and both are
8
+ * exported. AppleScript is a string until the moment it runs, so the part most
9
+ * likely to break — a field order that drifts, a property spelled wrong — is
10
+ * exactly the part a type checker cannot see. Keeping the parser pure means the
11
+ * wire format has real tests on a machine with no Spotify, no macOS and no
12
+ * network, which is where this package's CI runs.
13
+ */
14
+ /** ASCII unit separator. Written as an escape because the character is
15
+ * invisible in an editor, and chosen over a tab or a pipe because a track title
16
+ * is free text — anything a person might plausibly type would split a title in
17
+ * half. */
18
+ export const FIELD = '\u001F';
19
+ /** How long any one script may take. A beachballing player, or a first run
20
+ * waiting on the macOS Automation consent prompt, must not hold a sample
21
+ * forever and let the next one stack behind it. */
22
+ export const SCRIPT_TIMEOUT_MS = 5_000;
23
+ export function runOsascript(script) {
24
+ return new Promise((resolve, reject) => {
25
+ execFile('osascript', ['-e', script], { timeout: SCRIPT_TIMEOUT_MS }, (error, stdout, stderr) => {
26
+ if (!error)
27
+ return resolve(stdout);
28
+ const text = String(stderr || error.message);
29
+ if (error.code === 'ENOENT') {
30
+ return reject(new SpotifyError('unavailable', 'osascript is not available on this system'));
31
+ }
32
+ reject(new SpotifyError(classifyText(text), text.trim() || 'osascript failed'));
33
+ });
34
+ });
35
+ }
36
+ /**
37
+ * Read the app's state without ever launching it.
38
+ *
39
+ * `if application "Spotify" is running` is the load-bearing line. A bare
40
+ * `tell application "Spotify"` *launches* Spotify, so a sampler running every
41
+ * second would boot a music player onto the machine of someone who never opened
42
+ * one — and would do it on a poll the user never asked for. The guard is what
43
+ * makes this safe to run on a timer.
44
+ *
45
+ * Every property sits behind its own `try` so that one that errors — and
46
+ * `current track` errors whenever nothing is loaded — costs its own field
47
+ * rather than the whole reading.
48
+ */
49
+ export function stateScript() {
50
+ return `on run
51
+ set AppleScript's text item delimiters to (ASCII character 31)
52
+ if application "Spotify" is running then
53
+ tell application "Spotify"
54
+ set stateField to "idle"
55
+ set positionField to "0"
56
+ set trackField to ""
57
+ set durationField to ""
58
+ set volumeField to ""
59
+ try
60
+ set stateField to (player state as text)
61
+ end try
62
+ try
63
+ set positionField to (player position as text)
64
+ end try
65
+ try
66
+ set trackField to (id of current track as text)
67
+ end try
68
+ try
69
+ set durationField to (duration of current track as text)
70
+ end try
71
+ try
72
+ set volumeField to (sound volume as text)
73
+ end try
74
+ return {"running", stateField, positionField, trackField, durationField, volumeField} as text
75
+ end tell
76
+ end if
77
+ return ""
78
+ end run`;
79
+ }
80
+ /**
81
+ * Turn a reading into a `Sample`, or `null` if the text is not one.
82
+ *
83
+ * Anything malformed answers null rather than a half-filled sample: a sample
84
+ * with a NaN playhead would be read as a real position and could end a track
85
+ * that is still playing, where a missing sample simply means this tick learned
86
+ * nothing and the next one will.
87
+ */
88
+ export function parseSample(raw) {
89
+ const trimmed = raw.trim();
90
+ // Empty is the script's own word for "Spotify is not open" — a real answer.
91
+ if (!trimmed) {
92
+ return { running: false, status: 'idle', positionMs: 0, durationMs: null, nativeUri: null, volume: null };
93
+ }
94
+ const fields = trimmed.split(FIELD);
95
+ if (fields[0] !== 'running' || fields.length < 6)
96
+ return null;
97
+ const parsed = parseSpotifyUri(fields[3]);
98
+ const durationMs = positiveInt(fields[4]);
99
+ const volume = positiveFloat(fields[5]);
100
+ return {
101
+ running: true,
102
+ status: readStatus(fields[1]),
103
+ // Spotify reports the playhead in seconds and the track length in
104
+ // milliseconds. Everything above this line is milliseconds.
105
+ positionMs: Math.round((positiveFloat(fields[2]) ?? 0) * 1000),
106
+ durationMs,
107
+ nativeUri: parsed ? toSpotifyUri(parsed) : null,
108
+ volume: volume === null ? null : Math.min(1, Math.max(0, volume / 100)),
109
+ };
110
+ }
111
+ /** Spotify's dictionary spells the three states exactly this way. */
112
+ function readStatus(value) {
113
+ if (value === 'playing')
114
+ return 'playing';
115
+ if (value === 'paused')
116
+ return 'paused';
117
+ return 'idle';
118
+ }
119
+ function positiveFloat(value) {
120
+ const parsed = Number.parseFloat(value ?? '');
121
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
122
+ }
123
+ function positiveInt(value) {
124
+ const parsed = Number.parseInt(value ?? '', 10);
125
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
126
+ }
127
+ // -- commands ---------------------------------------------------------------
128
+ /**
129
+ * Start a specific track.
130
+ *
131
+ * This is the one script allowed to launch Spotify, because it only ever runs
132
+ * as the direct result of something asking for playback. `activate` is
133
+ * deliberately absent: starting a song should not take the foreground away from
134
+ * whatever the person is actually looking at.
135
+ *
136
+ * A start offset needs the pause. `play track` returns as soon as the command
137
+ * is accepted, not when the track is loaded, and moving the playhead of a track
138
+ * that has not loaded yet either errors or is silently discarded — so the seek
139
+ * is retried until it takes, which costs nothing in the common case where
140
+ * `startAtMs` is zero and none of this runs.
141
+ */
142
+ export function playTrackScript(nativeUri, startAtMs = 0) {
143
+ const seconds = Math.max(0, Math.round(startAtMs / 1000));
144
+ const seek = seconds > 0
145
+ ? `
146
+ repeat 20 times
147
+ try
148
+ if player state is playing then
149
+ set player position to ${seconds}
150
+ exit repeat
151
+ end if
152
+ end try
153
+ delay 0.1
154
+ end repeat`
155
+ : '';
156
+ return `tell application "Spotify"
157
+ play track ${quote(nativeUri)}${seek}
158
+ end tell`;
159
+ }
160
+ /**
161
+ * Any other verb, wrapped so it cannot launch the app.
162
+ *
163
+ * There is nothing to pause, seek or re-level in an app that is not open, and
164
+ * launching one to tell it to be quiet would be absurd — so unlike
165
+ * `playTrackScript`, everything here is behind the running guard.
166
+ */
167
+ export function commandScript(body) {
168
+ return `if application "Spotify" is running then
169
+ tell application "Spotify"
170
+ ${body}
171
+ end tell
172
+ end if`;
173
+ }
174
+ export const commands = {
175
+ play: 'play',
176
+ /**
177
+ * Spotify's dictionary has no stop that does not quit the app, and quitting
178
+ * someone's music player because a queue moved to a different source would be
179
+ * far ruder than leaving it paused. `stop` therefore means pause here, and
180
+ * that is the honest description of what happens.
181
+ */
182
+ pause: 'pause',
183
+ seek: (positionMs) => `set player position to ${Math.max(0, Math.round(positionMs / 1000))}`,
184
+ volume: (volume) => `set sound volume to ${Math.min(100, Math.max(0, Math.round(volume * 100)))}`,
185
+ };
186
+ /**
187
+ * A string literal AppleScript will read as one string.
188
+ *
189
+ * Only ever given a `spotify:` URI built by `toSpotifyUri` from a validated
190
+ * base-62 id, so there is nothing here to escape in practice — but this text
191
+ * becomes a program, and a quoting function that exists is cheaper than the
192
+ * argument about whether the caller can always be trusted.
193
+ */
194
+ function quote(value) {
195
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
196
+ }
197
+ //# sourceMappingURL=applescript.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"applescript.js","sourceRoot":"","sources":["../../src/applescript.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,100 @@
1
+ import { type Scheduler } from 'upnext-core';
2
+ import type { Adapter, AdapterEvent, Binding, Capabilities, MediaRef } from 'upnext-core';
3
+ import { type Osascript } from './applescript.js';
4
+ import { type TrackLookup } from './metadata.js';
5
+ export interface SpotifyDesktopOptions {
6
+ id?: string;
7
+ /**
8
+ * How often to read the app while something is loaded.
9
+ *
10
+ * This is the resolution of every time-dependent thing the backend can tell
11
+ * us — the playhead, a pause someone made in the app, the end of a track.
12
+ * A second is comfortably below what a person notices and cheap enough to
13
+ * run for hours; the cost of each sample is one short-lived `osascript`.
14
+ */
15
+ sampleIntervalMs?: number;
16
+ /** Injected so the whole adapter is testable off macOS. */
17
+ osascript?: Osascript;
18
+ scheduler?: Scheduler;
19
+ /**
20
+ * How a bare Spotify URI becomes a title and a cover. `null` turns it off and
21
+ * the queue simply shows URIs — nothing about playback depends on it.
22
+ */
23
+ lookup?: TrackLookup | null;
24
+ /** Overridden only by tests; a real host is on whatever it is on. */
25
+ platform?: string;
26
+ }
27
+ /**
28
+ * Drives the Spotify desktop app on macOS, with no credentials at all.
29
+ *
30
+ * There is no OAuth here, no client id, no Premium check and nothing to
31
+ * register: it talks to the copy of Spotify already running on the machine
32
+ * through the AppleScript dictionary that ships with it. If someone can play
33
+ * music in Spotify, this can play music in Spotify.
34
+ *
35
+ * It is also the first backend in this project that a **human can fight**, and
36
+ * that is the interesting part. A local file is ours alone; the Spotify app has
37
+ * its own transport bar, its own queue, and a person holding a phone who is
38
+ * fully entitled to press next. So it declares `externalControl: true`, keeps
39
+ * enough history to tell a natural track change from a deliberate one (see
40
+ * `sampler.ts`), and lets the runtime's reconciler decide who wins — which by
41
+ * default is the person.
42
+ *
43
+ * What it deliberately does *not* claim:
44
+ *
45
+ * `search: false` — the dictionary cannot search the catalogue. It could be
46
+ * faked by scraping something, and then every `resolve` of a title would be
47
+ * a guess dressed as a lookup. An adapter that says it cannot do a thing is
48
+ * correct and slightly limited; one that says it can and then does it badly
49
+ * is broken. Use `SpotifyWebAdapter` when you need search.
50
+ *
51
+ * No `poll()` — `position: 'authoritative'` would normally make the runtime's
52
+ * watcher poll this adapter on an interval. It samples itself instead,
53
+ * because only the adapter can compare a reading against the one before it,
54
+ * and that comparison is the whole rollover-versus-takeover decision.
55
+ * Offering `poll` as well would mean two timers reading the same app.
56
+ */
57
+ export declare class SpotifyDesktopAdapter implements Adapter {
58
+ #private;
59
+ readonly id: string;
60
+ readonly capabilities: Capabilities;
61
+ constructor(options?: SpotifyDesktopOptions);
62
+ /**
63
+ * Fail here rather than at the first song.
64
+ *
65
+ * A backend whose `init` throws is excluded from selection and shows up in
66
+ * `getState().adapters` as `available: false` with the reason — so a host on
67
+ * Linux gets one clear "this backend is macOS-only" at startup instead of an
68
+ * agent discovering it one failed track at a time.
69
+ *
70
+ * `id of application "Spotify"` resolves the app through LaunchServices
71
+ * *without launching it*, which is the only way to ask "is Spotify even
72
+ * installed" that does not open a music player on someone's desktop.
73
+ */
74
+ init(): Promise<void>;
75
+ /**
76
+ * Only things that already name a Spotify track.
77
+ *
78
+ * Without search there is no way to get from "Bad Habit by Steve Lacy" to a
79
+ * URI, so scoring anything else above zero would win the ref away from an
80
+ * adapter that could actually have played it.
81
+ */
82
+ match(ref: MediaRef): number;
83
+ resolve(ref: MediaRef): Promise<Binding | null>;
84
+ load(binding: Binding, opts?: {
85
+ startAtMs?: number;
86
+ }): Promise<void>;
87
+ play(): Promise<void>;
88
+ pause(): Promise<void>;
89
+ /**
90
+ * Spotify's dictionary has no stop that does not quit the app, so this
91
+ * pauses. Quitting somebody's music player because the queue moved on to a
92
+ * podcast would be a much bigger thing to do than the runtime is asking for.
93
+ */
94
+ stop(): Promise<void>;
95
+ seek(positionMs: number): Promise<void>;
96
+ setVolume(volume: number): Promise<void>;
97
+ subscribe(listener: (event: AdapterEvent) => void): () => void;
98
+ dispose(): Promise<void>;
99
+ }
100
+ //# sourceMappingURL=desktop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"desktop.d.ts","sourceRoot":"","sources":["../../src/desktop.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwC,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AACnF,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC1F,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,eAAe,CAAC;AAI9D,MAAM,WAAW,qBAAqB;IACpC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2DAA2D;IAC3D,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAC5B,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,qBAAa,qBAAsB,YAAW,OAAO;;IACnD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAEpB,QAAQ,CAAC,YAAY,EAAE,YAAY,CASjC;gBAaU,OAAO,GAAE,qBAA0B;IAoB/C;;;;;;;;;;;OAWG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAoB3B;;;;;;OAMG;IACH,KAAK,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM;IAKtB,OAAO,CAAC,GAAG,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC;IAyB/C,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAOpE,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAgBrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAI5B;;;;OAIG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAQrB,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvC,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAI9C,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxD,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;CAa/B"}
@@ -0,0 +1,194 @@
1
+ import { defaultCapabilities, systemScheduler } from 'upnext-core';
2
+ import { commandScript, commands, parseSample, playTrackScript, runOsascript, stateScript, } from './applescript.js';
3
+ import { SpotifyError } from './errors.js';
4
+ import { embedLookup } from './metadata.js';
5
+ import { isPlayableKind, parseSpotifyUri, toSpotifyUri } from './uri.js';
6
+ import { BackendWatcher } from './watch.js';
7
+ /**
8
+ * Drives the Spotify desktop app on macOS, with no credentials at all.
9
+ *
10
+ * There is no OAuth here, no client id, no Premium check and nothing to
11
+ * register: it talks to the copy of Spotify already running on the machine
12
+ * through the AppleScript dictionary that ships with it. If someone can play
13
+ * music in Spotify, this can play music in Spotify.
14
+ *
15
+ * It is also the first backend in this project that a **human can fight**, and
16
+ * that is the interesting part. A local file is ours alone; the Spotify app has
17
+ * its own transport bar, its own queue, and a person holding a phone who is
18
+ * fully entitled to press next. So it declares `externalControl: true`, keeps
19
+ * enough history to tell a natural track change from a deliberate one (see
20
+ * `sampler.ts`), and lets the runtime's reconciler decide who wins — which by
21
+ * default is the person.
22
+ *
23
+ * What it deliberately does *not* claim:
24
+ *
25
+ * `search: false` — the dictionary cannot search the catalogue. It could be
26
+ * faked by scraping something, and then every `resolve` of a title would be
27
+ * a guess dressed as a lookup. An adapter that says it cannot do a thing is
28
+ * correct and slightly limited; one that says it can and then does it badly
29
+ * is broken. Use `SpotifyWebAdapter` when you need search.
30
+ *
31
+ * No `poll()` — `position: 'authoritative'` would normally make the runtime's
32
+ * watcher poll this adapter on an interval. It samples itself instead,
33
+ * because only the adapter can compare a reading against the one before it,
34
+ * and that comparison is the whole rollover-versus-takeover decision.
35
+ * Offering `poll` as well would mean two timers reading the same app.
36
+ */
37
+ export class SpotifyDesktopAdapter {
38
+ id;
39
+ capabilities = {
40
+ ...defaultCapabilities,
41
+ endOfTrack: 'event',
42
+ position: 'authoritative',
43
+ externalControl: true,
44
+ seek: true,
45
+ pause: true,
46
+ volume: true,
47
+ search: false,
48
+ };
49
+ #osascript;
50
+ #lookup;
51
+ #platform;
52
+ #watcher;
53
+ #listeners = new Set();
54
+ #binding = null;
55
+ #startAtMs = 0;
56
+ /** Whether `play track` has been issued for the loaded binding. */
57
+ #started = false;
58
+ constructor(options = {}) {
59
+ this.id = options.id ?? 'spotify-desktop';
60
+ this.#osascript = options.osascript ?? runOsascript;
61
+ this.#lookup = options.lookup === undefined ? embedLookup : options.lookup;
62
+ this.#platform = options.platform ?? process.platform;
63
+ const intervalMs = options.sampleIntervalMs ?? 1000;
64
+ this.#watcher = new BackendWatcher({
65
+ read: async () => parseSample(await this.#osascript(stateScript())),
66
+ emit: (event) => this.#emit(event),
67
+ scheduler: options.scheduler ?? systemScheduler,
68
+ intervalMs,
69
+ // Two intervals wide: the last reading before a track change can be a
70
+ // whole interval short of the end, and a window tighter than that reads
71
+ // every natural rollover as a human taking over.
72
+ rolloverWindowMs: Math.max(2000, intervalMs * 2),
73
+ confirmWithin: Math.max(3, Math.ceil(5000 / intervalMs)),
74
+ });
75
+ }
76
+ /**
77
+ * Fail here rather than at the first song.
78
+ *
79
+ * A backend whose `init` throws is excluded from selection and shows up in
80
+ * `getState().adapters` as `available: false` with the reason — so a host on
81
+ * Linux gets one clear "this backend is macOS-only" at startup instead of an
82
+ * agent discovering it one failed track at a time.
83
+ *
84
+ * `id of application "Spotify"` resolves the app through LaunchServices
85
+ * *without launching it*, which is the only way to ask "is Spotify even
86
+ * installed" that does not open a music player on someone's desktop.
87
+ */
88
+ async init() {
89
+ if (this.#platform !== 'darwin') {
90
+ throw new SpotifyError('unavailable', `${this.id} drives the macOS Spotify app and cannot run on ${this.#platform}; ` +
91
+ 'use SpotifyWebAdapter instead');
92
+ }
93
+ try {
94
+ await this.#osascript('id of application "Spotify"');
95
+ }
96
+ catch (err) {
97
+ throw new SpotifyError('unavailable', `${this.id} could not find the Spotify desktop app: ${err instanceof Error ? err.message : String(err)}`);
98
+ }
99
+ }
100
+ /**
101
+ * Only things that already name a Spotify track.
102
+ *
103
+ * Without search there is no way to get from "Bad Habit by Steve Lacy" to a
104
+ * URI, so scoring anything else above zero would win the ref away from an
105
+ * adapter that could actually have played it.
106
+ */
107
+ match(ref) {
108
+ const parsed = parseSpotifyUri(ref.uri);
109
+ return parsed && isPlayableKind(parsed.kind) ? 1 : 0;
110
+ }
111
+ async resolve(ref) {
112
+ const parsed = parseSpotifyUri(ref.uri);
113
+ if (!parsed || !isPlayableKind(parsed.kind))
114
+ return null;
115
+ const nativeUri = toSpotifyUri(parsed);
116
+ // Best-effort only, and skipped entirely when the ref already says enough.
117
+ // A track with no title still plays; a resolve that throws does not.
118
+ let extra = {};
119
+ if (this.#lookup && !(ref.title && ref.artist)) {
120
+ try {
121
+ extra = await this.#lookup(parsed);
122
+ }
123
+ catch {
124
+ extra = {};
125
+ }
126
+ }
127
+ return {
128
+ adapterId: this.id,
129
+ nativeUri,
130
+ // What the caller already knew wins: it came from whoever built the
131
+ // queue, and the lookup is only filling gaps.
132
+ ref: { ...extra, ...ref, uri: nativeUri },
133
+ };
134
+ }
135
+ async load(binding, opts) {
136
+ this.#watcher.stop();
137
+ this.#binding = binding;
138
+ this.#startAtMs = opts?.startAtMs ?? 0;
139
+ this.#started = false;
140
+ }
141
+ async play() {
142
+ const binding = this.#binding;
143
+ if (!binding)
144
+ throw new SpotifyError('failed', `${this.id}: nothing is loaded`);
145
+ if (this.#started) {
146
+ // Resuming what is already loaded. `play` on its own is the dictionary's
147
+ // resume; re-issuing `play track` would restart the song from zero.
148
+ await this.#run(commandScript(commands.play));
149
+ return;
150
+ }
151
+ await this.#run(playTrackScript(binding.nativeUri, this.#startAtMs));
152
+ this.#started = true;
153
+ this.#watcher.start(binding.nativeUri);
154
+ }
155
+ async pause() {
156
+ await this.#run(commandScript(commands.pause));
157
+ }
158
+ /**
159
+ * Spotify's dictionary has no stop that does not quit the app, so this
160
+ * pauses. Quitting somebody's music player because the queue moved on to a
161
+ * podcast would be a much bigger thing to do than the runtime is asking for.
162
+ */
163
+ async stop() {
164
+ this.#watcher.stop();
165
+ const wasStarted = this.#started;
166
+ this.#binding = null;
167
+ this.#started = false;
168
+ if (wasStarted)
169
+ await this.#run(commandScript(commands.pause));
170
+ }
171
+ async seek(positionMs) {
172
+ await this.#run(commandScript(commands.seek(positionMs)));
173
+ }
174
+ async setVolume(volume) {
175
+ await this.#run(commandScript(commands.volume(volume)));
176
+ }
177
+ subscribe(listener) {
178
+ this.#listeners.add(listener);
179
+ return () => this.#listeners.delete(listener);
180
+ }
181
+ async dispose() {
182
+ this.#watcher.stop();
183
+ this.#listeners.clear();
184
+ this.#binding = null;
185
+ }
186
+ async #run(script) {
187
+ await this.#osascript(script);
188
+ }
189
+ #emit(event) {
190
+ for (const listener of [...this.#listeners])
191
+ listener(event);
192
+ }
193
+ }
194
+ //# sourceMappingURL=desktop.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"desktop.js","sourceRoot":"","sources":["../../src/desktop.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,61 @@
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
+ export declare class SpotifyError extends Error {
30
+ readonly reason: SpotifyFailure;
31
+ readonly status?: number;
32
+ readonly retryAfterMs?: number;
33
+ constructor(reason: SpotifyFailure, message: string, extra?: {
34
+ status?: number;
35
+ retryAfterMs?: number;
36
+ });
37
+ }
38
+ /**
39
+ * Map an HTTP status, plus Spotify's own `reason` when it sends one, onto a
40
+ * bucket.
41
+ *
42
+ * Two statuses are genuinely ambiguous and the body is what settles them:
43
+ * a 403 is a scope problem *or* a free account, and a 404 from the player
44
+ * endpoints is a missing track *or* the well-known `NO_ACTIVE_DEVICE`. Both
45
+ * distinctions change what a host should tell the user, so both are read.
46
+ */
47
+ export declare function classifyStatus(status: number, body?: unknown): SpotifyFailure;
48
+ /** Spotify's error body carries a human message worth passing through. */
49
+ export declare function errorMessage(body: unknown, fallback: string): string;
50
+ /**
51
+ * The same classification against free text, for the backend that has no status
52
+ * codes — osascript writes prose to stderr and nothing else.
53
+ *
54
+ * The ordering matters: rate limiting is tested first because a throttling
55
+ * message can contain the word "user" and must not be mistaken for the auth
56
+ * case below it.
57
+ */
58
+ export declare function classifyText(text: string): SpotifyFailure;
59
+ /** Seconds in a `Retry-After` header, in milliseconds. */
60
+ export declare function retryAfterMs(header: string | null): number | undefined;
61
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,cAAc;AACxB,mFAAmF;AACjF,cAAc;AAChB,6EAA6E;GAC3E,kBAAkB;AACpB,iFAAiF;GAC/E,WAAW;AACb,8EAA8E;GAC5E,cAAc;AAChB,+DAA+D;GAC7D,WAAW;AACb,+EAA+E;GAC7E,aAAa;AACf,4EAA4E;GAC1E,QAAQ,CAAC;AAEb,qBAAa,YAAa,SAAQ,KAAK;IACrC,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;gBAG7B,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,MAAM,EACf,KAAK,GAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAO;CAQzD;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,cAAc,CAW7E;AAWD,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CASpE;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,cAAc,CAazD;AAED,0DAA0D;AAC1D,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAItE"}