@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +84 -10
  4. package/dist/agent/alarm-service.js +210 -0
  5. package/dist/agent/cli.js +193 -0
  6. package/dist/agent/headless-host.js +165 -0
  7. package/dist/agent/launcher.js +10 -0
  8. package/dist/agent/mcp-install.js +467 -0
  9. package/dist/agent/mcp-server.js +139 -0
  10. package/dist/agent/service.js +347 -0
  11. package/dist/agent/session.js +232 -0
  12. package/dist/alarms/active-session.js +9 -12
  13. package/dist/alarms/cli.js +4 -1
  14. package/dist/alarms/guard.js +79 -36
  15. package/dist/alarms/inhibitor.js +27 -22
  16. package/dist/alarms/power-guard-store.js +2 -10
  17. package/dist/alarms/runner.js +96 -48
  18. package/dist/alarms/schedule.js +9 -2
  19. package/dist/alarms/scheduler.js +194 -52
  20. package/dist/alarms/setup-verification.js +1 -2
  21. package/dist/alarms/system-volume-ownership.js +267 -0
  22. package/dist/alarms/system-volume.js +155 -14
  23. package/dist/alarms/terminal-launcher.js +76 -136
  24. package/dist/alarms/tui-presence.js +2 -4
  25. package/dist/cli.js +157 -35
  26. package/dist/platform/capabilities.js +53 -0
  27. package/dist/platform/desktop.js +36 -0
  28. package/dist/{player/command.js → platform/executables.js} +43 -9
  29. package/dist/platform/ipc.js +14 -0
  30. package/dist/platform/launch-command.js +135 -0
  31. package/dist/platform/loopback.js +44 -0
  32. package/dist/platform/network.js +312 -0
  33. package/dist/platform/packages.js +216 -0
  34. package/dist/platform/paths.js +40 -0
  35. package/dist/platform/runtime.js +67 -0
  36. package/dist/platform/shell.js +24 -0
  37. package/dist/platform/storage.js +48 -0
  38. package/dist/platform/support.js +214 -0
  39. package/dist/platform/terminal.js +63 -0
  40. package/dist/platform/terminals.js +203 -0
  41. package/dist/player/airplay-discovery.js +4 -2
  42. package/dist/player/backend-install.js +13 -94
  43. package/dist/player/command-diagnostics.js +2 -2
  44. package/dist/player/mpv-ipc-client.js +2 -1
  45. package/dist/player/player-controller.js +220 -31
  46. package/dist/providers/cache.js +4 -26
  47. package/dist/providers/provider-manager.js +5 -0
  48. package/dist/providers/radio-browser.js +156 -54
  49. package/dist/providers/radio-garden.js +15 -22
  50. package/dist/setup.js +149 -152
  51. package/dist/storage/store.js +138 -53
  52. package/dist/streams/import-stream.js +163 -0
  53. package/dist/types.js +6 -0
  54. package/dist/ui/AdaptiveContent.js +54 -30
  55. package/dist/ui/App.js +466 -98
  56. package/dist/ui/AppContent.js +4 -4
  57. package/dist/ui/app-state.js +8 -1
  58. package/dist/ui/ascii.js +11 -2
  59. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  60. package/dist/ui/components/Logo.js +5 -2
  61. package/dist/ui/components/Menu.js +2 -2
  62. package/dist/ui/components/ScreenHeader.js +1 -1
  63. package/dist/ui/components/StationList.js +8 -8
  64. package/dist/ui/components/TopTabs.js +1 -1
  65. package/dist/ui/components/VersionIndicator.js +19 -0
  66. package/dist/ui/display-context.js +8 -11
  67. package/dist/ui/help-content.js +5 -5
  68. package/dist/ui/layout.js +5 -2
  69. package/dist/ui/page-footer.js +5 -3
  70. package/dist/ui/screen-items.js +42 -11
  71. package/dist/ui/screen-meta.js +1 -1
  72. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  73. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  74. package/dist/ui/screens/AlarmsScreen.js +2 -1
  75. package/dist/ui/screens/CountriesScreen.js +8 -5
  76. package/dist/ui/screens/ExploreScreen.js +2 -1
  77. package/dist/ui/screens/HelpScreen.js +5 -1
  78. package/dist/ui/screens/HomeScreen.js +7 -1
  79. package/dist/ui/screens/MapScreen.js +1 -1
  80. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  81. package/dist/ui/screens/SearchScreen.js +3 -1
  82. package/dist/ui/screens/SettingsScreen.js +80 -56
  83. package/dist/ui/screens/StatsScreen.js +3 -1
  84. package/dist/ui/system-actions.js +80 -52
  85. package/dist/ui/terminal-renderer.js +16 -0
  86. package/dist/ui/use-alarm-tui.js +40 -21
  87. package/dist/ui/use-app-input.js +69 -23
  88. package/dist/ui/use-command-executor.js +31 -7
  89. package/dist/ui/visualizers/gallop.js +118 -0
  90. package/dist/ui/visualizers/horse-stride.js +20 -0
  91. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  92. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  93. package/dist/ui/visualizers/retro-receivers.js +4 -0
  94. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  95. package/dist/update-check.js +33 -23
  96. 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. 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() {
@@ -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
- const response = await fetch('https://ipapi.co/json/', {
220
- signal: controller.signal,
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 controller = new AbortController();
387
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
388
- try {
389
- const response = await fetch(url, {
390
- signal: controller.signal,
391
- headers: {
392
- 'User-Agent': userAgent(' (+https://radio-browser.info)'),
393
- Accept: 'application/json'
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
- finally {
403
- 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);
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
- 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
- }