@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
@@ -3,14 +3,14 @@ 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
- const mpvAudioStartFallbackMs = 1500;
14
14
  export class PlaybackOutputError extends Error {
15
15
  constructor(message) {
16
16
  super(message);
@@ -25,6 +25,7 @@ export class PlayerController {
25
25
  process = null;
26
26
  backend = null;
27
27
  ipcPath = null;
28
+ mpvIpcClient = null;
28
29
  metadataTimer = null;
29
30
  playbackStateTimer = null;
30
31
  state = { backend: 'none', state: 'idle', volume: 70, muted: false, ready: false };
@@ -42,8 +43,13 @@ export class PlayerController {
42
43
  currentAirPlayDevice = null;
43
44
  currentAirPlayDeviceId = null;
44
45
  pendingAirPlayPasscode = null;
45
- nextMpvRequestId = 1;
46
46
  currentMpvMediaTitle = null;
47
+ metadataPollInFlight = false;
48
+ playbackStatePollInFlight = false;
49
+ pendingMpvVolume = null;
50
+ mpvVolumeFlush = null;
51
+ confirmedMpvVolume = 70;
52
+ mpvSessionId = 0;
47
53
  constructor(getSettings) {
48
54
  this.getSettings = getSettings;
49
55
  }
@@ -81,6 +87,11 @@ export class PlayerController {
81
87
  return this.detectedBackends();
82
88
  }
83
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;
84
95
  const backend = this.selectBackend();
85
96
  if (!backend) {
86
97
  await this.stop();
@@ -197,7 +208,12 @@ export class PlayerController {
197
208
  return unsupported;
198
209
  }
199
210
  if (this.backend === 'mpv') {
200
- 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
+ }
201
217
  const synced = await this.syncMpvPlaybackState();
202
218
  if (synced) {
203
219
  return { ok: true };
@@ -213,22 +229,24 @@ export class PlayerController {
213
229
  });
214
230
  return { ok: true };
215
231
  }
216
- async setVolume(volume) {
232
+ setVolume(volume) {
217
233
  const clamped = clampVolume(volume);
218
234
  const unsupported = this.unsupportedFfplayControl();
219
235
  if (unsupported) {
220
- return unsupported;
236
+ return Promise.resolve(unsupported);
221
237
  }
222
238
  if (this.backend === 'mpv') {
223
- await this.sendMpv({ command: ['set_property', 'volume', clamped] }).catch(() => undefined);
239
+ return this.queueMpvVolume(clamped);
224
240
  }
225
241
  else if (this.backend === 'airplay') {
226
- 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
+ }
227
245
  }
228
246
  this.setState({ ...this.state, volume: clamped });
229
- return { ok: true };
247
+ return Promise.resolve({ ok: true });
230
248
  }
231
- async adjustVolume(delta) {
249
+ adjustVolume(delta) {
232
250
  return this.setVolume(this.state.volume + delta);
233
251
  }
234
252
  async toggleMute() {
@@ -238,15 +256,23 @@ export class PlayerController {
238
256
  return unsupported;
239
257
  }
240
258
  if (this.backend === 'mpv') {
241
- 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
+ }
242
265
  }
243
266
  else if (this.backend === 'airplay') {
244
- this.sendAirPlayCommand({ type: 'setMuted', muted });
267
+ if (!this.sendAirPlayCommand({ type: 'setMuted', muted })) {
268
+ return { ok: false, message: 'The AirPlay worker is not available.' };
269
+ }
245
270
  }
246
271
  this.setState({ ...this.state, muted });
247
272
  return { ok: true };
248
273
  }
249
274
  async stop() {
275
+ const child = this.process;
250
276
  this.stopMpvPolling();
251
277
  this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
252
278
  this.rejectPendingAirPlayRetune(new Error('AirPlay playback stopped.'));
@@ -260,10 +286,16 @@ export class PlayerController {
260
286
  this.pendingAirPlayPasscode = null;
261
287
  this.airPlaySessionEstablished = false;
262
288
  }
263
- if (this.process && !this.process.killed) {
264
- 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;
265
298
  }
266
- this.process = null;
267
299
  this.cleanupIpc();
268
300
  this.setState({
269
301
  ...this.state,
@@ -335,11 +367,16 @@ export class PlayerController {
335
367
  }
336
368
  playWithMpv(url, initialTitle) {
337
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;
338
374
  this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
339
375
  this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
340
376
  '--no-video',
341
377
  '--really-quiet',
342
378
  '--force-window=no',
379
+ ...(process.env.RADIOCLI_MPV_AUDIO_OUTPUT ? [`--ao=${process.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] : []),
343
380
  `--force-media-title=${this.currentMpvMediaTitle}`,
344
381
  `--volume=${this.getSettings().volume}`,
345
382
  `--input-ipc-server=${this.ipcPath}`,
@@ -451,6 +488,7 @@ export class PlayerController {
451
488
  const workerPath = airPlayWorkerPath();
452
489
  const workerArgs = airPlayWorkerArgs(workerPath, encodeWorkerStart({
453
490
  streamUrl: url,
491
+ ffmpegPath: resolveCommand('ffmpeg') ?? 'ffmpeg',
454
492
  stationName,
455
493
  volume: this.getSettings().volume,
456
494
  muted: false,
@@ -585,6 +623,10 @@ export class PlayerController {
585
623
  if (!child) {
586
624
  return;
587
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?.();
588
630
  child.on('error', error => {
589
631
  this.setState({
590
632
  ...this.state,
@@ -619,6 +661,53 @@ export class PlayerController {
619
661
  sendMpv(payload) {
620
662
  return this.queryMpv(payload).then(() => undefined);
621
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
+ }
622
711
  sendAirPlayCommand(command) {
623
712
  if (this.backend === 'airplay' && this.process && !this.process.killed) {
624
713
  try {
@@ -662,72 +751,14 @@ export class PlayerController {
662
751
  rejecter?.(error);
663
752
  }
664
753
  queryMpv(payload) {
665
- if (!this.ipcPath) {
754
+ if (!this.mpvIpcClient) {
666
755
  return Promise.resolve(null);
667
756
  }
668
- return new Promise((resolve, reject) => {
669
- const socket = new Socket();
670
- let buffer = '';
671
- let settled = false;
672
- const requestId = this.nextMpvRequestId;
673
- this.nextMpvRequestId = this.nextMpvRequestId >= Number.MAX_SAFE_INTEGER ? 1 : this.nextMpvRequestId + 1;
674
- const requestPayload = attachMpvRequestId(payload, requestId);
675
- const settle = (callback) => {
676
- if (settled) {
677
- return;
678
- }
679
- settled = true;
680
- clearTimeout(timeout);
681
- socket.end();
682
- callback();
683
- };
684
- const timeout = setTimeout(() => {
685
- socket.destroy();
686
- settle(() => reject(new Error('mpv IPC timed out.')));
687
- }, 1000);
688
- socket.once('error', error => {
689
- settle(() => reject(error));
690
- });
691
- socket.on('data', chunk => {
692
- buffer += chunk.toString('utf8');
693
- let newlineIndex = buffer.indexOf('\n');
694
- while (newlineIndex !== -1) {
695
- const line = buffer.slice(0, newlineIndex);
696
- buffer = buffer.slice(newlineIndex + 1);
697
- newlineIndex = buffer.indexOf('\n');
698
- if (!line.trim()) {
699
- continue;
700
- }
701
- try {
702
- const parsed = JSON.parse(line);
703
- if (parsed.request_id !== requestId) {
704
- continue;
705
- }
706
- if (parsed.error && parsed.error !== 'success') {
707
- settle(() => reject(new Error(`mpv IPC failed: ${parsed.error}`)));
708
- }
709
- else {
710
- settle(() => resolve(parsed.data ?? null));
711
- }
712
- }
713
- catch {
714
- settle(() => resolve(null));
715
- }
716
- }
717
- });
718
- socket.connect(this.ipcPath, () => {
719
- socket.write(`${JSON.stringify(requestPayload)}\n`, error => {
720
- if (error) {
721
- settle(() => reject(error));
722
- }
723
- });
724
- });
725
- });
757
+ return this.mpvIpcClient.query(payload);
726
758
  }
727
759
  async waitForReady(backend) {
728
760
  const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
729
761
  const started = Date.now();
730
- let mpvPathReadyAt = 0;
731
762
  while (Date.now() - started < timeoutMs) {
732
763
  if (!this.process) {
733
764
  throw new Error('Player exited before the stream became ready.');
@@ -739,13 +770,9 @@ export class PlayerController {
739
770
  if (this.ipcPath) {
740
771
  try {
741
772
  await this.queryMpv({ command: ['get_property', 'path'] });
742
- mpvPathReadyAt = mpvPathReadyAt || Date.now();
743
773
  if (await this.hasMpvAudioStarted()) {
744
774
  return;
745
775
  }
746
- if (Date.now() - mpvPathReadyAt >= mpvAudioStartFallbackMs) {
747
- return;
748
- }
749
776
  }
750
777
  catch {
751
778
  // The IPC socket can exist briefly before accepting commands.
@@ -766,10 +793,12 @@ export class PlayerController {
766
793
  startMpvMetadataPolling() {
767
794
  this.stopMpvPolling();
768
795
  this.metadataTimer = setInterval(() => {
769
- void this.pollMpvMetadata();
796
+ if (!this.metadataPollInFlight)
797
+ void this.pollMpvMetadata();
770
798
  }, 2500);
771
799
  this.playbackStateTimer = setInterval(() => {
772
- void this.syncMpvPlaybackState();
800
+ if (!this.playbackStatePollInFlight)
801
+ void this.syncMpvPlaybackState();
773
802
  }, 500);
774
803
  void this.pollMpvMetadata();
775
804
  void this.syncMpvPlaybackState();
@@ -788,15 +817,21 @@ export class PlayerController {
788
817
  if (this.backend !== 'mpv' || !this.process) {
789
818
  return;
790
819
  }
791
- const metadata = await this.queryMpv({ command: ['get_property', 'metadata'] }).catch(() => null);
792
- const elapsed = await this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null);
793
- if (typeof elapsed === 'number') {
794
- 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
+ }
795
832
  }
796
- const title = extractMpvTitle(metadata);
797
- if (title) {
798
- await this.setMpvMediaTitle(title);
799
- this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
833
+ finally {
834
+ this.metadataPollInFlight = false;
800
835
  }
801
836
  }
802
837
  async setMpvMediaTitle(title) {
@@ -811,22 +846,40 @@ export class PlayerController {
811
846
  if (this.backend !== 'mpv' || !this.process || !this.state.ready) {
812
847
  return false;
813
848
  }
814
- const paused = await this.queryMpv({ command: ['get_property', 'pause'] }).catch(() => null);
815
- if (typeof paused !== 'boolean') {
849
+ if (this.playbackStatePollInFlight)
816
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;
817
862
  }
818
- const state = paused ? 'paused' : 'playing';
819
- if (this.state.state !== state) {
820
- this.setState({ ...this.state, state });
863
+ finally {
864
+ this.playbackStatePollInFlight = false;
821
865
  }
822
- return true;
823
866
  }
824
867
  emitMetadata(metadata) {
825
868
  for (const listener of this.metadataListeners) {
826
- listener(metadata);
869
+ try {
870
+ listener(metadata);
871
+ }
872
+ catch {
873
+ // UI/storage listeners must not terminate the player polling loop.
874
+ }
827
875
  }
828
876
  }
829
877
  cleanupIpc() {
878
+ this.mpvSessionId += 1;
879
+ this.mpvIpcClient?.close();
880
+ this.mpvIpcClient = null;
881
+ this.pendingMpvVolume = null;
882
+ this.mpvVolumeFlush = null;
830
883
  if (this.ipcPath && !isWindowsNamedPipePath(this.ipcPath) && existsSync(this.ipcPath)) {
831
884
  try {
832
885
  unlinkSync(this.ipcPath);
@@ -868,6 +921,25 @@ function isWindowsNamedPipePath(path) {
868
921
  function clampVolume(volume) {
869
922
  return Math.min(100, Math.max(0, Math.round(volume)));
870
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
+ }
871
943
  function delay(ms) {
872
944
  return new Promise(resolve => setTimeout(resolve, ms));
873
945
  }
@@ -891,12 +963,6 @@ export function extractMpvTitle(metadata) {
891
963
  }
892
964
  return undefined;
893
965
  }
894
- function attachMpvRequestId(payload, requestId) {
895
- if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
896
- return { ...payload, request_id: requestId };
897
- }
898
- return { command: payload, request_id: requestId };
899
- }
900
966
  async function waitForStartupWindow(getProcess, ms) {
901
967
  const started = Date.now();
902
968
  while (Date.now() - started < ms) {
@@ -957,7 +1023,7 @@ function leadingMetadataPrefix(value) {
957
1023
  return cleanMediaTitle(value.slice(0, firstFieldIndex).replace(/[-–—:;,]\s*$/, ''));
958
1024
  }
959
1025
  function cleanMediaTitle(value) {
960
- const cleaned = value?.replace(/\s+/g, ' ').trim().replace(/^"+|"+$/g, '').trim();
1026
+ const cleaned = sanitizeTerminalText(value)?.replace(/^"+|"+$/g, '').trim();
961
1027
  return cleaned || undefined;
962
1028
  }
963
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 });
@@ -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) || (options.offset ?? 0) > 0) {
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
+ }