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/http.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { SpotifyError, classifyStatus, errorMessage, retryAfterMs } from './errors.js';
2
+
3
+ /**
4
+ * Where the access token comes from — which is to say, not from here.
5
+ *
6
+ * This library holds no client id, runs no OAuth flow, opens no browser and
7
+ * stores no refresh token. That is the same boundary the core draws around
8
+ * `resolveIntent`: a library that acquires credentials has decided which
9
+ * provider you use, where the redirect lands and what your token storage looks
10
+ * like, and none of those are its business.
11
+ *
12
+ * So the host supplies a function. It can return a cached token, hit its own
13
+ * refresh endpoint, or read one out of a config file — the adapter only asks
14
+ * again when the one it has stops working.
15
+ */
16
+ export type TokenProvider = () => string | Promise<string>;
17
+
18
+ export interface SpotifyHttpOptions {
19
+ getAccessToken: TokenProvider;
20
+ /** Injected for tests, and for hosts that route their traffic somewhere. */
21
+ fetch?: typeof globalThis.fetch;
22
+ baseUrl?: string;
23
+ }
24
+
25
+ export interface RequestOptions {
26
+ query?: Record<string, string | number | undefined>;
27
+ body?: unknown;
28
+ /** Statuses to answer `null` for instead of throwing. */
29
+ tolerate?: number[];
30
+ }
31
+
32
+ /**
33
+ * One authenticated call to the Web API, with the two failures that actually
34
+ * happen handled in one place.
35
+ *
36
+ * **Expiry.** Tokens last an hour and a queue outlives that easily, so a 401 is
37
+ * a normal event, not an error. It triggers exactly one re-ask and one retry.
38
+ * One, because a provider that keeps returning a dead token would otherwise
39
+ * spin forever, and the second failure is the honest answer: this host cannot
40
+ * currently authenticate.
41
+ *
42
+ * **Concurrency.** Lookahead resolves several entries at once, so several calls
43
+ * can hit a 401 together. Without the single-flight below they would each fetch
44
+ * a token, which at best wastes refreshes and at worst trips the host's own
45
+ * rate limit at the exact moment everything is already failing. A second caller
46
+ * should adopt the first's work rather than start its own.
47
+ */
48
+ export class SpotifyHttp {
49
+ #getAccessToken: TokenProvider;
50
+ #fetch: typeof globalThis.fetch;
51
+ #baseUrl: string;
52
+
53
+ #token: string | null = null;
54
+ #fetching: Promise<string> | null = null;
55
+
56
+ constructor(options: SpotifyHttpOptions) {
57
+ this.#getAccessToken = options.getAccessToken;
58
+ this.#fetch = options.fetch ?? globalThis.fetch;
59
+ this.#baseUrl = (options.baseUrl ?? 'https://api.spotify.com/v1').replace(/\/$/, '');
60
+ }
61
+
62
+ async request(
63
+ method: 'GET' | 'PUT' | 'POST',
64
+ path: string,
65
+ options: RequestOptions = {},
66
+ ): Promise<unknown> {
67
+ const response = await this.#send(method, path, options, await this.#authorize(false));
68
+ if (response.status !== 401) return this.#read(response, options);
69
+
70
+ // The token died mid-queue. Get another and try exactly once more.
71
+ const retry = await this.#send(method, path, options, await this.#authorize(true));
72
+ return this.#read(retry, options);
73
+ }
74
+
75
+ /** Drop the cached token, so the next call asks the host for a new one. */
76
+ invalidate(): void {
77
+ this.#token = null;
78
+ }
79
+
80
+ async #send(
81
+ method: string,
82
+ path: string,
83
+ options: RequestOptions,
84
+ token: string,
85
+ ): Promise<Response> {
86
+ const url = new URL(`${this.#baseUrl}${path}`);
87
+ for (const [key, value] of Object.entries(options.query ?? {})) {
88
+ if (value !== undefined && value !== '') url.searchParams.set(key, String(value));
89
+ }
90
+
91
+ const headers: Record<string, string> = { authorization: `Bearer ${token}` };
92
+ if (options.body !== undefined) headers['content-type'] = 'application/json';
93
+
94
+ try {
95
+ return await this.#fetch(url.toString(), {
96
+ method,
97
+ headers,
98
+ ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
99
+ });
100
+ } catch (err) {
101
+ // DNS, a dropped connection, an offline laptop. Not an auth problem and
102
+ // not something a retry here would fix — the runtime will fall through to
103
+ // another adapter, which is the correct response to "Spotify is
104
+ // unreachable right now".
105
+ throw new SpotifyError(
106
+ 'failed',
107
+ `could not reach the Spotify Web API: ${err instanceof Error ? err.message : String(err)}`,
108
+ );
109
+ }
110
+ }
111
+
112
+ async #read(response: Response, options: RequestOptions): Promise<unknown> {
113
+ // 204 is Spotify's answer to most player commands, and to "nothing is
114
+ // playing" on the player endpoint. It is a success with nothing in it.
115
+ if (response.status === 204 || options.tolerate?.includes(response.status)) return null;
116
+
117
+ const body = await readJson(response);
118
+ if (response.ok) return body;
119
+
120
+ const reason = classifyStatus(response.status, body);
121
+ throw new SpotifyError(reason, errorMessage(body, `Spotify returned ${response.status}`), {
122
+ status: response.status,
123
+ ...(reason === 'rate-limited'
124
+ ? { retryAfterMs: retryAfterMs(response.headers.get('retry-after')) }
125
+ : {}),
126
+ });
127
+ }
128
+
129
+ async #authorize(force: boolean): Promise<string> {
130
+ if (!force && this.#token) return this.#token;
131
+ if (this.#fetching) return this.#fetching;
132
+
133
+ this.#fetching = (async () => {
134
+ const token = await this.#getAccessToken();
135
+ if (typeof token !== 'string' || !token.trim()) {
136
+ throw new SpotifyError('unauthorized', 'getAccessToken did not return an access token');
137
+ }
138
+ return token.trim();
139
+ })();
140
+
141
+ try {
142
+ this.#token = await this.#fetching;
143
+ return this.#token;
144
+ } finally {
145
+ this.#fetching = null;
146
+ }
147
+ }
148
+ }
149
+
150
+ /** A body that is not JSON is not worth failing over — the status already said
151
+ * what happened, and the parsed body only ever adds detail. */
152
+ async function readJson(response: Response): Promise<unknown> {
153
+ try {
154
+ const text = await response.text();
155
+ return text ? JSON.parse(text) : null;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
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
+
16
+ export { SpotifyDesktopAdapter } from './desktop.js';
17
+ export type { SpotifyDesktopOptions } from './desktop.js';
18
+
19
+ export { SpotifyWebAdapter, toSample } from './web.js';
20
+ export type { SpotifyWebOptions } from './web.js';
21
+
22
+ export { SpotifyHttp } from './http.js';
23
+ export type { TokenProvider, SpotifyHttpOptions, RequestOptions } from './http.js';
24
+
25
+ export { SpotifyError, classifyStatus, classifyText } from './errors.js';
26
+ export type { SpotifyFailure } from './errors.js';
27
+
28
+ // Identity helpers, for hosts normalising links a person pasted.
29
+ export { parseSpotifyUri, toSpotifyUri, toSpotifyUrl, isPlayableKind } from './uri.js';
30
+ export type { SpotifyId, SpotifyKind } from './uri.js';
31
+
32
+ // The pure pieces, exported because they are the parts worth testing and worth
33
+ // reusing if you write a third Spotify backend of your own.
34
+ export { readTrack, searchQuery } from './ref.js';
35
+ export { interpret, initialWatchState } from './sampler.js';
36
+ export type { WatchState, InterpretOptions, Interpretation } from './sampler.js';
37
+ export { BackendWatcher } from './watch.js';
38
+ export type { BackendWatcherDeps } from './watch.js';
39
+ export {
40
+ parseSample,
41
+ stateScript,
42
+ playTrackScript,
43
+ commandScript,
44
+ commands,
45
+ runOsascript,
46
+ FIELD,
47
+ } from './applescript.js';
48
+ export type { Sample, Osascript } from './applescript.js';
49
+
50
+ export { embedLookup, readEmbedHtml, clearMetadataCache } from './metadata.js';
51
+ export type { TrackLookup } from './metadata.js';
@@ -0,0 +1,167 @@
1
+ import type { MediaRef } from 'upnext-core';
2
+ import { toSpotifyUri, type SpotifyId } from './uri.js';
3
+
4
+ /**
5
+ * Turning a bare Spotify id into a title, an artist and a cover, without a
6
+ * token.
7
+ *
8
+ * The desktop backend needs this and the Web API backend does not. Spotify's
9
+ * AppleScript dictionary can only describe a track once it is *playing*, so a
10
+ * queue built from share links would show a column of raw URIs right up until
11
+ * each one started — which is the queue being useless for the one thing a queue
12
+ * is for, seeing what is coming.
13
+ *
14
+ * How it works, stated plainly because it matters: `open.spotify.com/embed/…`
15
+ * is the public embed player, and the page carries the track's metadata in a
16
+ * `__NEXT_DATA__` script tag. It needs no credentials, which is the whole
17
+ * point, but it is **not a documented API** — it is a page built for a browser,
18
+ * and Spotify can change its markup whenever it likes.
19
+ *
20
+ * So it is treated as a nicety, never a dependency: every failure answers an
21
+ * empty object, a lookup is skipped entirely when the ref already has what it
22
+ * needs, and a host that would rather not reach the network at all passes
23
+ * `lookup: null`. Nothing about whether a track *plays* runs through here.
24
+ *
25
+ * The `__NEXT_DATA__` walk and the cover-picking exist because the queue source
26
+ * returns ids with the names stripped out, and a queue showing raw URIs until
27
+ * each entry starts is useless for the one thing a queue is for.
28
+ */
29
+ export type TrackLookup = (id: SpotifyId) => Promise<Partial<MediaRef>>;
30
+
31
+ const EMPTY: Partial<MediaRef> = {};
32
+
33
+ /**
34
+ * Facts resolved once, kept for the life of the process.
35
+ *
36
+ * A title is a property of the track, not of this lookup, and a poll-driven
37
+ * queue re-reads the same entries repeatedly. Caching by URI turns a settled
38
+ * queue into zero network calls after the first pass.
39
+ *
40
+ * Only non-empty results are cached: a failed lookup should retry on the next
41
+ * pass rather than freezing a track as nameless for the whole session.
42
+ */
43
+ const cache = new Map<string, Partial<MediaRef>>();
44
+
45
+ /** Everything the queue would want to show, from the public embed page. */
46
+ export const embedLookup: TrackLookup = async (parsed) => {
47
+ const uri = toSpotifyUri(parsed);
48
+ const hit = cache.get(uri);
49
+ if (hit) return hit;
50
+
51
+ const facts = await fetchEmbed(parsed);
52
+ if (Object.keys(facts).length > 0) cache.set(uri, facts);
53
+ return facts;
54
+ };
55
+
56
+ /** Drop everything remembered. Exported for tests; a host has no reason to. */
57
+ export function clearMetadataCache(): void {
58
+ cache.clear();
59
+ }
60
+
61
+ async function fetchEmbed(parsed: SpotifyId): Promise<Partial<MediaRef>> {
62
+ try {
63
+ const response = await fetch(
64
+ `https://open.spotify.com/embed/${parsed.kind}/${encodeURIComponent(parsed.id)}`,
65
+ // The page varies its markup for a bare client, so it is asked for the
66
+ // way a browser would ask.
67
+ { headers: { 'user-agent': 'Mozilla/5.0' } },
68
+ );
69
+ if (!response.ok) return EMPTY;
70
+ return readEmbedHtml(await response.text());
71
+ } catch {
72
+ // Offline, DNS, a redirect loop, a changed page — all the same answer. A
73
+ // track with no title still plays.
74
+ return EMPTY;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Split from the fetch and exported so the fragile half — the shape of a page
80
+ * we do not control — is covered by a test holding a real captured payload,
81
+ * with no network in it.
82
+ */
83
+ export function readEmbedHtml(html: string): Partial<MediaRef> {
84
+ const match = html.match(
85
+ /<script id="__NEXT_DATA__" type="application\/json">(.*?)<\/script>/s,
86
+ );
87
+ if (!match?.[1]) return EMPTY;
88
+
89
+ let data: unknown;
90
+ try {
91
+ data = JSON.parse(match[1]);
92
+ } catch {
93
+ return EMPTY;
94
+ }
95
+
96
+ const entity = readPath(data, ['props', 'pageProps', 'state', 'data', 'entity']);
97
+ if (!entity || typeof entity !== 'object') return EMPTY;
98
+ const record = entity as Record<string, unknown>;
99
+
100
+ const out: Partial<MediaRef> = {};
101
+
102
+ const title = text(record.title) ?? text(record.name);
103
+ if (title) out.title = title;
104
+
105
+ const artist = Array.isArray(record.artists)
106
+ ? record.artists
107
+ .map((entry) =>
108
+ entry && typeof entry === 'object'
109
+ ? (text((entry as Record<string, unknown>).name) ?? '')
110
+ : '',
111
+ )
112
+ .filter(Boolean)
113
+ .join(', ')
114
+ : '';
115
+ if (artist) out.artist = artist;
116
+
117
+ const duration = record.duration;
118
+ if (typeof duration === 'number' && Number.isFinite(duration) && duration > 0) {
119
+ out.durationMs = Math.round(duration);
120
+ }
121
+
122
+ const visual =
123
+ record.visualIdentity && typeof record.visualIdentity === 'object'
124
+ ? (record.visualIdentity as Record<string, unknown>)
125
+ : {};
126
+ const artwork = pickCover(visual.image);
127
+ if (artwork) out.artwork = artwork;
128
+
129
+ return out;
130
+ }
131
+
132
+ /**
133
+ * The cover closest to the size a list actually draws: the smallest source at
134
+ * least 300px wide, or the largest on offer if none reach that.
135
+ */
136
+ function pickCover(images: unknown): string | null {
137
+ if (!Array.isArray(images)) return null;
138
+ const sized: Array<{ url: string; width: number }> = [];
139
+ for (const image of images) {
140
+ if (!image || typeof image !== 'object') continue;
141
+ const record = image as Record<string, unknown>;
142
+ const url = typeof record.url === 'string' ? record.url : '';
143
+ if (!url.startsWith('https://')) continue;
144
+ const width = typeof record.maxWidth === 'number' ? record.maxWidth : 0;
145
+ sized.push({ url, width });
146
+ }
147
+ if (sized.length === 0) return null;
148
+
149
+ const enough = sized.filter((entry) => entry.width >= 300).sort((a, b) => a.width - b.width);
150
+ if (enough[0]) return enough[0].url;
151
+ return [...sized].sort((a, b) => b.width - a.width)[0]?.url ?? null;
152
+ }
153
+
154
+ /** Walk a chain of keys through untrusted JSON, stopping at the first step that
155
+ * is not an object rather than throwing. */
156
+ function readPath(value: unknown, keys: string[]): unknown {
157
+ let current = value;
158
+ for (const key of keys) {
159
+ if (!current || typeof current !== 'object') return undefined;
160
+ current = (current as Record<string, unknown>)[key];
161
+ }
162
+ return current;
163
+ }
164
+
165
+ function text(value: unknown): string | null {
166
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
167
+ }
package/src/ref.ts ADDED
@@ -0,0 +1,145 @@
1
+ import type { MediaRef } from 'upnext-core';
2
+
3
+ /**
4
+ * Spotify's track JSON, read defensively into a `MediaRef`.
5
+ *
6
+ * Every field is checked rather than trusted. This is a network payload from a
7
+ * service that has changed its shapes before, and the difference between a
8
+ * missing field and a wrong one is the difference between a queue row with no
9
+ * cover and a `durationMs` of `NaN` — which the runtime would treat as a real
10
+ * duration and use to decide the track was over.
11
+ *
12
+ * The one field worth going out of the way for is `external_ids.isrc`. It is
13
+ * the recording-level id, and carrying it is what lets the same queue entry be
14
+ * found in someone else's Apple Music library, or fall back to a local file, or
15
+ * be handed to a completely different adapter when this one is unavailable. A
16
+ * Spotify URI identifies a row in Spotify's catalogue; an ISRC identifies the
17
+ * song.
18
+ */
19
+ export function readTrack(value: unknown): MediaRef | null {
20
+ if (!value || typeof value !== 'object') return null;
21
+ const track = value as Record<string, unknown>;
22
+
23
+ const uri = text(track.uri);
24
+ const title = text(track.name);
25
+ if (!uri && !title) return null;
26
+
27
+ const ref: MediaRef = {};
28
+ if (title) ref.title = title;
29
+ if (uri) ref.uri = uri;
30
+
31
+ const artist = readArtists(track.artists);
32
+ if (artist) ref.artist = artist;
33
+
34
+ const album = track.album;
35
+ if (album && typeof album === 'object') {
36
+ const name = text((album as Record<string, unknown>).name);
37
+ if (name) ref.album = name;
38
+ const artwork = pickImage((album as Record<string, unknown>).images);
39
+ if (artwork) ref.artwork = artwork;
40
+ }
41
+
42
+ // A podcast episode has no album and no artists: its cover hangs off the
43
+ // episode itself and its "artist" is the show. Reading both here is what
44
+ // stops an episode from arriving in the queue as a bare URI.
45
+ if (!ref.artwork) {
46
+ const artwork = pickImage(track.images);
47
+ if (artwork) ref.artwork = artwork;
48
+ }
49
+ if (!ref.artist) {
50
+ const show = track.show;
51
+ if (show && typeof show === 'object') {
52
+ const name = text((show as Record<string, unknown>).name);
53
+ if (name) ref.artist = name;
54
+ }
55
+ }
56
+
57
+ const durationMs = track.duration_ms;
58
+ if (typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs > 0) {
59
+ ref.durationMs = Math.round(durationMs);
60
+ }
61
+
62
+ const isrc = readIsrc(track.external_ids);
63
+ if (isrc) ref.isrc = isrc;
64
+
65
+ return ref;
66
+ }
67
+
68
+ /**
69
+ * Artists as a comma-joined line.
70
+ *
71
+ * Handles both shapes on purpose. The Web API returns objects with a `name`;
72
+ * tools that normalise Spotify's payloads — spogo among them — flatten the same
73
+ * field to plain strings. Accepting either costs three lines and means this
74
+ * function is reusable by an out-of-process adapter that went through one of
75
+ * them.
76
+ */
77
+ function readArtists(value: unknown): string | null {
78
+ if (!Array.isArray(value)) return null;
79
+ const names = value
80
+ .map((entry) => {
81
+ if (typeof entry === 'string') return entry.trim();
82
+ if (entry && typeof entry === 'object') {
83
+ return text((entry as Record<string, unknown>).name) ?? '';
84
+ }
85
+ return '';
86
+ })
87
+ .filter(Boolean);
88
+ return names.length > 0 ? names.join(', ') : null;
89
+ }
90
+
91
+ function readIsrc(value: unknown): string | null {
92
+ if (!value || typeof value !== 'object') return null;
93
+ const isrc = (value as Record<string, unknown>).isrc;
94
+ return typeof isrc === 'string' && /^[A-Za-z0-9]{12}$/.test(isrc.trim())
95
+ ? isrc.trim().toUpperCase()
96
+ : null;
97
+ }
98
+
99
+ /** The smallest cover at least 300px wide, or the largest on offer. */
100
+ function pickImage(value: unknown): string | null {
101
+ if (!Array.isArray(value)) return null;
102
+ const sized: Array<{ url: string; width: number }> = [];
103
+ for (const image of value) {
104
+ if (!image || typeof image !== 'object') continue;
105
+ const record = image as Record<string, unknown>;
106
+ const url = text(record.url);
107
+ if (!url?.startsWith('https://')) continue;
108
+ sized.push({ url, width: typeof record.width === 'number' ? record.width : 0 });
109
+ }
110
+ if (sized.length === 0) return null;
111
+ const enough = sized.filter((entry) => entry.width >= 300).sort((a, b) => a.width - b.width);
112
+ return enough[0]?.url ?? [...sized].sort((a, b) => b.width - a.width)[0]?.url ?? null;
113
+ }
114
+
115
+ function text(value: unknown): string | null {
116
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
117
+ }
118
+
119
+ /**
120
+ * A search query in Spotify's field syntax.
121
+ *
122
+ * Quoted fields rather than a bare concatenation, because "Bad Habit Steve
123
+ * Lacy" as free text returns covers and remixes above the original often
124
+ * enough to matter, and the runtime is going to score whatever comes back
125
+ * against what was asked for and reject it if it does not match closely enough.
126
+ * A tighter query means fewer of those rejections.
127
+ */
128
+ export function searchQuery(ref: MediaRef): string | null {
129
+ if (ref.isrc) return `isrc:${ref.isrc}`;
130
+ const parts: string[] = [];
131
+ if (ref.title) parts.push(`track:"${escapeQuery(ref.title)}"`);
132
+ if (ref.artist) parts.push(`artist:"${escapeQuery(primary(ref.artist))}"`);
133
+ if (ref.album && !ref.artist) parts.push(`album:"${escapeQuery(ref.album)}"`);
134
+ return parts.length > 0 ? parts.join(' ') : null;
135
+ }
136
+
137
+ /** Only the first credited artist: a featuring credit spelled differently on
138
+ * two services turns an exact-match query into a miss. */
139
+ function primary(artist: string): string {
140
+ return artist.split(/,| feat\.? | ft\.? | & /i)[0]?.trim() || artist;
141
+ }
142
+
143
+ function escapeQuery(value: string): string {
144
+ return value.replace(/["\\]/g, ' ').replace(/\s+/g, ' ').trim();
145
+ }