@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
@@ -3,12 +3,13 @@ import { existsSync, unlinkSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { Socket } from 'node:net';
7
6
  import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
8
7
  import { resolveCommand } from './command.js';
9
8
  import { discoverAirPlayDevices } from './airplay-discovery.js';
10
9
  import { airPlaySenderHealth } from './airplay-sender-health.js';
11
10
  import { encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage } from './airplay-worker-protocol.js';
11
+ import { safeMediaTarget, sanitizeTerminalText } from '../safety.js';
12
+ import { MpvIpcClient } from './mpv-ipc-client.js';
12
13
  const minAirPlayTuneTimeoutSeconds = 30;
13
14
  export class PlaybackOutputError extends Error {
14
15
  constructor(message) {
@@ -24,6 +25,7 @@ export class PlayerController {
24
25
  process = null;
25
26
  backend = null;
26
27
  ipcPath = null;
28
+ mpvIpcClient = null;
27
29
  metadataTimer = null;
28
30
  playbackStateTimer = null;
29
31
  state = { backend: 'none', state: 'idle', volume: 70, muted: false, ready: false };
@@ -41,8 +43,13 @@ export class PlayerController {
41
43
  currentAirPlayDevice = null;
42
44
  currentAirPlayDeviceId = null;
43
45
  pendingAirPlayPasscode = null;
44
- nextMpvRequestId = 1;
45
46
  currentMpvMediaTitle = null;
47
+ metadataPollInFlight = false;
48
+ playbackStatePollInFlight = false;
49
+ pendingMpvVolume = null;
50
+ mpvVolumeFlush = null;
51
+ confirmedMpvVolume = 70;
52
+ mpvSessionId = 0;
46
53
  constructor(getSettings) {
47
54
  this.getSettings = getSettings;
48
55
  }
@@ -80,6 +87,11 @@ export class PlayerController {
80
87
  return this.detectedBackends();
81
88
  }
82
89
  async play(station, url) {
90
+ const target = safeMediaTarget(url);
91
+ if (!target) {
92
+ throw new Error(`Station ${station.name} returned an unsupported stream URL.`);
93
+ }
94
+ url = target;
83
95
  const backend = this.selectBackend();
84
96
  if (!backend) {
85
97
  await this.stop();
@@ -196,7 +208,12 @@ export class PlayerController {
196
208
  return unsupported;
197
209
  }
198
210
  if (this.backend === 'mpv') {
199
- await this.sendMpv({ command: ['cycle', 'pause'] }).catch(() => undefined);
211
+ try {
212
+ await this.sendMpv({ command: ['cycle', 'pause'] });
213
+ }
214
+ catch {
215
+ return { ok: false, message: 'mpv did not acknowledge the pause command.' };
216
+ }
200
217
  const synced = await this.syncMpvPlaybackState();
201
218
  if (synced) {
202
219
  return { ok: true };
@@ -212,22 +229,24 @@ export class PlayerController {
212
229
  });
213
230
  return { ok: true };
214
231
  }
215
- async setVolume(volume) {
232
+ setVolume(volume) {
216
233
  const clamped = clampVolume(volume);
217
234
  const unsupported = this.unsupportedFfplayControl();
218
235
  if (unsupported) {
219
- return unsupported;
236
+ return Promise.resolve(unsupported);
220
237
  }
221
238
  if (this.backend === 'mpv') {
222
- await this.sendMpv({ command: ['set_property', 'volume', clamped] }).catch(() => undefined);
239
+ return this.queueMpvVolume(clamped);
223
240
  }
224
241
  else if (this.backend === 'airplay') {
225
- this.sendAirPlayCommand({ type: 'setVolume', volume: clamped });
242
+ if (!this.sendAirPlayCommand({ type: 'setVolume', volume: clamped })) {
243
+ return Promise.resolve({ ok: false, message: 'The AirPlay worker is not available.' });
244
+ }
226
245
  }
227
246
  this.setState({ ...this.state, volume: clamped });
228
- return { ok: true };
247
+ return Promise.resolve({ ok: true });
229
248
  }
230
- async adjustVolume(delta) {
249
+ adjustVolume(delta) {
231
250
  return this.setVolume(this.state.volume + delta);
232
251
  }
233
252
  async toggleMute() {
@@ -237,15 +256,23 @@ export class PlayerController {
237
256
  return unsupported;
238
257
  }
239
258
  if (this.backend === 'mpv') {
240
- await this.sendMpv({ command: ['set_property', 'mute', muted] }).catch(() => undefined);
259
+ try {
260
+ await this.sendMpv({ command: ['set_property', 'mute', muted] });
261
+ }
262
+ catch {
263
+ return { ok: false, message: 'mpv did not acknowledge the mute change.' };
264
+ }
241
265
  }
242
266
  else if (this.backend === 'airplay') {
243
- this.sendAirPlayCommand({ type: 'setMuted', muted });
267
+ if (!this.sendAirPlayCommand({ type: 'setMuted', muted })) {
268
+ return { ok: false, message: 'The AirPlay worker is not available.' };
269
+ }
244
270
  }
245
271
  this.setState({ ...this.state, muted });
246
272
  return { ok: true };
247
273
  }
248
274
  async stop() {
275
+ const child = this.process;
249
276
  this.stopMpvPolling();
250
277
  this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
251
278
  this.rejectPendingAirPlayRetune(new Error('AirPlay playback stopped.'));
@@ -259,10 +286,16 @@ export class PlayerController {
259
286
  this.pendingAirPlayPasscode = null;
260
287
  this.airPlaySessionEstablished = false;
261
288
  }
262
- if (this.process && !this.process.killed) {
263
- this.process.kill('SIGTERM');
289
+ if (child && child.exitCode == null && !child.killed) {
290
+ child.kill('SIGTERM');
291
+ if (!(await waitForChildExit(child, 1200)) && child.exitCode === null) {
292
+ child.kill('SIGKILL');
293
+ await waitForChildExit(child, 500);
294
+ }
295
+ }
296
+ if (this.process === child) {
297
+ this.process = null;
264
298
  }
265
- this.process = null;
266
299
  this.cleanupIpc();
267
300
  this.setState({
268
301
  ...this.state,
@@ -334,11 +367,16 @@ export class PlayerController {
334
367
  }
335
368
  playWithMpv(url, initialTitle) {
336
369
  this.ipcPath = createMpvIpcPath();
370
+ this.mpvIpcClient = new MpvIpcClient(this.ipcPath);
371
+ this.mpvSessionId += 1;
372
+ this.confirmedMpvVolume = clampVolume(this.getSettings().volume);
373
+ this.pendingMpvVolume = null;
337
374
  this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
338
375
  this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
339
376
  '--no-video',
340
377
  '--really-quiet',
341
378
  '--force-window=no',
379
+ ...(process.env.RADIOCLI_MPV_AUDIO_OUTPUT ? [`--ao=${process.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] : []),
342
380
  `--force-media-title=${this.currentMpvMediaTitle}`,
343
381
  `--volume=${this.getSettings().volume}`,
344
382
  `--input-ipc-server=${this.ipcPath}`,
@@ -450,6 +488,7 @@ export class PlayerController {
450
488
  const workerPath = airPlayWorkerPath();
451
489
  const workerArgs = airPlayWorkerArgs(workerPath, encodeWorkerStart({
452
490
  streamUrl: url,
491
+ ffmpegPath: resolveCommand('ffmpeg') ?? 'ffmpeg',
453
492
  stationName,
454
493
  volume: this.getSettings().volume,
455
494
  muted: false,
@@ -584,6 +623,10 @@ export class PlayerController {
584
623
  if (!child) {
585
624
  return;
586
625
  }
626
+ // Local players can write diagnostics indefinitely. Drain both pipes so a
627
+ // full OS pipe buffer can never stall playback.
628
+ child.stdout.resume?.();
629
+ child.stderr.resume?.();
587
630
  child.on('error', error => {
588
631
  this.setState({
589
632
  ...this.state,
@@ -618,6 +661,53 @@ export class PlayerController {
618
661
  sendMpv(payload) {
619
662
  return this.queryMpv(payload).then(() => undefined);
620
663
  }
664
+ queueMpvVolume(volume) {
665
+ this.pendingMpvVolume = volume;
666
+ this.setState({ ...this.state, volume });
667
+ if (this.mpvVolumeFlush) {
668
+ return this.mpvVolumeFlush;
669
+ }
670
+ const sessionId = this.mpvSessionId;
671
+ let trackedFlush;
672
+ trackedFlush = this.flushMpvVolume(sessionId).finally(() => {
673
+ if (this.mpvVolumeFlush === trackedFlush) {
674
+ this.mpvVolumeFlush = null;
675
+ }
676
+ });
677
+ this.mpvVolumeFlush = trackedFlush;
678
+ return trackedFlush;
679
+ }
680
+ async flushMpvVolume(sessionId) {
681
+ let lastError = null;
682
+ while (sessionId === this.mpvSessionId && this.backend === 'mpv' && this.pendingMpvVolume !== null) {
683
+ const target = this.pendingMpvVolume;
684
+ this.pendingMpvVolume = null;
685
+ try {
686
+ await this.sendMpv({ command: ['set_property', 'volume', target] });
687
+ if (sessionId !== this.mpvSessionId) {
688
+ lastError = new Error('mpv playback session changed.');
689
+ break;
690
+ }
691
+ this.confirmedMpvVolume = target;
692
+ lastError = null;
693
+ }
694
+ catch (error) {
695
+ lastError = error;
696
+ if (sessionId !== this.mpvSessionId) {
697
+ break;
698
+ }
699
+ if (this.pendingMpvVolume === null) {
700
+ if (this.state.volume === target) {
701
+ this.setState({ ...this.state, volume: this.confirmedMpvVolume });
702
+ }
703
+ break;
704
+ }
705
+ }
706
+ }
707
+ return lastError
708
+ ? { ok: false, message: 'mpv did not acknowledge the volume change.' }
709
+ : { ok: true };
710
+ }
621
711
  sendAirPlayCommand(command) {
622
712
  if (this.backend === 'airplay' && this.process && !this.process.killed) {
623
713
  try {
@@ -661,67 +751,10 @@ export class PlayerController {
661
751
  rejecter?.(error);
662
752
  }
663
753
  queryMpv(payload) {
664
- if (!this.ipcPath) {
754
+ if (!this.mpvIpcClient) {
665
755
  return Promise.resolve(null);
666
756
  }
667
- return new Promise((resolve, reject) => {
668
- const socket = new Socket();
669
- let buffer = '';
670
- let settled = false;
671
- const requestId = this.nextMpvRequestId;
672
- this.nextMpvRequestId = this.nextMpvRequestId >= Number.MAX_SAFE_INTEGER ? 1 : this.nextMpvRequestId + 1;
673
- const requestPayload = attachMpvRequestId(payload, requestId);
674
- const settle = (callback) => {
675
- if (settled) {
676
- return;
677
- }
678
- settled = true;
679
- clearTimeout(timeout);
680
- socket.end();
681
- callback();
682
- };
683
- const timeout = setTimeout(() => {
684
- socket.destroy();
685
- settle(() => reject(new Error('mpv IPC timed out.')));
686
- }, 1000);
687
- socket.once('error', error => {
688
- settle(() => reject(error));
689
- });
690
- socket.on('data', chunk => {
691
- buffer += chunk.toString('utf8');
692
- let newlineIndex = buffer.indexOf('\n');
693
- while (newlineIndex !== -1) {
694
- const line = buffer.slice(0, newlineIndex);
695
- buffer = buffer.slice(newlineIndex + 1);
696
- newlineIndex = buffer.indexOf('\n');
697
- if (!line.trim()) {
698
- continue;
699
- }
700
- try {
701
- const parsed = JSON.parse(line);
702
- if (parsed.request_id !== requestId) {
703
- continue;
704
- }
705
- if (parsed.error && parsed.error !== 'success') {
706
- settle(() => reject(new Error(`mpv IPC failed: ${parsed.error}`)));
707
- }
708
- else {
709
- settle(() => resolve(parsed.data ?? null));
710
- }
711
- }
712
- catch {
713
- settle(() => resolve(null));
714
- }
715
- }
716
- });
717
- socket.connect(this.ipcPath, () => {
718
- socket.write(`${JSON.stringify(requestPayload)}\n`, error => {
719
- if (error) {
720
- settle(() => reject(error));
721
- }
722
- });
723
- });
724
- });
757
+ return this.mpvIpcClient.query(payload);
725
758
  }
726
759
  async waitForReady(backend) {
727
760
  const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
@@ -737,7 +770,9 @@ export class PlayerController {
737
770
  if (this.ipcPath) {
738
771
  try {
739
772
  await this.queryMpv({ command: ['get_property', 'path'] });
740
- return;
773
+ if (await this.hasMpvAudioStarted()) {
774
+ return;
775
+ }
741
776
  }
742
777
  catch {
743
778
  // The IPC socket can exist briefly before accepting commands.
@@ -748,13 +783,22 @@ export class PlayerController {
748
783
  await this.stop();
749
784
  throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
750
785
  }
786
+ async hasMpvAudioStarted() {
787
+ const [timePos, audioPts] = await Promise.all([
788
+ this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null),
789
+ this.queryMpv({ command: ['get_property', 'audio-pts'] }).catch(() => null)
790
+ ]);
791
+ return typeof timePos === 'number' || typeof audioPts === 'number';
792
+ }
751
793
  startMpvMetadataPolling() {
752
794
  this.stopMpvPolling();
753
795
  this.metadataTimer = setInterval(() => {
754
- void this.pollMpvMetadata();
796
+ if (!this.metadataPollInFlight)
797
+ void this.pollMpvMetadata();
755
798
  }, 2500);
756
799
  this.playbackStateTimer = setInterval(() => {
757
- void this.syncMpvPlaybackState();
800
+ if (!this.playbackStatePollInFlight)
801
+ void this.syncMpvPlaybackState();
758
802
  }, 500);
759
803
  void this.pollMpvMetadata();
760
804
  void this.syncMpvPlaybackState();
@@ -773,15 +817,21 @@ export class PlayerController {
773
817
  if (this.backend !== 'mpv' || !this.process) {
774
818
  return;
775
819
  }
776
- const metadata = await this.queryMpv({ command: ['get_property', 'metadata'] }).catch(() => null);
777
- const elapsed = await this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null);
778
- if (typeof elapsed === 'number') {
779
- this.setState({ ...this.state, elapsedSeconds: Math.floor(elapsed) });
820
+ this.metadataPollInFlight = true;
821
+ try {
822
+ const metadata = await this.queryMpv({ command: ['get_property', 'metadata'] }).catch(() => null);
823
+ const elapsed = await this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null);
824
+ if (typeof elapsed === 'number') {
825
+ this.setState({ ...this.state, elapsedSeconds: Math.floor(elapsed) });
826
+ }
827
+ const title = extractMpvTitle(metadata);
828
+ if (title) {
829
+ await this.setMpvMediaTitle(title);
830
+ this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
831
+ }
780
832
  }
781
- const title = extractMpvTitle(metadata);
782
- if (title) {
783
- await this.setMpvMediaTitle(title);
784
- this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
833
+ finally {
834
+ this.metadataPollInFlight = false;
785
835
  }
786
836
  }
787
837
  async setMpvMediaTitle(title) {
@@ -796,22 +846,40 @@ export class PlayerController {
796
846
  if (this.backend !== 'mpv' || !this.process || !this.state.ready) {
797
847
  return false;
798
848
  }
799
- const paused = await this.queryMpv({ command: ['get_property', 'pause'] }).catch(() => null);
800
- if (typeof paused !== 'boolean') {
849
+ if (this.playbackStatePollInFlight)
801
850
  return false;
851
+ this.playbackStatePollInFlight = true;
852
+ try {
853
+ const paused = await this.queryMpv({ command: ['get_property', 'pause'] }).catch(() => null);
854
+ if (typeof paused !== 'boolean') {
855
+ return false;
856
+ }
857
+ const state = paused ? 'paused' : 'playing';
858
+ if (this.state.state !== state) {
859
+ this.setState({ ...this.state, state });
860
+ }
861
+ return true;
802
862
  }
803
- const state = paused ? 'paused' : 'playing';
804
- if (this.state.state !== state) {
805
- this.setState({ ...this.state, state });
863
+ finally {
864
+ this.playbackStatePollInFlight = false;
806
865
  }
807
- return true;
808
866
  }
809
867
  emitMetadata(metadata) {
810
868
  for (const listener of this.metadataListeners) {
811
- listener(metadata);
869
+ try {
870
+ listener(metadata);
871
+ }
872
+ catch {
873
+ // UI/storage listeners must not terminate the player polling loop.
874
+ }
812
875
  }
813
876
  }
814
877
  cleanupIpc() {
878
+ this.mpvSessionId += 1;
879
+ this.mpvIpcClient?.close();
880
+ this.mpvIpcClient = null;
881
+ this.pendingMpvVolume = null;
882
+ this.mpvVolumeFlush = null;
815
883
  if (this.ipcPath && !isWindowsNamedPipePath(this.ipcPath) && existsSync(this.ipcPath)) {
816
884
  try {
817
885
  unlinkSync(this.ipcPath);
@@ -853,6 +921,25 @@ function isWindowsNamedPipePath(path) {
853
921
  function clampVolume(volume) {
854
922
  return Math.min(100, Math.max(0, Math.round(volume)));
855
923
  }
924
+ function waitForChildExit(child, timeoutMs) {
925
+ if (child.exitCode === undefined) {
926
+ return Promise.resolve(true);
927
+ }
928
+ if (child.exitCode !== null) {
929
+ return Promise.resolve(true);
930
+ }
931
+ return new Promise(resolve => {
932
+ const onExit = () => {
933
+ clearTimeout(timeout);
934
+ resolve(true);
935
+ };
936
+ const timeout = setTimeout(() => {
937
+ child.off('exit', onExit);
938
+ resolve(false);
939
+ }, timeoutMs);
940
+ child.once('exit', onExit);
941
+ });
942
+ }
856
943
  function delay(ms) {
857
944
  return new Promise(resolve => setTimeout(resolve, ms));
858
945
  }
@@ -876,12 +963,6 @@ export function extractMpvTitle(metadata) {
876
963
  }
877
964
  return undefined;
878
965
  }
879
- function attachMpvRequestId(payload, requestId) {
880
- if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
881
- return { ...payload, request_id: requestId };
882
- }
883
- return { command: payload, request_id: requestId };
884
- }
885
966
  async function waitForStartupWindow(getProcess, ms) {
886
967
  const started = Date.now();
887
968
  while (Date.now() - started < ms) {
@@ -942,7 +1023,7 @@ function leadingMetadataPrefix(value) {
942
1023
  return cleanMediaTitle(value.slice(0, firstFieldIndex).replace(/[-–—:;,]\s*$/, ''));
943
1024
  }
944
1025
  function cleanMediaTitle(value) {
945
- const cleaned = value?.replace(/\s+/g, ' ').trim().replace(/^"+|"+$/g, '').trim();
1026
+ const cleaned = sanitizeTerminalText(value)?.replace(/^"+|"+$/g, '').trim();
946
1027
  return cleaned || undefined;
947
1028
  }
948
1029
  function stripIcyStreamTitleWrapper(value) {
@@ -1,30 +1,34 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
2
3
  import { homedir } from 'node:os';
3
- import { dirname, join } from 'node:path';
4
+ import { join } from 'node:path';
5
+ const maxCacheEntries = 256;
6
+ const maxCacheBytes = 128 * 1024 * 1024;
4
7
  export class ProviderCache {
5
8
  filePath;
6
9
  cache;
10
+ writesSincePrune = 0;
7
11
  constructor(filePath = defaultProviderCachePath()) {
8
12
  this.filePath = filePath;
9
13
  this.cache = this.read();
10
14
  }
11
15
  get(key, maxAgeMs) {
12
- const entry = this.cache.entries[key];
16
+ const entry = this.cache.entries[key] ?? this.readShard(key);
13
17
  if (!entry) {
14
18
  return null;
15
19
  }
16
20
  if (Date.now() - entry.createdAt > maxAgeMs) {
17
21
  return null;
18
22
  }
19
- return structuredClone(entry.value);
23
+ return entry.value;
20
24
  }
21
- getStale(key) {
22
- const entry = this.cache.entries[key];
23
- return entry ? structuredClone(entry.value) : null;
25
+ getStale(key, maxAgeMs = 30 * 24 * 60 * 60 * 1000) {
26
+ const entry = this.cache.entries[key] ?? this.readShard(key);
27
+ return entry && Date.now() - entry.createdAt <= maxAgeMs ? entry.value : null;
24
28
  }
25
29
  set(key, value) {
26
30
  this.cache.entries[key] = { createdAt: Date.now(), value };
27
- this.write();
31
+ this.writeEntry(key, this.cache.entries[key]);
28
32
  }
29
33
  read() {
30
34
  if (!existsSync(this.filePath)) {
@@ -39,9 +43,63 @@ export class ProviderCache {
39
43
  return { version: 1, entries: {} };
40
44
  }
41
45
  }
42
- write() {
43
- mkdirSync(dirname(this.filePath), { recursive: true });
44
- writeJsonAtomically(this.filePath, this.cache);
46
+ readShard(key) {
47
+ const path = join(this.shardDirectory(), `${createHash('sha256').update(key).digest('hex')}.json`);
48
+ if (!existsSync(path))
49
+ return undefined;
50
+ try {
51
+ const entry = JSON.parse(readFileSync(path, 'utf8'));
52
+ if (entry.version !== 1 || entry.key !== key || !Number.isFinite(entry.createdAt))
53
+ return undefined;
54
+ const cached = { createdAt: entry.createdAt, value: entry.value };
55
+ this.cache.entries[key] = cached;
56
+ return cached;
57
+ }
58
+ catch {
59
+ return undefined;
60
+ }
61
+ }
62
+ writeEntry(key, entry) {
63
+ const directory = this.shardDirectory();
64
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
65
+ const path = join(directory, `${createHash('sha256').update(key).digest('hex')}.json`);
66
+ writeJsonAtomically(path, { version: 1, key, ...entry });
67
+ this.writesSincePrune += 1;
68
+ if (this.writesSincePrune >= 16) {
69
+ this.writesSincePrune = 0;
70
+ this.pruneShards(directory);
71
+ }
72
+ }
73
+ pruneShards(directory) {
74
+ const files = readdirSync(directory)
75
+ .filter(name => name.endsWith('.json'))
76
+ .flatMap(name => {
77
+ try {
78
+ const path = join(directory, name);
79
+ const stat = statSync(path);
80
+ return [{ path, bytes: stat.size, modified: stat.mtimeMs }];
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ })
86
+ .sort((left, right) => right.modified - left.modified);
87
+ let totalBytes = files.reduce((total, file) => total + file.bytes, 0);
88
+ while (files.length > maxCacheEntries || totalBytes > maxCacheBytes) {
89
+ const file = files.pop();
90
+ if (!file)
91
+ break;
92
+ try {
93
+ unlinkSync(file.path);
94
+ totalBytes -= file.bytes;
95
+ }
96
+ catch {
97
+ // Cache eviction is best-effort.
98
+ }
99
+ }
100
+ }
101
+ shardDirectory() {
102
+ return `${this.filePath}.d`;
45
103
  }
46
104
  }
47
105
  function defaultProviderCachePath() {
@@ -57,7 +115,10 @@ function defaultProviderCachePath() {
57
115
  }
58
116
  function currentDefaultProviderCachePath() {
59
117
  if (process.platform === 'darwin') {
60
- return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli-cache.json');
118
+ return join(homedir(), 'Library', 'Caches', 'radiocli', 'radiocli-cache.json');
119
+ }
120
+ if (process.platform === 'win32') {
121
+ return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'radiocli-cache.json');
61
122
  }
62
123
  return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radiocli', 'radiocli-cache.json');
63
124
  }
@@ -70,8 +131,11 @@ function legacyDefaultProviderCachePath() {
70
131
  function writeJsonAtomically(filePath, value) {
71
132
  const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
72
133
  try {
73
- writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
134
+ writeFileSync(tempPath, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600 });
74
135
  renameSync(tempPath, filePath);
136
+ if (process.platform !== 'win32') {
137
+ chmodSync(filePath, 0o600);
138
+ }
75
139
  }
76
140
  catch (error) {
77
141
  rmSync(tempPath, { force: true });
@@ -13,8 +13,8 @@ export class ProviderManager {
13
13
  popular(limit) {
14
14
  return this.radioBrowser.popular(limit);
15
15
  }
16
- byCountry(countryCode, limit) {
17
- return this.radioBrowser.byCountry(countryCode, limit);
16
+ byCountry(countryCode, limit, offset) {
17
+ return this.radioBrowser.byCountry(countryCode, limit, offset);
18
18
  }
19
19
  nearby(location, limit) {
20
20
  return this.radioBrowser.nearby(location, limit);
@@ -23,13 +23,20 @@ export class ProviderManager {
23
23
  return this.radioBrowser.detectLocation();
24
24
  }
25
25
  async search(query, settings, options = {}) {
26
- const radioBrowser = await this.radioBrowser.search(query, options);
27
- if (!settings.enableRadioGarden && !options.includeExperimental) {
28
- return radioBrowser;
26
+ const includeGarden = (settings.enableRadioGarden || options.includeExperimental) && (options.offset ?? 0) === 0;
27
+ if (!includeGarden) {
28
+ return this.radioBrowser.search(query, options);
29
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);
30
+ const [browserResult, gardenResult] = await Promise.allSettled([
31
+ this.radioBrowser.search(query, options),
32
+ this.radioGarden.search(query, options)
33
+ ]);
34
+ const radioBrowser = browserResult.status === 'fulfilled' ? browserResult.value : [];
35
+ const radioGarden = gardenResult.status === 'fulfilled' ? gardenResult.value : [];
36
+ if (radioBrowser.length === 0 && radioGarden.length === 0 && browserResult.status === 'rejected') {
37
+ throw browserResult.reason;
38
+ }
39
+ return dedupeStations(interleave(radioBrowser, radioGarden)).slice(0, options.limit ?? 80);
33
40
  }
34
41
  async resolve(station) {
35
42
  if (station.provider === 'radio-garden') {
@@ -51,3 +58,16 @@ export class ProviderManager {
51
58
  };
52
59
  }
53
60
  }
61
+ function interleave(primary, secondary) {
62
+ const merged = [];
63
+ const length = Math.max(primary.length, secondary.length);
64
+ for (let index = 0; index < length; index += 1) {
65
+ const primaryStation = primary[index];
66
+ const secondaryStation = secondary[index];
67
+ if (primaryStation)
68
+ merged.push(primaryStation);
69
+ if (secondaryStation)
70
+ merged.push(secondaryStation);
71
+ }
72
+ return merged;
73
+ }