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/src/sampler.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { AdapterEvent } from 'upnext-core';
|
|
2
|
+
import type { Sample } from './applescript.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Deciding what two consecutive readings of the Spotify app *mean*.
|
|
6
|
+
*
|
|
7
|
+
* This is the only genuinely hard code in the package, and it exists because of
|
|
8
|
+
* one ambiguity the core cannot resolve on its own: **the track we loaded is no
|
|
9
|
+
* longer the track that is playing.** That single observation has two completely
|
|
10
|
+
* opposite causes.
|
|
11
|
+
*
|
|
12
|
+
* Our track finished and Spotify's own queue rolled to the next thing.
|
|
13
|
+
* → the queue should advance to *our* next entry. This is `ended`.
|
|
14
|
+
*
|
|
15
|
+
* A person picked up their phone and hit next, or clicked a different album.
|
|
16
|
+
* → the human wins. This is `external`, and the reconciler adopts it.
|
|
17
|
+
*
|
|
18
|
+
* Get it backwards and the library is broken in the most visible possible way:
|
|
19
|
+
* either an agent's carefully built queue silently defers to Spotify's autoplay
|
|
20
|
+
* after every single song, or a listener who chooses a song has it yanked away.
|
|
21
|
+
*
|
|
22
|
+
* What separates them is *where the playhead was on the previous reading*. A
|
|
23
|
+
* rollover can only happen at the end of a track, so a change of track that
|
|
24
|
+
* follows a sample near the end is a rollover, and a change that follows a
|
|
25
|
+
* sample in the middle is a person. The core cannot make that call, because by
|
|
26
|
+
* the time it sees the mismatch the evidence — the previous position — is gone.
|
|
27
|
+
* The adapter can, because it kept it. This is exactly the kind of knowledge the
|
|
28
|
+
* adapter contract exists to keep on the adapter's side.
|
|
29
|
+
*
|
|
30
|
+
* All of it is pure, and none of it needs a Mac. The state goes in and comes
|
|
31
|
+
* back out, so every branch below is a plain table-driven test rather than
|
|
32
|
+
* something you have to install Spotify and wait three minutes to observe.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export interface WatchState {
|
|
36
|
+
/** The previous reading, or null before the first one. */
|
|
37
|
+
last: Sample | null;
|
|
38
|
+
/** Have we ever seen Spotify actually playing the track we asked for? */
|
|
39
|
+
confirmed: boolean;
|
|
40
|
+
/** Readings taken since the load without that confirmation. */
|
|
41
|
+
attempts: number;
|
|
42
|
+
/** Whether the "it never started" complaint has already been made. */
|
|
43
|
+
complained: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const initialWatchState: WatchState = {
|
|
47
|
+
last: null,
|
|
48
|
+
confirmed: false,
|
|
49
|
+
attempts: 0,
|
|
50
|
+
complained: false,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface InterpretOptions {
|
|
54
|
+
/**
|
|
55
|
+
* How close to the end of a track counts as "at the end".
|
|
56
|
+
*
|
|
57
|
+
* Must be at least a couple of sampling intervals: the last reading before a
|
|
58
|
+
* rollover can be a whole interval short of the duration, and if the window
|
|
59
|
+
* is tighter than that, every natural track change is misread as a human
|
|
60
|
+
* taking over. Erring wide is the safer direction — the cost of a window that
|
|
61
|
+
* is slightly too generous is that a person who skips in the final seconds of
|
|
62
|
+
* a song gets the same outcome they were going to get anyway.
|
|
63
|
+
*/
|
|
64
|
+
rolloverWindowMs: number;
|
|
65
|
+
/**
|
|
66
|
+
* How many readings to wait for `play track` to take effect before saying it
|
|
67
|
+
* did not. Spotify can take a moment to load a track, and until it has, the
|
|
68
|
+
* app is still reporting whatever it was playing before.
|
|
69
|
+
*/
|
|
70
|
+
confirmWithin: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Interpretation {
|
|
74
|
+
events: AdapterEvent[];
|
|
75
|
+
state: WatchState;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function interpret(
|
|
79
|
+
ourUri: string,
|
|
80
|
+
state: WatchState,
|
|
81
|
+
next: Sample,
|
|
82
|
+
opts: InterpretOptions,
|
|
83
|
+
): Interpretation {
|
|
84
|
+
const events: AdapterEvent[] = [];
|
|
85
|
+
const wasAtEnd = nearEnd(state.last, ourUri, opts.rolloverWindowMs);
|
|
86
|
+
|
|
87
|
+
// -- the app is not open --------------------------------------------------
|
|
88
|
+
if (!next.running) {
|
|
89
|
+
// Quitting Spotify in the last seconds of a song is still that song being
|
|
90
|
+
// over. Anywhere else it is the backend going away underneath us, which is
|
|
91
|
+
// reported as paused because that is what a listener would say is true:
|
|
92
|
+
// nothing is coming out of the speakers, and nothing is broken enough to
|
|
93
|
+
// fail the entry over.
|
|
94
|
+
if (wasAtEnd) return { events: [{ type: 'ended' }], state: { ...state, last: next } };
|
|
95
|
+
if (state.last?.running !== false) events.push({ type: 'status', status: 'paused' });
|
|
96
|
+
return { events, state: { ...state, last: next } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// -- our track has not started yet ----------------------------------------
|
|
100
|
+
if (!state.confirmed) {
|
|
101
|
+
if (next.nativeUri !== ourUri) {
|
|
102
|
+
const attempts = state.attempts + 1;
|
|
103
|
+
// Whatever the app is still showing is the *previous* track, not a human
|
|
104
|
+
// choosing something. Reporting it as a takeover here would let the track
|
|
105
|
+
// we are in the middle of starting hijack its own start.
|
|
106
|
+
if (attempts >= opts.confirmWithin && !state.complained) {
|
|
107
|
+
events.push({
|
|
108
|
+
type: 'error',
|
|
109
|
+
code: 'not_playing',
|
|
110
|
+
message: `Spotify did not start ${ourUri}; it is playing ${next.nativeUri ?? 'nothing'}`,
|
|
111
|
+
});
|
|
112
|
+
return { events, state: { ...state, last: next, attempts, complained: true } };
|
|
113
|
+
}
|
|
114
|
+
return { events, state: { ...state, last: next, attempts } };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
events: report(state.last, next),
|
|
118
|
+
state: { ...state, last: next, confirmed: true },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// -- something else is playing --------------------------------------------
|
|
123
|
+
if (next.nativeUri !== ourUri) {
|
|
124
|
+
if (wasAtEnd) return { events: [{ type: 'ended' }], state: { ...state, last: next } };
|
|
125
|
+
if (next.nativeUri === null) {
|
|
126
|
+
// Spotify is open with nothing loaded. `external` carrying null is a
|
|
127
|
+
// no-op to the reconciler by design — "reporting nothing" is not the same
|
|
128
|
+
// as "reporting a different song" — so the honest signal is that playback
|
|
129
|
+
// stopped.
|
|
130
|
+
if (state.last?.nativeUri !== null) events.push({ type: 'status', status: 'paused' });
|
|
131
|
+
return { events, state: { ...state, last: next } };
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
events: [{ type: 'external', nativeUri: next.nativeUri }],
|
|
135
|
+
state: { ...state, last: next },
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// -- still our track ------------------------------------------------------
|
|
140
|
+
// Spotify with no autoplay behind it parks at the end of the last track
|
|
141
|
+
// rather than announcing anything, so a track that is at its end and no
|
|
142
|
+
// longer playing is a track that is over. Without this the queue would sit
|
|
143
|
+
// there, holding a finished song, waiting for a change that never comes.
|
|
144
|
+
if (next.status !== 'playing' && atEnd(next, opts.rolloverWindowMs)) {
|
|
145
|
+
return { events: [{ type: 'ended' }], state: { ...state, last: next } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { events: report(state.last, next), state: { ...state, last: next } };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Position every time, status only when it actually changed. */
|
|
152
|
+
function report(last: Sample | null, next: Sample): AdapterEvent[] {
|
|
153
|
+
const events: AdapterEvent[] = [
|
|
154
|
+
next.durationMs
|
|
155
|
+
? { type: 'position', positionMs: next.positionMs, durationMs: next.durationMs }
|
|
156
|
+
: { type: 'position', positionMs: next.positionMs },
|
|
157
|
+
];
|
|
158
|
+
if (last?.status !== next.status) {
|
|
159
|
+
events.push({ type: 'status', status: next.status === 'idle' ? 'paused' : next.status });
|
|
160
|
+
}
|
|
161
|
+
return events;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Was the previous reading our track, at the end of it? */
|
|
165
|
+
function nearEnd(last: Sample | null, ourUri: string, windowMs: number): boolean {
|
|
166
|
+
if (!last || !last.running || last.nativeUri !== ourUri) return false;
|
|
167
|
+
return atEnd(last, windowMs);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function atEnd(sample: Sample, windowMs: number): boolean {
|
|
171
|
+
if (sample.durationMs === null) return false;
|
|
172
|
+
return sample.positionMs >= sample.durationMs - windowMs;
|
|
173
|
+
}
|
package/src/uri.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
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
|
+
|
|
14
|
+
export type SpotifyKind = 'track' | 'episode' | 'album' | 'playlist' | 'artist' | 'show';
|
|
15
|
+
|
|
16
|
+
export interface SpotifyId {
|
|
17
|
+
kind: SpotifyKind;
|
|
18
|
+
/** The base-62 id, without any prefix. */
|
|
19
|
+
id: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const KINDS = new Set<string>(['track', 'episode', 'album', 'playlist', 'artist', 'show']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Kinds that name one playable item.
|
|
26
|
+
*
|
|
27
|
+
* The rest are *containers*, and a container is not a queue entry — this
|
|
28
|
+
* library's whole shape is one item at a time, with the ordering owned above
|
|
29
|
+
* the backend. An album URI is a request to enqueue many things, which is
|
|
30
|
+
* `expandContext` plus `enqueueMany`, not a single bind.
|
|
31
|
+
*/
|
|
32
|
+
export function isPlayableKind(kind: SpotifyKind): boolean {
|
|
33
|
+
return kind === 'track' || kind === 'episode';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Spotify ids are base-62. Length is not pinned: it is 22 today, and a format
|
|
37
|
+
* check that is wrong in two years is worse than one that is slightly loose. */
|
|
38
|
+
const ID = /^[A-Za-z0-9]{8,64}$/;
|
|
39
|
+
|
|
40
|
+
export function parseSpotifyUri(value: string | undefined | null): SpotifyId | null {
|
|
41
|
+
if (!value) return null;
|
|
42
|
+
const trimmed = value.trim();
|
|
43
|
+
if (!trimmed) return null;
|
|
44
|
+
return trimmed.toLowerCase().startsWith('spotify:')
|
|
45
|
+
? fromUri(trimmed)
|
|
46
|
+
: fromUrl(trimmed);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `spotify:track:ID`, and the legacy `spotify:user:someone:playlist:ID`.
|
|
51
|
+
*
|
|
52
|
+
* Scanning from the right rather than reading position 1 is what handles the
|
|
53
|
+
* legacy form, and it costs nothing on the common one. `spotify:local:…` has no
|
|
54
|
+
* base-62 id at all and falls out on its own — which is correct, since a local
|
|
55
|
+
* file in someone's Spotify library is not something either backend can locate.
|
|
56
|
+
*/
|
|
57
|
+
function fromUri(value: string): SpotifyId | null {
|
|
58
|
+
const parts = value.split(':');
|
|
59
|
+
for (let i = parts.length - 2; i >= 0; i--) {
|
|
60
|
+
const kind = parts[i]?.toLowerCase();
|
|
61
|
+
const id = parts[i + 1];
|
|
62
|
+
if (kind && KINDS.has(kind) && id && ID.test(id)) {
|
|
63
|
+
return { kind: kind as SpotifyKind, id };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `https://open.spotify.com/track/ID`, with or without a locale segment.
|
|
71
|
+
*
|
|
72
|
+
* The locale form (`/intl-de/track/ID`) is what the mobile app produces when
|
|
73
|
+
* someone shares from a non-English device, so it is not an edge case — it is
|
|
74
|
+
* half of the share links a person will actually paste. Scanning the path for a
|
|
75
|
+
* known kind absorbs it, and the legacy `play.spotify.com` host, without a
|
|
76
|
+
* pattern per variant.
|
|
77
|
+
*/
|
|
78
|
+
function fromUrl(value: string): SpotifyId | null {
|
|
79
|
+
let url: URL;
|
|
80
|
+
try {
|
|
81
|
+
url = new URL(value);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!/(^|\.)spotify\.com$/i.test(url.hostname)) return null;
|
|
86
|
+
|
|
87
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
88
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
89
|
+
const kind = segments[i]?.toLowerCase();
|
|
90
|
+
const id = segments[i + 1];
|
|
91
|
+
if (kind && KINDS.has(kind) && id && ID.test(id)) {
|
|
92
|
+
return { kind: kind as SpotifyKind, id };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function toSpotifyUri(parsed: SpotifyId): string {
|
|
99
|
+
return `spotify:${parsed.kind}:${parsed.id}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function toSpotifyUrl(parsed: SpotifyId): string {
|
|
103
|
+
return `https://open.spotify.com/${parsed.kind}/${parsed.id}`;
|
|
104
|
+
}
|
package/src/watch.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { Scheduler } from 'upnext-core';
|
|
2
|
+
import type { AdapterEvent } from 'upnext-core';
|
|
3
|
+
import type { Sample } from './applescript.js';
|
|
4
|
+
import { SpotifyError } from './errors.js';
|
|
5
|
+
import { initialWatchState, interpret, type WatchState } from './sampler.js';
|
|
6
|
+
|
|
7
|
+
export interface BackendWatcherDeps {
|
|
8
|
+
/** Take one reading. `null` means the reading was unusable, not that it failed. */
|
|
9
|
+
read(): Promise<Sample | null>;
|
|
10
|
+
emit(event: AdapterEvent): void;
|
|
11
|
+
scheduler: Scheduler;
|
|
12
|
+
intervalMs: number;
|
|
13
|
+
rolloverWindowMs: number;
|
|
14
|
+
confirmWithin: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The loop that turns readings into events, shared by both backends.
|
|
19
|
+
*
|
|
20
|
+
* Extracted because the desktop app and the Web API differ only in how a
|
|
21
|
+
* reading is *taken* — one shells out to `osascript`, the other makes an HTTP
|
|
22
|
+
* call — and are identical in what happens around it: do not let a slow read
|
|
23
|
+
* stack up behind itself, do not report the same outage once a second, decide
|
|
24
|
+
* what changed, and stop the moment a track ends so nothing ends twice.
|
|
25
|
+
*
|
|
26
|
+
* Keeping one copy is not only tidier, it is safer. The re-targeting below is
|
|
27
|
+
* subtle enough that having it in two places would mean having it wrong in one.
|
|
28
|
+
*/
|
|
29
|
+
export class BackendWatcher {
|
|
30
|
+
#deps: BackendWatcherDeps;
|
|
31
|
+
#target: string | null = null;
|
|
32
|
+
#state: WatchState = initialWatchState;
|
|
33
|
+
#timer: unknown = null;
|
|
34
|
+
#reading = false;
|
|
35
|
+
#failing = false;
|
|
36
|
+
|
|
37
|
+
constructor(deps: BackendWatcherDeps) {
|
|
38
|
+
this.#deps = deps;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What this watcher currently believes it is following. */
|
|
42
|
+
get target(): string | null {
|
|
43
|
+
return this.#target;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
start(nativeUri: string): void {
|
|
47
|
+
this.#target = nativeUri;
|
|
48
|
+
this.#state = initialWatchState;
|
|
49
|
+
this.#failing = false;
|
|
50
|
+
if (this.#timer !== null) return;
|
|
51
|
+
this.#timer = this.#deps.scheduler.setInterval(() => {
|
|
52
|
+
void this.tick();
|
|
53
|
+
}, this.#deps.intervalMs);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stop(): void {
|
|
57
|
+
this.#target = null;
|
|
58
|
+
this.#state = initialWatchState;
|
|
59
|
+
if (this.#timer === null) return;
|
|
60
|
+
this.#deps.scheduler.clearInterval(this.#timer);
|
|
61
|
+
this.#timer = null;
|
|
62
|
+
this.#reading = false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One pass. Exposed so a test can step it without a scheduler. */
|
|
66
|
+
async tick(): Promise<void> {
|
|
67
|
+
// A slow read must not let ticks pile up behind it. Skipping is the right
|
|
68
|
+
// response rather than queueing: the next reading supersedes this one, so a
|
|
69
|
+
// dropped pass costs a second of resolution and nothing else.
|
|
70
|
+
if (this.#reading) return;
|
|
71
|
+
const target = this.#target;
|
|
72
|
+
if (target === null) return;
|
|
73
|
+
|
|
74
|
+
this.#reading = true;
|
|
75
|
+
let sample: Sample | null;
|
|
76
|
+
try {
|
|
77
|
+
sample = await this.#deps.read();
|
|
78
|
+
this.#failing = false;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
this.#report(err);
|
|
81
|
+
return;
|
|
82
|
+
} finally {
|
|
83
|
+
this.#reading = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The world moved while we were awaiting: something else is loaded now and
|
|
87
|
+
// this reading describes the track before it.
|
|
88
|
+
if (this.#target !== target || !sample) return;
|
|
89
|
+
|
|
90
|
+
const { events, state } = interpret(target, this.#state, sample, {
|
|
91
|
+
rolloverWindowMs: this.#deps.rolloverWindowMs,
|
|
92
|
+
confirmWithin: this.#deps.confirmWithin,
|
|
93
|
+
});
|
|
94
|
+
this.#state = state;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Follow the backend when a person moves it.
|
|
98
|
+
*
|
|
99
|
+
* Without this the watcher would go on comparing every reading against the
|
|
100
|
+
* track the listener already abandoned, announce a takeover again a second
|
|
101
|
+
* later, and again after that — and since the runtime's default is to adopt
|
|
102
|
+
* what the human chose, each one would add another entry to the queue. A
|
|
103
|
+
* takeover is one piece of news; after it, this *is* the track we are on.
|
|
104
|
+
*
|
|
105
|
+
* Whichever way the runtime rules, this ends up correct: on `adopt` the
|
|
106
|
+
* runtime never calls `load` again, so re-targeting here is the only thing
|
|
107
|
+
* that keeps the two in step; on `correct` it calls `load` and `play`,
|
|
108
|
+
* which resets this watcher outright; on `ignore` following along is
|
|
109
|
+
* precisely what ignoring means.
|
|
110
|
+
*/
|
|
111
|
+
const takeover = events.find((event) => event.type === 'external');
|
|
112
|
+
if (takeover?.type === 'external' && takeover.nativeUri) {
|
|
113
|
+
this.#target = takeover.nativeUri;
|
|
114
|
+
this.#state = { last: sample, confirmed: true, attempts: 0, complained: false };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Stop before announcing. The runtime tears this down on its way to the
|
|
118
|
+
// next entry, but not before the timer could have fired again, and one
|
|
119
|
+
// track must never end twice.
|
|
120
|
+
if (events.some((event) => event.type === 'ended')) this.stop();
|
|
121
|
+
for (const event of events) this.#deps.emit(event);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Say an outage happened once, not once per tick.
|
|
126
|
+
*
|
|
127
|
+
* A machine that has just declined Automation permission, or a token that has
|
|
128
|
+
* stopped working, would otherwise emit an identical error every interval for
|
|
129
|
+
* as long as the queue is open. A rate limit additionally stops the loop: the
|
|
130
|
+
* watcher is the only thing here that runs unasked, so it is the only thing
|
|
131
|
+
* that can dig the hole deeper on its own.
|
|
132
|
+
*/
|
|
133
|
+
#report(err: unknown): void {
|
|
134
|
+
this.#reading = false;
|
|
135
|
+
if (!this.#failing) {
|
|
136
|
+
this.#failing = true;
|
|
137
|
+
this.#deps.emit({
|
|
138
|
+
type: 'error',
|
|
139
|
+
code: err instanceof SpotifyError ? err.reason : 'sample_failed',
|
|
140
|
+
message: err instanceof Error ? err.message : String(err),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
if (err instanceof SpotifyError && err.reason === 'rate-limited') this.stop();
|
|
144
|
+
}
|
|
145
|
+
}
|