@ciphore/radiocli 0.2.2 → 0.2.4
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 +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -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 +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +96 -48
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +157 -35
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +5 -3
- package/dist/ui/screen-items.js +42 -11
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +80 -56
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/use-alarm-tui.js +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- 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 +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { resolveSrv } from 'node:dns/promises';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { ProviderCache } from './cache.js';
|
|
3
4
|
import { userAgent } from '../version.js';
|
|
4
5
|
import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
|
|
6
|
+
import { allowsDirectDnsFallback, fetchHttpsWithSystemResolver, networkPolicy, withExternalResponse } from '../platform/network.js';
|
|
5
7
|
const stationSchema = z.object({
|
|
6
8
|
stationuuid: z.string(),
|
|
7
9
|
name: z.string().default('Untitled station'),
|
|
@@ -47,12 +49,16 @@ const locationSchema = z
|
|
|
47
49
|
.passthrough();
|
|
48
50
|
const geoAtlasLimit = 100_000;
|
|
49
51
|
const geoAtlasMaxAgeMs = 6 * 60 * 60 * 1000;
|
|
50
|
-
// The atlas is much larger than ordinary directory responses.
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// budget while retaining an overall bound and stale-cache fallback.
|
|
52
|
+
// The atlas is much larger than ordinary directory responses. Give each live,
|
|
53
|
+
// discovered server enough time to connect, download, and parse up to 100k rows
|
|
54
|
+
// while retaining an overall bound and stale-cache fallback.
|
|
54
55
|
const geoAtlasAttemptTimeoutMs = 30_000;
|
|
55
56
|
const geoAtlasTotalTimeoutMs = 75_000;
|
|
57
|
+
const mirrorDiscoveryTimeoutMs = 750;
|
|
58
|
+
const radioBrowserService = '_api._tcp.radio-browser.info';
|
|
59
|
+
const radioBrowserApiSuffix = '.api.radio-browser.info';
|
|
60
|
+
const nameResolutionErrorCodes = new Set(['EAI_AGAIN', 'ENOTFOUND', 'ESERVFAIL', 'ETIMEOUT', 'EREFUSED']);
|
|
61
|
+
const defaultRuntime = {};
|
|
56
62
|
class ProviderUnavailableError extends Error {
|
|
57
63
|
constructor(message, cause) {
|
|
58
64
|
super(message, cause ? { cause } : undefined);
|
|
@@ -64,15 +70,20 @@ export function isProviderUnavailableError(error) {
|
|
|
64
70
|
}
|
|
65
71
|
export class RadioBrowserProvider {
|
|
66
72
|
cache;
|
|
73
|
+
runtime;
|
|
67
74
|
id = 'radio-browser';
|
|
68
75
|
label = 'Radio Browser';
|
|
69
76
|
baseUrls;
|
|
77
|
+
discoverMirrors;
|
|
70
78
|
activeBaseUrl;
|
|
71
79
|
geoAtlasPromise = null;
|
|
72
80
|
geoAtlasLoadedAt = 0;
|
|
73
|
-
|
|
81
|
+
mirrorDiscoveryPromise = null;
|
|
82
|
+
constructor(baseUrls = undefined, cache = new ProviderCache(), runtime = defaultRuntime) {
|
|
74
83
|
this.cache = cache;
|
|
75
|
-
this.
|
|
84
|
+
this.runtime = runtime;
|
|
85
|
+
this.discoverMirrors = baseUrls === undefined;
|
|
86
|
+
this.baseUrls = (baseUrls ?? defaultRadioBrowserMirrors()).map(url => url.replace(/\/$/, ''));
|
|
76
87
|
this.activeBaseUrl = this.baseUrls[0] ?? 'https://all.api.radio-browser.info';
|
|
77
88
|
}
|
|
78
89
|
async health() {
|
|
@@ -115,6 +126,10 @@ export class RadioBrowserProvider {
|
|
|
115
126
|
}, { maxAgeMs: 30 * 60 * 1000 });
|
|
116
127
|
return this.normalizeStations(rows);
|
|
117
128
|
}
|
|
129
|
+
async byId(id) {
|
|
130
|
+
const rows = await this.request(`/json/stations/byuuid/${encodeURIComponent(id)}`, {}, { maxAgeMs: 10 * 60 * 1000 });
|
|
131
|
+
return this.normalizeStations(rows)[0] ?? null;
|
|
132
|
+
}
|
|
118
133
|
async search(query, options = {}) {
|
|
119
134
|
const trimmed = query.trim();
|
|
120
135
|
if (!trimmed) {
|
|
@@ -213,36 +228,30 @@ export class RadioBrowserProvider {
|
|
|
213
228
|
}
|
|
214
229
|
}
|
|
215
230
|
async detectLocation() {
|
|
216
|
-
const controller = new AbortController();
|
|
217
|
-
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
218
231
|
try {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
headers: { 'User-Agent': userAgent() }
|
|
232
|
+
return await withExternalResponse('https://ipapi.co/json/', {
|
|
233
|
+
timeoutMs: 5000,
|
|
234
|
+
init: { headers: { 'User-Agent': userAgent() } }
|
|
235
|
+
}, async (response) => {
|
|
236
|
+
if (!response.ok)
|
|
237
|
+
return null;
|
|
238
|
+
const parsed = locationSchema.parse(await response.json());
|
|
239
|
+
if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number')
|
|
240
|
+
return null;
|
|
241
|
+
return {
|
|
242
|
+
city: parsed.city,
|
|
243
|
+
region: parsed.region,
|
|
244
|
+
country: parsed.country_name,
|
|
245
|
+
countryCode: parsed.country_code,
|
|
246
|
+
latitude: parsed.latitude,
|
|
247
|
+
longitude: parsed.longitude,
|
|
248
|
+
source: 'ipapi.co'
|
|
249
|
+
};
|
|
222
250
|
});
|
|
223
|
-
if (!response.ok) {
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
const parsed = locationSchema.parse(await response.json());
|
|
227
|
-
if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number') {
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
230
|
-
return {
|
|
231
|
-
city: parsed.city,
|
|
232
|
-
region: parsed.region,
|
|
233
|
-
country: parsed.country_name,
|
|
234
|
-
countryCode: parsed.country_code,
|
|
235
|
-
latitude: parsed.latitude,
|
|
236
|
-
longitude: parsed.longitude,
|
|
237
|
-
source: 'ipapi.co'
|
|
238
|
-
};
|
|
239
251
|
}
|
|
240
252
|
catch {
|
|
241
253
|
return null;
|
|
242
254
|
}
|
|
243
|
-
finally {
|
|
244
|
-
clearTimeout(timeout);
|
|
245
|
-
}
|
|
246
255
|
}
|
|
247
256
|
normalizeStations(rows) {
|
|
248
257
|
return rows
|
|
@@ -297,7 +306,8 @@ export class RadioBrowserProvider {
|
|
|
297
306
|
}, {
|
|
298
307
|
maxAgeMs: geoAtlasMaxAgeMs,
|
|
299
308
|
timeoutMs: geoAtlasTotalTimeoutMs,
|
|
300
|
-
attemptTimeoutMs: geoAtlasAttemptTimeoutMs
|
|
309
|
+
attemptTimeoutMs: geoAtlasAttemptTimeoutMs,
|
|
310
|
+
cacheOnly: networkPolicy().lowBandwidth
|
|
301
311
|
});
|
|
302
312
|
return dedupeStations(this.normalizeStations(rows)).filter(hasValidCoordinates);
|
|
303
313
|
}
|
|
@@ -309,10 +319,20 @@ export class RadioBrowserProvider {
|
|
|
309
319
|
return cached;
|
|
310
320
|
}
|
|
311
321
|
}
|
|
322
|
+
const policy = networkPolicy();
|
|
323
|
+
if (policy.offline || options.cacheOnly) {
|
|
324
|
+
const stale = options.maxAgeMs ? this.cache.getStale(cacheKey) : null;
|
|
325
|
+
if (stale !== null)
|
|
326
|
+
return stale;
|
|
327
|
+
throw new ProviderUnavailableError(policy.offline
|
|
328
|
+
? `${this.label} is offline: no cached response is available.`
|
|
329
|
+
: `${this.label} is in low-bandwidth mode: no cached atlas is available. Disable RADIOCLI_LOW_BANDWIDTH to download it.`);
|
|
330
|
+
}
|
|
312
331
|
let lastError = null;
|
|
313
|
-
const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl);
|
|
314
332
|
const totalTimeoutMs = options.timeoutMs ?? 9000;
|
|
315
333
|
const deadline = Date.now() + totalTimeoutMs;
|
|
334
|
+
const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl);
|
|
335
|
+
let discoveryAttempted = !this.discoverMirrors;
|
|
316
336
|
for (let mirrorIndex = 0; mirrorIndex < mirrors.length; mirrorIndex += 1) {
|
|
317
337
|
const baseUrl = mirrors[mirrorIndex];
|
|
318
338
|
const remainingMs = deadline - Date.now();
|
|
@@ -324,10 +344,10 @@ export class RadioBrowserProvider {
|
|
|
324
344
|
url.searchParams.set(key, value);
|
|
325
345
|
}
|
|
326
346
|
try {
|
|
327
|
-
const mirrorsLeft = mirrors.length - mirrorIndex;
|
|
347
|
+
const mirrorsLeft = mirrors.length - mirrorIndex + (discoveryAttempted ? 0 : 1);
|
|
328
348
|
const fairShareTimeoutMs = Math.max(1, Math.floor(remainingMs / mirrorsLeft));
|
|
329
349
|
const attemptTimeoutMs = Math.max(1, Math.min(remainingMs, options.attemptTimeoutMs ?? fairShareTimeoutMs));
|
|
330
|
-
const value = await fetchJsonWithTimeout(url, attemptTimeoutMs);
|
|
350
|
+
const value = await fetchJsonWithTimeout(url, attemptTimeoutMs, this.runtime);
|
|
331
351
|
this.activeBaseUrl = baseUrl;
|
|
332
352
|
if (options.maxAgeMs) {
|
|
333
353
|
try {
|
|
@@ -343,6 +363,15 @@ export class RadioBrowserProvider {
|
|
|
343
363
|
catch (error) {
|
|
344
364
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
345
365
|
}
|
|
366
|
+
if (!discoveryAttempted && mirrorIndex === mirrors.length - 1) {
|
|
367
|
+
discoveryAttempted = true;
|
|
368
|
+
const discoveryBudgetMs = Math.min(mirrorDiscoveryTimeoutMs, Math.max(1, deadline - Date.now()));
|
|
369
|
+
const discovered = await this.loadDiscoveredMirrors(discoveryBudgetMs);
|
|
370
|
+
for (const baseUrl of discovered) {
|
|
371
|
+
if (!mirrors.includes(baseUrl))
|
|
372
|
+
mirrors.push(baseUrl);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
346
375
|
}
|
|
347
376
|
const stale = this.cache.getStale(cacheKey);
|
|
348
377
|
if (stale) {
|
|
@@ -350,10 +379,25 @@ export class RadioBrowserProvider {
|
|
|
350
379
|
}
|
|
351
380
|
throw new ProviderUnavailableError(`${this.label} unavailable: ${describeRequestError(lastError)}`, lastError ?? undefined);
|
|
352
381
|
}
|
|
382
|
+
async loadDiscoveredMirrors(timeoutMs) {
|
|
383
|
+
const discovery = this.mirrorDiscoveryPromise ?? settleWithin(discoverRadioBrowserMirrors(this.runtime), timeoutMs, []);
|
|
384
|
+
this.mirrorDiscoveryPromise = discovery;
|
|
385
|
+
const mirrors = await discovery;
|
|
386
|
+
// Empty discovery commonly means a temporary DNS failure or timeout. Do
|
|
387
|
+
// not poison this provider instance for the remainder of the process.
|
|
388
|
+
if (mirrors.length === 0 && this.mirrorDiscoveryPromise === discovery)
|
|
389
|
+
this.mirrorDiscoveryPromise = null;
|
|
390
|
+
return mirrors;
|
|
391
|
+
}
|
|
353
392
|
}
|
|
354
393
|
function describeRequestError(error) {
|
|
355
394
|
if (!error)
|
|
356
395
|
return 'request failed';
|
|
396
|
+
const nestedCode = findErrorCode(error);
|
|
397
|
+
if (nestedCode === 'EAI_AGAIN')
|
|
398
|
+
return 'temporary DNS lookup failed (EAI_AGAIN)';
|
|
399
|
+
if (nestedCode && nameResolutionErrorCodes.has(nestedCode))
|
|
400
|
+
return `DNS lookup failed (${nestedCode})`;
|
|
357
401
|
const cause = error.cause;
|
|
358
402
|
if (cause && typeof cause === 'object') {
|
|
359
403
|
const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : null;
|
|
@@ -368,12 +412,7 @@ function describeRequestError(error) {
|
|
|
368
412
|
return error.message || 'request failed';
|
|
369
413
|
}
|
|
370
414
|
function defaultRadioBrowserMirrors() {
|
|
371
|
-
return [
|
|
372
|
-
'https://all.api.radio-browser.info',
|
|
373
|
-
'https://de1.api.radio-browser.info',
|
|
374
|
-
'https://nl1.api.radio-browser.info',
|
|
375
|
-
'https://at1.api.radio-browser.info'
|
|
376
|
-
];
|
|
415
|
+
return ['https://all.api.radio-browser.info'];
|
|
377
416
|
}
|
|
378
417
|
function preferActiveMirror(baseUrls, active) {
|
|
379
418
|
return [active, ...baseUrls.filter(url => url !== active)];
|
|
@@ -382,27 +421,90 @@ function buildCacheKey(path, params) {
|
|
|
382
421
|
const pairs = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
|
|
383
422
|
return `${path}?${new URLSearchParams(pairs).toString()}`;
|
|
384
423
|
}
|
|
385
|
-
async function fetchJsonWithTimeout(url, timeoutMs) {
|
|
386
|
-
const
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
});
|
|
424
|
+
async function fetchJsonWithTimeout(url, timeoutMs, runtime) {
|
|
425
|
+
const startedAt = Date.now();
|
|
426
|
+
const init = {
|
|
427
|
+
headers: {
|
|
428
|
+
'User-Agent': userAgent(' (+https://radio-browser.info)'),
|
|
429
|
+
Accept: 'application/json'
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const consume = async (response) => {
|
|
396
433
|
if (!response.ok) {
|
|
397
434
|
throw new Error(`Radio Browser request failed: ${response.status} ${response.statusText}`);
|
|
398
435
|
}
|
|
399
|
-
// Keep the abort timer alive while consuming the body as well as headers.
|
|
400
436
|
return (await response.json());
|
|
437
|
+
};
|
|
438
|
+
try {
|
|
439
|
+
return await withExternalResponse(url, { timeoutMs, init }, consume);
|
|
401
440
|
}
|
|
402
|
-
|
|
403
|
-
|
|
441
|
+
catch (error) {
|
|
442
|
+
const remainingMs = timeoutMs - (Date.now() - startedAt);
|
|
443
|
+
if (remainingMs <= 0 ||
|
|
444
|
+
!isNameResolutionError(error) ||
|
|
445
|
+
!isRadioBrowserApiHostname(url.hostname) ||
|
|
446
|
+
!(runtime.allowsDirectDnsFallback ?? (target => allowsDirectDnsFallback(process.env, target)))(url)) {
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
return withExternalResponse(url, {
|
|
450
|
+
timeoutMs: remainingMs,
|
|
451
|
+
init,
|
|
452
|
+
fetchImpl: runtime.fetchWithSystemResolver ?? fetchHttpsWithSystemResolver
|
|
453
|
+
}, consume);
|
|
404
454
|
}
|
|
405
455
|
}
|
|
456
|
+
async function discoverRadioBrowserMirrors(runtime) {
|
|
457
|
+
const records = await (runtime.resolveSrv ?? resolveSrv)(radioBrowserService);
|
|
458
|
+
return dedupeUrls(records
|
|
459
|
+
.filter(record => record.port === 443)
|
|
460
|
+
.sort((a, b) => a.priority - b.priority || b.weight - a.weight || a.name.localeCompare(b.name))
|
|
461
|
+
.flatMap(record => {
|
|
462
|
+
const hostname = record.name.replace(/\.$/, '').toLowerCase();
|
|
463
|
+
return isRadioBrowserApiHostname(hostname) ? [`https://${hostname}`] : [];
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
function isRadioBrowserApiHostname(hostname) {
|
|
467
|
+
const normalized = hostname.toLowerCase();
|
|
468
|
+
return normalized === 'api.radio-browser.info' || normalized.endsWith(radioBrowserApiSuffix);
|
|
469
|
+
}
|
|
470
|
+
function isNameResolutionError(error) {
|
|
471
|
+
const code = findErrorCode(error);
|
|
472
|
+
return code !== null && nameResolutionErrorCodes.has(code);
|
|
473
|
+
}
|
|
474
|
+
function findErrorCode(error) {
|
|
475
|
+
const seen = new Set();
|
|
476
|
+
const pending = [error];
|
|
477
|
+
while (pending.length > 0) {
|
|
478
|
+
const value = pending.pop();
|
|
479
|
+
if (!value || seen.has(value))
|
|
480
|
+
continue;
|
|
481
|
+
seen.add(value);
|
|
482
|
+
if (typeof value === 'object') {
|
|
483
|
+
if ('code' in value && typeof value.code === 'string')
|
|
484
|
+
return value.code;
|
|
485
|
+
if ('cause' in value)
|
|
486
|
+
pending.push(value.cause);
|
|
487
|
+
if (value instanceof AggregateError)
|
|
488
|
+
pending.push(...value.errors);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
function dedupeUrls(urls) {
|
|
494
|
+
return [...new Set(urls)];
|
|
495
|
+
}
|
|
496
|
+
function settleWithin(promise, timeoutMs, fallback) {
|
|
497
|
+
return new Promise(resolve => {
|
|
498
|
+
const timeout = setTimeout(() => resolve(fallback), timeoutMs);
|
|
499
|
+
promise.then(value => {
|
|
500
|
+
clearTimeout(timeout);
|
|
501
|
+
resolve(value);
|
|
502
|
+
}, () => {
|
|
503
|
+
clearTimeout(timeout);
|
|
504
|
+
resolve(fallback);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
}
|
|
406
508
|
export function dedupeStations(stations) {
|
|
407
509
|
const seen = new Set();
|
|
408
510
|
const deduped = [];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { withExternalResponse } from '../platform/network.js';
|
|
2
3
|
const searchSchema = z
|
|
3
4
|
.object({
|
|
4
5
|
hits: z
|
|
@@ -27,13 +28,14 @@ export class RadioGardenProvider {
|
|
|
27
28
|
}
|
|
28
29
|
async health() {
|
|
29
30
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
return await withExternalResponse(`${this.baseUrl}/geo`, {
|
|
32
|
+
timeoutMs: 5000,
|
|
33
|
+
init: { headers: { 'User-Agent': 'radiocli/0.1' } }
|
|
34
|
+
}, response => {
|
|
35
|
+
if (response.status === 403)
|
|
36
|
+
return 'blocked by edge protection';
|
|
37
|
+
return response.ok ? 'online' : `${response.status} ${response.statusText}`;
|
|
32
38
|
});
|
|
33
|
-
if (response.status === 403) {
|
|
34
|
-
return 'blocked by edge protection';
|
|
35
|
-
}
|
|
36
|
-
return response.ok ? 'online' : `${response.status} ${response.statusText}`;
|
|
37
39
|
}
|
|
38
40
|
catch (error) {
|
|
39
41
|
return error instanceof Error ? error.message : 'unavailable';
|
|
@@ -42,13 +44,14 @@ export class RadioGardenProvider {
|
|
|
42
44
|
async search(query, options = {}) {
|
|
43
45
|
const url = new URL(`${this.baseUrl}/search`);
|
|
44
46
|
url.searchParams.set('q', query);
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
+
const parsed = await withExternalResponse(url, {
|
|
48
|
+
timeoutMs: 9000,
|
|
49
|
+
init: { headers: { 'User-Agent': 'radiocli/0.1', Accept: 'application/json' } }
|
|
50
|
+
}, async (response) => {
|
|
51
|
+
if (!response.ok)
|
|
52
|
+
throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`);
|
|
53
|
+
return searchSchema.parse(await response.json());
|
|
47
54
|
});
|
|
48
|
-
if (!response.ok) {
|
|
49
|
-
throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`);
|
|
50
|
-
}
|
|
51
|
-
const parsed = searchSchema.parse(await response.json());
|
|
52
55
|
return (parsed.hits ?? [])
|
|
53
56
|
.map(hit => hit._source)
|
|
54
57
|
.filter((source) => source?.type === 'channel' && Boolean(source.url))
|
|
@@ -75,13 +78,3 @@ export class RadioGardenProvider {
|
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
|
-
async function fetchWithTimeout(url, timeoutMs, init = {}) {
|
|
79
|
-
const controller = new AbortController();
|
|
80
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
81
|
-
try {
|
|
82
|
-
return await fetch(url, { ...init, signal: controller.signal });
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
clearTimeout(timeout);
|
|
86
|
-
}
|
|
87
|
-
}
|