@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.
- package/CHANGELOG.md +103 -2
- package/CONTRIBUTING.md +28 -0
- package/README.md +108 -253
- 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 +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- 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 +182 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- 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 +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +130 -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 +314 -76
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +21 -24
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/components/ReceiverVisualRow.js +39 -0
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +2 -21
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/theme.js +46 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +2 -2
- package/dist/ui/visualizers/receiver-style-registry.js +3 -1
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +36 -12
- 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,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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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 (
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
.
|
|
294
|
-
|
|
295
|
-
|
|
417
|
+
.finally(() => {
|
|
418
|
+
created.settled = true;
|
|
419
|
+
if (this.geoAtlasLoad === created)
|
|
420
|
+
this.geoAtlasLoad = null;
|
|
296
421
|
});
|
|
297
422
|
}
|
|
298
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
}
|