@ciphore/radiocli 0.2.1 → 0.2.3
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/CHANGELOG.md +133 -1
- package/README.md +76 -6
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +248 -0
- package/dist/alarms/active-session.js +183 -0
- package/dist/alarms/cli.js +312 -0
- package/dist/alarms/guard.js +343 -0
- package/dist/alarms/inhibitor.js +48 -0
- package/dist/alarms/power-guard-store.js +169 -0
- package/dist/alarms/runner.js +342 -0
- package/dist/alarms/runtime-health.js +79 -0
- package/dist/alarms/schedule.js +149 -0
- package/dist/alarms/scheduler.js +250 -0
- package/dist/alarms/setup-verification.js +187 -0
- package/dist/alarms/system-volume.js +43 -0
- package/dist/alarms/terminal-launcher.js +181 -0
- package/dist/alarms/tui-presence.js +38 -0
- package/dist/cli.js +113 -5
- package/dist/player/backend-install.js +2 -1
- package/dist/player/command-diagnostics.js +27 -0
- package/dist/player/command.js +123 -62
- package/dist/player/player-controller.js +32 -2
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +36 -6
- package/dist/setup.js +462 -0
- package/dist/storage/store.js +262 -2
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +111 -26
- package/dist/ui/App.js +401 -67
- package/dist/ui/AppContent.js +24 -7
- package/dist/ui/adaptive-explore-layout.js +47 -0
- package/dist/ui/alarm-editor.js +174 -0
- package/dist/ui/alarm-tui-service.js +84 -0
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/ascii.js +8 -0
- package/dist/ui/components/AdaptiveMarquee.js +28 -0
- package/dist/ui/components/StationList.js +10 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/cosmo-world-map.js +5 -2
- package/dist/ui/explore-map-layout.js +18 -6
- package/dist/ui/format.js +21 -0
- package/dist/ui/help-content.js +14 -2
- package/dist/ui/layout.js +1 -1
- package/dist/ui/page-footer.js +120 -2
- package/dist/ui/receiver-animation.js +68 -0
- package/dist/ui/screen-items.js +41 -9
- package/dist/ui/screen-meta.js +4 -0
- package/dist/ui/screens/AlarmsScreen.js +202 -0
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +8 -3
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/NowPlayingScreen.js +6 -2
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/screens/StationScreen.js +3 -2
- package/dist/ui/selection-state.js +10 -0
- package/dist/ui/terminal-mouse.js +18 -3
- package/dist/ui/use-alarm-tui.js +727 -0
- package/dist/ui/use-app-input.js +107 -46
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +14 -7
- package/dist/ui/visualizers/receiver-visualizers.js +233 -128
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +26 -7
- package/package.json +6 -1
package/dist/player/command.js
CHANGED
|
@@ -1,69 +1,93 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import { accessSync, constants, existsSync } from 'node:fs';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
|
-
import {
|
|
4
|
+
import { posix, win32 } from 'node:path';
|
|
4
5
|
export function commandExists(command) {
|
|
5
6
|
return resolveCommand(command) !== null;
|
|
6
7
|
}
|
|
7
8
|
// Resolve a runnable path for a command. GUI-launched terminals (macOS .app
|
|
8
9
|
// terminals, some Linux desktop launchers) frequently do not inherit the login
|
|
9
|
-
// shell PATH,
|
|
10
|
-
// package-manager shim looks "missing" to a bare PATH lookup. We therefore fall
|
|
11
|
-
// back to probing well-known install locations before giving up.
|
|
10
|
+
// shell PATH, while Windows installers often omit PATH entries entirely.
|
|
12
11
|
export function resolveCommand(command) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
return resolveCommandDetails(command).path;
|
|
13
|
+
}
|
|
14
|
+
export function resolveCommandDetails(command, overrides) {
|
|
15
|
+
if (!overrides) {
|
|
16
|
+
const cached = commandCache.get(command);
|
|
17
|
+
if (cached && Date.now() - cached.checkedAt < commandCacheTtlMs)
|
|
18
|
+
return cached.resolution;
|
|
16
19
|
}
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const platform = overrides?.platform ?? process.platform;
|
|
21
|
+
const options = {
|
|
22
|
+
platform,
|
|
23
|
+
env: overrides?.env ?? process.env,
|
|
24
|
+
home: overrides?.home ?? homedir(),
|
|
25
|
+
isRunnable: overrides?.isRunnable ?? (path => isRunnable(path, platform)),
|
|
26
|
+
registryPaths: overrides?.registryPaths ?? (name => windowsRegistryPaths(name, platform))
|
|
27
|
+
};
|
|
28
|
+
const resolution = resolveUncached(command, options);
|
|
29
|
+
if (!overrides)
|
|
30
|
+
commandCache.set(command, { resolution, checkedAt: Date.now() });
|
|
31
|
+
return resolution;
|
|
20
32
|
}
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
function resolveUncached(command, options) {
|
|
34
|
+
const pathMatch = lookupOnPath(command, options);
|
|
35
|
+
if (pathMatch)
|
|
36
|
+
return { path: pathMatch, discovery: 'path' };
|
|
37
|
+
const shimMatch = firstRunnable(knownBinaryCandidates(command, options), options.isRunnable);
|
|
38
|
+
if (shimMatch)
|
|
39
|
+
return { path: shimMatch, discovery: 'package-manager-shim' };
|
|
40
|
+
const appMatch = firstRunnable(applicationCandidates(command, options), options.isRunnable);
|
|
41
|
+
if (appMatch)
|
|
42
|
+
return { path: appMatch, discovery: 'application-directory' };
|
|
43
|
+
if (options.platform === 'win32') {
|
|
44
|
+
const registryMatch = firstRunnable(options.registryPaths(command), options.isRunnable);
|
|
45
|
+
if (registryMatch)
|
|
46
|
+
return { path: registryMatch, discovery: 'windows-registry' };
|
|
24
47
|
}
|
|
25
|
-
|
|
48
|
+
return { path: null, discovery: 'missing' };
|
|
49
|
+
}
|
|
50
|
+
function lookupOnPath(command, options) {
|
|
51
|
+
if (command.includes('/') || command.includes('\\'))
|
|
52
|
+
return options.isRunnable(command) ? command : null;
|
|
53
|
+
const pathApi = options.platform === 'win32' ? win32 : posix;
|
|
54
|
+
const separator = options.platform === 'win32' ? ';' : ':';
|
|
55
|
+
const pathEntries = (options.env.PATH ?? '').split(separator).filter(Boolean);
|
|
26
56
|
for (const directory of pathEntries) {
|
|
27
|
-
for (const name of candidateNames(command)) {
|
|
28
|
-
const candidate = join(directory.replace(/^"|"$/g, ''), name);
|
|
29
|
-
if (isRunnable(candidate))
|
|
57
|
+
for (const name of candidateNames(command, options.platform, options.env)) {
|
|
58
|
+
const candidate = pathApi.join(directory.replace(/^"|"$/g, ''), name);
|
|
59
|
+
if (options.isRunnable(candidate))
|
|
30
60
|
return candidate;
|
|
31
61
|
}
|
|
32
62
|
}
|
|
33
63
|
return null;
|
|
34
64
|
}
|
|
35
|
-
function
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
function candidatePaths(command) {
|
|
44
|
-
const paths = [];
|
|
45
|
-
const names = candidateNames(command);
|
|
46
|
-
for (const dir of knownBinaryDirs()) {
|
|
47
|
-
for (const name of names) {
|
|
48
|
-
paths.push(join(dir, name));
|
|
65
|
+
function knownBinaryCandidates(command, options) {
|
|
66
|
+
const pathApi = options.platform === 'win32' ? win32 : posix;
|
|
67
|
+
const candidates = [];
|
|
68
|
+
for (const directory of knownBinaryDirs(options)) {
|
|
69
|
+
for (const name of candidateNames(command, options.platform, options.env)) {
|
|
70
|
+
candidates.push(pathApi.join(directory, name));
|
|
49
71
|
}
|
|
50
72
|
}
|
|
51
|
-
|
|
52
|
-
return paths;
|
|
73
|
+
return candidates;
|
|
53
74
|
}
|
|
54
|
-
function candidateNames(command) {
|
|
55
|
-
if (
|
|
56
|
-
const extensions = (
|
|
75
|
+
function candidateNames(command, platform, env) {
|
|
76
|
+
if (platform === 'win32' && !/\.[a-z0-9]+$/i.test(command)) {
|
|
77
|
+
const extensions = (env.PATHEXT ?? '.EXE;.COM;.BAT;.CMD').split(';').filter(Boolean);
|
|
57
78
|
return [...extensions.map(extension => `${command}${extension.toLowerCase()}`), command];
|
|
58
79
|
}
|
|
59
80
|
return [command];
|
|
60
81
|
}
|
|
61
82
|
const commandCacheTtlMs = 5000;
|
|
62
83
|
const commandCache = new Map();
|
|
63
|
-
function
|
|
84
|
+
export function clearCommandCache() {
|
|
85
|
+
commandCache.clear();
|
|
86
|
+
}
|
|
87
|
+
function isRunnable(path, platform) {
|
|
64
88
|
if (!existsSync(path))
|
|
65
89
|
return false;
|
|
66
|
-
if (
|
|
90
|
+
if (platform === 'win32')
|
|
67
91
|
return true;
|
|
68
92
|
try {
|
|
69
93
|
accessSync(path, constants.X_OK);
|
|
@@ -73,20 +97,17 @@ function isRunnable(path) {
|
|
|
73
97
|
return false;
|
|
74
98
|
}
|
|
75
99
|
}
|
|
76
|
-
function knownBinaryDirs() {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', join(home, '.local', 'bin')];
|
|
100
|
+
function knownBinaryDirs({ platform, env, home }) {
|
|
101
|
+
if (platform === 'darwin') {
|
|
102
|
+
return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/opt/local/bin', '/sw/bin', posix.join(home, '.local', 'bin')];
|
|
80
103
|
}
|
|
81
|
-
if (
|
|
104
|
+
if (platform === 'win32') {
|
|
82
105
|
const dirs = [];
|
|
83
|
-
if (
|
|
84
|
-
dirs.push(join(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
dirs.push(join(process.env.ProgramData, 'chocolatey', 'bin'));
|
|
89
|
-
}
|
|
106
|
+
if (env.LOCALAPPDATA)
|
|
107
|
+
dirs.push(win32.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Links'));
|
|
108
|
+
dirs.push(win32.join(home, 'scoop', 'shims'));
|
|
109
|
+
if (env.ProgramData)
|
|
110
|
+
dirs.push(win32.join(env.ProgramData, 'chocolatey', 'bin'));
|
|
90
111
|
return dirs;
|
|
91
112
|
}
|
|
92
113
|
return [
|
|
@@ -96,24 +117,64 @@ function knownBinaryDirs() {
|
|
|
96
117
|
'/usr/local/sbin',
|
|
97
118
|
'/snap/bin',
|
|
98
119
|
'/var/lib/flatpak/exports/bin',
|
|
99
|
-
join(home, '.local', 'bin')
|
|
120
|
+
posix.join(home, '.local', 'bin')
|
|
100
121
|
];
|
|
101
122
|
}
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
function
|
|
105
|
-
if (command
|
|
106
|
-
return [];
|
|
107
|
-
}
|
|
108
|
-
if (process.platform === 'darwin') {
|
|
123
|
+
// Media-player installers commonly skip PATH. Probe only their documented,
|
|
124
|
+
// narrow install locations so startup stays fast and filesystem access bounded.
|
|
125
|
+
function applicationCandidates(command, { platform, env, home }) {
|
|
126
|
+
if ((command === 'vlc' || command === 'cvlc') && platform === 'darwin') {
|
|
109
127
|
return [
|
|
110
128
|
'/Applications/VLC.app/Contents/MacOS/VLC',
|
|
111
|
-
join(
|
|
129
|
+
posix.join(home, 'Applications', 'VLC.app', 'Contents', 'MacOS', 'VLC')
|
|
112
130
|
];
|
|
113
131
|
}
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
132
|
+
if (platform !== 'win32')
|
|
133
|
+
return [];
|
|
134
|
+
if (command === 'vlc' || command === 'cvlc') {
|
|
135
|
+
return windowsProgramRoots(env).map(root => win32.join(root, 'VideoLAN', 'VLC', 'vlc.exe'));
|
|
136
|
+
}
|
|
137
|
+
if (command === 'mpv') {
|
|
138
|
+
const candidates = windowsProgramRoots(env).flatMap(root => [
|
|
139
|
+
win32.join(root, 'MPV Player', 'mpv.exe'),
|
|
140
|
+
win32.join(root, 'MPV', 'mpv.exe'),
|
|
141
|
+
win32.join(root, 'mpv', 'mpv.exe')
|
|
142
|
+
]);
|
|
143
|
+
if (env.LOCALAPPDATA) {
|
|
144
|
+
candidates.push(win32.join(env.LOCALAPPDATA, 'Programs', 'MPV Player', 'mpv.exe'), win32.join(env.LOCALAPPDATA, 'Programs', 'MPV', 'mpv.exe'), win32.join(env.LOCALAPPDATA, 'mpv', 'mpv.exe'));
|
|
145
|
+
}
|
|
146
|
+
return candidates;
|
|
117
147
|
}
|
|
118
148
|
return [];
|
|
119
149
|
}
|
|
150
|
+
function windowsProgramRoots(env) {
|
|
151
|
+
return [env.ProgramFiles, env['ProgramFiles(x86)']].filter((root) => Boolean(root));
|
|
152
|
+
}
|
|
153
|
+
function windowsRegistryPaths(command, platform) {
|
|
154
|
+
if (platform !== 'win32' || command !== 'mpv')
|
|
155
|
+
return [];
|
|
156
|
+
const paths = [
|
|
157
|
+
queryRegistryPath('HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\mpv.exe', '/ve'),
|
|
158
|
+
queryRegistryPath('HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\mpv.exe', '/ve'),
|
|
159
|
+
queryRegistryPath('HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\MPV_Player_Automated_is1', '/v', 'InstallLocation')
|
|
160
|
+
];
|
|
161
|
+
return paths.flatMap(path => path ? [path.toLowerCase().endsWith('.exe') ? path : win32.join(path, 'mpv.exe')] : []);
|
|
162
|
+
}
|
|
163
|
+
function queryRegistryPath(key, ...args) {
|
|
164
|
+
const result = spawnSync('reg.exe', ['query', key, ...args], {
|
|
165
|
+
encoding: 'utf8',
|
|
166
|
+
timeout: 1000,
|
|
167
|
+
windowsHide: true
|
|
168
|
+
});
|
|
169
|
+
if (result.error || result.status !== 0)
|
|
170
|
+
return null;
|
|
171
|
+
const match = result.stdout.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/m);
|
|
172
|
+
return match?.[1]?.trim() || null;
|
|
173
|
+
}
|
|
174
|
+
function firstRunnable(paths, isRunnablePath) {
|
|
175
|
+
for (const path of paths) {
|
|
176
|
+
if (isRunnablePath(path))
|
|
177
|
+
return path;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
@@ -50,6 +50,7 @@ export class PlayerController {
|
|
|
50
50
|
mpvVolumeFlush = null;
|
|
51
51
|
confirmedMpvVolume = 70;
|
|
52
52
|
mpvSessionId = 0;
|
|
53
|
+
stopPromise = null;
|
|
53
54
|
constructor(getSettings) {
|
|
54
55
|
this.getSettings = getSettings;
|
|
55
56
|
}
|
|
@@ -229,6 +230,20 @@ export class PlayerController {
|
|
|
229
230
|
});
|
|
230
231
|
return { ok: true };
|
|
231
232
|
}
|
|
233
|
+
async pause() {
|
|
234
|
+
if (this.state.state === 'paused')
|
|
235
|
+
return { ok: true };
|
|
236
|
+
if (this.state.state !== 'playing')
|
|
237
|
+
return { ok: false, message: 'RadioCLI is not currently playing.' };
|
|
238
|
+
return this.togglePause();
|
|
239
|
+
}
|
|
240
|
+
async resume() {
|
|
241
|
+
if (this.state.state === 'playing')
|
|
242
|
+
return { ok: true };
|
|
243
|
+
if (this.state.state !== 'paused')
|
|
244
|
+
return { ok: false, message: 'RadioCLI has no paused station to resume.' };
|
|
245
|
+
return this.togglePause();
|
|
246
|
+
}
|
|
232
247
|
setVolume(volume) {
|
|
233
248
|
const clamped = clampVolume(volume);
|
|
234
249
|
const unsupported = this.unsupportedFfplayControl();
|
|
@@ -271,12 +286,27 @@ export class PlayerController {
|
|
|
271
286
|
this.setState({ ...this.state, muted });
|
|
272
287
|
return { ok: true };
|
|
273
288
|
}
|
|
274
|
-
async
|
|
289
|
+
async setMuted(muted) {
|
|
290
|
+
if (this.state.muted === muted)
|
|
291
|
+
return { ok: true };
|
|
292
|
+
return this.toggleMute();
|
|
293
|
+
}
|
|
294
|
+
stop() {
|
|
295
|
+
if (this.stopPromise)
|
|
296
|
+
return this.stopPromise;
|
|
297
|
+
const operation = this.performStop();
|
|
298
|
+
this.stopPromise = operation.finally(() => {
|
|
299
|
+
this.stopPromise = null;
|
|
300
|
+
});
|
|
301
|
+
return this.stopPromise;
|
|
302
|
+
}
|
|
303
|
+
async performStop() {
|
|
275
304
|
const child = this.process;
|
|
305
|
+
const startupInProgress = this.state.state === 'loading' && !this.state.ready;
|
|
276
306
|
this.stopMpvPolling();
|
|
277
307
|
this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
|
|
278
308
|
this.rejectPendingAirPlayRetune(new Error('AirPlay playback stopped.'));
|
|
279
|
-
if (this.backend === 'mpv') {
|
|
309
|
+
if (this.backend === 'mpv' && !startupInProgress) {
|
|
280
310
|
await this.sendMpv({ command: ['quit'] }).catch(() => undefined);
|
|
281
311
|
}
|
|
282
312
|
else if (this.backend === 'airplay') {
|
|
@@ -16,6 +16,11 @@ export class ProviderManager {
|
|
|
16
16
|
byCountry(countryCode, limit, offset) {
|
|
17
17
|
return this.radioBrowser.byCountry(countryCode, limit, offset);
|
|
18
18
|
}
|
|
19
|
+
byId(provider, id) {
|
|
20
|
+
if (provider !== 'radio-browser')
|
|
21
|
+
return Promise.resolve(null);
|
|
22
|
+
return this.radioBrowser.byId(id);
|
|
23
|
+
}
|
|
19
24
|
nearby(location, limit) {
|
|
20
25
|
return this.radioBrowser.nearby(location, limit);
|
|
21
26
|
}
|
|
@@ -47,10 +47,15 @@ const locationSchema = z
|
|
|
47
47
|
.passthrough();
|
|
48
48
|
const geoAtlasLimit = 100_000;
|
|
49
49
|
const geoAtlasMaxAgeMs = 6 * 60 * 60 * 1000;
|
|
50
|
-
|
|
50
|
+
// The atlas is much larger than ordinary directory responses. A single shared
|
|
51
|
+
// 20-second deadline used to give each of four mirrors roughly five seconds to
|
|
52
|
+
// connect, download, and parse up to 100k rows. Use a size-aware per-attempt
|
|
53
|
+
// budget while retaining an overall bound and stale-cache fallback.
|
|
54
|
+
const geoAtlasAttemptTimeoutMs = 30_000;
|
|
55
|
+
const geoAtlasTotalTimeoutMs = 75_000;
|
|
51
56
|
class ProviderUnavailableError extends Error {
|
|
52
|
-
constructor(message) {
|
|
53
|
-
super(message);
|
|
57
|
+
constructor(message, cause) {
|
|
58
|
+
super(message, cause ? { cause } : undefined);
|
|
54
59
|
this.name = 'ProviderUnavailableError';
|
|
55
60
|
}
|
|
56
61
|
}
|
|
@@ -110,6 +115,10 @@ export class RadioBrowserProvider {
|
|
|
110
115
|
}, { maxAgeMs: 30 * 60 * 1000 });
|
|
111
116
|
return this.normalizeStations(rows);
|
|
112
117
|
}
|
|
118
|
+
async byId(id) {
|
|
119
|
+
const rows = await this.request(`/json/stations/byuuid/${encodeURIComponent(id)}`, {}, { maxAgeMs: 10 * 60 * 1000 });
|
|
120
|
+
return this.normalizeStations(rows)[0] ?? null;
|
|
121
|
+
}
|
|
113
122
|
async search(query, options = {}) {
|
|
114
123
|
const trimmed = query.trim();
|
|
115
124
|
if (!trimmed) {
|
|
@@ -289,7 +298,11 @@ export class RadioBrowserProvider {
|
|
|
289
298
|
has_geo_info: 'true',
|
|
290
299
|
limit: String(geoAtlasLimit),
|
|
291
300
|
order: 'name'
|
|
292
|
-
}, {
|
|
301
|
+
}, {
|
|
302
|
+
maxAgeMs: geoAtlasMaxAgeMs,
|
|
303
|
+
timeoutMs: geoAtlasTotalTimeoutMs,
|
|
304
|
+
attemptTimeoutMs: geoAtlasAttemptTimeoutMs
|
|
305
|
+
});
|
|
293
306
|
return dedupeStations(this.normalizeStations(rows)).filter(hasValidCoordinates);
|
|
294
307
|
}
|
|
295
308
|
async request(path, params = {}, options = {}) {
|
|
@@ -316,7 +329,8 @@ export class RadioBrowserProvider {
|
|
|
316
329
|
}
|
|
317
330
|
try {
|
|
318
331
|
const mirrorsLeft = mirrors.length - mirrorIndex;
|
|
319
|
-
const
|
|
332
|
+
const fairShareTimeoutMs = Math.max(1, Math.floor(remainingMs / mirrorsLeft));
|
|
333
|
+
const attemptTimeoutMs = Math.max(1, Math.min(remainingMs, options.attemptTimeoutMs ?? fairShareTimeoutMs));
|
|
320
334
|
const value = await fetchJsonWithTimeout(url, attemptTimeoutMs);
|
|
321
335
|
this.activeBaseUrl = baseUrl;
|
|
322
336
|
if (options.maxAgeMs) {
|
|
@@ -338,8 +352,24 @@ export class RadioBrowserProvider {
|
|
|
338
352
|
if (stale) {
|
|
339
353
|
return stale;
|
|
340
354
|
}
|
|
341
|
-
throw new ProviderUnavailableError(
|
|
355
|
+
throw new ProviderUnavailableError(`${this.label} unavailable: ${describeRequestError(lastError)}`, lastError ?? undefined);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function describeRequestError(error) {
|
|
359
|
+
if (!error)
|
|
360
|
+
return 'request failed';
|
|
361
|
+
const cause = error.cause;
|
|
362
|
+
if (cause && typeof cause === 'object') {
|
|
363
|
+
const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : null;
|
|
364
|
+
const message = 'message' in cause && typeof cause.message === 'string' ? cause.message : null;
|
|
365
|
+
if (code && message)
|
|
366
|
+
return `${error.message} (${code}: ${message})`;
|
|
367
|
+
if (code)
|
|
368
|
+
return `${error.message} (${code})`;
|
|
369
|
+
if (message && message !== error.message)
|
|
370
|
+
return `${error.message} (${message})`;
|
|
342
371
|
}
|
|
372
|
+
return error.message || 'request failed';
|
|
343
373
|
}
|
|
344
374
|
function defaultRadioBrowserMirrors() {
|
|
345
375
|
return [
|