@ciphore/radiocli 0.1.9 → 0.2.1

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 (58) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +107 -411
  3. package/SECURITY.md +6 -3
  4. package/dist/activity/stats.js +14 -2
  5. package/dist/cli.js +53 -3
  6. package/dist/player/airplay-discovery.js +16 -4
  7. package/dist/player/airplay-worker-protocol.js +1 -0
  8. package/dist/player/airplay-worker.js +4 -1
  9. package/dist/player/command.js +37 -14
  10. package/dist/player/mpv-ipc-client.js +194 -0
  11. package/dist/player/player-controller.js +179 -98
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +28 -8
  14. package/dist/providers/radio-browser.js +141 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +264 -149
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +489 -54
  20. package/dist/ui/AppContent.js +11 -12
  21. package/dist/ui/app-state.js +26 -16
  22. package/dist/ui/ascii.js +42 -1
  23. package/dist/ui/components/Logo.js +6 -1
  24. package/dist/ui/components/ScreenHeader.js +2 -2
  25. package/dist/ui/components/StationList.js +8 -6
  26. package/dist/ui/components/TopTabs.js +8 -7
  27. package/dist/ui/cosmo-land-data.js +1 -1
  28. package/dist/ui/exit-confirmation.js +7 -0
  29. package/dist/ui/explore-map-layout.js +3 -1
  30. package/dist/ui/format.js +56 -2
  31. package/dist/ui/help-content.js +10 -2
  32. package/dist/ui/layout.js +26 -9
  33. package/dist/ui/page-footer.js +36 -13
  34. package/dist/ui/playback-footer.js +15 -2
  35. package/dist/ui/screen-items.js +60 -21
  36. package/dist/ui/screen-meta.js +35 -0
  37. package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
  38. package/dist/ui/screens/CountriesScreen.js +8 -5
  39. package/dist/ui/screens/ExploreScreen.js +1 -1
  40. package/dist/ui/screens/HelpScreen.js +17 -3
  41. package/dist/ui/screens/HomeScreen.js +2 -3
  42. package/dist/ui/screens/MapScreen.js +2 -2
  43. package/dist/ui/screens/NowPlayingScreen.js +31 -51
  44. package/dist/ui/screens/SearchScreen.js +1 -1
  45. package/dist/ui/screens/SettingsScreen.js +109 -10
  46. package/dist/ui/screens/StationScreen.js +14 -1
  47. package/dist/ui/screens/StatsScreen.js +35 -44
  48. package/dist/ui/system-actions.js +10 -3
  49. package/dist/ui/terminal-mouse.js +44 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +37 -6
  52. package/dist/ui/use-command-executor.js +34 -1
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/dist/update-check.js +122 -0
  56. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  57. package/package.json +2 -2
  58. package/dist/ui/screens/screen-render.test.js +0 -214
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { ProviderCache } from './cache.js';
3
3
  import { userAgent } from '../version.js';
4
+ import { safeExternalHttpUrl, safeMediaTarget, sanitizeTerminalText } from '../safety.js';
4
5
  const stationSchema = z.object({
5
6
  stationuuid: z.string(),
6
7
  name: z.string().default('Untitled station'),
@@ -47,6 +48,15 @@ const locationSchema = z
47
48
  const geoAtlasLimit = 100_000;
48
49
  const geoAtlasMaxAgeMs = 6 * 60 * 60 * 1000;
49
50
  const geoAtlasTimeoutMs = 20_000;
51
+ class ProviderUnavailableError extends Error {
52
+ constructor(message) {
53
+ super(message);
54
+ this.name = 'ProviderUnavailableError';
55
+ }
56
+ }
57
+ export function isProviderUnavailableError(error) {
58
+ return error instanceof ProviderUnavailableError;
59
+ }
50
60
  export class RadioBrowserProvider {
51
61
  cache;
52
62
  id = 'radio-browser';
@@ -66,9 +76,11 @@ export class RadioBrowserProvider {
66
76
  }
67
77
  async countries(limit = 240) {
68
78
  const rows = await this.request('/json/countries', { hidebroken: 'true' }, { maxAgeMs: 24 * 60 * 60 * 1000 });
69
- return z
70
- .array(countrySchema)
71
- .parse(rows)
79
+ return rows
80
+ .flatMap(row => {
81
+ const parsed = countrySchema.safeParse(row);
82
+ return parsed.success ? [parsed.data] : [];
83
+ })
72
84
  .map(row => ({
73
85
  name: row.name,
74
86
  code: row.iso_3166_1.toUpperCase(),
@@ -87,11 +99,12 @@ export class RadioBrowserProvider {
87
99
  }, { maxAgeMs: 15 * 60 * 1000 });
88
100
  return this.normalizeStations(rows);
89
101
  }
90
- async byCountry(countryCode, limit = 100) {
102
+ async byCountry(countryCode, limit = 100, offset = 0) {
91
103
  const rows = await this.request('/json/stations/search', {
92
104
  countrycode: countryCode.toUpperCase(),
93
105
  hidebroken: 'true',
94
106
  limit: String(limit),
107
+ offset: String(Math.max(0, offset)),
95
108
  order: 'clickcount',
96
109
  reverse: 'true'
97
110
  }, { maxAgeMs: 30 * 60 * 1000 });
@@ -103,67 +116,81 @@ export class RadioBrowserProvider {
103
116
  return this.popular(options.limit ?? 80);
104
117
  }
105
118
  const limit = options.limit ?? 60;
119
+ const offset = Math.max(0, options.offset ?? 0);
106
120
  const baseParams = {
107
121
  hidebroken: 'true',
108
- limit: String(limit),
122
+ // Build a stable merged result set before slicing the requested page;
123
+ // using the merged count as four independent provider offsets skips data.
124
+ limit: String(limit + offset),
125
+ offset: '0',
109
126
  order: 'clickcount',
110
127
  reverse: 'true',
111
128
  ...(options.codec ? { codec: options.codec } : {}),
112
129
  ...(options.language ? { language: options.language } : {}),
113
130
  ...(options.countryCode ? { countrycode: options.countryCode.toUpperCase() } : {})
114
131
  };
115
- const variants = await Promise.allSettled([
132
+ const primary = await Promise.allSettled([
116
133
  this.request('/json/stations/search', { ...baseParams, name: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
117
- this.request('/json/stations/search', { ...baseParams, tag: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
118
- this.request('/json/stations/search', { ...baseParams, country: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
119
- this.request('/json/stations/search', { ...baseParams, language: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
134
+ this.request('/json/stations/search', { ...baseParams, tag: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
120
135
  ]);
121
- let rows = variants.flatMap(result => (result.status === 'fulfilled' ? result.value : []));
122
- const failures = variants.filter(result => result.status === 'rejected');
123
- if (failures.length === variants.length) {
124
- const firstError = failures[0]?.reason;
125
- const message = firstError instanceof Error ? firstError.message : String(firstError ?? 'all search requests failed');
126
- throw new Error(`${this.label} search failed: ${message}`);
136
+ let attempts = [...primary];
137
+ let rows = fulfilledRows(primary);
138
+ if (rows.length < Math.min(12, limit + offset)) {
139
+ const secondary = await Promise.allSettled([
140
+ this.request('/json/stations/search', { ...baseParams, country: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
141
+ this.request('/json/stations/search', { ...baseParams, language: trimmed }, { maxAgeMs: 10 * 60 * 1000 })
142
+ ]);
143
+ attempts = [...attempts, ...secondary];
144
+ rows.push(...fulfilledRows(secondary));
127
145
  }
128
146
  const tokens = trimmed.split(/\s+/).filter(token => token.length > 2).slice(0, 4);
129
147
  if (rows.length === 0 && tokens.length > 1) {
130
- const tokenResults = await Promise.allSettled(tokens.flatMap(token => [
148
+ const tokenResults = await Promise.allSettled(tokens.slice(0, 3).flatMap(token => [
131
149
  this.request('/json/stations/search', { ...baseParams, name: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
132
- this.request('/json/stations/search', { ...baseParams, tag: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
133
- this.request('/json/stations/search', { ...baseParams, country: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
134
- this.request('/json/stations/search', { ...baseParams, language: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 })
150
+ this.request('/json/stations/search', { ...baseParams, tag: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 })
135
151
  ]));
136
- rows = tokenResults.flatMap(result => (result.status === 'fulfilled' ? result.value : []));
152
+ attempts.push(...tokenResults);
153
+ rows = fulfilledRows(tokenResults);
154
+ }
155
+ if (attempts.every(result => result.status === 'rejected')) {
156
+ const firstError = attempts[0]?.status === 'rejected' ? attempts[0].reason : 'all search requests failed';
157
+ const message = firstError instanceof Error ? firstError.message : String(firstError);
158
+ throw new Error(`${this.label} search failed: ${message}`);
137
159
  }
138
160
  return filterStations(dedupeStations(this.normalizeStations(rows)), options)
139
161
  .sort((a, b) => scoreStation(b, trimmed, tokens) - scoreStation(a, trimmed, tokens))
140
- .slice(0, limit);
162
+ .slice(offset, offset + limit);
141
163
  }
142
164
  async nearby(location, limit = 80) {
143
165
  const stations = await this.geotaggedAtlas();
144
- return stations
145
- .map(station => ({
146
- ...station,
147
- distanceKm: haversineKm(location.latitude, location.longitude, station.latitude, station.longitude)
148
- }))
149
- .sort(compareNearbyStations)
150
- .slice(0, limit);
166
+ return nearestStations(stations, location, limit);
151
167
  }
152
168
  async resolve(station) {
153
169
  if (station.provider !== this.id) {
154
- if (!station.streamUrl) {
170
+ const streamUrl = station.streamUrl ? safeMediaTarget(station.streamUrl) : null;
171
+ if (!streamUrl) {
155
172
  throw new Error(`Station ${station.name} does not expose a stream URL.`);
156
173
  }
157
- return { url: station.streamUrl, name: station.name };
174
+ return { url: streamUrl, name: station.name };
158
175
  }
159
- const result = clickResolveSchema.parse(await this.request(`/json/url/${encodeURIComponent(station.id)}`, {}, { maxAgeMs: 24 * 60 * 60 * 1000 }));
160
- if (!result.ok || !result.url) {
161
- if (station.streamUrl) {
162
- return { url: station.streamUrl, name: station.name };
176
+ try {
177
+ // Click URLs can rotate and the endpoint records directory engagement, so
178
+ // do not reuse a day-old resolution from the general provider cache.
179
+ const result = clickResolveSchema.parse(await this.request(`/json/url/${encodeURIComponent(station.id)}`, {}, { timeoutMs: 6000 }));
180
+ const resolvedUrl = result.url ? safeMediaTarget(result.url) : null;
181
+ if (result.ok && resolvedUrl) {
182
+ return { url: resolvedUrl, name: sanitizeTerminalText(result.name) ?? station.name };
163
183
  }
164
- throw new Error(result.message ?? `Radio Browser could not resolve ${station.name}.`);
165
184
  }
166
- return { url: result.url, name: result.name };
185
+ catch {
186
+ // A directory outage should not discard a validated URL already supplied
187
+ // with the station row.
188
+ }
189
+ const fallbackUrl = station.streamUrl ? safeMediaTarget(station.streamUrl) : null;
190
+ if (fallbackUrl) {
191
+ return { url: fallbackUrl, name: station.name };
192
+ }
193
+ throw new Error(`Radio Browser could not resolve ${station.name}.`);
167
194
  }
168
195
  // Give back to the directory: an upvote on favorite helps surface good
169
196
  // stations for everyone. Radio Browser rate-limits votes per IP, so this is
@@ -213,9 +240,11 @@ export class RadioBrowserProvider {
213
240
  }
214
241
  }
215
242
  normalizeStations(rows) {
216
- return z
217
- .array(stationSchema)
218
- .parse(rows)
243
+ return rows
244
+ .flatMap(row => {
245
+ const parsed = stationSchema.safeParse(row);
246
+ return parsed.success ? [parsed.data] : [];
247
+ })
219
248
  .map(row => ({
220
249
  id: row.stationuuid,
221
250
  provider: this.id,
@@ -228,9 +257,9 @@ export class RadioBrowserProvider {
228
257
  tags: splitCsv(row.tags ?? undefined).slice(0, 8),
229
258
  codec: cleanText(row.codec ?? undefined),
230
259
  bitrate: row.bitrate ?? undefined,
231
- homepage: cleanText(row.homepage ?? undefined),
232
- favicon: cleanText(row.favicon ?? undefined),
233
- streamUrl: cleanText(row.url_resolved ?? row.url ?? undefined),
260
+ homepage: safeOptionalExternalUrl(row.homepage),
261
+ favicon: safeOptionalExternalUrl(row.favicon),
262
+ streamUrl: safeOptionalMediaTarget(row.url_resolved ?? row.url),
234
263
  votes: row.votes ?? undefined,
235
264
  clickCount: row.clickcount ?? undefined,
236
265
  latitude: row.geo_lat ?? undefined,
@@ -272,20 +301,32 @@ export class RadioBrowserProvider {
272
301
  }
273
302
  }
274
303
  let lastError = null;
275
- for (const baseUrl of preferActiveMirror(this.baseUrls, this.activeBaseUrl)) {
304
+ const mirrors = preferActiveMirror(this.baseUrls, this.activeBaseUrl);
305
+ const totalTimeoutMs = options.timeoutMs ?? 9000;
306
+ const deadline = Date.now() + totalTimeoutMs;
307
+ for (let mirrorIndex = 0; mirrorIndex < mirrors.length; mirrorIndex += 1) {
308
+ const baseUrl = mirrors[mirrorIndex];
309
+ const remainingMs = deadline - Date.now();
310
+ if (remainingMs <= 0) {
311
+ break;
312
+ }
276
313
  const url = new URL(`${baseUrl}${path}`);
277
314
  for (const [key, value] of Object.entries(params)) {
278
315
  url.searchParams.set(key, value);
279
316
  }
280
317
  try {
281
- const response = await fetchWithTimeout(url, options.timeoutMs ?? 9000);
282
- if (!response.ok) {
283
- throw new Error(`${this.label} request failed: ${response.status} ${response.statusText}`);
284
- }
285
- const value = (await response.json());
318
+ const mirrorsLeft = mirrors.length - mirrorIndex;
319
+ const attemptTimeoutMs = Math.max(1, Math.floor(remainingMs / mirrorsLeft));
320
+ const value = await fetchJsonWithTimeout(url, attemptTimeoutMs);
286
321
  this.activeBaseUrl = baseUrl;
287
322
  if (options.maxAgeMs) {
288
- this.cache.set(cacheKey, value);
323
+ try {
324
+ this.cache.set(cacheKey, value);
325
+ }
326
+ catch {
327
+ // A read-only or full cache directory must not turn live data into
328
+ // an apparent provider outage.
329
+ }
289
330
  }
290
331
  return value;
291
332
  }
@@ -297,7 +338,7 @@ export class RadioBrowserProvider {
297
338
  if (stale) {
298
339
  return stale;
299
340
  }
300
- throw lastError ?? new Error(`${this.label} request failed.`);
341
+ throw new ProviderUnavailableError(lastError?.message ?? `${this.label} request failed.`);
301
342
  }
302
343
  }
303
344
  function defaultRadioBrowserMirrors() {
@@ -315,17 +356,22 @@ function buildCacheKey(path, params) {
315
356
  const pairs = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
316
357
  return `${path}?${new URLSearchParams(pairs).toString()}`;
317
358
  }
318
- async function fetchWithTimeout(url, timeoutMs) {
359
+ async function fetchJsonWithTimeout(url, timeoutMs) {
319
360
  const controller = new AbortController();
320
361
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
321
362
  try {
322
- return await fetch(url, {
363
+ const response = await fetch(url, {
323
364
  signal: controller.signal,
324
365
  headers: {
325
366
  'User-Agent': userAgent(' (+https://radio-browser.info)'),
326
367
  Accept: 'application/json'
327
368
  }
328
369
  });
370
+ if (!response.ok) {
371
+ throw new Error(`Radio Browser request failed: ${response.status} ${response.statusText}`);
372
+ }
373
+ // Keep the abort timer alive while consuming the body as well as headers.
374
+ return (await response.json());
329
375
  }
330
376
  finally {
331
377
  clearTimeout(timeout);
@@ -354,8 +400,15 @@ function splitCsv(value) {
354
400
  .filter((part) => Boolean(part));
355
401
  }
356
402
  function cleanText(value) {
357
- const cleaned = value?.replace(/\s+/g, ' ').trim();
358
- return cleaned ? cleaned : undefined;
403
+ return sanitizeTerminalText(value);
404
+ }
405
+ function safeOptionalExternalUrl(value) {
406
+ const cleaned = cleanText(value);
407
+ return cleaned ? safeExternalHttpUrl(cleaned) ?? undefined : undefined;
408
+ }
409
+ function safeOptionalMediaTarget(value) {
410
+ const cleaned = cleanText(value);
411
+ return cleaned ? safeMediaTarget(cleaned) ?? undefined : undefined;
359
412
  }
360
413
  function hasValidCoordinates(station) {
361
414
  return (typeof station.latitude === 'number' &&
@@ -376,6 +429,35 @@ function compareNearbyStations(a, b) {
376
429
  }
377
430
  return a.name.localeCompare(b.name);
378
431
  }
432
+ function nearestStations(stations, location, limit) {
433
+ const nearest = [];
434
+ const boundedLimit = Math.max(0, Math.floor(limit));
435
+ if (boundedLimit === 0)
436
+ return nearest;
437
+ for (const station of stations) {
438
+ if (!hasValidCoordinates(station))
439
+ continue;
440
+ const candidate = {
441
+ ...station,
442
+ distanceKm: haversineKm(location.latitude, location.longitude, station.latitude, station.longitude)
443
+ };
444
+ let low = 0;
445
+ let high = nearest.length;
446
+ while (low < high) {
447
+ const middle = (low + high) >>> 1;
448
+ if (compareNearbyStations(nearest[middle], candidate) <= 0)
449
+ low = middle + 1;
450
+ else
451
+ high = middle;
452
+ }
453
+ if (low < boundedLimit) {
454
+ nearest.splice(low, 0, candidate);
455
+ if (nearest.length > boundedLimit)
456
+ nearest.pop();
457
+ }
458
+ }
459
+ return nearest;
460
+ }
379
461
  function nearbyQualityScore(station) {
380
462
  return ((station.lastCheckedOk ? 100 : 0) +
381
463
  Math.log10((station.clickCount ?? 0) + 1) * 12 +
@@ -393,6 +475,9 @@ function haversineKm(lat1, lon1, lat2, lon2) {
393
475
  function toRad(value) {
394
476
  return (value * Math.PI) / 180;
395
477
  }
478
+ function fulfilledRows(results) {
479
+ return results.flatMap(result => result.status === 'fulfilled' ? result.value : []);
480
+ }
396
481
  function scoreStation(station, query, tokens) {
397
482
  const searchable = [
398
483
  station.name,
@@ -415,12 +500,11 @@ function filterStations(stations, options) {
415
500
  if (options.minBitrate && (station.bitrate ?? 0) < options.minBitrate) {
416
501
  return false;
417
502
  }
418
- if (options.codec && station.codec && station.codec.toLowerCase() !== options.codec.toLowerCase()) {
503
+ if (options.codec && (!station.codec || station.codec.toLowerCase() !== options.codec.toLowerCase())) {
419
504
  return false;
420
505
  }
421
506
  if (options.language &&
422
- station.language &&
423
- !station.language.toLowerCase().includes(options.language.toLowerCase())) {
507
+ (!station.language || !station.language.toLowerCase().includes(options.language.toLowerCase()))) {
424
508
  return false;
425
509
  }
426
510
  return true;
package/dist/safety.js ADDED
@@ -0,0 +1,44 @@
1
+ import { isAbsolute } from 'node:path';
2
+ // C0/C1 controls, DEL, ANSI CSI/OSC sequences, and bidi overrides can alter a
3
+ // terminal outside the text's visible cells. Preserve ordinary Unicode.
4
+ const terminalEscapePattern = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\)?)/g;
5
+ const unsafeControlPattern = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g;
6
+ const bidiControlPattern = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
7
+ export function sanitizeTerminalText(value) {
8
+ const cleaned = value
9
+ ?.replace(terminalEscapePattern, '')
10
+ .replace(unsafeControlPattern, '')
11
+ .replace(bidiControlPattern, '')
12
+ .replace(/\s+/g, ' ')
13
+ .trim();
14
+ return cleaned || undefined;
15
+ }
16
+ export function safeExternalHttpUrl(value) {
17
+ try {
18
+ const cleaned = value.trim();
19
+ const parsed = new URL(cleaned);
20
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? cleaned : null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ /** Accept public HTTP(S) streams and explicit local playlist targets. */
27
+ export function safeMediaTarget(value) {
28
+ const target = value.trim();
29
+ if (!target || target.startsWith('-')) {
30
+ return null;
31
+ }
32
+ if (isAbsolute(target)) {
33
+ return target;
34
+ }
35
+ try {
36
+ const parsed = new URL(target);
37
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' || parsed.protocol === 'file:'
38
+ ? target
39
+ : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }