@ciphore/radiocli 0.2.3 → 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 +77 -0
- package/CONTRIBUTING.md +36 -6
- package/README.md +54 -10
- package/dist/agent/headless-host.js +39 -17
- package/dist/agent/launcher.js +6 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/service.js +3 -3
- package/dist/agent/session.js +13 -29
- package/dist/alarms/active-session.js +9 -12
- 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 +56 -25
- 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 +103 -38
- 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 +203 -33
- package/dist/providers/cache.js +4 -26
- package/dist/providers/radio-browser.js +152 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +79 -151
- package/dist/storage/store.js +105 -52
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +33 -24
- package/dist/ui/App.js +173 -86
- package/dist/ui/AppContent.js +3 -3
- 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 +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- 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 +3 -3
- package/dist/ui/screen-items.js +2 -2
- 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/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +5 -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 +14 -7
- 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 +36 -21
- package/dist/ui/use-app-input.js +42 -22
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/update-check.js +8 -17
- package/package.json +1 -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() {
|
|
@@ -217,36 +228,30 @@ export class RadioBrowserProvider {
|
|
|
217
228
|
}
|
|
218
229
|
}
|
|
219
230
|
async detectLocation() {
|
|
220
|
-
const controller = new AbortController();
|
|
221
|
-
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
222
231
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
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
|
+
};
|
|
226
250
|
});
|
|
227
|
-
if (!response.ok) {
|
|
228
|
-
return null;
|
|
229
|
-
}
|
|
230
|
-
const parsed = locationSchema.parse(await response.json());
|
|
231
|
-
if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number') {
|
|
232
|
-
return null;
|
|
233
|
-
}
|
|
234
|
-
return {
|
|
235
|
-
city: parsed.city,
|
|
236
|
-
region: parsed.region,
|
|
237
|
-
country: parsed.country_name,
|
|
238
|
-
countryCode: parsed.country_code,
|
|
239
|
-
latitude: parsed.latitude,
|
|
240
|
-
longitude: parsed.longitude,
|
|
241
|
-
source: 'ipapi.co'
|
|
242
|
-
};
|
|
243
251
|
}
|
|
244
252
|
catch {
|
|
245
253
|
return null;
|
|
246
254
|
}
|
|
247
|
-
finally {
|
|
248
|
-
clearTimeout(timeout);
|
|
249
|
-
}
|
|
250
255
|
}
|
|
251
256
|
normalizeStations(rows) {
|
|
252
257
|
return rows
|
|
@@ -301,7 +306,8 @@ export class RadioBrowserProvider {
|
|
|
301
306
|
}, {
|
|
302
307
|
maxAgeMs: geoAtlasMaxAgeMs,
|
|
303
308
|
timeoutMs: geoAtlasTotalTimeoutMs,
|
|
304
|
-
attemptTimeoutMs: geoAtlasAttemptTimeoutMs
|
|
309
|
+
attemptTimeoutMs: geoAtlasAttemptTimeoutMs,
|
|
310
|
+
cacheOnly: networkPolicy().lowBandwidth
|
|
305
311
|
});
|
|
306
312
|
return dedupeStations(this.normalizeStations(rows)).filter(hasValidCoordinates);
|
|
307
313
|
}
|
|
@@ -313,10 +319,20 @@ export class RadioBrowserProvider {
|
|
|
313
319
|
return cached;
|
|
314
320
|
}
|
|
315
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
|
+
}
|
|
316
331
|
let lastError = null;
|
|
317
|
-
const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl);
|
|
318
332
|
const totalTimeoutMs = options.timeoutMs ?? 9000;
|
|
319
333
|
const deadline = Date.now() + totalTimeoutMs;
|
|
334
|
+
const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl);
|
|
335
|
+
let discoveryAttempted = !this.discoverMirrors;
|
|
320
336
|
for (let mirrorIndex = 0; mirrorIndex < mirrors.length; mirrorIndex += 1) {
|
|
321
337
|
const baseUrl = mirrors[mirrorIndex];
|
|
322
338
|
const remainingMs = deadline - Date.now();
|
|
@@ -328,10 +344,10 @@ export class RadioBrowserProvider {
|
|
|
328
344
|
url.searchParams.set(key, value);
|
|
329
345
|
}
|
|
330
346
|
try {
|
|
331
|
-
const mirrorsLeft = mirrors.length - mirrorIndex;
|
|
347
|
+
const mirrorsLeft = mirrors.length - mirrorIndex + (discoveryAttempted ? 0 : 1);
|
|
332
348
|
const fairShareTimeoutMs = Math.max(1, Math.floor(remainingMs / mirrorsLeft));
|
|
333
349
|
const attemptTimeoutMs = Math.max(1, Math.min(remainingMs, options.attemptTimeoutMs ?? fairShareTimeoutMs));
|
|
334
|
-
const value = await fetchJsonWithTimeout(url, attemptTimeoutMs);
|
|
350
|
+
const value = await fetchJsonWithTimeout(url, attemptTimeoutMs, this.runtime);
|
|
335
351
|
this.activeBaseUrl = baseUrl;
|
|
336
352
|
if (options.maxAgeMs) {
|
|
337
353
|
try {
|
|
@@ -347,6 +363,15 @@ export class RadioBrowserProvider {
|
|
|
347
363
|
catch (error) {
|
|
348
364
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
349
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
|
+
}
|
|
350
375
|
}
|
|
351
376
|
const stale = this.cache.getStale(cacheKey);
|
|
352
377
|
if (stale) {
|
|
@@ -354,10 +379,25 @@ export class RadioBrowserProvider {
|
|
|
354
379
|
}
|
|
355
380
|
throw new ProviderUnavailableError(`${this.label} unavailable: ${describeRequestError(lastError)}`, lastError ?? undefined);
|
|
356
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
|
+
}
|
|
357
392
|
}
|
|
358
393
|
function describeRequestError(error) {
|
|
359
394
|
if (!error)
|
|
360
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})`;
|
|
361
401
|
const cause = error.cause;
|
|
362
402
|
if (cause && typeof cause === 'object') {
|
|
363
403
|
const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : null;
|
|
@@ -372,12 +412,7 @@ function describeRequestError(error) {
|
|
|
372
412
|
return error.message || 'request failed';
|
|
373
413
|
}
|
|
374
414
|
function defaultRadioBrowserMirrors() {
|
|
375
|
-
return [
|
|
376
|
-
'https://all.api.radio-browser.info',
|
|
377
|
-
'https://de1.api.radio-browser.info',
|
|
378
|
-
'https://nl1.api.radio-browser.info',
|
|
379
|
-
'https://at1.api.radio-browser.info'
|
|
380
|
-
];
|
|
415
|
+
return ['https://all.api.radio-browser.info'];
|
|
381
416
|
}
|
|
382
417
|
function preferActiveMirror(baseUrls, active) {
|
|
383
418
|
return [active, ...baseUrls.filter(url => url !== active)];
|
|
@@ -386,27 +421,90 @@ function buildCacheKey(path, params) {
|
|
|
386
421
|
const pairs = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
|
|
387
422
|
return `${path}?${new URLSearchParams(pairs).toString()}`;
|
|
388
423
|
}
|
|
389
|
-
async function fetchJsonWithTimeout(url, timeoutMs) {
|
|
390
|
-
const
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
});
|
|
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) => {
|
|
400
433
|
if (!response.ok) {
|
|
401
434
|
throw new Error(`Radio Browser request failed: ${response.status} ${response.statusText}`);
|
|
402
435
|
}
|
|
403
|
-
// Keep the abort timer alive while consuming the body as well as headers.
|
|
404
436
|
return (await response.json());
|
|
437
|
+
};
|
|
438
|
+
try {
|
|
439
|
+
return await withExternalResponse(url, { timeoutMs, init }, consume);
|
|
405
440
|
}
|
|
406
|
-
|
|
407
|
-
|
|
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);
|
|
408
454
|
}
|
|
409
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
|
+
}
|
|
410
508
|
export function dedupeStations(stations) {
|
|
411
509
|
const seen = new Set();
|
|
412
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
|
-
}
|