@ciphore/radiocli 0.1.0

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 (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CODE_OF_CONDUCT.md +13 -0
  3. package/CONTRIBUTING.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +364 -0
  6. package/SECURITY.md +31 -0
  7. package/dist/activity/stats.js +122 -0
  8. package/dist/cli.js +143 -0
  9. package/dist/player/backend-install.js +122 -0
  10. package/dist/player/command.js +8 -0
  11. package/dist/player/player-controller.js +494 -0
  12. package/dist/playlists/playlist.js +169 -0
  13. package/dist/providers/cache.js +92 -0
  14. package/dist/providers/provider-manager.js +50 -0
  15. package/dist/providers/radio-browser.js +412 -0
  16. package/dist/providers/radio-garden.js +87 -0
  17. package/dist/storage/store.js +369 -0
  18. package/dist/types.js +55 -0
  19. package/dist/ui/App.js +770 -0
  20. package/dist/ui/AppContent.js +45 -0
  21. package/dist/ui/app-state.js +250 -0
  22. package/dist/ui/components/Logo.js +9 -0
  23. package/dist/ui/components/Menu.js +23 -0
  24. package/dist/ui/components/ScreenHeader.js +15 -0
  25. package/dist/ui/components/StationList.js +23 -0
  26. package/dist/ui/components/TopTabs.js +82 -0
  27. package/dist/ui/cosmo-land-data.js +4 -0
  28. package/dist/ui/cosmo-world-map.js +156 -0
  29. package/dist/ui/explore-map-layout.js +24 -0
  30. package/dist/ui/format.js +22 -0
  31. package/dist/ui/layout.js +27 -0
  32. package/dist/ui/list-window.js +8 -0
  33. package/dist/ui/page-footer.js +45 -0
  34. package/dist/ui/playback-footer.js +48 -0
  35. package/dist/ui/screen-items.js +26 -0
  36. package/dist/ui/screens/CountriesScreen.js +10 -0
  37. package/dist/ui/screens/ExploreScreen.js +44 -0
  38. package/dist/ui/screens/HomeScreen.js +9 -0
  39. package/dist/ui/screens/MapScreen.js +59 -0
  40. package/dist/ui/screens/NowPlayingScreen.js +79 -0
  41. package/dist/ui/screens/SearchScreen.js +12 -0
  42. package/dist/ui/screens/SettingsScreen.js +38 -0
  43. package/dist/ui/screens/StationScreen.js +7 -0
  44. package/dist/ui/screens/StatsScreen.js +90 -0
  45. package/dist/ui/terminal-mouse.js +38 -0
  46. package/dist/ui/theme.js +105 -0
  47. package/dist/ui/use-app-input.js +387 -0
  48. package/dist/ui/use-command-executor.js +156 -0
  49. package/dist/ui/visualizers/receiver-visualizers.js +2188 -0
  50. package/dist/ui/world-map.js +274 -0
  51. package/docs/THIRD_PARTY_NOTICES.md +33 -0
  52. package/package.json +83 -0
@@ -0,0 +1,92 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ export class ProviderCache {
5
+ filePath;
6
+ cache;
7
+ constructor(filePath = defaultProviderCachePath()) {
8
+ this.filePath = filePath;
9
+ this.cache = this.read();
10
+ }
11
+ get(key, maxAgeMs) {
12
+ const entry = this.cache.entries[key];
13
+ if (!entry) {
14
+ return null;
15
+ }
16
+ if (Date.now() - entry.createdAt > maxAgeMs) {
17
+ return null;
18
+ }
19
+ return structuredClone(entry.value);
20
+ }
21
+ getStale(key) {
22
+ const entry = this.cache.entries[key];
23
+ return entry ? structuredClone(entry.value) : null;
24
+ }
25
+ set(key, value) {
26
+ this.cache.entries[key] = { createdAt: Date.now(), value };
27
+ this.write();
28
+ }
29
+ read() {
30
+ if (!existsSync(this.filePath)) {
31
+ return { version: 1, entries: {} };
32
+ }
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(this.filePath, 'utf8'));
35
+ return parsed.version === 1 && parsed.entries ? parsed : { version: 1, entries: {} };
36
+ }
37
+ catch {
38
+ backupBadFile(this.filePath);
39
+ return { version: 1, entries: {} };
40
+ }
41
+ }
42
+ write() {
43
+ mkdirSync(dirname(this.filePath), { recursive: true });
44
+ writeJsonAtomically(this.filePath, this.cache);
45
+ }
46
+ }
47
+ function defaultProviderCachePath() {
48
+ if (process.env.RADIOCLI_HOME) {
49
+ return join(process.env.RADIOCLI_HOME, 'radiocli-cache.json');
50
+ }
51
+ if (process.env.RADIO_ATLAS_HOME) {
52
+ return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas-cache.json');
53
+ }
54
+ const currentPath = currentDefaultProviderCachePath();
55
+ const legacyPath = legacyDefaultProviderCachePath();
56
+ return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath;
57
+ }
58
+ function currentDefaultProviderCachePath() {
59
+ if (process.platform === 'darwin') {
60
+ return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli-cache.json');
61
+ }
62
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radiocli', 'radiocli-cache.json');
63
+ }
64
+ function legacyDefaultProviderCachePath() {
65
+ if (process.platform === 'darwin') {
66
+ return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas-cache.json');
67
+ }
68
+ return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radio-atlas', 'radio-atlas-cache.json');
69
+ }
70
+ function writeJsonAtomically(filePath, value) {
71
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
72
+ try {
73
+ writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
74
+ renameSync(tempPath, filePath);
75
+ }
76
+ catch (error) {
77
+ rmSync(tempPath, { force: true });
78
+ throw error;
79
+ }
80
+ }
81
+ export function backupBadFile(filePath) {
82
+ if (!existsSync(filePath)) {
83
+ return;
84
+ }
85
+ const backupPath = `${filePath}.bad-${new Date().toISOString().replace(/[:.]/g, '-')}`;
86
+ try {
87
+ renameSync(filePath, backupPath);
88
+ }
89
+ catch {
90
+ // If backup fails, leave the original in place and continue with defaults in memory.
91
+ }
92
+ }
@@ -0,0 +1,50 @@
1
+ import { RadioBrowserProvider, dedupeStations } from './radio-browser.js';
2
+ import { RadioGardenProvider } from './radio-garden.js';
3
+ export class ProviderManager {
4
+ radioBrowser;
5
+ radioGarden;
6
+ constructor(radioBrowser = new RadioBrowserProvider(), radioGarden = new RadioGardenProvider()) {
7
+ this.radioBrowser = radioBrowser;
8
+ this.radioGarden = radioGarden;
9
+ }
10
+ countries(limit) {
11
+ return this.radioBrowser.countries(limit);
12
+ }
13
+ popular(limit) {
14
+ return this.radioBrowser.popular(limit);
15
+ }
16
+ byCountry(countryCode, limit) {
17
+ return this.radioBrowser.byCountry(countryCode, limit);
18
+ }
19
+ nearby(location, limit) {
20
+ return this.radioBrowser.nearby(location, limit);
21
+ }
22
+ detectLocation() {
23
+ return this.radioBrowser.detectLocation();
24
+ }
25
+ async search(query, settings, options = {}) {
26
+ const radioBrowser = await this.radioBrowser.search(query, options);
27
+ if (!settings.enableRadioGarden && !options.includeExperimental) {
28
+ return radioBrowser;
29
+ }
30
+ const experimental = await Promise.allSettled([this.radioGarden.search(query, options)]);
31
+ const radioGarden = experimental.flatMap(result => (result.status === 'fulfilled' ? result.value : []));
32
+ return dedupeStations([...radioBrowser, ...radioGarden]).slice(0, options.limit ?? 80);
33
+ }
34
+ async resolve(station) {
35
+ if (station.provider === 'radio-garden') {
36
+ return this.radioGarden.resolve(station);
37
+ }
38
+ return this.radioBrowser.resolve(station);
39
+ }
40
+ async health(settings) {
41
+ const radioBrowser = await this.radioBrowser.health().catch(error => error instanceof Error ? error.message : 'unavailable');
42
+ const radioGarden = settings.enableRadioGarden
43
+ ? await this.radioGarden.health()
44
+ : 'off; enable in settings';
45
+ return {
46
+ 'Radio Browser': radioBrowser,
47
+ 'Radio Garden': radioGarden
48
+ };
49
+ }
50
+ }
@@ -0,0 +1,412 @@
1
+ import { z } from 'zod';
2
+ import { ProviderCache } from './cache.js';
3
+ const stationSchema = z.object({
4
+ stationuuid: z.string(),
5
+ name: z.string().default('Untitled station'),
6
+ url_resolved: z.string().optional().nullable(),
7
+ url: z.string().optional().nullable(),
8
+ homepage: z.string().optional().nullable(),
9
+ favicon: z.string().optional().nullable(),
10
+ tags: z.string().optional().nullable(),
11
+ country: z.string().optional().nullable(),
12
+ countrycode: z.string().optional().nullable(),
13
+ state: z.string().optional().nullable(),
14
+ language: z.string().optional().nullable(),
15
+ languagecodes: z.string().optional().nullable(),
16
+ votes: z.number().optional().nullable(),
17
+ codec: z.string().optional().nullable(),
18
+ bitrate: z.number().optional().nullable(),
19
+ hls: z.union([z.number(), z.boolean()]).optional().nullable(),
20
+ lastcheckok: z.union([z.number(), z.boolean()]).optional().nullable(),
21
+ clickcount: z.number().optional().nullable(),
22
+ geo_lat: z.number().optional().nullable(),
23
+ geo_long: z.number().optional().nullable()
24
+ });
25
+ const countrySchema = z.object({
26
+ name: z.string(),
27
+ iso_3166_1: z.string(),
28
+ stationcount: z.number()
29
+ });
30
+ const clickResolveSchema = z.object({
31
+ ok: z.boolean(),
32
+ message: z.string().optional(),
33
+ name: z.string().optional(),
34
+ url: z.string().optional()
35
+ });
36
+ const locationSchema = z
37
+ .object({
38
+ city: z.string().optional(),
39
+ region: z.string().optional(),
40
+ country_name: z.string().optional(),
41
+ country_code: z.string().optional(),
42
+ latitude: z.number().optional(),
43
+ longitude: z.number().optional()
44
+ })
45
+ .passthrough();
46
+ const geoAtlasLimit = 100_000;
47
+ const geoAtlasMaxAgeMs = 6 * 60 * 60 * 1000;
48
+ const geoAtlasTimeoutMs = 20_000;
49
+ export class RadioBrowserProvider {
50
+ cache;
51
+ id = 'radio-browser';
52
+ label = 'Radio Browser';
53
+ baseUrls;
54
+ activeBaseUrl;
55
+ geoAtlasPromise = null;
56
+ geoAtlasLoadedAt = 0;
57
+ constructor(baseUrls = defaultRadioBrowserMirrors(), cache = new ProviderCache()) {
58
+ this.cache = cache;
59
+ this.baseUrls = baseUrls.map(url => url.replace(/\/$/, ''));
60
+ this.activeBaseUrl = this.baseUrls[0] ?? 'https://all.api.radio-browser.info';
61
+ }
62
+ async health() {
63
+ const stats = await this.request('/json/stats', {}, { timeoutMs: 5000 });
64
+ return stats ? 'online' : 'unknown';
65
+ }
66
+ async countries(limit = 240) {
67
+ const rows = await this.request('/json/countries', { hidebroken: 'true' }, { maxAgeMs: 24 * 60 * 60 * 1000 });
68
+ return z
69
+ .array(countrySchema)
70
+ .parse(rows)
71
+ .map(row => ({
72
+ name: row.name,
73
+ code: row.iso_3166_1.toUpperCase(),
74
+ stationCount: row.stationcount
75
+ }))
76
+ .filter(country => country.stationCount > 0)
77
+ .sort((a, b) => b.stationCount - a.stationCount || a.name.localeCompare(b.name))
78
+ .slice(0, limit);
79
+ }
80
+ async popular(limit = 80) {
81
+ const rows = await this.request('/json/stations/search', {
82
+ hidebroken: 'true',
83
+ limit: String(limit),
84
+ order: 'clickcount',
85
+ reverse: 'true'
86
+ }, { maxAgeMs: 15 * 60 * 1000 });
87
+ return this.normalizeStations(rows);
88
+ }
89
+ async byCountry(countryCode, limit = 100) {
90
+ const rows = await this.request('/json/stations/search', {
91
+ countrycode: countryCode.toUpperCase(),
92
+ hidebroken: 'true',
93
+ limit: String(limit),
94
+ order: 'clickcount',
95
+ reverse: 'true'
96
+ }, { maxAgeMs: 30 * 60 * 1000 });
97
+ return this.normalizeStations(rows);
98
+ }
99
+ async search(query, options = {}) {
100
+ const trimmed = query.trim();
101
+ if (!trimmed) {
102
+ return this.popular(options.limit ?? 80);
103
+ }
104
+ const limit = options.limit ?? 60;
105
+ const baseParams = {
106
+ hidebroken: 'true',
107
+ limit: String(limit),
108
+ order: 'clickcount',
109
+ reverse: 'true',
110
+ ...(options.codec ? { codec: options.codec } : {}),
111
+ ...(options.language ? { language: options.language } : {}),
112
+ ...(options.countryCode ? { countrycode: options.countryCode.toUpperCase() } : {})
113
+ };
114
+ const variants = await Promise.allSettled([
115
+ this.request('/json/stations/search', { ...baseParams, name: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
116
+ this.request('/json/stations/search', { ...baseParams, tag: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
117
+ this.request('/json/stations/search', { ...baseParams, country: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
118
+ this.request('/json/stations/search', { ...baseParams, language: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
119
+ ]);
120
+ let rows = variants.flatMap(result => (result.status === 'fulfilled' ? result.value : []));
121
+ const failures = variants.filter(result => result.status === 'rejected');
122
+ if (failures.length === variants.length) {
123
+ const firstError = failures[0]?.reason;
124
+ const message = firstError instanceof Error ? firstError.message : String(firstError ?? 'all search requests failed');
125
+ throw new Error(`${this.label} search failed: ${message}`);
126
+ }
127
+ const tokens = trimmed.split(/\s+/).filter(token => token.length > 2).slice(0, 4);
128
+ if (rows.length === 0 && tokens.length > 1) {
129
+ const tokenResults = await Promise.allSettled(tokens.flatMap(token => [
130
+ this.request('/json/stations/search', { ...baseParams, name: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
131
+ this.request('/json/stations/search', { ...baseParams, tag: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
132
+ this.request('/json/stations/search', { ...baseParams, country: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
133
+ this.request('/json/stations/search', { ...baseParams, language: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 })
134
+ ]));
135
+ rows = tokenResults.flatMap(result => (result.status === 'fulfilled' ? result.value : []));
136
+ }
137
+ return filterStations(dedupeStations(this.normalizeStations(rows)), options)
138
+ .sort((a, b) => scoreStation(b, trimmed, tokens) - scoreStation(a, trimmed, tokens))
139
+ .slice(0, limit);
140
+ }
141
+ async nearby(location, limit = 80) {
142
+ const stations = await this.geotaggedAtlas();
143
+ return stations
144
+ .map(station => ({
145
+ ...station,
146
+ distanceKm: haversineKm(location.latitude, location.longitude, station.latitude, station.longitude)
147
+ }))
148
+ .sort(compareNearbyStations)
149
+ .slice(0, limit);
150
+ }
151
+ async resolve(station) {
152
+ if (station.provider !== this.id) {
153
+ if (!station.streamUrl) {
154
+ throw new Error(`Station ${station.name} does not expose a stream URL.`);
155
+ }
156
+ return { url: station.streamUrl, name: station.name };
157
+ }
158
+ const result = clickResolveSchema.parse(await this.request(`/json/url/${encodeURIComponent(station.id)}`, {}, { maxAgeMs: 24 * 60 * 60 * 1000 }));
159
+ if (!result.ok || !result.url) {
160
+ if (station.streamUrl) {
161
+ return { url: station.streamUrl, name: station.name };
162
+ }
163
+ throw new Error(result.message ?? `Radio Browser could not resolve ${station.name}.`);
164
+ }
165
+ return { url: result.url, name: result.name };
166
+ }
167
+ async detectLocation() {
168
+ const controller = new AbortController();
169
+ const timeout = setTimeout(() => controller.abort(), 5000);
170
+ try {
171
+ const response = await fetch('https://ipapi.co/json/', {
172
+ signal: controller.signal,
173
+ headers: { 'User-Agent': 'radiocli/0.1' }
174
+ });
175
+ if (!response.ok) {
176
+ return null;
177
+ }
178
+ const parsed = locationSchema.parse(await response.json());
179
+ if (typeof parsed.latitude !== 'number' || typeof parsed.longitude !== 'number') {
180
+ return null;
181
+ }
182
+ return {
183
+ city: parsed.city,
184
+ region: parsed.region,
185
+ country: parsed.country_name,
186
+ countryCode: parsed.country_code,
187
+ latitude: parsed.latitude,
188
+ longitude: parsed.longitude,
189
+ source: 'ipapi.co'
190
+ };
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ finally {
196
+ clearTimeout(timeout);
197
+ }
198
+ }
199
+ normalizeStations(rows) {
200
+ return z
201
+ .array(stationSchema)
202
+ .parse(rows)
203
+ .map(row => ({
204
+ id: row.stationuuid,
205
+ provider: this.id,
206
+ name: cleanText(row.name) || 'Untitled station',
207
+ country: cleanText(row.country ?? undefined),
208
+ countryCode: cleanText(row.countrycode ?? undefined)?.toUpperCase(),
209
+ state: cleanText(row.state ?? undefined),
210
+ language: cleanText(row.language ?? undefined),
211
+ languageCodes: splitCsv(row.languagecodes ?? undefined),
212
+ tags: splitCsv(row.tags ?? undefined).slice(0, 8),
213
+ codec: cleanText(row.codec ?? undefined),
214
+ bitrate: row.bitrate ?? undefined,
215
+ homepage: cleanText(row.homepage ?? undefined),
216
+ favicon: cleanText(row.favicon ?? undefined),
217
+ streamUrl: cleanText(row.url_resolved ?? row.url ?? undefined),
218
+ votes: row.votes ?? undefined,
219
+ clickCount: row.clickcount ?? undefined,
220
+ latitude: row.geo_lat ?? undefined,
221
+ longitude: row.geo_long ?? undefined,
222
+ hls: row.hls === 1 || row.hls === true,
223
+ lastCheckedOk: row.lastcheckok === 1 || row.lastcheckok === true
224
+ }));
225
+ }
226
+ async geotaggedAtlas() {
227
+ const atlasExpired = this.geoAtlasLoadedAt > 0 && Date.now() - this.geoAtlasLoadedAt > geoAtlasMaxAgeMs;
228
+ if (!this.geoAtlasPromise || atlasExpired) {
229
+ this.geoAtlasPromise = this.loadGeotaggedAtlas()
230
+ .then(stations => {
231
+ this.geoAtlasLoadedAt = Date.now();
232
+ return stations;
233
+ })
234
+ .catch(error => {
235
+ this.geoAtlasPromise = null;
236
+ throw error;
237
+ });
238
+ }
239
+ return this.geoAtlasPromise;
240
+ }
241
+ async loadGeotaggedAtlas() {
242
+ const rows = await this.request('/json/stations/search', {
243
+ hidebroken: 'true',
244
+ has_geo_info: 'true',
245
+ limit: String(geoAtlasLimit),
246
+ order: 'name'
247
+ }, { maxAgeMs: geoAtlasMaxAgeMs, timeoutMs: geoAtlasTimeoutMs });
248
+ return dedupeStations(this.normalizeStations(rows)).filter(hasValidCoordinates);
249
+ }
250
+ async request(path, params = {}, options = {}) {
251
+ const cacheKey = buildCacheKey(path, params);
252
+ if (options.maxAgeMs) {
253
+ const cached = this.cache.get(cacheKey, options.maxAgeMs);
254
+ if (cached) {
255
+ return cached;
256
+ }
257
+ }
258
+ let lastError = null;
259
+ for (const baseUrl of preferActiveMirror(this.baseUrls, this.activeBaseUrl)) {
260
+ const url = new URL(`${baseUrl}${path}`);
261
+ for (const [key, value] of Object.entries(params)) {
262
+ url.searchParams.set(key, value);
263
+ }
264
+ try {
265
+ const response = await fetchWithTimeout(url, options.timeoutMs ?? 9000);
266
+ if (!response.ok) {
267
+ throw new Error(`${this.label} request failed: ${response.status} ${response.statusText}`);
268
+ }
269
+ const value = (await response.json());
270
+ this.activeBaseUrl = baseUrl;
271
+ if (options.maxAgeMs) {
272
+ this.cache.set(cacheKey, value);
273
+ }
274
+ return value;
275
+ }
276
+ catch (error) {
277
+ lastError = error instanceof Error ? error : new Error(String(error));
278
+ }
279
+ }
280
+ const stale = this.cache.getStale(cacheKey);
281
+ if (stale) {
282
+ return stale;
283
+ }
284
+ throw lastError ?? new Error(`${this.label} request failed.`);
285
+ }
286
+ }
287
+ function defaultRadioBrowserMirrors() {
288
+ return [
289
+ 'https://all.api.radio-browser.info',
290
+ 'https://de1.api.radio-browser.info',
291
+ 'https://nl1.api.radio-browser.info',
292
+ 'https://at1.api.radio-browser.info'
293
+ ];
294
+ }
295
+ function preferActiveMirror(baseUrls, active) {
296
+ return [active, ...baseUrls.filter(url => url !== active)];
297
+ }
298
+ function buildCacheKey(path, params) {
299
+ const pairs = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
300
+ return `${path}?${new URLSearchParams(pairs).toString()}`;
301
+ }
302
+ async function fetchWithTimeout(url, timeoutMs) {
303
+ const controller = new AbortController();
304
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
305
+ try {
306
+ return await fetch(url, {
307
+ signal: controller.signal,
308
+ headers: {
309
+ 'User-Agent': 'radiocli/0.1 (+https://radio-browser.info)',
310
+ Accept: 'application/json'
311
+ }
312
+ });
313
+ }
314
+ finally {
315
+ clearTimeout(timeout);
316
+ }
317
+ }
318
+ export function dedupeStations(stations) {
319
+ const seen = new Set();
320
+ const deduped = [];
321
+ for (const station of stations) {
322
+ const key = `${station.provider}:${station.id}`;
323
+ if (seen.has(key)) {
324
+ continue;
325
+ }
326
+ seen.add(key);
327
+ deduped.push(station);
328
+ }
329
+ return deduped;
330
+ }
331
+ function splitCsv(value) {
332
+ if (!value) {
333
+ return [];
334
+ }
335
+ return value
336
+ .split(',')
337
+ .map(part => cleanText(part))
338
+ .filter((part) => Boolean(part));
339
+ }
340
+ function cleanText(value) {
341
+ const cleaned = value?.replace(/\s+/g, ' ').trim();
342
+ return cleaned ? cleaned : undefined;
343
+ }
344
+ function hasValidCoordinates(station) {
345
+ return (typeof station.latitude === 'number' &&
346
+ Number.isFinite(station.latitude) &&
347
+ Math.abs(station.latitude) <= 90 &&
348
+ typeof station.longitude === 'number' &&
349
+ Number.isFinite(station.longitude) &&
350
+ Math.abs(station.longitude) <= 180);
351
+ }
352
+ function compareNearbyStations(a, b) {
353
+ const distanceDelta = (a.distanceKm ?? Number.POSITIVE_INFINITY) - (b.distanceKm ?? Number.POSITIVE_INFINITY);
354
+ if (Math.abs(distanceDelta) > 0.01) {
355
+ return distanceDelta;
356
+ }
357
+ const qualityDelta = nearbyQualityScore(b) - nearbyQualityScore(a);
358
+ if (qualityDelta !== 0) {
359
+ return qualityDelta;
360
+ }
361
+ return a.name.localeCompare(b.name);
362
+ }
363
+ function nearbyQualityScore(station) {
364
+ return ((station.lastCheckedOk ? 100 : 0) +
365
+ Math.log10((station.clickCount ?? 0) + 1) * 12 +
366
+ Math.log10((station.votes ?? 0) + 1) * 6 +
367
+ Math.min(station.bitrate ?? 0, 320) / 32);
368
+ }
369
+ function haversineKm(lat1, lon1, lat2, lon2) {
370
+ const radiusKm = 6371;
371
+ const dLat = toRad(lat2 - lat1);
372
+ const dLon = toRad(lon2 - lon1);
373
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
374
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
375
+ return radiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
376
+ }
377
+ function toRad(value) {
378
+ return (value * Math.PI) / 180;
379
+ }
380
+ function scoreStation(station, query, tokens) {
381
+ const searchable = [
382
+ station.name,
383
+ station.country,
384
+ station.state,
385
+ station.language,
386
+ station.tags.join(' '),
387
+ station.codec
388
+ ]
389
+ .filter(Boolean)
390
+ .join(' ')
391
+ .toLowerCase();
392
+ const loweredQuery = query.toLowerCase();
393
+ const tokenScore = tokens.reduce((score, token) => score + (searchable.includes(token.toLowerCase()) ? 4 : 0), 0);
394
+ const exactScore = searchable.includes(loweredQuery) ? 12 : 0;
395
+ return exactScore + tokenScore + Math.log10((station.clickCount ?? 0) + 1);
396
+ }
397
+ function filterStations(stations, options) {
398
+ return stations.filter(station => {
399
+ if (options.minBitrate && (station.bitrate ?? 0) < options.minBitrate) {
400
+ return false;
401
+ }
402
+ if (options.codec && station.codec && station.codec.toLowerCase() !== options.codec.toLowerCase()) {
403
+ return false;
404
+ }
405
+ if (options.language &&
406
+ station.language &&
407
+ !station.language.toLowerCase().includes(options.language.toLowerCase())) {
408
+ return false;
409
+ }
410
+ return true;
411
+ });
412
+ }
@@ -0,0 +1,87 @@
1
+ import { z } from 'zod';
2
+ const searchSchema = z
3
+ .object({
4
+ hits: z
5
+ .array(z
6
+ .object({
7
+ _source: z
8
+ .object({
9
+ type: z.string().optional(),
10
+ title: z.string().optional(),
11
+ subtitle: z.string().optional(),
12
+ url: z.string().optional()
13
+ })
14
+ .passthrough()
15
+ .optional()
16
+ })
17
+ .passthrough())
18
+ .optional()
19
+ })
20
+ .passthrough();
21
+ export class RadioGardenProvider {
22
+ id = 'radio-garden';
23
+ label = 'Radio Garden';
24
+ baseUrl;
25
+ constructor(baseUrl = 'https://radio.garden/api') {
26
+ this.baseUrl = baseUrl.replace(/\/$/, '');
27
+ }
28
+ async health() {
29
+ try {
30
+ const response = await fetchWithTimeout(`${this.baseUrl}/geo`, 5000, {
31
+ headers: { 'User-Agent': 'radiocli/0.1' }
32
+ });
33
+ if (response.status === 403) {
34
+ return 'blocked by edge protection';
35
+ }
36
+ return response.ok ? 'online' : `${response.status} ${response.statusText}`;
37
+ }
38
+ catch (error) {
39
+ return error instanceof Error ? error.message : 'unavailable';
40
+ }
41
+ }
42
+ async search(query, options = {}) {
43
+ const url = new URL(`${this.baseUrl}/search`);
44
+ url.searchParams.set('q', query);
45
+ const response = await fetchWithTimeout(url, 9000, {
46
+ headers: { 'User-Agent': 'radiocli/0.1', Accept: 'application/json' }
47
+ });
48
+ if (!response.ok) {
49
+ throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`);
50
+ }
51
+ const parsed = searchSchema.parse(await response.json());
52
+ return (parsed.hits ?? [])
53
+ .map(hit => hit._source)
54
+ .filter((source) => source?.type === 'channel' && Boolean(source.url))
55
+ .map(source => {
56
+ const id = source.url.split('/').filter(Boolean).pop() ?? source.url;
57
+ return {
58
+ id,
59
+ provider: this.id,
60
+ name: source.title ?? id,
61
+ country: source.subtitle,
62
+ tags: ['radio garden'],
63
+ streamUrl: `${this.baseUrl}/ara/content/listen/${encodeURIComponent(id)}/channel.mp3`
64
+ };
65
+ })
66
+ .slice(0, options.limit ?? 30);
67
+ }
68
+ async resolve(station) {
69
+ if (station.streamUrl) {
70
+ return { url: station.streamUrl, name: station.name };
71
+ }
72
+ return {
73
+ url: `${this.baseUrl}/ara/content/listen/${encodeURIComponent(station.id)}/channel.mp3`,
74
+ name: station.name
75
+ };
76
+ }
77
+ }
78
+ async function fetchWithTimeout(url, timeoutMs, init = {}) {
79
+ const controller = new AbortController();
80
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
81
+ try {
82
+ return await fetch(url, { ...init, signal: controller.signal });
83
+ }
84
+ finally {
85
+ clearTimeout(timeout);
86
+ }
87
+ }