@ciphore/radiocli 0.2.3 → 0.2.5

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 (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,7 +1,12 @@
1
+ import { resolveSrv } from 'node:dns/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
1
4
  import { z } from 'zod';
2
5
  import { ProviderCache } from './cache.js';
6
+ import { abortable, DiscoverySessions } from './discovery-session.js';
3
7
  import { userAgent } from '../version.js';
4
8
  import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
9
+ import { allowsDirectDnsFallback, fetchHttpsWithSystemResolver, networkPolicy, withExternalResponse } from '../platform/network.js';
5
10
  const stationSchema = z.object({
6
11
  stationuuid: z.string(),
7
12
  name: z.string().default('Untitled station'),
@@ -46,13 +51,19 @@ const locationSchema = z
46
51
  })
47
52
  .passthrough();
48
53
  const geoAtlasLimit = 100_000;
54
+ const geoAtlasMaxBytes = 32 * 1024 * 1024;
49
55
  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.
56
+ // The atlas is much larger than ordinary directory responses. Give each live,
57
+ // discovered server enough time to connect, download, and parse up to 100k rows
58
+ // while retaining an overall bound and stale-cache fallback.
54
59
  const geoAtlasAttemptTimeoutMs = 30_000;
55
60
  const geoAtlasTotalTimeoutMs = 75_000;
61
+ const mirrorDiscoveryTimeoutMs = 750;
62
+ const radioBrowserService = '_api._tcp.radio-browser.info';
63
+ const radioBrowserApiSuffix = '.api.radio-browser.info';
64
+ const nameResolutionErrorCodes = new Set(['EAI_AGAIN', 'ENOTFOUND', 'ESERVFAIL', 'ETIMEOUT', 'EREFUSED']);
65
+ const maxDiscoveryRoundsPerPage = 4;
66
+ const defaultRuntime = {};
56
67
  class ProviderUnavailableError extends Error {
57
68
  constructor(message, cause) {
58
69
  super(message, cause ? { cause } : undefined);
@@ -64,15 +75,23 @@ export function isProviderUnavailableError(error) {
64
75
  }
65
76
  export class RadioBrowserProvider {
66
77
  cache;
78
+ runtime;
67
79
  id = 'radio-browser';
68
80
  label = 'Radio Browser';
69
81
  baseUrls;
82
+ discoverMirrors;
70
83
  activeBaseUrl;
71
- geoAtlasPromise = null;
84
+ geoAtlas = null;
85
+ geoAtlasLoad = null;
72
86
  geoAtlasLoadedAt = 0;
73
- constructor(baseUrls = defaultRadioBrowserMirrors(), cache = new ProviderCache()) {
87
+ mirrorDiscoveryPromise = null;
88
+ discovery = new DiscoverySessions(state => state.bytes + state.seen.size * 128 + 1024);
89
+ pendingRequests = new Map();
90
+ constructor(baseUrls = undefined, cache = new ProviderCache(), runtime = defaultRuntime) {
74
91
  this.cache = cache;
75
- this.baseUrls = baseUrls.map(url => url.replace(/\/$/, ''));
92
+ this.runtime = runtime;
93
+ this.discoverMirrors = baseUrls === undefined;
94
+ this.baseUrls = (baseUrls ?? defaultRadioBrowserMirrors()).map(url => url.replace(/\/$/, ''));
76
95
  this.activeBaseUrl = this.baseUrls[0] ?? 'https://all.api.radio-browser.info';
77
96
  }
78
97
  async health() {
@@ -95,16 +114,16 @@ export class RadioBrowserProvider {
95
114
  .sort((a, b) => b.stationCount - a.stationCount || a.name.localeCompare(b.name))
96
115
  .slice(0, limit);
97
116
  }
98
- async popular(limit = 80) {
117
+ async popular(limit = 80, options = {}) {
99
118
  const rows = await this.request('/json/stations/search', {
100
119
  hidebroken: 'true',
101
120
  limit: String(limit),
102
121
  order: 'clickcount',
103
122
  reverse: 'true'
104
- }, { maxAgeMs: 15 * 60 * 1000 });
123
+ }, { maxAgeMs: 15 * 60 * 1000, signal: options.signal });
105
124
  return this.normalizeStations(rows);
106
125
  }
107
- async byCountry(countryCode, limit = 100, offset = 0) {
126
+ async byCountry(countryCode, limit = 100, offset = 0, options = {}) {
108
127
  const rows = await this.request('/json/stations/search', {
109
128
  countrycode: countryCode.toUpperCase(),
110
129
  hidebroken: 'true',
@@ -112,17 +131,115 @@ export class RadioBrowserProvider {
112
131
  offset: String(Math.max(0, offset)),
113
132
  order: 'clickcount',
114
133
  reverse: 'true'
115
- }, { maxAgeMs: 30 * 60 * 1000 });
134
+ }, { maxAgeMs: 30 * 60 * 1000, signal: options.signal });
116
135
  return this.normalizeStations(rows);
117
136
  }
137
+ byCountryPage(countryCode, options = {}) {
138
+ const limit = pageLimit(options.limit, 100);
139
+ const country = countryCode.trim().toUpperCase();
140
+ return this.discovery.page(JSON.stringify(['country', country, limit]), options.cursor, () => ({
141
+ query: country, tokens: [], filters: {}, sources: [discoverySource('country', { countrycode: country })],
142
+ buffer: [], seen: new Set(), initialized: true, country: true, bytes: 0
143
+ }), state => this.discoveryPage(state, limit, options.signal), options.signal);
144
+ }
145
+ searchPage(query, options = {}) {
146
+ const trimmed = query.trim();
147
+ const limit = pageLimit(options.limit, trimmed ? 60 : 80);
148
+ const filters = searchFilters(options);
149
+ return this.discovery.page(JSON.stringify(['search', trimmed, filters, limit]), options.cursor, () => ({
150
+ query: trimmed, tokens: trimmed.split(/\s+/).filter(token => token.length > 2).slice(0, 4), filters,
151
+ sources: trimmed ? [discoverySource('name', { name: trimmed }), discoverySource('tag', { tag: trimmed })] : [discoverySource('popular', {})],
152
+ buffer: [], seen: new Set(), initialized: !trimmed, country: false, bytes: 0
153
+ }), state => this.discoveryPage(state, limit, options.signal), options.signal);
154
+ }
155
+ async discoveryPage(state, limit, signal) {
156
+ signal?.throwIfAborted();
157
+ let successfulRequests = 0;
158
+ let requestError;
159
+ const load = async (sources) => {
160
+ const results = await Promise.allSettled(sources.map(source => this.request('/json/stations/search', {
161
+ hidebroken: 'true', limit: String(limit), offset: String(source.consumed), order: 'clickcount', reverse: 'true',
162
+ ...searchFilterParams(state.filters), ...source.params
163
+ }, { maxAgeMs: state.country ? 30 * 60 * 1000 : 10 * 60 * 1000, signal })));
164
+ signal?.throwIfAborted();
165
+ let rawCount = 0;
166
+ for (const [index, result] of results.entries()) {
167
+ const source = sources[index];
168
+ if (result.status === 'rejected' || !Array.isArray(result.value)) {
169
+ requestError = result.status === 'rejected' ? result.reason : new Error('Radio Browser returned an invalid station list.');
170
+ source.status = 'unavailable';
171
+ source.message = requestError instanceof Error ? requestError.message : String(requestError);
172
+ continue;
173
+ }
174
+ successfulRequests += 1;
175
+ const rows = result.value;
176
+ source.status = 'available';
177
+ source.message = undefined;
178
+ source.consumed += rows.length;
179
+ source.exhausted = rows.length < limit;
180
+ rawCount += rows.length;
181
+ for (const station of this.normalizeStations(rows)) {
182
+ const key = `${station.provider}:${station.id}`;
183
+ if (state.seen.has(key))
184
+ continue;
185
+ state.seen.add(key);
186
+ state.bytes += key.length * 2;
187
+ if (filterStations([station], state.filters).length === 0)
188
+ continue;
189
+ state.buffer.push(station);
190
+ state.bytes += JSON.stringify(station).length * 2;
191
+ }
192
+ }
193
+ return rawCount;
194
+ };
195
+ for (let round = 0; state.buffer.length < limit && round < maxDiscoveryRoundsPerPage; round += 1) {
196
+ const pending = state.sources.filter(source => !source.exhausted);
197
+ if (!pending.length)
198
+ break;
199
+ let rawCount = await load(pending);
200
+ if (!state.initialized) {
201
+ // Preserve the first-page broadening policy and relevance score. Each
202
+ // activated source subsequently keeps its own raw offset.
203
+ if (rawCount < Math.min(12, limit)) {
204
+ const secondary = [discoverySource('country', { country: state.query }), discoverySource('language', { language: state.query })];
205
+ state.sources.push(...secondary);
206
+ rawCount += await load(secondary);
207
+ }
208
+ if (rawCount === 0 && state.tokens.length > 1) {
209
+ const tokenSources = state.tokens.slice(0, 3).flatMap((token, index) => [
210
+ discoverySource(`token-${index}-name`, { name: token }), discoverySource(`token-${index}-tag`, { tag: token })
211
+ ]);
212
+ state.sources.push(...tokenSources);
213
+ await load(tokenSources);
214
+ }
215
+ state.initialized = true;
216
+ }
217
+ if (!state.country)
218
+ state.buffer.sort((left, right) => scoreStation(right, state.query, state.tokens) - scoreStation(left, state.query, state.tokens));
219
+ // Failures remain retryable on the next cursor; do not spend four full
220
+ // network deadlines retrying an unavailable source in a single call.
221
+ if (state.sources.some(source => source.status === 'unavailable'))
222
+ break;
223
+ }
224
+ if (!state.buffer.length && !successfulRequests && requestError)
225
+ throw requestError;
226
+ const items = state.buffer.splice(0, limit);
227
+ state.bytes -= items.reduce((sum, station) => sum + JSON.stringify(station).length * 2, 0);
228
+ return {
229
+ items,
230
+ exhausted: state.buffer.length === 0 && state.sources.every(source => source.exhausted),
231
+ sources: state.sources.map(({ params: _params, ...source }) => source)
232
+ };
233
+ }
118
234
  async byId(id) {
119
235
  const rows = await this.request(`/json/stations/byuuid/${encodeURIComponent(id)}`, {}, { maxAgeMs: 10 * 60 * 1000 });
120
236
  return this.normalizeStations(rows)[0] ?? null;
121
237
  }
122
238
  async search(query, options = {}) {
239
+ options.signal?.throwIfAborted();
123
240
  const trimmed = query.trim();
124
241
  if (!trimmed) {
125
- return this.popular(options.limit ?? 80);
242
+ return this.popular(options.limit ?? 80, { signal: options.signal });
126
243
  }
127
244
  const limit = options.limit ?? 60;
128
245
  const offset = Math.max(0, options.offset ?? 0);
@@ -136,28 +253,32 @@ export class RadioBrowserProvider {
136
253
  reverse: 'true',
137
254
  ...(options.codec ? { codec: options.codec } : {}),
138
255
  ...(options.language ? { language: options.language } : {}),
139
- ...(options.countryCode ? { countrycode: options.countryCode.toUpperCase() } : {})
256
+ ...(options.countryCode ? { countrycode: options.countryCode.toUpperCase() } : {}),
257
+ ...(options.minBitrate ? { bitrateMin: String(options.minBitrate) } : {})
140
258
  };
141
259
  const primary = await Promise.allSettled([
142
- this.request('/json/stations/search', { ...baseParams, name: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
143
- this.request('/json/stations/search', { ...baseParams, tag: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
260
+ this.request('/json/stations/search', { ...baseParams, name: trimmed }, { maxAgeMs: 10 * 60 * 1000, signal: options.signal }),
261
+ this.request('/json/stations/search', { ...baseParams, tag: trimmed }, { maxAgeMs: 10 * 60 * 1000, signal: options.signal })
144
262
  ]);
263
+ options.signal?.throwIfAborted();
145
264
  let attempts = [...primary];
146
265
  let rows = fulfilledRows(primary);
147
266
  if (rows.length < Math.min(12, limit + offset)) {
148
267
  const secondary = await Promise.allSettled([
149
- this.request('/json/stations/search', { ...baseParams, country: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
150
- this.request('/json/stations/search', { ...baseParams, language: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
268
+ this.request('/json/stations/search', { ...baseParams, country: trimmed }, { maxAgeMs: 10 * 60 * 1000, signal: options.signal }),
269
+ this.request('/json/stations/search', { ...baseParams, language: trimmed }, { maxAgeMs: 10 * 60 * 1000, signal: options.signal })
151
270
  ]);
271
+ options.signal?.throwIfAborted();
152
272
  attempts = [...attempts, ...secondary];
153
273
  rows.push(...fulfilledRows(secondary));
154
274
  }
155
275
  const tokens = trimmed.split(/\s+/).filter(token => token.length > 2).slice(0, 4);
156
276
  if (rows.length === 0 && tokens.length > 1) {
157
277
  const tokenResults = await Promise.allSettled(tokens.slice(0, 3).flatMap(token => [
158
- this.request('/json/stations/search', { ...baseParams, name: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
159
- this.request('/json/stations/search', { ...baseParams, tag: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 })
278
+ this.request('/json/stations/search', { ...baseParams, name: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000, signal: options.signal }),
279
+ this.request('/json/stations/search', { ...baseParams, tag: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000, signal: options.signal })
160
280
  ]));
281
+ options.signal?.throwIfAborted();
161
282
  attempts.push(...tokenResults);
162
283
  rows = fulfilledRows(tokenResults);
163
284
  }
@@ -170,11 +291,13 @@ export class RadioBrowserProvider {
170
291
  .sort((a, b) => scoreStation(b, trimmed, tokens) - scoreStation(a, trimmed, tokens))
171
292
  .slice(offset, offset + limit);
172
293
  }
173
- async nearby(location, limit = 80) {
174
- const stations = await this.geotaggedAtlas();
175
- return nearestStations(stations, location, limit);
294
+ async nearby(location, limit = 80, options = {}) {
295
+ options.signal?.throwIfAborted();
296
+ const stations = await this.geotaggedAtlas(options.signal);
297
+ return nearestStations(stations, location, limit, options.signal);
176
298
  }
177
- async resolve(station) {
299
+ async resolve(station, options = {}) {
300
+ options.signal?.throwIfAborted();
178
301
  if (station.provider !== this.id) {
179
302
  const streamUrl = station.streamUrl ? safeMediaTarget(station.streamUrl) : null;
180
303
  if (!streamUrl) {
@@ -185,13 +308,14 @@ export class RadioBrowserProvider {
185
308
  try {
186
309
  // Click URLs can rotate and the endpoint records directory engagement, so
187
310
  // do not reuse a day-old resolution from the general provider cache.
188
- const result = clickResolveSchema.parse(await this.request(`/json/url/${encodeURIComponent(station.id)}`, {}, { timeoutMs: 6000 }));
311
+ const result = clickResolveSchema.parse(await this.request(`/json/url/${encodeURIComponent(station.id)}`, {}, { timeoutMs: 6000, signal: options.signal }));
189
312
  const resolvedUrl = result.url ? safeMediaTarget(result.url) : null;
190
313
  if (result.ok && resolvedUrl) {
191
314
  return { url: resolvedUrl, name: sanitizeTerminalText(result.name) ?? station.name };
192
315
  }
193
316
  }
194
317
  catch {
318
+ options.signal?.throwIfAborted();
195
319
  // A directory outage should not discard a validated URL already supplied
196
320
  // with the station row.
197
321
  }
@@ -216,37 +340,33 @@ export class RadioBrowserProvider {
216
340
  return false;
217
341
  }
218
342
  }
219
- async detectLocation() {
220
- const controller = new AbortController();
221
- const timeout = setTimeout(() => controller.abort(), 5000);
343
+ async detectLocation(options = {}) {
344
+ options.signal?.throwIfAborted();
222
345
  try {
223
- const response = await fetch('https://ipapi.co/json/', {
224
- signal: controller.signal,
225
- headers: { 'User-Agent': userAgent() }
346
+ return await withExternalResponse('https://ipapi.co/json/', {
347
+ timeoutMs: 5000,
348
+ init: { headers: { 'User-Agent': userAgent() }, signal: options.signal }
349
+ }, async (response) => {
350
+ if (!response.ok)
351
+ return null;
352
+ const parsed = locationSchema.parse(await response.json());
353
+ if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number')
354
+ return null;
355
+ return {
356
+ city: parsed.city,
357
+ region: parsed.region,
358
+ country: parsed.country_name,
359
+ countryCode: parsed.country_code,
360
+ latitude: parsed.latitude,
361
+ longitude: parsed.longitude,
362
+ source: 'ipapi.co'
363
+ };
226
364
  });
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
365
  }
244
366
  catch {
367
+ options.signal?.throwIfAborted();
245
368
  return null;
246
369
  }
247
- finally {
248
- clearTimeout(timeout);
249
- }
250
370
  }
251
371
  normalizeStations(rows) {
252
372
  return rows
@@ -277,22 +397,43 @@ export class RadioBrowserProvider {
277
397
  lastCheckedOk: row.lastcheckok === 1 || row.lastcheckok === true
278
398
  }));
279
399
  }
280
- async geotaggedAtlas() {
400
+ async geotaggedAtlas(signal) {
401
+ signal?.throwIfAborted();
281
402
  const atlasExpired = this.geoAtlasLoadedAt > 0 && Date.now() - this.geoAtlasLoadedAt > geoAtlasMaxAgeMs;
282
- if (!this.geoAtlasPromise || atlasExpired) {
283
- this.geoAtlasPromise = this.loadGeotaggedAtlas()
284
- .then(stations => {
403
+ if (this.geoAtlas && !atlasExpired)
404
+ return this.geoAtlas;
405
+ let load = this.geoAtlasLoad;
406
+ if (!load) {
407
+ const controller = new AbortController();
408
+ load = { controller, subscribers: 0, settled: false, promise: Promise.resolve([]) };
409
+ const created = load;
410
+ this.geoAtlasLoad = created;
411
+ created.promise = this.loadGeotaggedAtlas(controller.signal).then(stations => {
412
+ controller.signal.throwIfAborted();
413
+ this.geoAtlas = stations;
285
414
  this.geoAtlasLoadedAt = Date.now();
286
415
  return stations;
287
416
  })
288
- .catch(error => {
289
- this.geoAtlasPromise = null;
290
- throw error;
417
+ .finally(() => {
418
+ created.settled = true;
419
+ if (this.geoAtlasLoad === created)
420
+ this.geoAtlasLoad = null;
291
421
  });
292
422
  }
293
- return this.geoAtlasPromise;
423
+ load.subscribers += 1;
424
+ try {
425
+ return await abortable(load.promise, signal);
426
+ }
427
+ finally {
428
+ load.subscribers -= 1;
429
+ if (load.subscribers === 0 && !load.settled) {
430
+ if (this.geoAtlasLoad === load)
431
+ this.geoAtlasLoad = null;
432
+ load.controller.abort();
433
+ }
434
+ }
294
435
  }
295
- async loadGeotaggedAtlas() {
436
+ async loadGeotaggedAtlas(signal) {
296
437
  const rows = await this.request('/json/stations/search', {
297
438
  hidebroken: 'true',
298
439
  has_geo_info: 'true',
@@ -301,23 +442,97 @@ export class RadioBrowserProvider {
301
442
  }, {
302
443
  maxAgeMs: geoAtlasMaxAgeMs,
303
444
  timeoutMs: geoAtlasTotalTimeoutMs,
304
- attemptTimeoutMs: geoAtlasAttemptTimeoutMs
445
+ attemptTimeoutMs: geoAtlasAttemptTimeoutMs,
446
+ cacheOnly: networkPolicy().lowBandwidth,
447
+ signal
305
448
  });
306
- return dedupeStations(this.normalizeStations(rows)).filter(hasValidCoordinates);
449
+ signal?.throwIfAborted();
450
+ if (!Array.isArray(rows) || rows.length > geoAtlasLimit)
451
+ throw new Error('Radio Browser atlas exceeded the supported row limit or returned an invalid response.');
452
+ // Normalize the full atlas in bounded chunks so keyboard and playback
453
+ // events can run between batches. Preserve the original row/dedup order.
454
+ const stations = [];
455
+ const seen = new Set();
456
+ let retainedBytes = 0;
457
+ const chunkSize = 250;
458
+ for (let offset = 0; offset < rows.length; offset += chunkSize) {
459
+ signal?.throwIfAborted();
460
+ for (const station of this.normalizeStations(rows.slice(offset, offset + chunkSize))) {
461
+ const key = `${station.provider}:${station.id}`;
462
+ // Dedupe before validating coordinates, matching ordinary normalization.
463
+ if (seen.has(key))
464
+ continue;
465
+ seen.add(key);
466
+ if (hasValidCoordinates(station)) {
467
+ retainedBytes += JSON.stringify(station).length * 2 + 128;
468
+ if (retainedBytes > geoAtlasMaxBytes)
469
+ throw new Error('Radio Browser atlas exceeded the local memory budget. Use country or search discovery.');
470
+ stations.push(station);
471
+ }
472
+ }
473
+ if (offset + chunkSize < rows.length)
474
+ await yieldToEventLoop();
475
+ }
476
+ return stations;
307
477
  }
308
478
  async request(path, params = {}, options = {}) {
479
+ options.signal?.throwIfAborted();
480
+ // Cacheable reads share work, but click/vote endpoints keep their effects.
481
+ // Cancellation belongs to each subscriber, never to cache identity.
482
+ const key = JSON.stringify([buildCacheKey(path, params), options.maxAgeMs, options.timeoutMs, options.attemptTimeoutMs, options.cacheOnly,
483
+ options.maxAgeMs ? undefined : randomUUID()]);
484
+ let pending = this.pendingRequests.get(key);
485
+ if (!pending) {
486
+ if (this.pendingRequests.size >= 64)
487
+ throw new ProviderUnavailableError('Radio Browser is busy. Retry after the current directory requests finish.');
488
+ const controller = new AbortController();
489
+ const operation = { controller, subscribers: 0, promise: Promise.resolve(undefined) };
490
+ operation.promise = this.performRequest(path, params, { ...options, signal: controller.signal }).finally(() => {
491
+ if (this.pendingRequests.get(key) === operation)
492
+ this.pendingRequests.delete(key);
493
+ });
494
+ pending = operation;
495
+ this.pendingRequests.set(key, pending);
496
+ }
497
+ pending.subscribers += 1;
498
+ try {
499
+ return await abortable(pending.promise, options.signal);
500
+ }
501
+ finally {
502
+ pending.subscribers -= 1;
503
+ if (pending.subscribers === 0 && this.pendingRequests.get(key) === pending) {
504
+ this.pendingRequests.delete(key);
505
+ pending.controller.abort(new DOMException('Directory request has no active callers.', 'AbortError'));
506
+ }
507
+ }
508
+ }
509
+ async performRequest(path, params, options) {
510
+ options.signal?.throwIfAborted();
309
511
  const cacheKey = buildCacheKey(path, params);
310
512
  if (options.maxAgeMs) {
311
- const cached = this.cache.get(cacheKey, options.maxAgeMs);
513
+ const cached = await abortable(this.cache.getAsync(cacheKey, options.maxAgeMs), options.signal);
514
+ options.signal?.throwIfAborted();
312
515
  if (cached) {
313
516
  return cached;
314
517
  }
315
518
  }
519
+ const policy = networkPolicy();
520
+ if (policy.offline || options.cacheOnly) {
521
+ const stale = options.maxAgeMs ? await abortable(this.cache.getStaleAsync(cacheKey), options.signal) : null;
522
+ options.signal?.throwIfAborted();
523
+ if (stale !== null)
524
+ return stale;
525
+ throw new ProviderUnavailableError(policy.offline
526
+ ? `${this.label} is offline: no cached response is available.`
527
+ : `${this.label} is in low-bandwidth mode: no cached atlas is available. Disable RADIOCLI_LOW_BANDWIDTH to download it.`);
528
+ }
316
529
  let lastError = null;
317
- const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl);
318
530
  const totalTimeoutMs = options.timeoutMs ?? 9000;
319
531
  const deadline = Date.now() + totalTimeoutMs;
532
+ const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl);
533
+ let discoveryAttempted = !this.discoverMirrors;
320
534
  for (let mirrorIndex = 0; mirrorIndex < mirrors.length; mirrorIndex += 1) {
535
+ options.signal?.throwIfAborted();
321
536
  const baseUrl = mirrors[mirrorIndex];
322
537
  const remainingMs = deadline - Date.now();
323
538
  if (remainingMs <= 0) {
@@ -328,16 +543,18 @@ export class RadioBrowserProvider {
328
543
  url.searchParams.set(key, value);
329
544
  }
330
545
  try {
331
- const mirrorsLeft = mirrors.length - mirrorIndex;
546
+ const mirrorsLeft = mirrors.length - mirrorIndex + (discoveryAttempted ? 0 : 1);
332
547
  const fairShareTimeoutMs = Math.max(1, Math.floor(remainingMs / mirrorsLeft));
333
548
  const attemptTimeoutMs = Math.max(1, Math.min(remainingMs, options.attemptTimeoutMs ?? fairShareTimeoutMs));
334
- const value = await fetchJsonWithTimeout(url, attemptTimeoutMs);
549
+ const value = await fetchJsonWithTimeout(url, attemptTimeoutMs, this.runtime, options.signal);
550
+ options.signal?.throwIfAborted();
335
551
  this.activeBaseUrl = baseUrl;
336
552
  if (options.maxAgeMs) {
337
553
  try {
338
- this.cache.set(cacheKey, value);
554
+ await abortable(this.cache.setAsync(cacheKey, value), options.signal);
339
555
  }
340
556
  catch {
557
+ options.signal?.throwIfAborted();
341
558
  // A read-only or full cache directory must not turn live data into
342
559
  // an apparent provider outage.
343
560
  }
@@ -345,19 +562,45 @@ export class RadioBrowserProvider {
345
562
  return value;
346
563
  }
347
564
  catch (error) {
565
+ options.signal?.throwIfAborted();
348
566
  lastError = error instanceof Error ? error : new Error(String(error));
349
567
  }
568
+ if (!discoveryAttempted && mirrorIndex === mirrors.length - 1) {
569
+ discoveryAttempted = true;
570
+ const discoveryBudgetMs = Math.min(mirrorDiscoveryTimeoutMs, Math.max(1, deadline - Date.now()));
571
+ const discovered = await abortable(this.loadDiscoveredMirrors(discoveryBudgetMs), options.signal);
572
+ for (const baseUrl of discovered) {
573
+ if (!mirrors.includes(baseUrl))
574
+ mirrors.push(baseUrl);
575
+ }
576
+ }
350
577
  }
351
- const stale = this.cache.getStale(cacheKey);
578
+ const stale = await abortable(this.cache.getStaleAsync(cacheKey), options.signal);
579
+ options.signal?.throwIfAborted();
352
580
  if (stale) {
353
581
  return stale;
354
582
  }
355
583
  throw new ProviderUnavailableError(`${this.label} unavailable: ${describeRequestError(lastError)}`, lastError ?? undefined);
356
584
  }
585
+ async loadDiscoveredMirrors(timeoutMs) {
586
+ const discovery = this.mirrorDiscoveryPromise ?? settleWithin(discoverRadioBrowserMirrors(this.runtime), timeoutMs, []);
587
+ this.mirrorDiscoveryPromise = discovery;
588
+ const mirrors = await discovery;
589
+ // Empty discovery commonly means a temporary DNS failure or timeout. Do
590
+ // not poison this provider instance for the remainder of the process.
591
+ if (mirrors.length === 0 && this.mirrorDiscoveryPromise === discovery)
592
+ this.mirrorDiscoveryPromise = null;
593
+ return mirrors;
594
+ }
357
595
  }
358
596
  function describeRequestError(error) {
359
597
  if (!error)
360
598
  return 'request failed';
599
+ const nestedCode = findErrorCode(error);
600
+ if (nestedCode === 'EAI_AGAIN')
601
+ return 'temporary DNS lookup failed (EAI_AGAIN)';
602
+ if (nestedCode && nameResolutionErrorCodes.has(nestedCode))
603
+ return `DNS lookup failed (${nestedCode})`;
361
604
  const cause = error.cause;
362
605
  if (cause && typeof cause === 'object') {
363
606
  const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : null;
@@ -372,12 +615,7 @@ function describeRequestError(error) {
372
615
  return error.message || 'request failed';
373
616
  }
374
617
  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
- ];
618
+ return ['https://all.api.radio-browser.info'];
381
619
  }
382
620
  function preferActiveMirror(baseUrls, active) {
383
621
  return [active, ...baseUrls.filter(url => url !== active)];
@@ -386,28 +624,93 @@ function buildCacheKey(path, params) {
386
624
  const pairs = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
387
625
  return `${path}?${new URLSearchParams(pairs).toString()}`;
388
626
  }
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
- });
627
+ async function fetchJsonWithTimeout(url, timeoutMs, runtime, signal) {
628
+ const startedAt = Date.now();
629
+ const init = {
630
+ signal,
631
+ headers: {
632
+ 'User-Agent': userAgent(' (+https://radio-browser.info)'),
633
+ Accept: 'application/json'
634
+ }
635
+ };
636
+ const consume = async (response) => {
400
637
  if (!response.ok) {
401
638
  throw new Error(`Radio Browser request failed: ${response.status} ${response.statusText}`);
402
639
  }
403
- // Keep the abort timer alive while consuming the body as well as headers.
404
640
  return (await response.json());
641
+ };
642
+ try {
643
+ return await withExternalResponse(url, { timeoutMs, init }, consume);
405
644
  }
406
- finally {
407
- clearTimeout(timeout);
645
+ catch (error) {
646
+ signal?.throwIfAborted();
647
+ const remainingMs = timeoutMs - (Date.now() - startedAt);
648
+ if (remainingMs <= 0 ||
649
+ !isNameResolutionError(error) ||
650
+ !isRadioBrowserApiHostname(url.hostname) ||
651
+ !(runtime.allowsDirectDnsFallback ?? (target => allowsDirectDnsFallback(process.env, target)))(url)) {
652
+ throw error;
653
+ }
654
+ return withExternalResponse(url, {
655
+ timeoutMs: remainingMs,
656
+ init,
657
+ fetchImpl: runtime.fetchWithSystemResolver ?? fetchHttpsWithSystemResolver
658
+ }, consume);
408
659
  }
409
660
  }
410
- export function dedupeStations(stations) {
661
+ async function discoverRadioBrowserMirrors(runtime) {
662
+ const records = await (runtime.resolveSrv ?? resolveSrv)(radioBrowserService);
663
+ return dedupeUrls(records
664
+ .filter(record => record.port === 443)
665
+ .sort((a, b) => a.priority - b.priority || b.weight - a.weight || a.name.localeCompare(b.name))
666
+ .flatMap(record => {
667
+ const hostname = record.name.replace(/\.$/, '').toLowerCase();
668
+ return isRadioBrowserApiHostname(hostname) ? [`https://${hostname}`] : [];
669
+ }));
670
+ }
671
+ function isRadioBrowserApiHostname(hostname) {
672
+ const normalized = hostname.toLowerCase();
673
+ return normalized === 'api.radio-browser.info' || normalized.endsWith(radioBrowserApiSuffix);
674
+ }
675
+ function isNameResolutionError(error) {
676
+ const code = findErrorCode(error);
677
+ return code !== null && nameResolutionErrorCodes.has(code);
678
+ }
679
+ function findErrorCode(error) {
680
+ const seen = new Set();
681
+ const pending = [error];
682
+ while (pending.length > 0) {
683
+ const value = pending.pop();
684
+ if (!value || seen.has(value))
685
+ continue;
686
+ seen.add(value);
687
+ if (typeof value === 'object') {
688
+ if ('code' in value && typeof value.code === 'string')
689
+ return value.code;
690
+ if ('cause' in value)
691
+ pending.push(value.cause);
692
+ if (value instanceof AggregateError)
693
+ pending.push(...value.errors);
694
+ }
695
+ }
696
+ return null;
697
+ }
698
+ function dedupeUrls(urls) {
699
+ return [...new Set(urls)];
700
+ }
701
+ function settleWithin(promise, timeoutMs, fallback) {
702
+ return new Promise(resolve => {
703
+ const timeout = setTimeout(() => resolve(fallback), timeoutMs);
704
+ promise.then(value => {
705
+ clearTimeout(timeout);
706
+ resolve(value);
707
+ }, () => {
708
+ clearTimeout(timeout);
709
+ resolve(fallback);
710
+ });
711
+ });
712
+ }
713
+ function dedupeStations(stations) {
411
714
  const seen = new Set();
412
715
  const deduped = [];
413
716
  for (const station of stations) {
@@ -459,12 +762,17 @@ function compareNearbyStations(a, b) {
459
762
  }
460
763
  return a.name.localeCompare(b.name);
461
764
  }
462
- function nearestStations(stations, location, limit) {
765
+ async function nearestStations(stations, location, limit, signal) {
463
766
  const nearest = [];
464
767
  const boundedLimit = Math.max(0, Math.floor(limit));
465
768
  if (boundedLimit === 0)
466
769
  return nearest;
467
- for (const station of stations) {
770
+ for (let index = 0; index < stations.length; index += 1) {
771
+ if (index > 0 && index % 500 === 0)
772
+ await yieldToEventLoop();
773
+ if (index % 500 === 0)
774
+ signal?.throwIfAborted();
775
+ const station = stations[index];
468
776
  if (!hasValidCoordinates(station))
469
777
  continue;
470
778
  const candidate = {
@@ -525,6 +833,27 @@ function scoreStation(station, query, tokens) {
525
833
  const exactScore = searchable.includes(loweredQuery) ? 12 : 0;
526
834
  return exactScore + tokenScore + Math.log10((station.clickCount ?? 0) + 1);
527
835
  }
836
+ function discoverySource(source, params) {
837
+ return { provider: 'radio-browser', source, consumed: 0, exhausted: false, status: 'available', params };
838
+ }
839
+ function pageLimit(value, fallback) {
840
+ if (value === undefined)
841
+ return fallback;
842
+ if (!Number.isInteger(value) || value < 1 || value > 1000)
843
+ throw new Error('Discovery page size must be between 1 and 1000.');
844
+ return value;
845
+ }
846
+ function searchFilters(options) {
847
+ return { codec: options.codec, language: options.language, countryCode: options.countryCode?.toUpperCase(), minBitrate: options.minBitrate };
848
+ }
849
+ function searchFilterParams(options) {
850
+ return {
851
+ ...(options.codec ? { codec: options.codec } : {}),
852
+ ...(options.language ? { language: options.language } : {}),
853
+ ...(options.countryCode ? { countrycode: options.countryCode.toUpperCase() } : {}),
854
+ ...(options.minBitrate ? { bitrateMin: String(options.minBitrate) } : {})
855
+ };
856
+ }
528
857
  function filterStations(stations, options) {
529
858
  return stations.filter(station => {
530
859
  if (options.minBitrate && (station.bitrate ?? 0) < options.minBitrate) {