@ciphore/radiocli 0.2.4 → 0.2.6

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 (145) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/CONTRIBUTING.md +28 -0
  3. package/README.md +108 -253
  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 +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +182 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +130 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +314 -76
  71. package/dist/types.js +22 -1
  72. package/dist/ui/AdaptiveContent.js +21 -24
  73. package/dist/ui/App.js +708 -454
  74. package/dist/ui/AppContent.js +5 -5
  75. package/dist/ui/airplay-settings.js +12 -5
  76. package/dist/ui/bidi-ranges.js +45 -0
  77. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  78. package/dist/ui/components/ReceiverVisualRow.js +39 -0
  79. package/dist/ui/format.js +37 -4
  80. package/dist/ui/help-content.js +5 -2
  81. package/dist/ui/rtl-output.js +137 -0
  82. package/dist/ui/screen-items.js +10 -4
  83. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  84. package/dist/ui/screens/AlarmsScreen.js +5 -2
  85. package/dist/ui/screens/HelpScreen.js +3 -3
  86. package/dist/ui/screens/NowPlayingScreen.js +2 -21
  87. package/dist/ui/screens/SettingsScreen.js +16 -16
  88. package/dist/ui/screens/StatsScreen.js +1 -1
  89. package/dist/ui/text-direction.js +9 -0
  90. package/dist/ui/theme.js +46 -0
  91. package/dist/ui/use-alarm-tui.js +24 -24
  92. package/dist/ui/use-app-input.js +42 -8
  93. package/dist/ui/use-command-executor.js +21 -14
  94. package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
  95. package/dist/ui/visualizers/boat-lake.js +161 -0
  96. package/dist/ui/visualizers/gallop.js +2 -2
  97. package/dist/ui/visualizers/receiver-style-registry.js +3 -1
  98. package/dist/ui/visualizers/receiver-visualizers.js +6 -108
  99. package/dist/ui/visualizers/sumi-ocean.js +183 -0
  100. package/dist/ui/visualizers/terminal-cell.js +45 -0
  101. package/dist/update-check.js +2 -1
  102. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  103. package/docs/boat-lake.md +88 -0
  104. package/docs/sumi-ocean.md +80 -0
  105. package/package.json +36 -12
  106. package/scripts/build-airplay-native.mjs +22 -0
  107. package/vendor/node-airtunes2/LICENSE +208 -0
  108. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  109. package/vendor/node-airtunes2/README.md +225 -0
  110. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  111. package/vendor/node-airtunes2/binding.gyp +41 -0
  112. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  113. package/vendor/node-airtunes2/lib/atv.js +307 -0
  114. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  115. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  116. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  117. package/vendor/node-airtunes2/lib/config.js +26 -0
  118. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  119. package/vendor/node-airtunes2/lib/devices.js +266 -0
  120. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  121. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  122. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  123. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  124. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  125. package/vendor/node-airtunes2/lib/http.js +189 -0
  126. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  127. package/vendor/node-airtunes2/lib/index.js +127 -0
  128. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  129. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  130. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  131. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  132. package/vendor/node-airtunes2/lib/srp.js +158 -0
  133. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  134. package/vendor/node-airtunes2/lib/util.js +12 -0
  135. package/vendor/node-airtunes2/package.json +31 -0
  136. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  137. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  138. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  139. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  140. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  141. package/vendor/node-airtunes2/src/base64.c +136 -0
  142. package/vendor/node-airtunes2/src/base64.h +42 -0
  143. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  144. package/vendor/node-airtunes2/src/codec.cc +170 -0
  145. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,6 +1,9 @@
1
1
  import { resolveSrv } from 'node:dns/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
2
4
  import { z } from 'zod';
3
5
  import { ProviderCache } from './cache.js';
6
+ import { abortable, DiscoverySessions } from './discovery-session.js';
4
7
  import { userAgent } from '../version.js';
5
8
  import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
6
9
  import { allowsDirectDnsFallback, fetchHttpsWithSystemResolver, networkPolicy, withExternalResponse } from '../platform/network.js';
@@ -48,6 +51,7 @@ const locationSchema = z
48
51
  })
49
52
  .passthrough();
50
53
  const geoAtlasLimit = 100_000;
54
+ const geoAtlasMaxBytes = 32 * 1024 * 1024;
51
55
  const geoAtlasMaxAgeMs = 6 * 60 * 60 * 1000;
52
56
  // The atlas is much larger than ordinary directory responses. Give each live,
53
57
  // discovered server enough time to connect, download, and parse up to 100k rows
@@ -58,6 +62,7 @@ const mirrorDiscoveryTimeoutMs = 750;
58
62
  const radioBrowserService = '_api._tcp.radio-browser.info';
59
63
  const radioBrowserApiSuffix = '.api.radio-browser.info';
60
64
  const nameResolutionErrorCodes = new Set(['EAI_AGAIN', 'ENOTFOUND', 'ESERVFAIL', 'ETIMEOUT', 'EREFUSED']);
65
+ const maxDiscoveryRoundsPerPage = 4;
61
66
  const defaultRuntime = {};
62
67
  class ProviderUnavailableError extends Error {
63
68
  constructor(message, cause) {
@@ -76,9 +81,12 @@ export class RadioBrowserProvider {
76
81
  baseUrls;
77
82
  discoverMirrors;
78
83
  activeBaseUrl;
79
- geoAtlasPromise = null;
84
+ geoAtlas = null;
85
+ geoAtlasLoad = null;
80
86
  geoAtlasLoadedAt = 0;
81
87
  mirrorDiscoveryPromise = null;
88
+ discovery = new DiscoverySessions(state => state.bytes + state.seen.size * 128 + 1024);
89
+ pendingRequests = new Map();
82
90
  constructor(baseUrls = undefined, cache = new ProviderCache(), runtime = defaultRuntime) {
83
91
  this.cache = cache;
84
92
  this.runtime = runtime;
@@ -106,16 +114,16 @@ export class RadioBrowserProvider {
106
114
  .sort((a, b) => b.stationCount - a.stationCount || a.name.localeCompare(b.name))
107
115
  .slice(0, limit);
108
116
  }
109
- async popular(limit = 80) {
117
+ async popular(limit = 80, options = {}) {
110
118
  const rows = await this.request('/json/stations/search', {
111
119
  hidebroken: 'true',
112
120
  limit: String(limit),
113
121
  order: 'clickcount',
114
122
  reverse: 'true'
115
- }, { maxAgeMs: 15 * 60 * 1000 });
123
+ }, { maxAgeMs: 15 * 60 * 1000, signal: options.signal });
116
124
  return this.normalizeStations(rows);
117
125
  }
118
- async byCountry(countryCode, limit = 100, offset = 0) {
126
+ async byCountry(countryCode, limit = 100, offset = 0, options = {}) {
119
127
  const rows = await this.request('/json/stations/search', {
120
128
  countrycode: countryCode.toUpperCase(),
121
129
  hidebroken: 'true',
@@ -123,17 +131,115 @@ export class RadioBrowserProvider {
123
131
  offset: String(Math.max(0, offset)),
124
132
  order: 'clickcount',
125
133
  reverse: 'true'
126
- }, { maxAgeMs: 30 * 60 * 1000 });
134
+ }, { maxAgeMs: 30 * 60 * 1000, signal: options.signal });
127
135
  return this.normalizeStations(rows);
128
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
+ }
129
234
  async byId(id) {
130
235
  const rows = await this.request(`/json/stations/byuuid/${encodeURIComponent(id)}`, {}, { maxAgeMs: 10 * 60 * 1000 });
131
236
  return this.normalizeStations(rows)[0] ?? null;
132
237
  }
133
238
  async search(query, options = {}) {
239
+ options.signal?.throwIfAborted();
134
240
  const trimmed = query.trim();
135
241
  if (!trimmed) {
136
- return this.popular(options.limit ?? 80);
242
+ return this.popular(options.limit ?? 80, { signal: options.signal });
137
243
  }
138
244
  const limit = options.limit ?? 60;
139
245
  const offset = Math.max(0, options.offset ?? 0);
@@ -147,28 +253,32 @@ export class RadioBrowserProvider {
147
253
  reverse: 'true',
148
254
  ...(options.codec ? { codec: options.codec } : {}),
149
255
  ...(options.language ? { language: options.language } : {}),
150
- ...(options.countryCode ? { countrycode: options.countryCode.toUpperCase() } : {})
256
+ ...(options.countryCode ? { countrycode: options.countryCode.toUpperCase() } : {}),
257
+ ...(options.minBitrate ? { bitrateMin: String(options.minBitrate) } : {})
151
258
  };
152
259
  const primary = await Promise.allSettled([
153
- this.request('/json/stations/search', { ...baseParams, name: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
154
- 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 })
155
262
  ]);
263
+ options.signal?.throwIfAborted();
156
264
  let attempts = [...primary];
157
265
  let rows = fulfilledRows(primary);
158
266
  if (rows.length < Math.min(12, limit + offset)) {
159
267
  const secondary = await Promise.allSettled([
160
- this.request('/json/stations/search', { ...baseParams, country: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
161
- 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 })
162
270
  ]);
271
+ options.signal?.throwIfAborted();
163
272
  attempts = [...attempts, ...secondary];
164
273
  rows.push(...fulfilledRows(secondary));
165
274
  }
166
275
  const tokens = trimmed.split(/\s+/).filter(token => token.length > 2).slice(0, 4);
167
276
  if (rows.length === 0 && tokens.length > 1) {
168
277
  const tokenResults = await Promise.allSettled(tokens.slice(0, 3).flatMap(token => [
169
- this.request('/json/stations/search', { ...baseParams, name: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
170
- 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 })
171
280
  ]));
281
+ options.signal?.throwIfAborted();
172
282
  attempts.push(...tokenResults);
173
283
  rows = fulfilledRows(tokenResults);
174
284
  }
@@ -181,11 +291,13 @@ export class RadioBrowserProvider {
181
291
  .sort((a, b) => scoreStation(b, trimmed, tokens) - scoreStation(a, trimmed, tokens))
182
292
  .slice(offset, offset + limit);
183
293
  }
184
- async nearby(location, limit = 80) {
185
- const stations = await this.geotaggedAtlas();
186
- 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);
187
298
  }
188
- async resolve(station) {
299
+ async resolve(station, options = {}) {
300
+ options.signal?.throwIfAborted();
189
301
  if (station.provider !== this.id) {
190
302
  const streamUrl = station.streamUrl ? safeMediaTarget(station.streamUrl) : null;
191
303
  if (!streamUrl) {
@@ -196,13 +308,14 @@ export class RadioBrowserProvider {
196
308
  try {
197
309
  // Click URLs can rotate and the endpoint records directory engagement, so
198
310
  // do not reuse a day-old resolution from the general provider cache.
199
- 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 }));
200
312
  const resolvedUrl = result.url ? safeMediaTarget(result.url) : null;
201
313
  if (result.ok && resolvedUrl) {
202
314
  return { url: resolvedUrl, name: sanitizeTerminalText(result.name) ?? station.name };
203
315
  }
204
316
  }
205
317
  catch {
318
+ options.signal?.throwIfAborted();
206
319
  // A directory outage should not discard a validated URL already supplied
207
320
  // with the station row.
208
321
  }
@@ -227,11 +340,12 @@ export class RadioBrowserProvider {
227
340
  return false;
228
341
  }
229
342
  }
230
- async detectLocation() {
343
+ async detectLocation(options = {}) {
344
+ options.signal?.throwIfAborted();
231
345
  try {
232
346
  return await withExternalResponse('https://ipapi.co/json/', {
233
347
  timeoutMs: 5000,
234
- init: { headers: { 'User-Agent': userAgent() } }
348
+ init: { headers: { 'User-Agent': userAgent() }, signal: options.signal }
235
349
  }, async (response) => {
236
350
  if (!response.ok)
237
351
  return null;
@@ -250,6 +364,7 @@ export class RadioBrowserProvider {
250
364
  });
251
365
  }
252
366
  catch {
367
+ options.signal?.throwIfAborted();
253
368
  return null;
254
369
  }
255
370
  }
@@ -282,22 +397,43 @@ export class RadioBrowserProvider {
282
397
  lastCheckedOk: row.lastcheckok === 1 || row.lastcheckok === true
283
398
  }));
284
399
  }
285
- async geotaggedAtlas() {
400
+ async geotaggedAtlas(signal) {
401
+ signal?.throwIfAborted();
286
402
  const atlasExpired = this.geoAtlasLoadedAt > 0 && Date.now() - this.geoAtlasLoadedAt > geoAtlasMaxAgeMs;
287
- if (!this.geoAtlasPromise || atlasExpired) {
288
- this.geoAtlasPromise = this.loadGeotaggedAtlas()
289
- .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;
290
414
  this.geoAtlasLoadedAt = Date.now();
291
415
  return stations;
292
416
  })
293
- .catch(error => {
294
- this.geoAtlasPromise = null;
295
- throw error;
417
+ .finally(() => {
418
+ created.settled = true;
419
+ if (this.geoAtlasLoad === created)
420
+ this.geoAtlasLoad = null;
296
421
  });
297
422
  }
298
- 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
+ }
299
435
  }
300
- async loadGeotaggedAtlas() {
436
+ async loadGeotaggedAtlas(signal) {
301
437
  const rows = await this.request('/json/stations/search', {
302
438
  hidebroken: 'true',
303
439
  has_geo_info: 'true',
@@ -307,21 +443,83 @@ export class RadioBrowserProvider {
307
443
  maxAgeMs: geoAtlasMaxAgeMs,
308
444
  timeoutMs: geoAtlasTotalTimeoutMs,
309
445
  attemptTimeoutMs: geoAtlasAttemptTimeoutMs,
310
- cacheOnly: networkPolicy().lowBandwidth
446
+ cacheOnly: networkPolicy().lowBandwidth,
447
+ signal
311
448
  });
312
- 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;
313
477
  }
314
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();
315
511
  const cacheKey = buildCacheKey(path, params);
316
512
  if (options.maxAgeMs) {
317
- 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();
318
515
  if (cached) {
319
516
  return cached;
320
517
  }
321
518
  }
322
519
  const policy = networkPolicy();
323
520
  if (policy.offline || options.cacheOnly) {
324
- const stale = options.maxAgeMs ? this.cache.getStale(cacheKey) : null;
521
+ const stale = options.maxAgeMs ? await abortable(this.cache.getStaleAsync(cacheKey), options.signal) : null;
522
+ options.signal?.throwIfAborted();
325
523
  if (stale !== null)
326
524
  return stale;
327
525
  throw new ProviderUnavailableError(policy.offline
@@ -334,6 +532,7 @@ export class RadioBrowserProvider {
334
532
  const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl);
335
533
  let discoveryAttempted = !this.discoverMirrors;
336
534
  for (let mirrorIndex = 0; mirrorIndex < mirrors.length; mirrorIndex += 1) {
535
+ options.signal?.throwIfAborted();
337
536
  const baseUrl = mirrors[mirrorIndex];
338
537
  const remainingMs = deadline - Date.now();
339
538
  if (remainingMs <= 0) {
@@ -347,13 +546,15 @@ export class RadioBrowserProvider {
347
546
  const mirrorsLeft = mirrors.length - mirrorIndex + (discoveryAttempted ? 0 : 1);
348
547
  const fairShareTimeoutMs = Math.max(1, Math.floor(remainingMs / mirrorsLeft));
349
548
  const attemptTimeoutMs = Math.max(1, Math.min(remainingMs, options.attemptTimeoutMs ?? fairShareTimeoutMs));
350
- const value = await fetchJsonWithTimeout(url, attemptTimeoutMs, this.runtime);
549
+ const value = await fetchJsonWithTimeout(url, attemptTimeoutMs, this.runtime, options.signal);
550
+ options.signal?.throwIfAborted();
351
551
  this.activeBaseUrl = baseUrl;
352
552
  if (options.maxAgeMs) {
353
553
  try {
354
- this.cache.set(cacheKey, value);
554
+ await abortable(this.cache.setAsync(cacheKey, value), options.signal);
355
555
  }
356
556
  catch {
557
+ options.signal?.throwIfAborted();
357
558
  // A read-only or full cache directory must not turn live data into
358
559
  // an apparent provider outage.
359
560
  }
@@ -361,19 +562,21 @@ export class RadioBrowserProvider {
361
562
  return value;
362
563
  }
363
564
  catch (error) {
565
+ options.signal?.throwIfAborted();
364
566
  lastError = error instanceof Error ? error : new Error(String(error));
365
567
  }
366
568
  if (!discoveryAttempted && mirrorIndex === mirrors.length - 1) {
367
569
  discoveryAttempted = true;
368
570
  const discoveryBudgetMs = Math.min(mirrorDiscoveryTimeoutMs, Math.max(1, deadline - Date.now()));
369
- const discovered = await this.loadDiscoveredMirrors(discoveryBudgetMs);
571
+ const discovered = await abortable(this.loadDiscoveredMirrors(discoveryBudgetMs), options.signal);
370
572
  for (const baseUrl of discovered) {
371
573
  if (!mirrors.includes(baseUrl))
372
574
  mirrors.push(baseUrl);
373
575
  }
374
576
  }
375
577
  }
376
- const stale = this.cache.getStale(cacheKey);
578
+ const stale = await abortable(this.cache.getStaleAsync(cacheKey), options.signal);
579
+ options.signal?.throwIfAborted();
377
580
  if (stale) {
378
581
  return stale;
379
582
  }
@@ -421,9 +624,10 @@ function buildCacheKey(path, params) {
421
624
  const pairs = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
422
625
  return `${path}?${new URLSearchParams(pairs).toString()}`;
423
626
  }
424
- async function fetchJsonWithTimeout(url, timeoutMs, runtime) {
627
+ async function fetchJsonWithTimeout(url, timeoutMs, runtime, signal) {
425
628
  const startedAt = Date.now();
426
629
  const init = {
630
+ signal,
427
631
  headers: {
428
632
  'User-Agent': userAgent(' (+https://radio-browser.info)'),
429
633
  Accept: 'application/json'
@@ -439,6 +643,7 @@ async function fetchJsonWithTimeout(url, timeoutMs, runtime) {
439
643
  return await withExternalResponse(url, { timeoutMs, init }, consume);
440
644
  }
441
645
  catch (error) {
646
+ signal?.throwIfAborted();
442
647
  const remainingMs = timeoutMs - (Date.now() - startedAt);
443
648
  if (remainingMs <= 0 ||
444
649
  !isNameResolutionError(error) ||
@@ -505,7 +710,7 @@ function settleWithin(promise, timeoutMs, fallback) {
505
710
  });
506
711
  });
507
712
  }
508
- export function dedupeStations(stations) {
713
+ function dedupeStations(stations) {
509
714
  const seen = new Set();
510
715
  const deduped = [];
511
716
  for (const station of stations) {
@@ -557,12 +762,17 @@ function compareNearbyStations(a, b) {
557
762
  }
558
763
  return a.name.localeCompare(b.name);
559
764
  }
560
- function nearestStations(stations, location, limit) {
765
+ async function nearestStations(stations, location, limit, signal) {
561
766
  const nearest = [];
562
767
  const boundedLimit = Math.max(0, Math.floor(limit));
563
768
  if (boundedLimit === 0)
564
769
  return nearest;
565
- 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];
566
776
  if (!hasValidCoordinates(station))
567
777
  continue;
568
778
  const candidate = {
@@ -623,6 +833,27 @@ function scoreStation(station, query, tokens) {
623
833
  const exactScore = searchable.includes(loweredQuery) ? 12 : 0;
624
834
  return exactScore + tokenScore + Math.log10((station.clickCount ?? 0) + 1);
625
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
+ }
626
857
  function filterStations(stations, options) {
627
858
  return stations.filter(station => {
628
859
  if (options.minBitrate && (station.bitrate ?? 0) < options.minBitrate) {
@@ -42,11 +42,12 @@ export class RadioGardenProvider {
42
42
  }
43
43
  }
44
44
  async search(query, options = {}) {
45
+ options.signal?.throwIfAborted();
45
46
  const url = new URL(`${this.baseUrl}/search`);
46
47
  url.searchParams.set('q', query);
47
48
  const parsed = await withExternalResponse(url, {
48
49
  timeoutMs: 9000,
49
- init: { headers: { 'User-Agent': 'radiocli/0.1', Accept: 'application/json' } }
50
+ init: { headers: { 'User-Agent': 'radiocli/0.1', Accept: 'application/json' }, signal: options.signal }
50
51
  }, async (response) => {
51
52
  if (!response.ok)
52
53
  throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`);
@@ -68,7 +69,8 @@ export class RadioGardenProvider {
68
69
  })
69
70
  .slice(0, options.limit ?? 30);
70
71
  }
71
- async resolve(station) {
72
+ async resolve(station, options = {}) {
73
+ options.signal?.throwIfAborted();
72
74
  if (station.streamUrl) {
73
75
  return { url: station.streamUrl, name: station.name };
74
76
  }