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,93 @@
1
+ export class SpotifyError extends Error {
2
+ reason;
3
+ status;
4
+ retryAfterMs;
5
+ constructor(reason, message, extra = {}) {
6
+ super(message);
7
+ this.name = 'SpotifyError';
8
+ this.reason = reason;
9
+ if (extra.status !== undefined)
10
+ this.status = extra.status;
11
+ if (extra.retryAfterMs !== undefined)
12
+ this.retryAfterMs = extra.retryAfterMs;
13
+ }
14
+ }
15
+ /**
16
+ * Map an HTTP status, plus Spotify's own `reason` when it sends one, onto a
17
+ * bucket.
18
+ *
19
+ * Two statuses are genuinely ambiguous and the body is what settles them:
20
+ * a 403 is a scope problem *or* a free account, and a 404 from the player
21
+ * endpoints is a missing track *or* the well-known `NO_ACTIVE_DEVICE`. Both
22
+ * distinctions change what a host should tell the user, so both are read.
23
+ */
24
+ export function classifyStatus(status, body) {
25
+ const reason = errorReason(body);
26
+ if (status === 429)
27
+ return 'rate-limited';
28
+ if (status === 401)
29
+ return 'unauthorized';
30
+ if (status === 403) {
31
+ return reason === 'PREMIUM_REQUIRED' ? 'premium-required' : 'unauthorized';
32
+ }
33
+ if (status === 404) {
34
+ return reason === 'NO_ACTIVE_DEVICE' ? 'no-device' : 'not-found';
35
+ }
36
+ return 'failed';
37
+ }
38
+ /** Spotify's player errors carry `{ error: { status, message, reason } }`. */
39
+ function errorReason(body) {
40
+ if (!body || typeof body !== 'object')
41
+ return null;
42
+ const error = body.error;
43
+ if (!error || typeof error !== 'object')
44
+ return null;
45
+ const reason = error.reason;
46
+ return typeof reason === 'string' ? reason : null;
47
+ }
48
+ /** Spotify's error body carries a human message worth passing through. */
49
+ export function errorMessage(body, fallback) {
50
+ if (!body || typeof body !== 'object')
51
+ return fallback;
52
+ const error = body.error;
53
+ if (typeof error === 'string')
54
+ return error;
55
+ if (error && typeof error === 'object') {
56
+ const message = error.message;
57
+ if (typeof message === 'string' && message.trim())
58
+ return message.trim();
59
+ }
60
+ return fallback;
61
+ }
62
+ /**
63
+ * The same classification against free text, for the backend that has no status
64
+ * codes — osascript writes prose to stderr and nothing else.
65
+ *
66
+ * The ordering matters: rate limiting is tested first because a throttling
67
+ * message can contain the word "user" and must not be mistaken for the auth
68
+ * case below it.
69
+ */
70
+ export function classifyText(text) {
71
+ const lower = text.toLowerCase();
72
+ if (/rate limit|too many|429/.test(lower))
73
+ return 'rate-limited';
74
+ if (/not authorized|not authorised|-1743|assistive access/.test(lower)) {
75
+ // macOS Automation consent was declined. Not a Spotify login problem, but
76
+ // it is the same shape of problem: a permission the user has to grant.
77
+ return 'unauthorized';
78
+ }
79
+ if (/(-600|-609)|isn't running|is not running|application isn't/.test(lower)) {
80
+ return 'no-device';
81
+ }
82
+ if (/can't get|cant get|-1728/.test(lower))
83
+ return 'not-found';
84
+ return 'failed';
85
+ }
86
+ /** Seconds in a `Retry-After` header, in milliseconds. */
87
+ export function retryAfterMs(header) {
88
+ if (!header)
89
+ return undefined;
90
+ const seconds = Number.parseInt(header, 10);
91
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined;
92
+ }
93
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/errors.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Where the access token comes from — which is to say, not from here.
3
+ *
4
+ * This library holds no client id, runs no OAuth flow, opens no browser and
5
+ * stores no refresh token. That is the same boundary the core draws around
6
+ * `resolveIntent`: a library that acquires credentials has decided which
7
+ * provider you use, where the redirect lands and what your token storage looks
8
+ * like, and none of those are its business.
9
+ *
10
+ * So the host supplies a function. It can return a cached token, hit its own
11
+ * refresh endpoint, or read one out of a config file — the adapter only asks
12
+ * again when the one it has stops working.
13
+ */
14
+ export type TokenProvider = () => string | Promise<string>;
15
+ export interface SpotifyHttpOptions {
16
+ getAccessToken: TokenProvider;
17
+ /** Injected for tests, and for hosts that route their traffic somewhere. */
18
+ fetch?: typeof globalThis.fetch;
19
+ baseUrl?: string;
20
+ }
21
+ export interface RequestOptions {
22
+ query?: Record<string, string | number | undefined>;
23
+ body?: unknown;
24
+ /** Statuses to answer `null` for instead of throwing. */
25
+ tolerate?: number[];
26
+ }
27
+ /**
28
+ * One authenticated call to the Web API, with the two failures that actually
29
+ * happen handled in one place.
30
+ *
31
+ * **Expiry.** Tokens last an hour and a queue outlives that easily, so a 401 is
32
+ * a normal event, not an error. It triggers exactly one re-ask and one retry.
33
+ * One, because a provider that keeps returning a dead token would otherwise
34
+ * spin forever, and the second failure is the honest answer: this host cannot
35
+ * currently authenticate.
36
+ *
37
+ * **Concurrency.** Lookahead resolves several entries at once, so several calls
38
+ * can hit a 401 together. Without the single-flight below they would each fetch
39
+ * a token, which at best wastes refreshes and at worst trips the host's own
40
+ * rate limit at the exact moment everything is already failing. A second caller
41
+ * should adopt the first's work rather than start its own.
42
+ */
43
+ export declare class SpotifyHttp {
44
+ #private;
45
+ constructor(options: SpotifyHttpOptions);
46
+ request(method: 'GET' | 'PUT' | 'POST', path: string, options?: RequestOptions): Promise<unknown>;
47
+ /** Drop the cached token, so the next call asks the host for a new one. */
48
+ invalidate(): void;
49
+ }
50
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/http.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,aAAa,GAAG,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AAE3D,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,aAAa,CAAC;IAC9B,4EAA4E;IAC5E,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC;IACpD,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB;AAED;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,WAAW;;gBAQV,OAAO,EAAE,kBAAkB;IAMjC,OAAO,CACX,MAAM,EAAE,KAAK,GAAG,KAAK,GAAG,MAAM,EAC9B,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,OAAO,CAAC;IASnB,2EAA2E;IAC3E,UAAU,IAAI,IAAI;CAwEnB"}
@@ -0,0 +1,113 @@
1
+ import { SpotifyError, classifyStatus, errorMessage, retryAfterMs } from './errors.js';
2
+ /**
3
+ * One authenticated call to the Web API, with the two failures that actually
4
+ * happen handled in one place.
5
+ *
6
+ * **Expiry.** Tokens last an hour and a queue outlives that easily, so a 401 is
7
+ * a normal event, not an error. It triggers exactly one re-ask and one retry.
8
+ * One, because a provider that keeps returning a dead token would otherwise
9
+ * spin forever, and the second failure is the honest answer: this host cannot
10
+ * currently authenticate.
11
+ *
12
+ * **Concurrency.** Lookahead resolves several entries at once, so several calls
13
+ * can hit a 401 together. Without the single-flight below they would each fetch
14
+ * a token, which at best wastes refreshes and at worst trips the host's own
15
+ * rate limit at the exact moment everything is already failing. A second caller
16
+ * should adopt the first's work rather than start its own.
17
+ */
18
+ export class SpotifyHttp {
19
+ #getAccessToken;
20
+ #fetch;
21
+ #baseUrl;
22
+ #token = null;
23
+ #fetching = null;
24
+ constructor(options) {
25
+ this.#getAccessToken = options.getAccessToken;
26
+ this.#fetch = options.fetch ?? globalThis.fetch;
27
+ this.#baseUrl = (options.baseUrl ?? 'https://api.spotify.com/v1').replace(/\/$/, '');
28
+ }
29
+ async request(method, path, options = {}) {
30
+ const response = await this.#send(method, path, options, await this.#authorize(false));
31
+ if (response.status !== 401)
32
+ return this.#read(response, options);
33
+ // The token died mid-queue. Get another and try exactly once more.
34
+ const retry = await this.#send(method, path, options, await this.#authorize(true));
35
+ return this.#read(retry, options);
36
+ }
37
+ /** Drop the cached token, so the next call asks the host for a new one. */
38
+ invalidate() {
39
+ this.#token = null;
40
+ }
41
+ async #send(method, path, options, token) {
42
+ const url = new URL(`${this.#baseUrl}${path}`);
43
+ for (const [key, value] of Object.entries(options.query ?? {})) {
44
+ if (value !== undefined && value !== '')
45
+ url.searchParams.set(key, String(value));
46
+ }
47
+ const headers = { authorization: `Bearer ${token}` };
48
+ if (options.body !== undefined)
49
+ headers['content-type'] = 'application/json';
50
+ try {
51
+ return await this.#fetch(url.toString(), {
52
+ method,
53
+ headers,
54
+ ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
55
+ });
56
+ }
57
+ catch (err) {
58
+ // DNS, a dropped connection, an offline laptop. Not an auth problem and
59
+ // not something a retry here would fix — the runtime will fall through to
60
+ // another adapter, which is the correct response to "Spotify is
61
+ // unreachable right now".
62
+ throw new SpotifyError('failed', `could not reach the Spotify Web API: ${err instanceof Error ? err.message : String(err)}`);
63
+ }
64
+ }
65
+ async #read(response, options) {
66
+ // 204 is Spotify's answer to most player commands, and to "nothing is
67
+ // playing" on the player endpoint. It is a success with nothing in it.
68
+ if (response.status === 204 || options.tolerate?.includes(response.status))
69
+ return null;
70
+ const body = await readJson(response);
71
+ if (response.ok)
72
+ return body;
73
+ const reason = classifyStatus(response.status, body);
74
+ throw new SpotifyError(reason, errorMessage(body, `Spotify returned ${response.status}`), {
75
+ status: response.status,
76
+ ...(reason === 'rate-limited'
77
+ ? { retryAfterMs: retryAfterMs(response.headers.get('retry-after')) }
78
+ : {}),
79
+ });
80
+ }
81
+ async #authorize(force) {
82
+ if (!force && this.#token)
83
+ return this.#token;
84
+ if (this.#fetching)
85
+ return this.#fetching;
86
+ this.#fetching = (async () => {
87
+ const token = await this.#getAccessToken();
88
+ if (typeof token !== 'string' || !token.trim()) {
89
+ throw new SpotifyError('unauthorized', 'getAccessToken did not return an access token');
90
+ }
91
+ return token.trim();
92
+ })();
93
+ try {
94
+ this.#token = await this.#fetching;
95
+ return this.#token;
96
+ }
97
+ finally {
98
+ this.#fetching = null;
99
+ }
100
+ }
101
+ }
102
+ /** A body that is not JSON is not worth failing over — the status already said
103
+ * what happened, and the parsed body only ever adds detail. */
104
+ async function readJson(response) {
105
+ try {
106
+ const text = await response.text();
107
+ return text ? JSON.parse(text) : null;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.js","sourceRoot":"","sources":["../../src/http.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Two ways to play Spotify, with honestly different capabilities.
3
+ *
4
+ * They are separate adapters rather than one adapter with a mode because they
5
+ * genuinely are not the same backend. One needs no credentials and cannot
6
+ * search; the other searches a hundred million tracks and needs a token, a
7
+ * Premium account and a device that is awake. Collapsing that into a single
8
+ * class would mean a `capabilities` object that is a lie half the time — which
9
+ * is the exact failure this library exists to avoid.
10
+ *
11
+ * Registering both is a reasonable thing to do. They score the same on a
12
+ * Spotify URI, so the runtime tries one and falls through to the other if it
13
+ * cannot deliver: the desktop app when it is open, the Web API when it is not.
14
+ */
15
+ export { SpotifyDesktopAdapter } from './desktop.js';
16
+ export type { SpotifyDesktopOptions } from './desktop.js';
17
+ export { SpotifyWebAdapter, toSample } from './web.js';
18
+ export type { SpotifyWebOptions } from './web.js';
19
+ export { SpotifyHttp } from './http.js';
20
+ export type { TokenProvider, SpotifyHttpOptions, RequestOptions } from './http.js';
21
+ export { SpotifyError, classifyStatus, classifyText } from './errors.js';
22
+ export type { SpotifyFailure } from './errors.js';
23
+ export { parseSpotifyUri, toSpotifyUri, toSpotifyUrl, isPlayableKind } from './uri.js';
24
+ export type { SpotifyId, SpotifyKind } from './uri.js';
25
+ export { readTrack, searchQuery } from './ref.js';
26
+ export { interpret, initialWatchState } from './sampler.js';
27
+ export type { WatchState, InterpretOptions, Interpretation } from './sampler.js';
28
+ export { BackendWatcher } from './watch.js';
29
+ export type { BackendWatcherDeps } from './watch.js';
30
+ export { parseSample, stateScript, playTrackScript, commandScript, commands, runOsascript, FIELD, } from './applescript.js';
31
+ export type { Sample, Osascript } from './applescript.js';
32
+ export { embedLookup, readEmbedHtml, clearMetadataCache } from './metadata.js';
33
+ export type { TrackLookup } from './metadata.js';
34
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAE1D,OAAO,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACvD,YAAY,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAElD,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACxC,YAAY,EAAE,aAAa,EAAE,kBAAkB,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEnF,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACzE,YAAY,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAGlD,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AACvF,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAIvD,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAC5D,YAAY,EAAE,UAAU,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACjF,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,EACL,WAAW,EACX,WAAW,EACX,eAAe,EACf,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,KAAK,GACN,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAE1D,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAC/E,YAAY,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Two ways to play Spotify, with honestly different capabilities.
3
+ *
4
+ * They are separate adapters rather than one adapter with a mode because they
5
+ * genuinely are not the same backend. One needs no credentials and cannot
6
+ * search; the other searches a hundred million tracks and needs a token, a
7
+ * Premium account and a device that is awake. Collapsing that into a single
8
+ * class would mean a `capabilities` object that is a lie half the time — which
9
+ * is the exact failure this library exists to avoid.
10
+ *
11
+ * Registering both is a reasonable thing to do. They score the same on a
12
+ * Spotify URI, so the runtime tries one and falls through to the other if it
13
+ * cannot deliver: the desktop app when it is open, the Web API when it is not.
14
+ */
15
+ export { SpotifyDesktopAdapter } from './desktop.js';
16
+ export { SpotifyWebAdapter, toSample } from './web.js';
17
+ export { SpotifyHttp } from './http.js';
18
+ export { SpotifyError, classifyStatus, classifyText } from './errors.js';
19
+ // Identity helpers, for hosts normalising links a person pasted.
20
+ export { parseSpotifyUri, toSpotifyUri, toSpotifyUrl, isPlayableKind } from './uri.js';
21
+ // The pure pieces, exported because they are the parts worth testing and worth
22
+ // reusing if you write a third Spotify backend of your own.
23
+ export { readTrack, searchQuery } from './ref.js';
24
+ export { interpret, initialWatchState } from './sampler.js';
25
+ export { BackendWatcher } from './watch.js';
26
+ export { parseSample, stateScript, playTrackScript, commandScript, commands, runOsascript, FIELD, } from './applescript.js';
27
+ export { embedLookup, readEmbedHtml, clearMetadataCache } from './metadata.js';
28
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAGrD,OAAO,EAAE,iBAAiB,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAGvD,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAGxC,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAGzE,iEAAiE;AACjE,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAGvF,+EAA+E;AAC/E,4DAA4D;AAC5D,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAE5D,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAE5C,OAAO,EACL,WAAW,EACX,WAAW,EACX,eAAe,EACf,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,KAAK,GACN,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EAAE,WAAW,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC"}
@@ -0,0 +1,39 @@
1
+ import type { MediaRef } from 'upnext-core';
2
+ import { type SpotifyId } from './uri.js';
3
+ /**
4
+ * Turning a bare Spotify id into a title, an artist and a cover, without a
5
+ * token.
6
+ *
7
+ * The desktop backend needs this and the Web API backend does not. Spotify's
8
+ * AppleScript dictionary can only describe a track once it is *playing*, so a
9
+ * queue built from share links would show a column of raw URIs right up until
10
+ * each one started — which is the queue being useless for the one thing a queue
11
+ * is for, seeing what is coming.
12
+ *
13
+ * How it works, stated plainly because it matters: `open.spotify.com/embed/…`
14
+ * is the public embed player, and the page carries the track's metadata in a
15
+ * `__NEXT_DATA__` script tag. It needs no credentials, which is the whole
16
+ * point, but it is **not a documented API** — it is a page built for a browser,
17
+ * and Spotify can change its markup whenever it likes.
18
+ *
19
+ * So it is treated as a nicety, never a dependency: every failure answers an
20
+ * empty object, a lookup is skipped entirely when the ref already has what it
21
+ * needs, and a host that would rather not reach the network at all passes
22
+ * `lookup: null`. Nothing about whether a track *plays* runs through here.
23
+ *
24
+ * The `__NEXT_DATA__` walk and the cover-picking exist because the queue source
25
+ * returns ids with the names stripped out, and a queue showing raw URIs until
26
+ * each entry starts is useless for the one thing a queue is for.
27
+ */
28
+ export type TrackLookup = (id: SpotifyId) => Promise<Partial<MediaRef>>;
29
+ /** Everything the queue would want to show, from the public embed page. */
30
+ export declare const embedLookup: TrackLookup;
31
+ /** Drop everything remembered. Exported for tests; a host has no reason to. */
32
+ export declare function clearMetadataCache(): void;
33
+ /**
34
+ * Split from the fetch and exported so the fragile half — the shape of a page
35
+ * we do not control — is covered by a test holding a real captured payload,
36
+ * with no network in it.
37
+ */
38
+ export declare function readEmbedHtml(html: string): Partial<MediaRef>;
39
+ //# sourceMappingURL=metadata.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metadata.d.ts","sourceRoot":"","sources":["../../src/metadata.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAgB,KAAK,SAAS,EAAE,MAAM,UAAU,CAAC;AAExD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,EAAE,EAAE,SAAS,KAAK,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;AAgBxE,2EAA2E;AAC3E,eAAO,MAAM,WAAW,EAAE,WAQzB,CAAC;AAEF,+EAA+E;AAC/E,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC;AAmBD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CA+C7D"}
@@ -0,0 +1,130 @@
1
+ import { toSpotifyUri } from './uri.js';
2
+ const EMPTY = {};
3
+ /**
4
+ * Facts resolved once, kept for the life of the process.
5
+ *
6
+ * A title is a property of the track, not of this lookup, and a poll-driven
7
+ * queue re-reads the same entries repeatedly. Caching by URI turns a settled
8
+ * queue into zero network calls after the first pass.
9
+ *
10
+ * Only non-empty results are cached: a failed lookup should retry on the next
11
+ * pass rather than freezing a track as nameless for the whole session.
12
+ */
13
+ const cache = new Map();
14
+ /** Everything the queue would want to show, from the public embed page. */
15
+ export const embedLookup = async (parsed) => {
16
+ const uri = toSpotifyUri(parsed);
17
+ const hit = cache.get(uri);
18
+ if (hit)
19
+ return hit;
20
+ const facts = await fetchEmbed(parsed);
21
+ if (Object.keys(facts).length > 0)
22
+ cache.set(uri, facts);
23
+ return facts;
24
+ };
25
+ /** Drop everything remembered. Exported for tests; a host has no reason to. */
26
+ export function clearMetadataCache() {
27
+ cache.clear();
28
+ }
29
+ async function fetchEmbed(parsed) {
30
+ try {
31
+ const response = await fetch(`https://open.spotify.com/embed/${parsed.kind}/${encodeURIComponent(parsed.id)}`,
32
+ // The page varies its markup for a bare client, so it is asked for the
33
+ // way a browser would ask.
34
+ { headers: { 'user-agent': 'Mozilla/5.0' } });
35
+ if (!response.ok)
36
+ return EMPTY;
37
+ return readEmbedHtml(await response.text());
38
+ }
39
+ catch {
40
+ // Offline, DNS, a redirect loop, a changed page — all the same answer. A
41
+ // track with no title still plays.
42
+ return EMPTY;
43
+ }
44
+ }
45
+ /**
46
+ * Split from the fetch and exported so the fragile half — the shape of a page
47
+ * we do not control — is covered by a test holding a real captured payload,
48
+ * with no network in it.
49
+ */
50
+ export function readEmbedHtml(html) {
51
+ const match = html.match(/<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/s);
52
+ if (!match?.[1])
53
+ return EMPTY;
54
+ let data;
55
+ try {
56
+ data = JSON.parse(match[1]);
57
+ }
58
+ catch {
59
+ return EMPTY;
60
+ }
61
+ const entity = readPath(data, ['props', 'pageProps', 'state', 'data', 'entity']);
62
+ if (!entity || typeof entity !== 'object')
63
+ return EMPTY;
64
+ const record = entity;
65
+ const out = {};
66
+ const title = text(record.title) ?? text(record.name);
67
+ if (title)
68
+ out.title = title;
69
+ const artist = Array.isArray(record.artists)
70
+ ? record.artists
71
+ .map((entry) => entry && typeof entry === 'object'
72
+ ? (text(entry.name) ?? '')
73
+ : '')
74
+ .filter(Boolean)
75
+ .join(', ')
76
+ : '';
77
+ if (artist)
78
+ out.artist = artist;
79
+ const duration = record.duration;
80
+ if (typeof duration === 'number' && Number.isFinite(duration) && duration > 0) {
81
+ out.durationMs = Math.round(duration);
82
+ }
83
+ const visual = record.visualIdentity && typeof record.visualIdentity === 'object'
84
+ ? record.visualIdentity
85
+ : {};
86
+ const artwork = pickCover(visual.image);
87
+ if (artwork)
88
+ out.artwork = artwork;
89
+ return out;
90
+ }
91
+ /**
92
+ * The cover closest to the size a list actually draws: the smallest source at
93
+ * least 300px wide, or the largest on offer if none reach that.
94
+ */
95
+ function pickCover(images) {
96
+ if (!Array.isArray(images))
97
+ return null;
98
+ const sized = [];
99
+ for (const image of images) {
100
+ if (!image || typeof image !== 'object')
101
+ continue;
102
+ const record = image;
103
+ const url = typeof record.url === 'string' ? record.url : '';
104
+ if (!url.startsWith('https://'))
105
+ continue;
106
+ const width = typeof record.maxWidth === 'number' ? record.maxWidth : 0;
107
+ sized.push({ url, width });
108
+ }
109
+ if (sized.length === 0)
110
+ return null;
111
+ const enough = sized.filter((entry) => entry.width >= 300).sort((a, b) => a.width - b.width);
112
+ if (enough[0])
113
+ return enough[0].url;
114
+ return [...sized].sort((a, b) => b.width - a.width)[0]?.url ?? null;
115
+ }
116
+ /** Walk a chain of keys through untrusted JSON, stopping at the first step that
117
+ * is not an object rather than throwing. */
118
+ function readPath(value, keys) {
119
+ let current = value;
120
+ for (const key of keys) {
121
+ if (!current || typeof current !== 'object')
122
+ return undefined;
123
+ current = current[key];
124
+ }
125
+ return current;
126
+ }
127
+ function text(value) {
128
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
129
+ }
130
+ //# sourceMappingURL=metadata.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metadata.js","sourceRoot":"","sources":["../../src/metadata.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ import type { MediaRef } from 'upnext-core';
2
+ /**
3
+ * Spotify's track JSON, read defensively into a `MediaRef`.
4
+ *
5
+ * Every field is checked rather than trusted. This is a network payload from a
6
+ * service that has changed its shapes before, and the difference between a
7
+ * missing field and a wrong one is the difference between a queue row with no
8
+ * cover and a `durationMs` of `NaN` — which the runtime would treat as a real
9
+ * duration and use to decide the track was over.
10
+ *
11
+ * The one field worth going out of the way for is `external_ids.isrc`. It is
12
+ * the recording-level id, and carrying it is what lets the same queue entry be
13
+ * found in someone else's Apple Music library, or fall back to a local file, or
14
+ * be handed to a completely different adapter when this one is unavailable. A
15
+ * Spotify URI identifies a row in Spotify's catalogue; an ISRC identifies the
16
+ * song.
17
+ */
18
+ export declare function readTrack(value: unknown): MediaRef | null;
19
+ /**
20
+ * A search query in Spotify's field syntax.
21
+ *
22
+ * Quoted fields rather than a bare concatenation, because "Bad Habit Steve
23
+ * Lacy" as free text returns covers and remixes above the original often
24
+ * enough to matter, and the runtime is going to score whatever comes back
25
+ * against what was asked for and reject it if it does not match closely enough.
26
+ * A tighter query means fewer of those rejections.
27
+ */
28
+ export declare function searchQuery(ref: MediaRef): string | null;
29
+ //# sourceMappingURL=ref.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ref.d.ts","sourceRoot":"","sources":["../../src/ref.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,CA+CzD;AAqDD;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAOxD"}