@ciphore/radiocli 0.2.0 → 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 (57) hide show
  1. package/CHANGELOG.md +44 -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 +36 -2
  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 +169 -103
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +26 -6
  14. package/dist/providers/radio-browser.js +139 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +253 -152
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +251 -47
  20. package/dist/ui/AppContent.js +9 -10
  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 +9 -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 +12 -1
  35. package/dist/ui/screen-items.js +60 -22
  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 +93 -21
  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 +29 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +24 -7
  52. package/dist/ui/use-command-executor.js +28 -0
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  56. package/package.json +2 -2
  57. package/dist/ui/screens/screen-render.test.js +0 -235
@@ -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(),
@@ -104,68 +116,81 @@ export class RadioBrowserProvider {
104
116
  return this.popular(options.limit ?? 80);
105
117
  }
106
118
  const limit = options.limit ?? 60;
119
+ const offset = Math.max(0, options.offset ?? 0);
107
120
  const baseParams = {
108
121
  hidebroken: 'true',
109
- limit: String(limit),
110
- offset: String(Math.max(0, options.offset ?? 0)),
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',
111
126
  order: 'clickcount',
112
127
  reverse: 'true',
113
128
  ...(options.codec ? { codec: options.codec } : {}),
114
129
  ...(options.language ? { language: options.language } : {}),
115
130
  ...(options.countryCode ? { countrycode: options.countryCode.toUpperCase() } : {})
116
131
  };
117
- const variants = await Promise.allSettled([
132
+ const primary = await Promise.allSettled([
118
133
  this.request('/json/stations/search', { ...baseParams, name: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
119
- this.request('/json/stations/search', { ...baseParams, tag: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
120
- this.request('/json/stations/search', { ...baseParams, country: trimmed }, { maxAgeMs: 10 * 60 * 1000 }),
121
- 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 })
122
135
  ]);
123
- let rows = variants.flatMap(result => (result.status === 'fulfilled' ? result.value : []));
124
- const failures = variants.filter(result => result.status === 'rejected');
125
- if (failures.length === variants.length) {
126
- const firstError = failures[0]?.reason;
127
- const message = firstError instanceof Error ? firstError.message : String(firstError ?? 'all search requests failed');
128
- 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));
129
145
  }
130
146
  const tokens = trimmed.split(/\s+/).filter(token => token.length > 2).slice(0, 4);
131
147
  if (rows.length === 0 && tokens.length > 1) {
132
- const tokenResults = await Promise.allSettled(tokens.flatMap(token => [
148
+ const tokenResults = await Promise.allSettled(tokens.slice(0, 3).flatMap(token => [
133
149
  this.request('/json/stations/search', { ...baseParams, name: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
134
- this.request('/json/stations/search', { ...baseParams, tag: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
135
- this.request('/json/stations/search', { ...baseParams, country: token, limit: '25' }, { maxAgeMs: 10 * 60 * 1000 }),
136
- 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 })
137
151
  ]));
138
- 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}`);
139
159
  }
140
160
  return filterStations(dedupeStations(this.normalizeStations(rows)), options)
141
161
  .sort((a, b) => scoreStation(b, trimmed, tokens) - scoreStation(a, trimmed, tokens))
142
- .slice(0, limit);
162
+ .slice(offset, offset + limit);
143
163
  }
144
164
  async nearby(location, limit = 80) {
145
165
  const stations = await this.geotaggedAtlas();
146
- return stations
147
- .map(station => ({
148
- ...station,
149
- distanceKm: haversineKm(location.latitude, location.longitude, station.latitude, station.longitude)
150
- }))
151
- .sort(compareNearbyStations)
152
- .slice(0, limit);
166
+ return nearestStations(stations, location, limit);
153
167
  }
154
168
  async resolve(station) {
155
169
  if (station.provider !== this.id) {
156
- if (!station.streamUrl) {
170
+ const streamUrl = station.streamUrl ? safeMediaTarget(station.streamUrl) : null;
171
+ if (!streamUrl) {
157
172
  throw new Error(`Station ${station.name} does not expose a stream URL.`);
158
173
  }
159
- return { url: station.streamUrl, name: station.name };
174
+ return { url: streamUrl, name: station.name };
160
175
  }
161
- const result = clickResolveSchema.parse(await this.request(`/json/url/${encodeURIComponent(station.id)}`, {}, { maxAgeMs: 24 * 60 * 60 * 1000 }));
162
- if (!result.ok || !result.url) {
163
- if (station.streamUrl) {
164
- 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 };
165
183
  }
166
- throw new Error(result.message ?? `Radio Browser could not resolve ${station.name}.`);
167
184
  }
168
- 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}.`);
169
194
  }
170
195
  // Give back to the directory: an upvote on favorite helps surface good
171
196
  // stations for everyone. Radio Browser rate-limits votes per IP, so this is
@@ -215,9 +240,11 @@ export class RadioBrowserProvider {
215
240
  }
216
241
  }
217
242
  normalizeStations(rows) {
218
- return z
219
- .array(stationSchema)
220
- .parse(rows)
243
+ return rows
244
+ .flatMap(row => {
245
+ const parsed = stationSchema.safeParse(row);
246
+ return parsed.success ? [parsed.data] : [];
247
+ })
221
248
  .map(row => ({
222
249
  id: row.stationuuid,
223
250
  provider: this.id,
@@ -230,9 +257,9 @@ export class RadioBrowserProvider {
230
257
  tags: splitCsv(row.tags ?? undefined).slice(0, 8),
231
258
  codec: cleanText(row.codec ?? undefined),
232
259
  bitrate: row.bitrate ?? undefined,
233
- homepage: cleanText(row.homepage ?? undefined),
234
- favicon: cleanText(row.favicon ?? undefined),
235
- 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),
236
263
  votes: row.votes ?? undefined,
237
264
  clickCount: row.clickcount ?? undefined,
238
265
  latitude: row.geo_lat ?? undefined,
@@ -274,20 +301,32 @@ export class RadioBrowserProvider {
274
301
  }
275
302
  }
276
303
  let lastError = null;
277
- 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
+ }
278
313
  const url = new URL(`${baseUrl}${path}`);
279
314
  for (const [key, value] of Object.entries(params)) {
280
315
  url.searchParams.set(key, value);
281
316
  }
282
317
  try {
283
- const response = await fetchWithTimeout(url, options.timeoutMs ?? 9000);
284
- if (!response.ok) {
285
- throw new Error(`${this.label} request failed: ${response.status} ${response.statusText}`);
286
- }
287
- 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);
288
321
  this.activeBaseUrl = baseUrl;
289
322
  if (options.maxAgeMs) {
290
- 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
+ }
291
330
  }
292
331
  return value;
293
332
  }
@@ -299,7 +338,7 @@ export class RadioBrowserProvider {
299
338
  if (stale) {
300
339
  return stale;
301
340
  }
302
- throw lastError ?? new Error(`${this.label} request failed.`);
341
+ throw new ProviderUnavailableError(lastError?.message ?? `${this.label} request failed.`);
303
342
  }
304
343
  }
305
344
  function defaultRadioBrowserMirrors() {
@@ -317,17 +356,22 @@ function buildCacheKey(path, params) {
317
356
  const pairs = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
318
357
  return `${path}?${new URLSearchParams(pairs).toString()}`;
319
358
  }
320
- async function fetchWithTimeout(url, timeoutMs) {
359
+ async function fetchJsonWithTimeout(url, timeoutMs) {
321
360
  const controller = new AbortController();
322
361
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
323
362
  try {
324
- return await fetch(url, {
363
+ const response = await fetch(url, {
325
364
  signal: controller.signal,
326
365
  headers: {
327
366
  'User-Agent': userAgent(' (+https://radio-browser.info)'),
328
367
  Accept: 'application/json'
329
368
  }
330
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());
331
375
  }
332
376
  finally {
333
377
  clearTimeout(timeout);
@@ -356,8 +400,15 @@ function splitCsv(value) {
356
400
  .filter((part) => Boolean(part));
357
401
  }
358
402
  function cleanText(value) {
359
- const cleaned = value?.replace(/\s+/g, ' ').trim();
360
- 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;
361
412
  }
362
413
  function hasValidCoordinates(station) {
363
414
  return (typeof station.latitude === 'number' &&
@@ -378,6 +429,35 @@ function compareNearbyStations(a, b) {
378
429
  }
379
430
  return a.name.localeCompare(b.name);
380
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
+ }
381
461
  function nearbyQualityScore(station) {
382
462
  return ((station.lastCheckedOk ? 100 : 0) +
383
463
  Math.log10((station.clickCount ?? 0) + 1) * 12 +
@@ -395,6 +475,9 @@ function haversineKm(lat1, lon1, lat2, lon2) {
395
475
  function toRad(value) {
396
476
  return (value * Math.PI) / 180;
397
477
  }
478
+ function fulfilledRows(results) {
479
+ return results.flatMap(result => result.status === 'fulfilled' ? result.value : []);
480
+ }
398
481
  function scoreStation(station, query, tokens) {
399
482
  const searchable = [
400
483
  station.name,
@@ -417,12 +500,11 @@ function filterStations(stations, options) {
417
500
  if (options.minBitrate && (station.bitrate ?? 0) < options.minBitrate) {
418
501
  return false;
419
502
  }
420
- if (options.codec && station.codec && station.codec.toLowerCase() !== options.codec.toLowerCase()) {
503
+ if (options.codec && (!station.codec || station.codec.toLowerCase() !== options.codec.toLowerCase())) {
421
504
  return false;
422
505
  }
423
506
  if (options.language &&
424
- station.language &&
425
- !station.language.toLowerCase().includes(options.language.toLowerCase())) {
507
+ (!station.language || !station.language.toLowerCase().includes(options.language.toLowerCase()))) {
426
508
  return false;
427
509
  }
428
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
+ }