@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.
- package/CHANGELOG.md +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- 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.
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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
|
-
|
|
84
|
+
geoAtlas = null;
|
|
85
|
+
geoAtlasLoad = null;
|
|
72
86
|
geoAtlasLoadedAt = 0;
|
|
73
|
-
|
|
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.
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
221
|
-
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
343
|
+
async detectLocation(options = {}) {
|
|
344
|
+
options.signal?.throwIfAborted();
|
|
222
345
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
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 (
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
.
|
|
289
|
-
|
|
290
|
-
|
|
417
|
+
.finally(() => {
|
|
418
|
+
created.settled = true;
|
|
419
|
+
if (this.geoAtlasLoad === created)
|
|
420
|
+
this.geoAtlasLoad = null;
|
|
291
421
|
});
|
|
292
422
|
}
|
|
293
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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 (
|
|
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) {
|