@ciphore/radiocli 0.2.2 → 0.2.4

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 (96) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +84 -10
  4. package/dist/agent/alarm-service.js +210 -0
  5. package/dist/agent/cli.js +193 -0
  6. package/dist/agent/headless-host.js +165 -0
  7. package/dist/agent/launcher.js +10 -0
  8. package/dist/agent/mcp-install.js +467 -0
  9. package/dist/agent/mcp-server.js +139 -0
  10. package/dist/agent/service.js +347 -0
  11. package/dist/agent/session.js +232 -0
  12. package/dist/alarms/active-session.js +9 -12
  13. package/dist/alarms/cli.js +4 -1
  14. package/dist/alarms/guard.js +79 -36
  15. package/dist/alarms/inhibitor.js +27 -22
  16. package/dist/alarms/power-guard-store.js +2 -10
  17. package/dist/alarms/runner.js +96 -48
  18. package/dist/alarms/schedule.js +9 -2
  19. package/dist/alarms/scheduler.js +194 -52
  20. package/dist/alarms/setup-verification.js +1 -2
  21. package/dist/alarms/system-volume-ownership.js +267 -0
  22. package/dist/alarms/system-volume.js +155 -14
  23. package/dist/alarms/terminal-launcher.js +76 -136
  24. package/dist/alarms/tui-presence.js +2 -4
  25. package/dist/cli.js +157 -35
  26. package/dist/platform/capabilities.js +53 -0
  27. package/dist/platform/desktop.js +36 -0
  28. package/dist/{player/command.js → platform/executables.js} +43 -9
  29. package/dist/platform/ipc.js +14 -0
  30. package/dist/platform/launch-command.js +135 -0
  31. package/dist/platform/loopback.js +44 -0
  32. package/dist/platform/network.js +312 -0
  33. package/dist/platform/packages.js +216 -0
  34. package/dist/platform/paths.js +40 -0
  35. package/dist/platform/runtime.js +67 -0
  36. package/dist/platform/shell.js +24 -0
  37. package/dist/platform/storage.js +48 -0
  38. package/dist/platform/support.js +214 -0
  39. package/dist/platform/terminal.js +63 -0
  40. package/dist/platform/terminals.js +203 -0
  41. package/dist/player/airplay-discovery.js +4 -2
  42. package/dist/player/backend-install.js +13 -94
  43. package/dist/player/command-diagnostics.js +2 -2
  44. package/dist/player/mpv-ipc-client.js +2 -1
  45. package/dist/player/player-controller.js +220 -31
  46. package/dist/providers/cache.js +4 -26
  47. package/dist/providers/provider-manager.js +5 -0
  48. package/dist/providers/radio-browser.js +156 -54
  49. package/dist/providers/radio-garden.js +15 -22
  50. package/dist/setup.js +149 -152
  51. package/dist/storage/store.js +138 -53
  52. package/dist/streams/import-stream.js +163 -0
  53. package/dist/types.js +6 -0
  54. package/dist/ui/AdaptiveContent.js +54 -30
  55. package/dist/ui/App.js +466 -98
  56. package/dist/ui/AppContent.js +4 -4
  57. package/dist/ui/app-state.js +8 -1
  58. package/dist/ui/ascii.js +11 -2
  59. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  60. package/dist/ui/components/Logo.js +5 -2
  61. package/dist/ui/components/Menu.js +2 -2
  62. package/dist/ui/components/ScreenHeader.js +1 -1
  63. package/dist/ui/components/StationList.js +8 -8
  64. package/dist/ui/components/TopTabs.js +1 -1
  65. package/dist/ui/components/VersionIndicator.js +19 -0
  66. package/dist/ui/display-context.js +8 -11
  67. package/dist/ui/help-content.js +5 -5
  68. package/dist/ui/layout.js +5 -2
  69. package/dist/ui/page-footer.js +5 -3
  70. package/dist/ui/screen-items.js +42 -11
  71. package/dist/ui/screen-meta.js +1 -1
  72. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  73. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  74. package/dist/ui/screens/AlarmsScreen.js +2 -1
  75. package/dist/ui/screens/CountriesScreen.js +8 -5
  76. package/dist/ui/screens/ExploreScreen.js +2 -1
  77. package/dist/ui/screens/HelpScreen.js +5 -1
  78. package/dist/ui/screens/HomeScreen.js +7 -1
  79. package/dist/ui/screens/MapScreen.js +1 -1
  80. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  81. package/dist/ui/screens/SearchScreen.js +3 -1
  82. package/dist/ui/screens/SettingsScreen.js +80 -56
  83. package/dist/ui/screens/StatsScreen.js +3 -1
  84. package/dist/ui/system-actions.js +80 -52
  85. package/dist/ui/terminal-renderer.js +16 -0
  86. package/dist/ui/use-alarm-tui.js +40 -21
  87. package/dist/ui/use-app-input.js +69 -23
  88. package/dist/ui/use-command-executor.js +31 -7
  89. package/dist/ui/visualizers/gallop.js +118 -0
  90. package/dist/ui/visualizers/horse-stride.js +20 -0
  91. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  92. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  93. package/dist/ui/visualizers/retro-receivers.js +4 -0
  94. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  95. package/dist/update-check.js +33 -23
  96. package/package.json +4 -1
@@ -1,16 +1,17 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { existsSync, unlinkSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
3
  import { dirname, join } from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
- import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
7
- import { resolveCommand } from './command.js';
5
+ import { airPlayMacOSOnlyMessage, detectPlaybackBackends, ffplayLimitedControlsMessage, isAirPlayPlatformSupported, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
6
+ import { resolveCommand } from '../platform/executables.js';
8
7
  import { discoverAirPlayDevices } from './airplay-discovery.js';
9
8
  import { airPlaySenderHealth } from './airplay-sender-health.js';
10
9
  import { encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage } from './airplay-worker-protocol.js';
11
10
  import { safeMediaTarget, sanitizeTerminalText } from '../safety.js';
12
11
  import { MpvIpcClient } from './mpv-ipc-client.js';
12
+ import { mpvIpcPath } from '../platform/ipc.js';
13
13
  const minAirPlayTuneTimeoutSeconds = 30;
14
+ const maxPlayerDiagnosticCharacters = 4096;
14
15
  export class PlaybackOutputError extends Error {
15
16
  constructor(message) {
16
17
  super(message);
@@ -22,6 +23,7 @@ export function isPlaybackOutputError(error) {
22
23
  }
23
24
  export class PlayerController {
24
25
  getSettings;
26
+ runtime;
25
27
  process = null;
26
28
  backend = null;
27
29
  ipcPath = null;
@@ -46,13 +48,17 @@ export class PlayerController {
46
48
  currentMpvMediaTitle = null;
47
49
  metadataPollInFlight = false;
48
50
  playbackStatePollInFlight = false;
51
+ mpvLiveRetuneInFlight = false;
49
52
  pendingMpvVolume = null;
50
53
  mpvVolumeFlush = null;
51
54
  confirmedMpvVolume = 70;
52
55
  mpvSessionId = 0;
56
+ playbackSessionId = 0;
53
57
  stopPromise = null;
54
- constructor(getSettings) {
58
+ lastPlayerExit = null;
59
+ constructor(getSettings, runtime = { platform: process.platform, arch: process.arch, env: process.env }) {
55
60
  this.getSettings = getSettings;
61
+ this.runtime = runtime;
56
62
  }
57
63
  onChange(listener) {
58
64
  this.listeners.add(listener);
@@ -84,7 +90,7 @@ export class PlayerController {
84
90
  return [...(this.availableBackends ?? [])];
85
91
  }
86
92
  refreshDetectedBackends() {
87
- this.availableBackends = detectPlaybackBackends();
93
+ this.availableBackends = detectPlaybackBackends({ platform: this.runtime.platform });
88
94
  return this.detectedBackends();
89
95
  }
90
96
  async play(station, url) {
@@ -175,7 +181,27 @@ export class PlayerController {
175
181
  });
176
182
  if (backend === 'mpv') {
177
183
  this.playWithMpv(url, station.name);
178
- await this.waitForReady(backend);
184
+ try {
185
+ await this.waitForReady(backend);
186
+ }
187
+ catch (error) {
188
+ if (!this.shouldRetryMpvWithAlsa(error))
189
+ throw error;
190
+ await this.stop();
191
+ this.backend = backend;
192
+ this.setState({
193
+ backend,
194
+ state: 'loading',
195
+ message: `Opening ${station.name}`,
196
+ volume: this.getSettings().volume,
197
+ muted: false,
198
+ stationName: station.name,
199
+ streamUrl: url,
200
+ ready: false
201
+ });
202
+ this.playWithMpv(url, station.name, 'alsa,');
203
+ await this.waitForReady(backend);
204
+ }
179
205
  }
180
206
  else if (backend === 'ffplay') {
181
207
  this.playWithFfplay(url);
@@ -209,6 +235,9 @@ export class PlayerController {
209
235
  return unsupported;
210
236
  }
211
237
  if (this.backend === 'mpv') {
238
+ if (this.state.state === 'paused') {
239
+ return this.resumeMpvAtLiveEdge();
240
+ }
212
241
  try {
213
242
  await this.sendMpv({ command: ['cycle', 'pause'] });
214
243
  }
@@ -230,6 +259,45 @@ export class PlayerController {
230
259
  });
231
260
  return { ok: true };
232
261
  }
262
+ async pause() {
263
+ if (this.state.state === 'paused')
264
+ return { ok: true };
265
+ if (this.state.state !== 'playing')
266
+ return { ok: false, message: 'RadioCLI is not currently playing.' };
267
+ return this.togglePause();
268
+ }
269
+ async resume() {
270
+ if (this.state.state === 'playing')
271
+ return { ok: true };
272
+ if (this.state.state !== 'paused')
273
+ return { ok: false, message: 'RadioCLI has no paused station to resume.' };
274
+ return this.togglePause();
275
+ }
276
+ async resumeMpvAtLiveEdge() {
277
+ if (this.mpvLiveRetuneInFlight)
278
+ return { ok: true, message: 'Reconnecting at the live broadcast.' };
279
+ const url = this.state.streamUrl ? safeMediaTarget(this.state.streamUrl) : null;
280
+ if (!url)
281
+ return { ok: false, message: 'The paused station no longer has a usable stream URL.' };
282
+ this.mpvLiveRetuneInFlight = true;
283
+ try {
284
+ // Reloading the same URL discards mpv's delayed live buffer and opens a
285
+ // fresh stream connection. This keeps radio pause/play semantics at the
286
+ // live edge instead of behaving like time-shifted on-demand audio.
287
+ await this.sendMpv({ command: ['loadfile', url, 'replace'] });
288
+ await this.sendMpv({ command: ['set_property', 'pause', false] });
289
+ }
290
+ catch {
291
+ return { ok: false, message: 'mpv could not reconnect the paused station at the live edge.' };
292
+ }
293
+ finally {
294
+ this.mpvLiveRetuneInFlight = false;
295
+ }
296
+ this.currentMpvMediaTitle = cleanMediaTitle(this.state.stationName ?? '') ?? 'RadioCLI';
297
+ this.emitMetadata({ updatedAt: new Date().toISOString() });
298
+ this.setState({ ...this.state, state: 'playing', ready: true, startedAt: new Date().toISOString() });
299
+ return { ok: true, message: 'Reconnected at the live broadcast.' };
300
+ }
233
301
  setVolume(volume) {
234
302
  const clamped = clampVolume(volume);
235
303
  const unsupported = this.unsupportedFfplayControl();
@@ -272,6 +340,32 @@ export class PlayerController {
272
340
  this.setState({ ...this.state, muted });
273
341
  return { ok: true };
274
342
  }
343
+ async setMuted(muted) {
344
+ const unsupported = this.unsupportedFfplayControl();
345
+ if (unsupported)
346
+ return this.state.muted === muted ? { ok: true } : unsupported;
347
+ // mpv can inherit mute=yes from its configuration, so the controller's
348
+ // startup default is not authoritative. Explicit setters must always reach
349
+ // the backend even when the requested value matches our local state.
350
+ if (this.backend === 'mpv') {
351
+ try {
352
+ await this.sendMpv({ command: ['set_property', 'mute', muted] });
353
+ }
354
+ catch {
355
+ return { ok: false, message: 'mpv did not acknowledge the mute change.' };
356
+ }
357
+ }
358
+ else if (this.backend === 'airplay') {
359
+ if (!this.sendAirPlayCommand({ type: 'setMuted', muted })) {
360
+ return { ok: false, message: 'The AirPlay worker is not available.' };
361
+ }
362
+ }
363
+ else if (this.state.muted === muted) {
364
+ return { ok: true };
365
+ }
366
+ this.setState({ ...this.state, muted });
367
+ return { ok: true };
368
+ }
275
369
  stop() {
276
370
  if (this.stopPromise)
277
371
  return this.stopPromise;
@@ -334,7 +428,7 @@ export class PlayerController {
334
428
  return { ok: true, message };
335
429
  }
336
430
  async refreshAirPlayDevices() {
337
- this.availableAirPlayDevices = await discoverAirPlayDevices();
431
+ this.availableAirPlayDevices = await discoverAirPlayDevices({ platform: this.runtime.platform });
338
432
  return [...this.availableAirPlayDevices];
339
433
  }
340
434
  detectedAirPlayDevices() {
@@ -369,6 +463,9 @@ export class PlayerController {
369
463
  playbackUnavailableMessage() {
370
464
  const preferred = this.getSettings().preferredBackend;
371
465
  if (preferred === 'airplay') {
466
+ if (!isAirPlayPlatformSupported(this.runtime.platform)) {
467
+ return airPlayMacOSOnlyMessage;
468
+ }
372
469
  return `AirPlay is not ready on this install. Run radiocli doctor. ${airPlaySenderHealth().message}`;
373
470
  }
374
471
  if (preferred === 'mpv' || preferred === 'ffplay') {
@@ -376,8 +473,9 @@ export class PlayerController {
376
473
  }
377
474
  return `No playback backend found. ${playbackBackendInstallHint()}`;
378
475
  }
379
- playWithMpv(url, initialTitle) {
380
- this.ipcPath = createMpvIpcPath();
476
+ playWithMpv(url, initialTitle, recoveredAudioOutput) {
477
+ this.playbackSessionId += 1;
478
+ this.ipcPath = mpvIpcPath();
381
479
  this.mpvIpcClient = new MpvIpcClient(this.ipcPath);
382
480
  this.mpvSessionId += 1;
383
481
  this.confirmedMpvVolume = clampVolume(this.getSettings().volume);
@@ -385,9 +483,11 @@ export class PlayerController {
385
483
  this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
386
484
  this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
387
485
  '--no-video',
388
- '--really-quiet',
486
+ '--msg-level=all=warn',
389
487
  '--force-window=no',
390
- ...(process.env.RADIOCLI_MPV_AUDIO_OUTPUT ? [`--ao=${process.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] : []),
488
+ ...(recoveredAudioOutput || this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT
489
+ ? [`--ao=${recoveredAudioOutput ?? this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT}`]
490
+ : []),
391
491
  `--force-media-title=${this.currentMpvMediaTitle}`,
392
492
  `--volume=${this.getSettings().volume}`,
393
493
  `--input-ipc-server=${this.ipcPath}`,
@@ -398,12 +498,14 @@ export class PlayerController {
398
498
  this.wireProcess();
399
499
  }
400
500
  playWithFfplay(url) {
501
+ this.playbackSessionId += 1;
401
502
  this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
402
503
  stdio: ['pipe', 'pipe', 'pipe']
403
504
  });
404
505
  this.wireProcess();
405
506
  }
406
507
  playWithVlc(url) {
508
+ this.playbackSessionId += 1;
407
509
  // VLC ships a `cvlc` shim for headless playback; fall back to the main `vlc`
408
510
  // binary (resolved from app bundles when off PATH) with a dummy interface.
409
511
  const binary = resolveCommand('cvlc') ?? resolveCommand('vlc') ?? 'cvlc';
@@ -631,24 +733,49 @@ export class PlayerController {
631
733
  }
632
734
  wireProcess() {
633
735
  const child = this.process;
634
- if (!child) {
736
+ const backend = this.backend;
737
+ if (!child || !backend) {
635
738
  return;
636
739
  }
740
+ this.lastPlayerExit = null;
741
+ let diagnostic = '';
742
+ child.stdout.on('data', chunk => {
743
+ diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk));
744
+ });
745
+ child.stderr.on('data', chunk => {
746
+ diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk));
747
+ });
637
748
  // Local players can write diagnostics indefinitely. Drain both pipes so a
638
749
  // full OS pipe buffer can never stall playback.
639
750
  child.stdout.resume?.();
640
751
  child.stderr.resume?.();
641
752
  child.on('error', error => {
753
+ diagnostic = appendPlayerDiagnostic(diagnostic, error.message);
754
+ if (this.process !== child)
755
+ return;
756
+ this.lastPlayerExit = { backend, code: null, signal: null, diagnostic, spawnError: true };
757
+ this.rejectPendingAirPlayReady(error);
758
+ this.rejectPendingAirPlayRetune(error);
759
+ if (backend === 'airplay') {
760
+ this.currentAirPlayDevice = null;
761
+ this.currentAirPlayDeviceId = null;
762
+ this.pendingAirPlayPasscode = null;
763
+ this.airPlaySessionEstablished = false;
764
+ }
765
+ this.process = null;
766
+ this.stopMpvPolling();
767
+ this.cleanupIpc();
642
768
  this.setState({
643
769
  ...this.state,
644
- backend: this.backend ?? 'none',
770
+ backend,
645
771
  state: 'error',
646
772
  message: error.message,
647
773
  ready: false
648
774
  });
649
775
  });
650
- child.on('exit', code => {
776
+ child.on('exit', (code, signal) => {
651
777
  if (this.process === child) {
778
+ this.lastPlayerExit = { backend, code, signal, diagnostic };
652
779
  this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.'));
653
780
  if (this.backend === 'airplay') {
654
781
  this.currentAirPlayDevice = null;
@@ -770,18 +897,30 @@ export class PlayerController {
770
897
  async waitForReady(backend) {
771
898
  const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
772
899
  const started = Date.now();
900
+ const child = this.process;
901
+ const playbackSessionId = this.playbackSessionId;
902
+ const mpvClient = this.mpvIpcClient;
903
+ const wasSuperseded = () => this.playbackSessionId !== playbackSessionId;
904
+ const isCurrent = () => !wasSuperseded() && this.process === child;
773
905
  while (Date.now() - started < timeoutMs) {
774
- if (!this.process) {
775
- throw new Error('Player exited before the stream became ready.');
906
+ if (wasSuperseded()) {
907
+ throw new Error(`${backend} playback request was superseded.`);
908
+ }
909
+ if (!child || this.process !== child) {
910
+ throw this.playerExitedBeforeReady(backend);
776
911
  }
777
912
  if (backend === 'ffplay' || backend === 'vlc') {
778
- await waitForStartupWindow(() => this.process, Math.min(500, timeoutMs));
913
+ await waitForStartupWindow(() => this.process === child ? child : null, Math.min(500, timeoutMs), () => this.playerExitedBeforeReady(backend));
914
+ if (wasSuperseded())
915
+ throw new Error(`${backend} playback request was superseded.`);
916
+ if (!isCurrent())
917
+ throw this.playerExitedBeforeReady(backend);
779
918
  return;
780
919
  }
781
- if (this.ipcPath) {
920
+ if (mpvClient) {
782
921
  try {
783
- await this.queryMpv({ command: ['get_property', 'path'] });
784
- if (await this.hasMpvAudioStarted()) {
922
+ await mpvClient.query({ command: ['get_property', 'path'] });
923
+ if (await this.hasMpvAudioStarted(mpvClient) && isCurrent()) {
785
924
  return;
786
925
  }
787
926
  }
@@ -791,13 +930,36 @@ export class PlayerController {
791
930
  }
792
931
  await delay(150);
793
932
  }
933
+ if (wasSuperseded()) {
934
+ throw new Error(`${backend} playback request was superseded.`);
935
+ }
936
+ if (this.process !== child)
937
+ throw this.playerExitedBeforeReady(backend);
794
938
  await this.stop();
795
939
  throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
796
940
  }
797
- async hasMpvAudioStarted() {
941
+ shouldRetryMpvWithAlsa(error) {
942
+ return this.runtime.platform === 'linux' &&
943
+ (this.runtime.arch === 'arm' || this.runtime.arch === 'arm64') &&
944
+ !this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT &&
945
+ isAudioOutputInitializationFailure(this.lastPlayerExit?.diagnostic ?? '') &&
946
+ isPlaybackOutputError(error);
947
+ }
948
+ playerExitedBeforeReady(backend) {
949
+ const exit = this.lastPlayerExit?.backend === backend ? this.lastPlayerExit : null;
950
+ const reason = exit
951
+ ? exit.signal ? `signal ${exit.signal}` : exit.code === null ? 'without an exit code' : `code ${exit.code}`
952
+ : 'before its exit status was available';
953
+ const diagnostic = concisePlayerDiagnostic(exit?.diagnostic ?? '');
954
+ const message = `${backend} exited before the stream became ready (${reason})${diagnostic ? `: ${diagnostic}` : '.'}`;
955
+ return isBackendInitializationFailure(backend, exit, diagnostic)
956
+ ? new PlaybackOutputError(message)
957
+ : new Error(message);
958
+ }
959
+ async hasMpvAudioStarted(client) {
798
960
  const [timePos, audioPts] = await Promise.all([
799
- this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null),
800
- this.queryMpv({ command: ['get_property', 'audio-pts'] }).catch(() => null)
961
+ client.query({ command: ['get_property', 'time-pos'] }).catch(() => null),
962
+ client.query({ command: ['get_property', 'audio-pts'] }).catch(() => null)
801
963
  ]);
802
964
  return typeof timePos === 'number' || typeof audioPts === 'number';
803
965
  }
@@ -865,6 +1027,9 @@ export class PlayerController {
865
1027
  if (typeof paused !== 'boolean') {
866
1028
  return false;
867
1029
  }
1030
+ if (!paused && this.state.state === 'paused' && !this.mpvLiveRetuneInFlight) {
1031
+ return (await this.resumeMpvAtLiveEdge()).ok;
1032
+ }
868
1033
  const state = paused ? 'paused' : 'playing';
869
1034
  if (this.state.state !== state) {
870
1035
  this.setState({ ...this.state, state });
@@ -909,12 +1074,6 @@ export class PlayerController {
909
1074
  }
910
1075
  }
911
1076
  }
912
- export function createMpvIpcPath(platform = process.platform, pid = process.pid, timestamp = Date.now()) {
913
- if (platform === 'win32') {
914
- return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`;
915
- }
916
- return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`);
917
- }
918
1077
  function airPlayWorkerPath() {
919
1078
  const currentPath = fileURLToPath(import.meta.url);
920
1079
  const extension = currentPath.endsWith('.ts') || currentPath.endsWith('.tsx') ? '.ts' : '.js';
@@ -974,15 +1133,45 @@ export function extractMpvTitle(metadata) {
974
1133
  }
975
1134
  return undefined;
976
1135
  }
977
- async function waitForStartupWindow(getProcess, ms) {
1136
+ async function waitForStartupWindow(getProcess, ms, exitedError = () => new Error('Player exited before the stream became ready.')) {
978
1137
  const started = Date.now();
979
1138
  while (Date.now() - started < ms) {
980
1139
  if (!getProcess()) {
981
- throw new Error('Player exited before the stream became ready.');
1140
+ throw exitedError();
982
1141
  }
983
1142
  await delay(Math.min(100, ms - (Date.now() - started)));
984
1143
  }
985
1144
  }
1145
+ function appendPlayerDiagnostic(current, chunk) {
1146
+ const combined = current + chunk;
1147
+ return combined.length <= maxPlayerDiagnosticCharacters
1148
+ ? combined
1149
+ : combined.slice(-maxPlayerDiagnosticCharacters);
1150
+ }
1151
+ function concisePlayerDiagnostic(value) {
1152
+ const lines = value
1153
+ .split(/\r?\n/)
1154
+ .map(line => sanitizeTerminalText(line.replace(/https?:\/\/\S+/gi, '[stream URL]')))
1155
+ .filter((line) => Boolean(line))
1156
+ .slice(-3)
1157
+ .join(' · ')
1158
+ .slice(0, 700);
1159
+ return lines;
1160
+ }
1161
+ function isAudioOutputInitializationFailure(value) {
1162
+ return /audio output initialization failed|could not open\/initialize audio device|failed to initialize audio driver/i.test(value);
1163
+ }
1164
+ function isBackendInitializationFailure(backend, exit, diagnostic) {
1165
+ if (isAudioOutputInitializationFailure(diagnostic))
1166
+ return true;
1167
+ if (exit?.spawnError)
1168
+ return true;
1169
+ if (/unknown (?:command line )?option|error initializing|failed to (?:create|connect to) .*(?:socket|pipe)/i.test(diagnostic))
1170
+ return true;
1171
+ // mpv reserves status 1 for player initialization and invalid options. A
1172
+ // stream that could not be played uses status 2 and remains skippable.
1173
+ return backend === 'mpv' && exit?.code === 1;
1174
+ }
986
1175
  function cleanMetadataTitle(value) {
987
1176
  const normalized = cleanMediaTitle(value);
988
1177
  if (!normalized) {
@@ -1,6 +1,6 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
- import { homedir } from 'node:os';
3
+ import { platformPaths } from '../platform/paths.js';
4
4
  import { join } from 'node:path';
5
5
  const maxCacheEntries = 256;
6
6
  const maxCacheBytes = 128 * 1024 * 1024;
@@ -103,30 +103,8 @@ export class ProviderCache {
103
103
  }
104
104
  }
105
105
  function defaultProviderCachePath() {
106
- if (process.env.RADIOCLI_HOME) {
107
- return join(process.env.RADIOCLI_HOME, 'radiocli-cache.json');
108
- }
109
- if (process.env.RADIO_ATLAS_HOME) {
110
- return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas-cache.json');
111
- }
112
- const currentPath = currentDefaultProviderCachePath();
113
- const legacyPath = legacyDefaultProviderCachePath();
114
- return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath;
115
- }
116
- function currentDefaultProviderCachePath() {
117
- if (process.platform === 'darwin') {
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');
122
- }
123
- return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radiocli', 'radiocli-cache.json');
124
- }
125
- function legacyDefaultProviderCachePath() {
126
- if (process.platform === 'darwin') {
127
- return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas-cache.json');
128
- }
129
- return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radio-atlas', 'radio-atlas-cache.json');
106
+ const { cache, legacyCache } = platformPaths();
107
+ return existsSync(cache) || !existsSync(legacyCache) ? cache : legacyCache;
130
108
  }
131
109
  function writeJsonAtomically(filePath, value) {
132
110
  const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
@@ -142,7 +120,7 @@ function writeJsonAtomically(filePath, value) {
142
120
  throw error;
143
121
  }
144
122
  }
145
- export function backupBadFile(filePath) {
123
+ function backupBadFile(filePath) {
146
124
  if (!existsSync(filePath)) {
147
125
  return;
148
126
  }
@@ -16,6 +16,11 @@ export class ProviderManager {
16
16
  byCountry(countryCode, limit, offset) {
17
17
  return this.radioBrowser.byCountry(countryCode, limit, offset);
18
18
  }
19
+ byId(provider, id) {
20
+ if (provider !== 'radio-browser')
21
+ return Promise.resolve(null);
22
+ return this.radioBrowser.byId(id);
23
+ }
19
24
  nearby(location, limit) {
20
25
  return this.radioBrowser.nearby(location, limit);
21
26
  }