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
package/src/web.ts ADDED
@@ -0,0 +1,343 @@
1
+ import { defaultCapabilities, systemScheduler, type Scheduler } from 'upnext-core';
2
+ import type { Adapter, AdapterEvent, Binding, Capabilities, MediaRef } from 'upnext-core';
3
+ import type { Sample } from './applescript.js';
4
+ import { SpotifyError } from './errors.js';
5
+ import { SpotifyHttp, type SpotifyHttpOptions, type TokenProvider } from './http.js';
6
+ import { readTrack, searchQuery } from './ref.js';
7
+ import { isPlayableKind, parseSpotifyUri, type SpotifyId } from './uri.js';
8
+ import { BackendWatcher } from './watch.js';
9
+
10
+ export interface SpotifyWebOptions extends SpotifyHttpOptions {
11
+ id?: string;
12
+ /**
13
+ * Which device to command. Without one Spotify targets whatever is currently
14
+ * active, which is usually right and occasionally surprising — a phone that
15
+ * woke up last is as "active" as the laptop in front of you.
16
+ */
17
+ deviceId?: string;
18
+ /**
19
+ * How often to read player state while something is loaded.
20
+ *
21
+ * Slower than the desktop backend's default on purpose: every sample is a
22
+ * real API call against a shared rate limit, and the host is using that same
23
+ * budget for its own work. Two seconds is still well inside what a person
24
+ * would call responsive for a progress bar.
25
+ */
26
+ sampleIntervalMs?: number;
27
+ scheduler?: Scheduler;
28
+ /** ISO 3166-1 alpha-2, so search and lookups return what this listener can play. */
29
+ market?: string;
30
+ }
31
+
32
+ /**
33
+ * Plays Spotify through the Web API, anywhere Node runs.
34
+ *
35
+ * The counterpart to `SpotifyDesktopAdapter`, and a deliberate demonstration of
36
+ * why capabilities are a per-adapter fact rather than a per-service one: these
37
+ * two drive the same service and are not interchangeable. This one can search a
38
+ * catalogue of a hundred million tracks and runs on Linux; it also needs an
39
+ * OAuth token, a Premium account and a device that is awake. The desktop one
40
+ * needs none of that and cannot search at all.
41
+ *
42
+ * An agent does not have to know any of that. It asks `runtime.can('search')`
43
+ * and gets the truth for whichever one is loaded.
44
+ *
45
+ * What the host has to provide:
46
+ *
47
+ * - `getAccessToken`, returning a user token with `user-read-playback-state`
48
+ * and `user-modify-playback-state`. This library never runs an OAuth flow.
49
+ * - A Spotify Premium account. Every playback-control endpoint is Premium-only
50
+ * and there is nothing an adapter can do about that; a free account gets a
51
+ * `premium-required` failure rather than a mystery.
52
+ * - Somewhere to play. The Web API commands a device, it is not one — so the
53
+ * Spotify app has to be open somewhere, or `deviceId` has to name something
54
+ * real. `no-device` is the failure when it is not.
55
+ */
56
+ export class SpotifyWebAdapter implements Adapter {
57
+ readonly id: string;
58
+
59
+ readonly capabilities: Capabilities = {
60
+ ...defaultCapabilities,
61
+ endOfTrack: 'event',
62
+ position: 'authoritative',
63
+ externalControl: true,
64
+ seek: true,
65
+ pause: true,
66
+ volume: true,
67
+ search: true,
68
+ };
69
+
70
+ #http: SpotifyHttp;
71
+ #deviceId: string | undefined;
72
+ #market: string | undefined;
73
+ #watcher: BackendWatcher;
74
+
75
+ #listeners = new Set<(event: AdapterEvent) => void>();
76
+ #binding: Binding | null = null;
77
+ #startAtMs = 0;
78
+ #started = false;
79
+
80
+ constructor(options: SpotifyWebOptions) {
81
+ this.id = options.id ?? 'spotify-web';
82
+ this.#http = new SpotifyHttp(options);
83
+ this.#deviceId = options.deviceId;
84
+ this.#market = options.market;
85
+
86
+ const intervalMs = options.sampleIntervalMs ?? 2000;
87
+ this.#watcher = new BackendWatcher({
88
+ read: async () =>
89
+ toSample(await this.#http.request('GET', '/me/player', { query: { market: this.#market } })),
90
+ emit: (event) => this.#emit(event),
91
+ scheduler: options.scheduler ?? systemScheduler,
92
+ intervalMs,
93
+ rolloverWindowMs: Math.max(3000, intervalMs * 2),
94
+ confirmWithin: Math.max(3, Math.ceil(6000 / intervalMs)),
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Prove the token works before the first song rather than during it.
100
+ *
101
+ * Reading player state is the right probe because it needs exactly the scope
102
+ * everything else here needs, so a token that passes this has been checked
103
+ * against the real requirement rather than a cheaper one. `204` — nothing
104
+ * playing — is a pass: it means the call was authorised, which is all this
105
+ * is asking.
106
+ */
107
+ async init(): Promise<void> {
108
+ await this.#http.request('GET', '/me/player');
109
+ }
110
+
111
+ match(ref: MediaRef): number {
112
+ const parsed = parseSpotifyUri(ref.uri);
113
+ if (parsed) return isPlayableKind(parsed.kind) ? 1 : 0;
114
+ // An ISRC is a recording id, so a search on it lands on the same recording
115
+ // rather than something with a similar name — nearly as good as a URI.
116
+ if (ref.isrc) return 0.9;
117
+ if (ref.title) return 0.65;
118
+ return 0;
119
+ }
120
+
121
+ async resolve(ref: MediaRef): Promise<Binding | null> {
122
+ const parsed = parseSpotifyUri(ref.uri);
123
+ const found = parsed ? await this.#byId(parsed) : await this.#bySearch(ref);
124
+ if (!found?.uri) return null;
125
+
126
+ return {
127
+ adapterId: this.id,
128
+ nativeUri: found.uri,
129
+ // Spotify's copy wins here, unlike the desktop backend: this came from the
130
+ // catalogue itself, so its duration and its ISRC are authoritative in a
131
+ // way a caller's guess at a title is not. The caller's title survives only
132
+ // where Spotify had nothing.
133
+ ref: { ...ref, ...found },
134
+ };
135
+ }
136
+
137
+ async search(query: string, limit = 10): Promise<MediaRef[]> {
138
+ const body = await this.#http.request('GET', '/search', {
139
+ query: { q: query, type: 'track', limit, market: this.#market },
140
+ });
141
+ return readTrackList(readPath(body, ['tracks', 'items']));
142
+ }
143
+
144
+ /**
145
+ * Everything inside an album or a playlist, as refs.
146
+ *
147
+ * Not part of the `Adapter` contract, because a container is not a queue
148
+ * entry — this runtime holds one item at a time and owns the ordering itself.
149
+ * "Play my Discover Weekly" is therefore two steps, and they are two steps on
150
+ * purpose: the host gets to see, filter and reorder the list before it becomes
151
+ * a queue, rather than having thirty entries appear because one URI was
152
+ * enqueued.
153
+ *
154
+ * const tracks = await spotify.expandContext('spotify:playlist:37i9…');
155
+ * runtime.enqueueMany(tracks);
156
+ */
157
+ async expandContext(uri: string, limit = 100): Promise<MediaRef[]> {
158
+ const parsed = parseSpotifyUri(uri);
159
+ if (!parsed) return [];
160
+
161
+ if (parsed.kind === 'album') {
162
+ const body = await this.#http.request('GET', `/albums/${parsed.id}/tracks`, {
163
+ query: { limit, market: this.#market },
164
+ });
165
+ return readTrackList(readPath(body, ['items']));
166
+ }
167
+ if (parsed.kind === 'playlist') {
168
+ const body = await this.#http.request('GET', `/playlists/${parsed.id}/tracks`, {
169
+ query: { limit, market: this.#market },
170
+ });
171
+ const items = readPath(body, ['items']);
172
+ // A playlist wraps each track in an entry that also carries who added it
173
+ // and when; the track itself is one level down.
174
+ return Array.isArray(items)
175
+ ? readTrackList(items.map((entry) => readPath(entry, ['track'])))
176
+ : [];
177
+ }
178
+ return [];
179
+ }
180
+
181
+ async load(binding: Binding, opts?: { startAtMs?: number }): Promise<void> {
182
+ this.#watcher.stop();
183
+ this.#binding = binding;
184
+ this.#startAtMs = opts?.startAtMs ?? 0;
185
+ this.#started = false;
186
+ }
187
+
188
+ async play(): Promise<void> {
189
+ const binding = this.#binding;
190
+ if (!binding) throw new SpotifyError('failed', `${this.id}: nothing is loaded`);
191
+
192
+ // With no body this is "resume"; with `uris` it is "play exactly this".
193
+ // Handing it a single URI rather than a context is what keeps Spotify from
194
+ // rolling into an album we did not queue when the track ends.
195
+ const body = this.#started
196
+ ? undefined
197
+ : { uris: [binding.nativeUri], ...(this.#startAtMs > 0 ? { position_ms: this.#startAtMs } : {}) };
198
+
199
+ await this.#http.request('PUT', '/me/player/play', {
200
+ query: { device_id: this.#deviceId },
201
+ ...(body ? { body } : {}),
202
+ });
203
+ this.#started = true;
204
+ this.#watcher.start(binding.nativeUri);
205
+ }
206
+
207
+ async pause(): Promise<void> {
208
+ await this.#http.request('PUT', '/me/player/pause', {
209
+ query: { device_id: this.#deviceId },
210
+ });
211
+ }
212
+
213
+ /**
214
+ * The Web API has no stop, so this pauses — and tolerates being told it could
215
+ * not. The runtime stops the outgoing backend on every transition, and by the
216
+ * time that lands the device may already have moved on or gone to sleep;
217
+ * Spotify answers that with a 403 "restriction violated". Failing a queue
218
+ * transition because the thing we were stopping had already stopped would be
219
+ * absurd.
220
+ */
221
+ async stop(): Promise<void> {
222
+ this.#watcher.stop();
223
+ const wasStarted = this.#started;
224
+ this.#binding = null;
225
+ this.#started = false;
226
+ if (!wasStarted) return;
227
+ await this.#http.request('PUT', '/me/player/pause', {
228
+ query: { device_id: this.#deviceId },
229
+ tolerate: [403, 404],
230
+ });
231
+ }
232
+
233
+ async seek(positionMs: number): Promise<void> {
234
+ await this.#http.request('PUT', '/me/player/seek', {
235
+ query: { position_ms: Math.max(0, Math.round(positionMs)), device_id: this.#deviceId },
236
+ });
237
+ }
238
+
239
+ async setVolume(volume: number): Promise<void> {
240
+ await this.#http.request('PUT', '/me/player/volume', {
241
+ query: {
242
+ volume_percent: Math.min(100, Math.max(0, Math.round(volume * 100))),
243
+ device_id: this.#deviceId,
244
+ },
245
+ });
246
+ }
247
+
248
+ subscribe(listener: (event: AdapterEvent) => void): () => void {
249
+ this.#listeners.add(listener);
250
+ return () => this.#listeners.delete(listener);
251
+ }
252
+
253
+ async dispose(): Promise<void> {
254
+ this.#watcher.stop();
255
+ this.#listeners.clear();
256
+ this.#binding = null;
257
+ }
258
+
259
+ // -- resolution -----------------------------------------------------------
260
+
261
+ async #byId(parsed: SpotifyId): Promise<MediaRef | null> {
262
+ if (!isPlayableKind(parsed.kind)) return null;
263
+ const path = parsed.kind === 'episode' ? `/episodes/${parsed.id}` : `/tracks/${parsed.id}`;
264
+ try {
265
+ return readTrack(await this.#http.request('GET', path, { query: { market: this.#market } }));
266
+ } catch (err) {
267
+ // A track Spotify does not have, or does not have here, is this adapter
268
+ // answering "not me" — the runtime should try another source rather than
269
+ // treat the entry as broken.
270
+ if (err instanceof SpotifyError && err.reason === 'not-found') return null;
271
+ throw err;
272
+ }
273
+ }
274
+
275
+ async #bySearch(ref: MediaRef): Promise<MediaRef | null> {
276
+ const query = searchQuery(ref);
277
+ if (!query) return null;
278
+ const hits = await this.search(query, 5);
279
+ // The runtime scores this against what was asked for and rejects it if the
280
+ // match is too loose, so returning the top hit is safe: a wrong guess is
281
+ // caught one layer up rather than played.
282
+ return hits[0] ?? null;
283
+ }
284
+
285
+ #emit(event: AdapterEvent): void {
286
+ for (const listener of [...this.#listeners]) listener(event);
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Player state as a `Sample`, so the Web API and the desktop app run through
292
+ * the same interpretation.
293
+ *
294
+ * The two backends fail in different ways but the *question* is identical —
295
+ * did our track end, or did a person take over — and it is subtle enough that
296
+ * having one answer to it, tested once, is worth the small mapping here.
297
+ *
298
+ * A `204` (which arrives as `null`) means no active device: nothing is playing
299
+ * and there is nowhere for it to play. That is the same situation as the
300
+ * desktop app being closed, so it maps to the same `running: false`.
301
+ */
302
+ export function toSample(body: unknown): Sample {
303
+ if (!body || typeof body !== 'object') {
304
+ return { running: false, status: 'idle', positionMs: 0, durationMs: null, nativeUri: null, volume: null };
305
+ }
306
+ const state = body as Record<string, unknown>;
307
+ const item = state.item && typeof state.item === 'object' ? (state.item as Record<string, unknown>) : null;
308
+
309
+ const durationMs = typeof item?.duration_ms === 'number' ? item.duration_ms : null;
310
+ const progress = typeof state.progress_ms === 'number' ? state.progress_ms : 0;
311
+ const device = state.device && typeof state.device === 'object' ? (state.device as Record<string, unknown>) : null;
312
+ const volume = typeof device?.volume_percent === 'number' ? device.volume_percent : null;
313
+
314
+ return {
315
+ running: true,
316
+ status: state.is_playing === true ? 'playing' : 'paused',
317
+ positionMs: Number.isFinite(progress) && progress > 0 ? Math.round(progress) : 0,
318
+ durationMs: durationMs !== null && Number.isFinite(durationMs) && durationMs > 0 ? Math.round(durationMs) : null,
319
+ nativeUri: typeof item?.uri === 'string' ? item.uri : null,
320
+ volume: volume === null ? null : Math.min(1, Math.max(0, volume / 100)),
321
+ };
322
+ }
323
+
324
+ function readTrackList(value: unknown): MediaRef[] {
325
+ if (!Array.isArray(value)) return [];
326
+ const out: MediaRef[] = [];
327
+ for (const entry of value) {
328
+ const ref = readTrack(entry);
329
+ if (ref) out.push(ref);
330
+ }
331
+ return out;
332
+ }
333
+
334
+ function readPath(value: unknown, keys: string[]): unknown {
335
+ let current = value;
336
+ for (const key of keys) {
337
+ if (!current || typeof current !== 'object') return undefined;
338
+ current = (current as Record<string, unknown>)[key];
339
+ }
340
+ return current;
341
+ }
342
+
343
+ export type { TokenProvider };