@ciphore/radiocli 0.2.3 → 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 (81) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +54 -10
  4. package/dist/agent/headless-host.js +39 -17
  5. package/dist/agent/launcher.js +6 -67
  6. package/dist/agent/mcp-install.js +15 -15
  7. package/dist/agent/service.js +3 -3
  8. package/dist/agent/session.js +13 -29
  9. package/dist/alarms/active-session.js +9 -12
  10. package/dist/alarms/guard.js +79 -36
  11. package/dist/alarms/inhibitor.js +27 -22
  12. package/dist/alarms/power-guard-store.js +2 -10
  13. package/dist/alarms/runner.js +56 -25
  14. package/dist/alarms/schedule.js +9 -2
  15. package/dist/alarms/scheduler.js +194 -52
  16. package/dist/alarms/setup-verification.js +1 -2
  17. package/dist/alarms/system-volume-ownership.js +267 -0
  18. package/dist/alarms/system-volume.js +155 -14
  19. package/dist/alarms/terminal-launcher.js +76 -136
  20. package/dist/alarms/tui-presence.js +2 -4
  21. package/dist/cli.js +103 -38
  22. package/dist/platform/capabilities.js +53 -0
  23. package/dist/platform/desktop.js +36 -0
  24. package/dist/{player/command.js → platform/executables.js} +43 -9
  25. package/dist/platform/ipc.js +14 -0
  26. package/dist/platform/launch-command.js +135 -0
  27. package/dist/platform/loopback.js +44 -0
  28. package/dist/platform/network.js +312 -0
  29. package/dist/platform/packages.js +216 -0
  30. package/dist/platform/paths.js +40 -0
  31. package/dist/platform/runtime.js +67 -0
  32. package/dist/platform/shell.js +24 -0
  33. package/dist/platform/storage.js +48 -0
  34. package/dist/platform/support.js +214 -0
  35. package/dist/platform/terminal.js +63 -0
  36. package/dist/platform/terminals.js +203 -0
  37. package/dist/player/airplay-discovery.js +4 -2
  38. package/dist/player/backend-install.js +13 -94
  39. package/dist/player/command-diagnostics.js +2 -2
  40. package/dist/player/mpv-ipc-client.js +2 -1
  41. package/dist/player/player-controller.js +203 -33
  42. package/dist/providers/cache.js +4 -26
  43. package/dist/providers/radio-browser.js +152 -54
  44. package/dist/providers/radio-garden.js +15 -22
  45. package/dist/setup.js +79 -151
  46. package/dist/storage/store.js +105 -52
  47. package/dist/streams/import-stream.js +163 -0
  48. package/dist/ui/AdaptiveContent.js +33 -24
  49. package/dist/ui/App.js +173 -86
  50. package/dist/ui/AppContent.js +3 -3
  51. package/dist/ui/app-state.js +8 -1
  52. package/dist/ui/ascii.js +11 -2
  53. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  54. package/dist/ui/components/Logo.js +5 -2
  55. package/dist/ui/components/Menu.js +2 -2
  56. package/dist/ui/components/ScreenHeader.js +1 -1
  57. package/dist/ui/components/StationList.js +6 -4
  58. package/dist/ui/components/TopTabs.js +1 -1
  59. package/dist/ui/display-context.js +8 -11
  60. package/dist/ui/help-content.js +5 -5
  61. package/dist/ui/layout.js +5 -2
  62. package/dist/ui/page-footer.js +3 -3
  63. package/dist/ui/screen-items.js +2 -2
  64. package/dist/ui/screen-meta.js +1 -1
  65. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  66. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  67. package/dist/ui/screens/ExploreScreen.js +2 -1
  68. package/dist/ui/screens/HelpScreen.js +5 -1
  69. package/dist/ui/screens/HomeScreen.js +5 -1
  70. package/dist/ui/screens/MapScreen.js +1 -1
  71. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  72. package/dist/ui/screens/SearchScreen.js +3 -1
  73. package/dist/ui/screens/SettingsScreen.js +14 -7
  74. package/dist/ui/screens/StatsScreen.js +3 -1
  75. package/dist/ui/system-actions.js +80 -52
  76. package/dist/ui/terminal-renderer.js +16 -0
  77. package/dist/ui/use-alarm-tui.js +36 -21
  78. package/dist/ui/use-app-input.js +42 -22
  79. package/dist/ui/use-command-executor.js +31 -7
  80. package/dist/update-check.js +8 -17
  81. package/package.json +1 -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
  }
@@ -244,6 +273,31 @@ export class PlayerController {
244
273
  return { ok: false, message: 'RadioCLI has no paused station to resume.' };
245
274
  return this.togglePause();
246
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
+ }
247
301
  setVolume(volume) {
248
302
  const clamped = clampVolume(volume);
249
303
  const unsupported = this.unsupportedFfplayControl();
@@ -287,9 +341,30 @@ export class PlayerController {
287
341
  return { ok: true };
288
342
  }
289
343
  async setMuted(muted) {
290
- if (this.state.muted === 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) {
291
364
  return { ok: true };
292
- return this.toggleMute();
365
+ }
366
+ this.setState({ ...this.state, muted });
367
+ return { ok: true };
293
368
  }
294
369
  stop() {
295
370
  if (this.stopPromise)
@@ -353,7 +428,7 @@ export class PlayerController {
353
428
  return { ok: true, message };
354
429
  }
355
430
  async refreshAirPlayDevices() {
356
- this.availableAirPlayDevices = await discoverAirPlayDevices();
431
+ this.availableAirPlayDevices = await discoverAirPlayDevices({ platform: this.runtime.platform });
357
432
  return [...this.availableAirPlayDevices];
358
433
  }
359
434
  detectedAirPlayDevices() {
@@ -388,6 +463,9 @@ export class PlayerController {
388
463
  playbackUnavailableMessage() {
389
464
  const preferred = this.getSettings().preferredBackend;
390
465
  if (preferred === 'airplay') {
466
+ if (!isAirPlayPlatformSupported(this.runtime.platform)) {
467
+ return airPlayMacOSOnlyMessage;
468
+ }
391
469
  return `AirPlay is not ready on this install. Run radiocli doctor. ${airPlaySenderHealth().message}`;
392
470
  }
393
471
  if (preferred === 'mpv' || preferred === 'ffplay') {
@@ -395,8 +473,9 @@ export class PlayerController {
395
473
  }
396
474
  return `No playback backend found. ${playbackBackendInstallHint()}`;
397
475
  }
398
- playWithMpv(url, initialTitle) {
399
- this.ipcPath = createMpvIpcPath();
476
+ playWithMpv(url, initialTitle, recoveredAudioOutput) {
477
+ this.playbackSessionId += 1;
478
+ this.ipcPath = mpvIpcPath();
400
479
  this.mpvIpcClient = new MpvIpcClient(this.ipcPath);
401
480
  this.mpvSessionId += 1;
402
481
  this.confirmedMpvVolume = clampVolume(this.getSettings().volume);
@@ -404,9 +483,11 @@ export class PlayerController {
404
483
  this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
405
484
  this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
406
485
  '--no-video',
407
- '--really-quiet',
486
+ '--msg-level=all=warn',
408
487
  '--force-window=no',
409
- ...(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
+ : []),
410
491
  `--force-media-title=${this.currentMpvMediaTitle}`,
411
492
  `--volume=${this.getSettings().volume}`,
412
493
  `--input-ipc-server=${this.ipcPath}`,
@@ -417,12 +498,14 @@ export class PlayerController {
417
498
  this.wireProcess();
418
499
  }
419
500
  playWithFfplay(url) {
501
+ this.playbackSessionId += 1;
420
502
  this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
421
503
  stdio: ['pipe', 'pipe', 'pipe']
422
504
  });
423
505
  this.wireProcess();
424
506
  }
425
507
  playWithVlc(url) {
508
+ this.playbackSessionId += 1;
426
509
  // VLC ships a `cvlc` shim for headless playback; fall back to the main `vlc`
427
510
  // binary (resolved from app bundles when off PATH) with a dummy interface.
428
511
  const binary = resolveCommand('cvlc') ?? resolveCommand('vlc') ?? 'cvlc';
@@ -650,24 +733,49 @@ export class PlayerController {
650
733
  }
651
734
  wireProcess() {
652
735
  const child = this.process;
653
- if (!child) {
736
+ const backend = this.backend;
737
+ if (!child || !backend) {
654
738
  return;
655
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
+ });
656
748
  // Local players can write diagnostics indefinitely. Drain both pipes so a
657
749
  // full OS pipe buffer can never stall playback.
658
750
  child.stdout.resume?.();
659
751
  child.stderr.resume?.();
660
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();
661
768
  this.setState({
662
769
  ...this.state,
663
- backend: this.backend ?? 'none',
770
+ backend,
664
771
  state: 'error',
665
772
  message: error.message,
666
773
  ready: false
667
774
  });
668
775
  });
669
- child.on('exit', code => {
776
+ child.on('exit', (code, signal) => {
670
777
  if (this.process === child) {
778
+ this.lastPlayerExit = { backend, code, signal, diagnostic };
671
779
  this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.'));
672
780
  if (this.backend === 'airplay') {
673
781
  this.currentAirPlayDevice = null;
@@ -789,18 +897,30 @@ export class PlayerController {
789
897
  async waitForReady(backend) {
790
898
  const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
791
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;
792
905
  while (Date.now() - started < timeoutMs) {
793
- if (!this.process) {
794
- 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);
795
911
  }
796
912
  if (backend === 'ffplay' || backend === 'vlc') {
797
- 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);
798
918
  return;
799
919
  }
800
- if (this.ipcPath) {
920
+ if (mpvClient) {
801
921
  try {
802
- await this.queryMpv({ command: ['get_property', 'path'] });
803
- if (await this.hasMpvAudioStarted()) {
922
+ await mpvClient.query({ command: ['get_property', 'path'] });
923
+ if (await this.hasMpvAudioStarted(mpvClient) && isCurrent()) {
804
924
  return;
805
925
  }
806
926
  }
@@ -810,13 +930,36 @@ export class PlayerController {
810
930
  }
811
931
  await delay(150);
812
932
  }
933
+ if (wasSuperseded()) {
934
+ throw new Error(`${backend} playback request was superseded.`);
935
+ }
936
+ if (this.process !== child)
937
+ throw this.playerExitedBeforeReady(backend);
813
938
  await this.stop();
814
939
  throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
815
940
  }
816
- 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) {
817
960
  const [timePos, audioPts] = await Promise.all([
818
- this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null),
819
- 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)
820
963
  ]);
821
964
  return typeof timePos === 'number' || typeof audioPts === 'number';
822
965
  }
@@ -884,6 +1027,9 @@ export class PlayerController {
884
1027
  if (typeof paused !== 'boolean') {
885
1028
  return false;
886
1029
  }
1030
+ if (!paused && this.state.state === 'paused' && !this.mpvLiveRetuneInFlight) {
1031
+ return (await this.resumeMpvAtLiveEdge()).ok;
1032
+ }
887
1033
  const state = paused ? 'paused' : 'playing';
888
1034
  if (this.state.state !== state) {
889
1035
  this.setState({ ...this.state, state });
@@ -928,12 +1074,6 @@ export class PlayerController {
928
1074
  }
929
1075
  }
930
1076
  }
931
- export function createMpvIpcPath(platform = process.platform, pid = process.pid, timestamp = Date.now()) {
932
- if (platform === 'win32') {
933
- return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`;
934
- }
935
- return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`);
936
- }
937
1077
  function airPlayWorkerPath() {
938
1078
  const currentPath = fileURLToPath(import.meta.url);
939
1079
  const extension = currentPath.endsWith('.ts') || currentPath.endsWith('.tsx') ? '.ts' : '.js';
@@ -993,15 +1133,45 @@ export function extractMpvTitle(metadata) {
993
1133
  }
994
1134
  return undefined;
995
1135
  }
996
- async function waitForStartupWindow(getProcess, ms) {
1136
+ async function waitForStartupWindow(getProcess, ms, exitedError = () => new Error('Player exited before the stream became ready.')) {
997
1137
  const started = Date.now();
998
1138
  while (Date.now() - started < ms) {
999
1139
  if (!getProcess()) {
1000
- throw new Error('Player exited before the stream became ready.');
1140
+ throw exitedError();
1001
1141
  }
1002
1142
  await delay(Math.min(100, ms - (Date.now() - started)));
1003
1143
  }
1004
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
+ }
1005
1175
  function cleanMetadataTitle(value) {
1006
1176
  const normalized = cleanMediaTitle(value);
1007
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
  }