upnext-adapter-spotify 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +224 -0
- package/dist/src/applescript.d.ts +100 -0
- package/dist/src/applescript.d.ts.map +1 -0
- package/dist/src/applescript.js +197 -0
- package/dist/src/applescript.js.map +1 -0
- package/dist/src/desktop.d.ts +100 -0
- package/dist/src/desktop.d.ts.map +1 -0
- package/dist/src/desktop.js +194 -0
- package/dist/src/desktop.js.map +1 -0
- package/dist/src/errors.d.ts +61 -0
- package/dist/src/errors.d.ts.map +1 -0
- package/dist/src/errors.js +93 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/http.d.ts +50 -0
- package/dist/src/http.d.ts.map +1 -0
- package/dist/src/http.js +113 -0
- package/dist/src/http.js.map +1 -0
- package/dist/src/index.d.ts +34 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +28 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/metadata.d.ts +39 -0
- package/dist/src/metadata.d.ts.map +1 -0
- package/dist/src/metadata.js +130 -0
- package/dist/src/metadata.js.map +1 -0
- package/dist/src/ref.d.ts +29 -0
- package/dist/src/ref.d.ts.map +1 -0
- package/dist/src/ref.js +150 -0
- package/dist/src/ref.js.map +1 -0
- package/dist/src/sampler.d.ts +68 -0
- package/dist/src/sampler.d.ts.map +1 -0
- package/dist/src/sampler.js +96 -0
- package/dist/src/sampler.js.map +1 -0
- package/dist/src/uri.d.ts +31 -0
- package/dist/src/uri.d.ts.map +1 -0
- package/dist/src/uri.js +92 -0
- package/dist/src/uri.js.map +1 -0
- package/dist/src/watch.d.ts +35 -0
- package/dist/src/watch.d.ts.map +1 -0
- package/dist/src/watch.js +131 -0
- package/dist/src/watch.js.map +1 -0
- package/dist/src/web.d.ts +115 -0
- package/dist/src/web.d.ts.map +1 -0
- package/dist/src/web.js +300 -0
- package/dist/src/web.js.map +1 -0
- package/package.json +42 -0
- package/src/applescript.ts +241 -0
- package/src/desktop.ts +250 -0
- package/src/errors.ts +119 -0
- package/src/http.ts +159 -0
- package/src/index.ts +51 -0
- package/src/metadata.ts +167 -0
- package/src/ref.ts +145 -0
- package/src/sampler.ts +173 -0
- package/src/uri.ts +104 -0
- package/src/watch.ts +145 -0
- package/src/web.ts +343 -0
package/dist/src/ref.js
ADDED
|
@@ -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"}
|
package/dist/src/uri.js
ADDED
|
@@ -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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"}
|