@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. 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. 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.
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
- constructor(baseUrls = defaultRadioBrowserMirrors(), cache = new ProviderCache()) {
81
+ mirrorDiscoveryPromise = null;
82
+ constructor(baseUrls = undefined, cache = new ProviderCache(), runtime = defaultRuntime) {
74
83
  this.cache = cache;
75
- this.baseUrls = baseUrls.map(url => url.replace(/\/$/, ''));
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
- const response = await fetch('https://ipapi.co/json/', {
224
- signal: controller.signal,
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 controller = new AbortController();
391
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
392
- try {
393
- const response = await fetch(url, {
394
- signal: controller.signal,
395
- headers: {
396
- 'User-Agent': userAgent(' (+https://radio-browser.info)'),
397
- Accept: 'application/json'
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
- finally {
407
- clearTimeout(timeout);
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
- const response = await fetchWithTimeout(`${this.baseUrl}/geo`, 5000, {
31
- headers: { 'User-Agent': 'radiocli/0.1' }
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 response = await fetchWithTimeout(url, 9000, {
46
- headers: { 'User-Agent': 'radiocli/0.1', Accept: 'application/json' }
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
- }