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,150 @@
1
+ /**
2
+ * Spotify's track JSON, read defensively into a `MediaRef`.
3
+ *
4
+ * Every field is checked rather than trusted. This is a network payload from a
5
+ * service that has changed its shapes before, and the difference between a
6
+ * missing field and a wrong one is the difference between a queue row with no
7
+ * cover and a `durationMs` of `NaN` — which the runtime would treat as a real
8
+ * duration and use to decide the track was over.
9
+ *
10
+ * The one field worth going out of the way for is `external_ids.isrc`. It is
11
+ * the recording-level id, and carrying it is what lets the same queue entry be
12
+ * found in someone else's Apple Music library, or fall back to a local file, or
13
+ * be handed to a completely different adapter when this one is unavailable. A
14
+ * Spotify URI identifies a row in Spotify's catalogue; an ISRC identifies the
15
+ * song.
16
+ */
17
+ export function readTrack(value) {
18
+ if (!value || typeof value !== 'object')
19
+ return null;
20
+ const track = value;
21
+ const uri = text(track.uri);
22
+ const title = text(track.name);
23
+ if (!uri && !title)
24
+ return null;
25
+ const ref = {};
26
+ if (title)
27
+ ref.title = title;
28
+ if (uri)
29
+ ref.uri = uri;
30
+ const artist = readArtists(track.artists);
31
+ if (artist)
32
+ ref.artist = artist;
33
+ const album = track.album;
34
+ if (album && typeof album === 'object') {
35
+ const name = text(album.name);
36
+ if (name)
37
+ ref.album = name;
38
+ const artwork = pickImage(album.images);
39
+ if (artwork)
40
+ ref.artwork = artwork;
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)
48
+ ref.artwork = artwork;
49
+ }
50
+ if (!ref.artist) {
51
+ const show = track.show;
52
+ if (show && typeof show === 'object') {
53
+ const name = text(show.name);
54
+ if (name)
55
+ ref.artist = name;
56
+ }
57
+ }
58
+ const durationMs = track.duration_ms;
59
+ if (typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs > 0) {
60
+ ref.durationMs = Math.round(durationMs);
61
+ }
62
+ const isrc = readIsrc(track.external_ids);
63
+ if (isrc)
64
+ ref.isrc = isrc;
65
+ return ref;
66
+ }
67
+ /**
68
+ * Artists as a comma-joined line.
69
+ *
70
+ * Handles both shapes on purpose. The Web API returns objects with a `name`;
71
+ * tools that normalise Spotify's payloads — spogo among them — flatten the same
72
+ * field to plain strings. Accepting either costs three lines and means this
73
+ * function is reusable by an out-of-process adapter that went through one of
74
+ * them.
75
+ */
76
+ function readArtists(value) {
77
+ if (!Array.isArray(value))
78
+ return null;
79
+ const names = value
80
+ .map((entry) => {
81
+ if (typeof entry === 'string')
82
+ return entry.trim();
83
+ if (entry && typeof entry === 'object') {
84
+ return text(entry.name) ?? '';
85
+ }
86
+ return '';
87
+ })
88
+ .filter(Boolean);
89
+ return names.length > 0 ? names.join(', ') : null;
90
+ }
91
+ function readIsrc(value) {
92
+ if (!value || typeof value !== 'object')
93
+ return null;
94
+ const isrc = value.isrc;
95
+ return typeof isrc === 'string' && /^[A-Za-z0-9]{12}$/.test(isrc.trim())
96
+ ? isrc.trim().toUpperCase()
97
+ : null;
98
+ }
99
+ /** The smallest cover at least 300px wide, or the largest on offer. */
100
+ function pickImage(value) {
101
+ if (!Array.isArray(value))
102
+ return null;
103
+ const sized = [];
104
+ for (const image of value) {
105
+ if (!image || typeof image !== 'object')
106
+ continue;
107
+ const record = image;
108
+ const url = text(record.url);
109
+ if (!url?.startsWith('https://'))
110
+ continue;
111
+ sized.push({ url, width: typeof record.width === 'number' ? record.width : 0 });
112
+ }
113
+ if (sized.length === 0)
114
+ return null;
115
+ const enough = sized.filter((entry) => entry.width >= 300).sort((a, b) => a.width - b.width);
116
+ return enough[0]?.url ?? [...sized].sort((a, b) => b.width - a.width)[0]?.url ?? null;
117
+ }
118
+ function text(value) {
119
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
120
+ }
121
+ /**
122
+ * A search query in Spotify's field syntax.
123
+ *
124
+ * Quoted fields rather than a bare concatenation, because "Bad Habit Steve
125
+ * Lacy" as free text returns covers and remixes above the original often
126
+ * enough to matter, and the runtime is going to score whatever comes back
127
+ * against what was asked for and reject it if it does not match closely enough.
128
+ * A tighter query means fewer of those rejections.
129
+ */
130
+ export function searchQuery(ref) {
131
+ if (ref.isrc)
132
+ return `isrc:${ref.isrc}`;
133
+ const parts = [];
134
+ if (ref.title)
135
+ parts.push(`track:"${escapeQuery(ref.title)}"`);
136
+ if (ref.artist)
137
+ parts.push(`artist:"${escapeQuery(primary(ref.artist))}"`);
138
+ if (ref.album && !ref.artist)
139
+ parts.push(`album:"${escapeQuery(ref.album)}"`);
140
+ return parts.length > 0 ? parts.join(' ') : null;
141
+ }
142
+ /** Only the first credited artist: a featuring credit spelled differently on
143
+ * two services turns an exact-match query into a miss. */
144
+ function primary(artist) {
145
+ return artist.split(/,| feat\.? | ft\.? | & /i)[0]?.trim() || artist;
146
+ }
147
+ function escapeQuery(value) {
148
+ return value.replace(/["\\]/g, ' ').replace(/\s+/g, ' ').trim();
149
+ }
150
+ //# sourceMappingURL=ref.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ref.js","sourceRoot":"","sources":["../../src/ref.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,68 @@
1
+ import type { AdapterEvent } from 'upnext-core';
2
+ import type { Sample } from './applescript.js';
3
+ /**
4
+ * Deciding what two consecutive readings of the Spotify app *mean*.
5
+ *
6
+ * This is the only genuinely hard code in the package, and it exists because of
7
+ * one ambiguity the core cannot resolve on its own: **the track we loaded is no
8
+ * longer the track that is playing.** That single observation has two completely
9
+ * opposite causes.
10
+ *
11
+ * Our track finished and Spotify's own queue rolled to the next thing.
12
+ * → the queue should advance to *our* next entry. This is `ended`.
13
+ *
14
+ * A person picked up their phone and hit next, or clicked a different album.
15
+ * → the human wins. This is `external`, and the reconciler adopts it.
16
+ *
17
+ * Get it backwards and the library is broken in the most visible possible way:
18
+ * either an agent's carefully built queue silently defers to Spotify's autoplay
19
+ * after every single song, or a listener who chooses a song has it yanked away.
20
+ *
21
+ * What separates them is *where the playhead was on the previous reading*. A
22
+ * rollover can only happen at the end of a track, so a change of track that
23
+ * follows a sample near the end is a rollover, and a change that follows a
24
+ * sample in the middle is a person. The core cannot make that call, because by
25
+ * the time it sees the mismatch the evidence — the previous position — is gone.
26
+ * The adapter can, because it kept it. This is exactly the kind of knowledge the
27
+ * adapter contract exists to keep on the adapter's side.
28
+ *
29
+ * All of it is pure, and none of it needs a Mac. The state goes in and comes
30
+ * back out, so every branch below is a plain table-driven test rather than
31
+ * something you have to install Spotify and wait three minutes to observe.
32
+ */
33
+ export interface WatchState {
34
+ /** The previous reading, or null before the first one. */
35
+ last: Sample | null;
36
+ /** Have we ever seen Spotify actually playing the track we asked for? */
37
+ confirmed: boolean;
38
+ /** Readings taken since the load without that confirmation. */
39
+ attempts: number;
40
+ /** Whether the "it never started" complaint has already been made. */
41
+ complained: boolean;
42
+ }
43
+ export declare const initialWatchState: WatchState;
44
+ export interface InterpretOptions {
45
+ /**
46
+ * How close to the end of a track counts as "at the end".
47
+ *
48
+ * Must be at least a couple of sampling intervals: the last reading before a
49
+ * rollover can be a whole interval short of the duration, and if the window
50
+ * is tighter than that, every natural track change is misread as a human
51
+ * taking over. Erring wide is the safer direction — the cost of a window that
52
+ * is slightly too generous is that a person who skips in the final seconds of
53
+ * a song gets the same outcome they were going to get anyway.
54
+ */
55
+ rolloverWindowMs: number;
56
+ /**
57
+ * How many readings to wait for `play track` to take effect before saying it
58
+ * did not. Spotify can take a moment to load a track, and until it has, the
59
+ * app is still reporting whatever it was playing before.
60
+ */
61
+ confirmWithin: number;
62
+ }
63
+ export interface Interpretation {
64
+ events: AdapterEvent[];
65
+ state: WatchState;
66
+ }
67
+ export declare function interpret(ourUri: string, state: WatchState, next: Sample, opts: InterpretOptions): Interpretation;
68
+ //# sourceMappingURL=sampler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sampler.d.ts","sourceRoot":"","sources":["../../src/sampler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,WAAW,UAAU;IACzB,0DAA0D;IAC1D,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,yEAAyE;IACzE,SAAS,EAAE,OAAO,CAAC;IACnB,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,UAAU,EAAE,OAAO,CAAC;CACrB;AAED,eAAO,MAAM,iBAAiB,EAAE,UAK/B,CAAC;AAEF,MAAM,WAAW,gBAAgB;IAC/B;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,KAAK,EAAE,UAAU,CAAC;CACnB;AAED,wBAAgB,SAAS,CACvB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,gBAAgB,GACrB,cAAc,CAkEhB"}
@@ -0,0 +1,96 @@
1
+ export const initialWatchState = {
2
+ last: null,
3
+ confirmed: false,
4
+ attempts: 0,
5
+ complained: false,
6
+ };
7
+ export function interpret(ourUri, state, next, opts) {
8
+ const events = [];
9
+ const wasAtEnd = nearEnd(state.last, ourUri, opts.rolloverWindowMs);
10
+ // -- the app is not open --------------------------------------------------
11
+ if (!next.running) {
12
+ // Quitting Spotify in the last seconds of a song is still that song being
13
+ // over. Anywhere else it is the backend going away underneath us, which is
14
+ // reported as paused because that is what a listener would say is true:
15
+ // nothing is coming out of the speakers, and nothing is broken enough to
16
+ // fail the entry over.
17
+ if (wasAtEnd)
18
+ return { events: [{ type: 'ended' }], state: { ...state, last: next } };
19
+ if (state.last?.running !== false)
20
+ events.push({ type: 'status', status: 'paused' });
21
+ return { events, state: { ...state, last: next } };
22
+ }
23
+ // -- our track has not started yet ----------------------------------------
24
+ if (!state.confirmed) {
25
+ if (next.nativeUri !== ourUri) {
26
+ const attempts = state.attempts + 1;
27
+ // Whatever the app is still showing is the *previous* track, not a human
28
+ // choosing something. Reporting it as a takeover here would let the track
29
+ // we are in the middle of starting hijack its own start.
30
+ if (attempts >= opts.confirmWithin && !state.complained) {
31
+ events.push({
32
+ type: 'error',
33
+ code: 'not_playing',
34
+ message: `Spotify did not start ${ourUri}; it is playing ${next.nativeUri ?? 'nothing'}`,
35
+ });
36
+ return { events, state: { ...state, last: next, attempts, complained: true } };
37
+ }
38
+ return { events, state: { ...state, last: next, attempts } };
39
+ }
40
+ return {
41
+ events: report(state.last, next),
42
+ state: { ...state, last: next, confirmed: true },
43
+ };
44
+ }
45
+ // -- something else is playing --------------------------------------------
46
+ if (next.nativeUri !== ourUri) {
47
+ if (wasAtEnd)
48
+ return { events: [{ type: 'ended' }], state: { ...state, last: next } };
49
+ if (next.nativeUri === null) {
50
+ // Spotify is open with nothing loaded. `external` carrying null is a
51
+ // no-op to the reconciler by design — "reporting nothing" is not the same
52
+ // as "reporting a different song" — so the honest signal is that playback
53
+ // stopped.
54
+ if (state.last?.nativeUri !== null)
55
+ events.push({ type: 'status', status: 'paused' });
56
+ return { events, state: { ...state, last: next } };
57
+ }
58
+ return {
59
+ events: [{ type: 'external', nativeUri: next.nativeUri }],
60
+ state: { ...state, last: next },
61
+ };
62
+ }
63
+ // -- still our track ------------------------------------------------------
64
+ // Spotify with no autoplay behind it parks at the end of the last track
65
+ // rather than announcing anything, so a track that is at its end and no
66
+ // longer playing is a track that is over. Without this the queue would sit
67
+ // there, holding a finished song, waiting for a change that never comes.
68
+ if (next.status !== 'playing' && atEnd(next, opts.rolloverWindowMs)) {
69
+ return { events: [{ type: 'ended' }], state: { ...state, last: next } };
70
+ }
71
+ return { events: report(state.last, next), state: { ...state, last: next } };
72
+ }
73
+ /** Position every time, status only when it actually changed. */
74
+ function report(last, next) {
75
+ const events = [
76
+ next.durationMs
77
+ ? { type: 'position', positionMs: next.positionMs, durationMs: next.durationMs }
78
+ : { type: 'position', positionMs: next.positionMs },
79
+ ];
80
+ if (last?.status !== next.status) {
81
+ events.push({ type: 'status', status: next.status === 'idle' ? 'paused' : next.status });
82
+ }
83
+ return events;
84
+ }
85
+ /** Was the previous reading our track, at the end of it? */
86
+ function nearEnd(last, ourUri, windowMs) {
87
+ if (!last || !last.running || last.nativeUri !== ourUri)
88
+ return false;
89
+ return atEnd(last, windowMs);
90
+ }
91
+ function atEnd(sample, windowMs) {
92
+ if (sample.durationMs === null)
93
+ return false;
94
+ return sample.positionMs >= sample.durationMs - windowMs;
95
+ }
96
+ //# sourceMappingURL=sampler.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sampler.js","sourceRoot":"","sources":["../../src/sampler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Reading Spotify's two ways of naming the same thing.
3
+ *
4
+ * A ref can arrive carrying either form — `spotify:track:6f3Slt0GbA2bPZlz0aIFXN`
5
+ * from an API, or `https://open.spotify.com/track/6f3Slt0GbA2bPZlz0aIFXN?si=…`
6
+ * from a person pasting a share link — and both adapters have to treat them as
7
+ * the same track. Everything downstream works in the `spotify:` form, because
8
+ * that is what the AppleScript dictionary and the Web API both accept.
9
+ *
10
+ * Parsing is total: anything unrecognised answers `null` rather than throwing,
11
+ * so `match` can score a ref without a try/catch around it.
12
+ */
13
+ export type SpotifyKind = 'track' | 'episode' | 'album' | 'playlist' | 'artist' | 'show';
14
+ export interface SpotifyId {
15
+ kind: SpotifyKind;
16
+ /** The base-62 id, without any prefix. */
17
+ id: string;
18
+ }
19
+ /**
20
+ * Kinds that name one playable item.
21
+ *
22
+ * The rest are *containers*, and a container is not a queue entry — this
23
+ * library's whole shape is one item at a time, with the ordering owned above
24
+ * the backend. An album URI is a request to enqueue many things, which is
25
+ * `expandContext` plus `enqueueMany`, not a single bind.
26
+ */
27
+ export declare function isPlayableKind(kind: SpotifyKind): boolean;
28
+ export declare function parseSpotifyUri(value: string | undefined | null): SpotifyId | null;
29
+ export declare function toSpotifyUri(parsed: SpotifyId): string;
30
+ export declare function toSpotifyUrl(parsed: SpotifyId): string;
31
+ //# sourceMappingURL=uri.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uri.d.ts","sourceRoot":"","sources":["../../src/uri.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEzF,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,WAAW,CAAC;IAClB,0CAA0C;IAC1C,EAAE,EAAE,MAAM,CAAC;CACZ;AAID;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAEzD;AAMD,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,CAOlF;AAmDD,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAEtD;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,CAEtD"}
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Reading Spotify's two ways of naming the same thing.
3
+ *
4
+ * A ref can arrive carrying either form — `spotify:track:6f3Slt0GbA2bPZlz0aIFXN`
5
+ * from an API, or `https://open.spotify.com/track/6f3Slt0GbA2bPZlz0aIFXN?si=…`
6
+ * from a person pasting a share link — and both adapters have to treat them as
7
+ * the same track. Everything downstream works in the `spotify:` form, because
8
+ * that is what the AppleScript dictionary and the Web API both accept.
9
+ *
10
+ * Parsing is total: anything unrecognised answers `null` rather than throwing,
11
+ * so `match` can score a ref without a try/catch around it.
12
+ */
13
+ const KINDS = new Set(['track', 'episode', 'album', 'playlist', 'artist', 'show']);
14
+ /**
15
+ * Kinds that name one playable item.
16
+ *
17
+ * The rest are *containers*, and a container is not a queue entry — this
18
+ * library's whole shape is one item at a time, with the ordering owned above
19
+ * the backend. An album URI is a request to enqueue many things, which is
20
+ * `expandContext` plus `enqueueMany`, not a single bind.
21
+ */
22
+ export function isPlayableKind(kind) {
23
+ return kind === 'track' || kind === 'episode';
24
+ }
25
+ /** Spotify ids are base-62. Length is not pinned: it is 22 today, and a format
26
+ * check that is wrong in two years is worse than one that is slightly loose. */
27
+ const ID = /^[A-Za-z0-9]{8,64}$/;
28
+ export function parseSpotifyUri(value) {
29
+ if (!value)
30
+ return null;
31
+ const trimmed = value.trim();
32
+ if (!trimmed)
33
+ return null;
34
+ return trimmed.toLowerCase().startsWith('spotify:')
35
+ ? fromUri(trimmed)
36
+ : fromUrl(trimmed);
37
+ }
38
+ /**
39
+ * `spotify:track:ID`, and the legacy `spotify:user:someone:playlist:ID`.
40
+ *
41
+ * Scanning from the right rather than reading position 1 is what handles the
42
+ * legacy form, and it costs nothing on the common one. `spotify:local:…` has no
43
+ * base-62 id at all and falls out on its own — which is correct, since a local
44
+ * file in someone's Spotify library is not something either backend can locate.
45
+ */
46
+ function fromUri(value) {
47
+ const parts = value.split(':');
48
+ for (let i = parts.length - 2; i >= 0; i--) {
49
+ const kind = parts[i]?.toLowerCase();
50
+ const id = parts[i + 1];
51
+ if (kind && KINDS.has(kind) && id && ID.test(id)) {
52
+ return { kind: kind, id };
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ /**
58
+ * `https://open.spotify.com/track/ID`, with or without a locale segment.
59
+ *
60
+ * The locale form (`/intl-de/track/ID`) is what the mobile app produces when
61
+ * someone shares from a non-English device, so it is not an edge case — it is
62
+ * half of the share links a person will actually paste. Scanning the path for a
63
+ * known kind absorbs it, and the legacy `play.spotify.com` host, without a
64
+ * pattern per variant.
65
+ */
66
+ function fromUrl(value) {
67
+ let url;
68
+ try {
69
+ url = new URL(value);
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ if (!/(^|\.)spotify\.com$/i.test(url.hostname))
75
+ return null;
76
+ const segments = url.pathname.split('/').filter(Boolean);
77
+ for (let i = 0; i < segments.length - 1; i++) {
78
+ const kind = segments[i]?.toLowerCase();
79
+ const id = segments[i + 1];
80
+ if (kind && KINDS.has(kind) && id && ID.test(id)) {
81
+ return { kind: kind, id };
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+ export function toSpotifyUri(parsed) {
87
+ return `spotify:${parsed.kind}:${parsed.id}`;
88
+ }
89
+ export function toSpotifyUrl(parsed) {
90
+ return `https://open.spotify.com/${parsed.kind}/${parsed.id}`;
91
+ }
92
+ //# sourceMappingURL=uri.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"uri.js","sourceRoot":"","sources":["../../src/uri.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ import type { Scheduler } from 'upnext-core';
2
+ import type { AdapterEvent } from 'upnext-core';
3
+ import type { Sample } from './applescript.js';
4
+ export interface BackendWatcherDeps {
5
+ /** Take one reading. `null` means the reading was unusable, not that it failed. */
6
+ read(): Promise<Sample | null>;
7
+ emit(event: AdapterEvent): void;
8
+ scheduler: Scheduler;
9
+ intervalMs: number;
10
+ rolloverWindowMs: number;
11
+ confirmWithin: number;
12
+ }
13
+ /**
14
+ * The loop that turns readings into events, shared by both backends.
15
+ *
16
+ * Extracted because the desktop app and the Web API differ only in how a
17
+ * reading is *taken* — one shells out to `osascript`, the other makes an HTTP
18
+ * call — and are identical in what happens around it: do not let a slow read
19
+ * stack up behind itself, do not report the same outage once a second, decide
20
+ * what changed, and stop the moment a track ends so nothing ends twice.
21
+ *
22
+ * Keeping one copy is not only tidier, it is safer. The re-targeting below is
23
+ * subtle enough that having it in two places would mean having it wrong in one.
24
+ */
25
+ export declare class BackendWatcher {
26
+ #private;
27
+ constructor(deps: BackendWatcherDeps);
28
+ /** What this watcher currently believes it is following. */
29
+ get target(): string | null;
30
+ start(nativeUri: string): void;
31
+ stop(): void;
32
+ /** One pass. Exposed so a test can step it without a scheduler. */
33
+ tick(): Promise<void>;
34
+ }
35
+ //# sourceMappingURL=watch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watch.d.ts","sourceRoot":"","sources":["../../src/watch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAI/C,MAAM,WAAW,kBAAkB;IACjC,mFAAmF;IACnF,IAAI,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC/B,IAAI,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAAC;IAChC,SAAS,EAAE,SAAS,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,cAAc;;gBAQb,IAAI,EAAE,kBAAkB;IAIpC,4DAA4D;IAC5D,IAAI,MAAM,IAAI,MAAM,GAAG,IAAI,CAE1B;IAED,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAU9B,IAAI,IAAI,IAAI;IASZ,mEAAmE;IAC7D,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CA+E5B"}
@@ -0,0 +1,131 @@
1
+ import { SpotifyError } from './errors.js';
2
+ import { initialWatchState, interpret } from './sampler.js';
3
+ /**
4
+ * The loop that turns readings into events, shared by both backends.
5
+ *
6
+ * Extracted because the desktop app and the Web API differ only in how a
7
+ * reading is *taken* — one shells out to `osascript`, the other makes an HTTP
8
+ * call — and are identical in what happens around it: do not let a slow read
9
+ * stack up behind itself, do not report the same outage once a second, decide
10
+ * what changed, and stop the moment a track ends so nothing ends twice.
11
+ *
12
+ * Keeping one copy is not only tidier, it is safer. The re-targeting below is
13
+ * subtle enough that having it in two places would mean having it wrong in one.
14
+ */
15
+ export class BackendWatcher {
16
+ #deps;
17
+ #target = null;
18
+ #state = initialWatchState;
19
+ #timer = null;
20
+ #reading = false;
21
+ #failing = false;
22
+ constructor(deps) {
23
+ this.#deps = deps;
24
+ }
25
+ /** What this watcher currently believes it is following. */
26
+ get target() {
27
+ return this.#target;
28
+ }
29
+ start(nativeUri) {
30
+ this.#target = nativeUri;
31
+ this.#state = initialWatchState;
32
+ this.#failing = false;
33
+ if (this.#timer !== null)
34
+ return;
35
+ this.#timer = this.#deps.scheduler.setInterval(() => {
36
+ void this.tick();
37
+ }, this.#deps.intervalMs);
38
+ }
39
+ stop() {
40
+ this.#target = null;
41
+ this.#state = initialWatchState;
42
+ if (this.#timer === null)
43
+ return;
44
+ this.#deps.scheduler.clearInterval(this.#timer);
45
+ this.#timer = null;
46
+ this.#reading = false;
47
+ }
48
+ /** One pass. Exposed so a test can step it without a scheduler. */
49
+ async tick() {
50
+ // A slow read must not let ticks pile up behind it. Skipping is the right
51
+ // response rather than queueing: the next reading supersedes this one, so a
52
+ // dropped pass costs a second of resolution and nothing else.
53
+ if (this.#reading)
54
+ return;
55
+ const target = this.#target;
56
+ if (target === null)
57
+ return;
58
+ this.#reading = true;
59
+ let sample;
60
+ try {
61
+ sample = await this.#deps.read();
62
+ this.#failing = false;
63
+ }
64
+ catch (err) {
65
+ this.#report(err);
66
+ return;
67
+ }
68
+ finally {
69
+ this.#reading = false;
70
+ }
71
+ // The world moved while we were awaiting: something else is loaded now and
72
+ // this reading describes the track before it.
73
+ if (this.#target !== target || !sample)
74
+ return;
75
+ const { events, state } = interpret(target, this.#state, sample, {
76
+ rolloverWindowMs: this.#deps.rolloverWindowMs,
77
+ confirmWithin: this.#deps.confirmWithin,
78
+ });
79
+ this.#state = state;
80
+ /**
81
+ * Follow the backend when a person moves it.
82
+ *
83
+ * Without this the watcher would go on comparing every reading against the
84
+ * track the listener already abandoned, announce a takeover again a second
85
+ * later, and again after that — and since the runtime's default is to adopt
86
+ * what the human chose, each one would add another entry to the queue. A
87
+ * takeover is one piece of news; after it, this *is* the track we are on.
88
+ *
89
+ * Whichever way the runtime rules, this ends up correct: on `adopt` the
90
+ * runtime never calls `load` again, so re-targeting here is the only thing
91
+ * that keeps the two in step; on `correct` it calls `load` and `play`,
92
+ * which resets this watcher outright; on `ignore` following along is
93
+ * precisely what ignoring means.
94
+ */
95
+ const takeover = events.find((event) => event.type === 'external');
96
+ if (takeover?.type === 'external' && takeover.nativeUri) {
97
+ this.#target = takeover.nativeUri;
98
+ this.#state = { last: sample, confirmed: true, attempts: 0, complained: false };
99
+ }
100
+ // Stop before announcing. The runtime tears this down on its way to the
101
+ // next entry, but not before the timer could have fired again, and one
102
+ // track must never end twice.
103
+ if (events.some((event) => event.type === 'ended'))
104
+ this.stop();
105
+ for (const event of events)
106
+ this.#deps.emit(event);
107
+ }
108
+ /**
109
+ * Say an outage happened once, not once per tick.
110
+ *
111
+ * A machine that has just declined Automation permission, or a token that has
112
+ * stopped working, would otherwise emit an identical error every interval for
113
+ * as long as the queue is open. A rate limit additionally stops the loop: the
114
+ * watcher is the only thing here that runs unasked, so it is the only thing
115
+ * that can dig the hole deeper on its own.
116
+ */
117
+ #report(err) {
118
+ this.#reading = false;
119
+ if (!this.#failing) {
120
+ this.#failing = true;
121
+ this.#deps.emit({
122
+ type: 'error',
123
+ code: err instanceof SpotifyError ? err.reason : 'sample_failed',
124
+ message: err instanceof Error ? err.message : String(err),
125
+ });
126
+ }
127
+ if (err instanceof SpotifyError && err.reason === 'rate-limited')
128
+ this.stop();
129
+ }
130
+ }
131
+ //# sourceMappingURL=watch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"watch.js","sourceRoot":"","sources":["../../src/watch.ts"],"names":[],"mappings":"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"}