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.
- package/LICENSE +201 -0
- package/README.md +224 -0
- package/dist/src/applescript.d.ts +100 -0
- package/dist/src/applescript.d.ts.map +1 -0
- package/dist/src/applescript.js +197 -0
- package/dist/src/applescript.js.map +1 -0
- package/dist/src/desktop.d.ts +100 -0
- package/dist/src/desktop.d.ts.map +1 -0
- package/dist/src/desktop.js +194 -0
- package/dist/src/desktop.js.map +1 -0
- package/dist/src/errors.d.ts +61 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +93 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/http.d.ts +50 -0
- package/dist/src/http.d.ts.map +1 -0
- package/dist/src/http.js +113 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/index.d.ts +34 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +28 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/metadata.d.ts +39 -0
- package/dist/src/metadata.d.ts.map +1 -0
- package/dist/src/metadata.js +130 -0
- package/dist/src/metadata.js.map +1 -0
- package/dist/src/ref.d.ts +29 -0
- package/dist/src/ref.d.ts.map +1 -0
- package/dist/src/ref.js +150 -0
- package/dist/src/ref.js.map +1 -0
- package/dist/src/sampler.d.ts +68 -0
- package/dist/src/sampler.d.ts.map +1 -0
- package/dist/src/sampler.js +96 -0
- package/dist/src/sampler.js.map +1 -0
- package/dist/src/uri.d.ts +31 -0
- package/dist/src/uri.d.ts.map +1 -0
- package/dist/src/uri.js +92 -0
- package/dist/src/uri.js.map +1 -0
- package/dist/src/watch.d.ts +35 -0
- package/dist/src/watch.d.ts.map +1 -0
- package/dist/src/watch.js +131 -0
- package/dist/src/watch.js.map +1 -0
- package/dist/src/web.d.ts +115 -0
- package/dist/src/web.d.ts.map +1 -0
- package/dist/src/web.js +300 -0
- package/dist/src/web.js.map +1 -0
- package/package.json +42 -0
- package/src/applescript.ts +241 -0
- package/src/desktop.ts +250 -0
- package/src/errors.ts +119 -0
- package/src/http.ts +159 -0
- package/src/index.ts +51 -0
- package/src/metadata.ts +167 -0
- package/src/ref.ts +145 -0
- package/src/sampler.ts +173 -0
- package/src/uri.ts +104 -0
- package/src/watch.ts +145 -0
- 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"}
|