@ciphore/radiocli 0.2.4 → 0.2.5

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 (134) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/CONTRIBUTING.md +10 -0
  3. package/README.md +108 -253
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +178 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +113 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +249 -76
  71. package/dist/ui/AdaptiveContent.js +18 -10
  72. package/dist/ui/App.js +708 -454
  73. package/dist/ui/AppContent.js +5 -5
  74. package/dist/ui/airplay-settings.js +12 -5
  75. package/dist/ui/bidi-ranges.js +45 -0
  76. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  77. package/dist/ui/format.js +37 -4
  78. package/dist/ui/help-content.js +5 -2
  79. package/dist/ui/rtl-output.js +137 -0
  80. package/dist/ui/screen-items.js +10 -4
  81. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  82. package/dist/ui/screens/AlarmsScreen.js +5 -2
  83. package/dist/ui/screens/HelpScreen.js +3 -3
  84. package/dist/ui/screens/SettingsScreen.js +16 -16
  85. package/dist/ui/screens/StatsScreen.js +1 -1
  86. package/dist/ui/text-direction.js +9 -0
  87. package/dist/ui/use-alarm-tui.js +24 -24
  88. package/dist/ui/use-app-input.js +42 -8
  89. package/dist/ui/use-command-executor.js +21 -14
  90. package/dist/ui/visualizers/gallop.js +1 -1
  91. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  92. package/dist/update-check.js +2 -1
  93. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  94. package/package.json +34 -11
  95. package/scripts/build-airplay-native.mjs +22 -0
  96. package/vendor/node-airtunes2/LICENSE +208 -0
  97. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  98. package/vendor/node-airtunes2/README.md +225 -0
  99. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  100. package/vendor/node-airtunes2/binding.gyp +41 -0
  101. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  102. package/vendor/node-airtunes2/lib/atv.js +307 -0
  103. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  104. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  105. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  106. package/vendor/node-airtunes2/lib/config.js +26 -0
  107. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  108. package/vendor/node-airtunes2/lib/devices.js +266 -0
  109. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  110. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  111. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  112. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  113. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  114. package/vendor/node-airtunes2/lib/http.js +189 -0
  115. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  116. package/vendor/node-airtunes2/lib/index.js +127 -0
  117. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  118. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  119. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  120. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  121. package/vendor/node-airtunes2/lib/srp.js +158 -0
  122. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  123. package/vendor/node-airtunes2/lib/util.js +12 -0
  124. package/vendor/node-airtunes2/package.json +31 -0
  125. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  126. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  127. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  128. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  129. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  130. package/vendor/node-airtunes2/src/base64.c +136 -0
  131. package/vendor/node-airtunes2/src/base64.h +42 -0
  132. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  133. package/vendor/node-airtunes2/src/codec.cc +170 -0
  134. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export async function renderTui(printHelp, encodedCommand) {
3
+ if (!process.stdin.isTTY) {
4
+ console.error('The interactive TUI needs terminal input. Use radiocli commands in a headless session, or connect with a terminal (for example, ssh -t).');
5
+ printHelp();
6
+ process.exitCode = 1;
7
+ return;
8
+ }
9
+ const { configureTerminalRenderer } = await import('../ui/terminal-renderer.js');
10
+ const terminal = await configureTerminalRenderer(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
11
+ const { rtlSafeOutput } = await import('../ui/rtl-output.js');
12
+ const [{ render }, { App }, { decodeAgentCommand }, { openTuiSession }] = await Promise.all([import('ink'), import('../ui/App.js'), import('../agent/service.js'), import('../agent/tui-session.js')]);
13
+ const initialAgentCommand = encodedCommand ? decodeAgentCommand(encodedCommand) : undefined;
14
+ const { session: playbackSession, warning: playbackRecoveryWarning } = await openTuiSession();
15
+ try {
16
+ const app = render(_jsx(App, { playbackSession: playbackSession, playbackRecoveryWarning: playbackRecoveryWarning, initialAgentCommand: initialAgentCommand }), {
17
+ stdout: terminal.interactive && process.stdout.isTTY && !terminal.screenReader ? rtlSafeOutput(process.stdout) : process.stdout,
18
+ // App owns Ctrl+C so it can confirm before performing a clean shutdown.
19
+ exitOnCtrlC: false,
20
+ isScreenReaderEnabled: terminal.screenReader,
21
+ interactive: terminal.interactive ? undefined : false,
22
+ debug: !terminal.interactive,
23
+ kittyKeyboard: { mode: terminal.interactive ? 'auto' : 'disabled', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
24
+ });
25
+ await app.waitUntilExit();
26
+ }
27
+ catch (error) {
28
+ playbackSession?.disconnect();
29
+ if (playbackSession)
30
+ console.error('The TUI closed unexpectedly. Playback continues; reopen radiocli to reconnect, or run radiocli stop.');
31
+ throw error;
32
+ }
33
+ await playbackSession?.close();
34
+ }
package/dist/cli.js CHANGED
@@ -1,38 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { jsx as _jsx } from "react/jsx-runtime";
3
- import { realpathSync } from 'node:fs';
2
+ import { readFileSync, realpathSync } from 'node:fs';
4
3
  import { resolve } from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
- import { ProviderManager } from './providers/provider-manager.js';
7
- import { PlayerController } from './player/player-controller.js';
8
- import { defaultLibraryPath, JsonLibraryStore } from './storage/store.js';
9
- import { parsePlaylistFile, writeM3u } from './playlists/playlist.js';
10
- import { importStreamUrl } from './streams/import-stream.js';
11
- import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
12
- import { resolveCommand } from './platform/executables.js';
13
- import { diagnoseCommand } from './player/command-diagnostics.js';
14
- import { airPlaySenderHealth } from './player/airplay-sender-health.js';
15
5
  import { appVersion } from './version.js';
16
- import { checkForUpdate, installUpdate, updateCommandForInstall } from './update-check.js';
17
- import { runAlarmCommand } from './alarms/cli.js';
18
- import { createSchedulerAdapter } from './alarms/scheduler.js';
19
- import { runSetup } from './setup.js';
20
- import { runAgentCliCommand, runMcpCommand } from './agent/cli.js';
21
- import { decodeAgentCommand } from './agent/service.js';
22
- import { runHeadlessAgentHost } from './agent/headless-host.js';
23
- import { configureMcpIntegrations } from './agent/mcp-install.js';
24
- import { defaultAgentControlSettings } from './types.js';
25
- import { identifyPlatform } from './platform/runtime.js';
26
- import { platformCapabilities } from './platform/capabilities.js';
27
- import { hasGraphicalSession } from './platform/desktop.js';
28
- import { detectPackageManager } from './platform/packages.js';
29
- import { storageReadiness } from './platform/storage.js';
30
- import { resolveTerminalCapabilities } from './platform/terminal.js';
31
- import { networkDiagnostic } from './platform/network.js';
32
- import { assessPlatformSupport } from './platform/support.js';
6
+ import { completionCandidates, isListenerCommand, listenerCommands, listenerHelp } from './cli/arguments.js';
7
+ import { sanitizeTerminalText } from './safety.js';
33
8
  const runtime = { nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url) };
34
9
  if (isDirectRun(process.argv[1], import.meta.url)) {
35
10
  const args = process.argv.slice(2);
11
+ process.stdout.on('error', error => {
12
+ if (error.code === 'EPIPE')
13
+ process.exit(0);
14
+ else
15
+ throw error;
16
+ });
36
17
  if (args[0] === 'agent-ui') {
37
18
  const encoded = args[1];
38
19
  if (!encoded)
@@ -40,12 +21,14 @@ if (isDirectRun(process.argv[1], import.meta.url)) {
40
21
  await renderTui(encoded);
41
22
  }
42
23
  else if (args[0] === 'agent-host') {
24
+ const { runHeadlessAgentHost } = await import('./agent/headless-host.js');
43
25
  await runHeadlessAgentHost();
44
26
  }
45
27
  else if (args.length > 0) {
46
28
  await runCommand(args).catch(error => {
47
- console.error(error instanceof Error ? error.message : String(error));
48
- process.exitCode = 1;
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ console.error(message.split('\n').map(line => sanitizeTerminalText(line) ?? '').join('\n'));
31
+ process.exitCode = error instanceof Error && error.name === 'AbortError' ? 130 : 1;
49
32
  });
50
33
  }
51
34
  else {
@@ -53,83 +36,115 @@ if (isDirectRun(process.argv[1], import.meta.url)) {
53
36
  }
54
37
  }
55
38
  async function renderTui(encodedCommand) {
56
- if (!process.stdin.isTTY) {
57
- console.error('The interactive TUI needs terminal input. Use radiocli commands in a headless session, or connect with a terminal (for example, ssh -t).');
58
- printHelp();
59
- process.exitCode = 1;
60
- return;
61
- }
62
- const { configureTerminalRenderer } = await import('./ui/terminal-renderer.js');
63
- const terminal = await configureTerminalRenderer(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
64
- const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
65
- render(_jsx(App, { initialAgentCommand: encodedCommand ? decodeAgentCommand(encodedCommand) : undefined }), {
66
- // App owns Ctrl+C so it can confirm before performing a clean shutdown.
67
- exitOnCtrlC: false,
68
- isScreenReaderEnabled: terminal.screenReader,
69
- interactive: terminal.interactive ? undefined : false,
70
- debug: !terminal.interactive,
71
- kittyKeyboard: { mode: terminal.interactive ? 'auto' : 'disabled', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
72
- });
39
+ const { renderTui: render } = await import('./cli/render-tui.js');
40
+ await render(printHelp, encodedCommand);
73
41
  }
74
42
  export async function runCommand(args) {
75
43
  const [command, ...rest] = args;
76
44
  if (!command || command === 'help' || command === '--help' || command === '-h') {
77
- printHelp();
45
+ if (command === 'help' && rest[0] && rest[0] !== '--all') {
46
+ await runCommand([rest[0], '--help']);
47
+ return;
48
+ }
49
+ printHelp(rest.includes('--all'));
78
50
  return;
79
51
  }
80
52
  if (command === 'version' || command === '--version' || command === '-v') {
81
53
  console.log(appVersion());
82
54
  return;
83
55
  }
56
+ if (command === '_complete') {
57
+ let presets = [];
58
+ if (rest.length > 1 && ['play', 'favorite', 'unfavorite', 'preset'].includes(rest[0])) {
59
+ try {
60
+ // Completion is read-only, including when a library needs repair.
61
+ const { defaultLibraryPath } = await import('./storage/store.js');
62
+ const library = JSON.parse(readFileSync(defaultLibraryPath(), 'utf8'));
63
+ if (Array.isArray(library.presets))
64
+ presets = library.presets.flatMap(preset => typeof preset?.name === 'string' && /^[a-z0-9][a-z0-9_-]{0,39}$/.test(preset.name) ? [preset.name] : []);
65
+ }
66
+ catch { /* Completion must remain quiet when storage is unavailable. */ }
67
+ }
68
+ const candidates = completionCandidates(rest, presets);
69
+ if (candidates.length)
70
+ console.log(candidates.join('\n'));
71
+ return;
72
+ }
73
+ const optionArgs = rest.includes('--') ? rest.slice(0, rest.indexOf('--')) : rest;
74
+ const wantsHelp = optionArgs.includes('--help') || optionArgs.includes('-h');
75
+ if (isListenerCommand(command)) {
76
+ if (wantsHelp)
77
+ console.log(listenerHelp(command));
78
+ else {
79
+ const { runListenerCommand } = await import('./cli/listener.js');
80
+ await runListenerCommand(command, rest, runtime);
81
+ }
82
+ return;
83
+ }
84
+ if (command === 'agent' && wantsHelp) {
85
+ const { agentCommandUsage } = await import('./agent/cli.js');
86
+ console.log(`${agentCommandUsage()}\nJSON controls for local agents. Everyday listening is available directly: radiocli play, now, favorites.`);
87
+ return;
88
+ }
89
+ const commandHelp = legacyCommandHelp()[command];
90
+ if (wantsHelp && commandHelp) {
91
+ console.log(commandHelp);
92
+ return;
93
+ }
84
94
  if (command === 'alarm') {
95
+ const { runAlarmCommand } = await import('./alarms/cli.js');
85
96
  await runAlarmCommand(rest);
86
97
  return;
87
98
  }
88
99
  if (command === 'mcp') {
100
+ const { runMcpCommand } = await import('./agent/cli.js');
89
101
  await runMcpCommand(rest, runtime);
90
102
  return;
91
103
  }
92
104
  if (command === 'agent') {
105
+ const { runAgentCliCommand } = await import('./agent/cli.js');
93
106
  await runAgentCliCommand(rest, runtime);
94
107
  return;
95
108
  }
96
109
  if (command === 'setup') {
97
110
  if (rest.includes('--help') || rest.includes('-h')) {
98
- printSetupHelp();
111
+ await printSetupHelp();
99
112
  return;
100
113
  }
114
+ const { runSetup } = await import('./setup.js');
101
115
  await runSetup({ args: rest });
102
116
  return;
103
117
  }
118
+ if (command === 'media-controls') {
119
+ if (rest.length > 1 || rest[0] !== undefined && !['auto', 'on', 'off'].includes(rest[0])) {
120
+ throw new Error('Usage: radiocli media-controls [auto|on|off]');
121
+ }
122
+ const [{ JsonLibraryStore }, { desktopMediaPolicy }] = await Promise.all([import('./storage/store.js'), import('./platform/desktop-media.js')]);
123
+ const store = new JsonLibraryStore();
124
+ if (rest[0])
125
+ store.updateSettings({ desktopMediaControls: rest[0] });
126
+ const policy = desktopMediaPolicy(store.snapshot().settings);
127
+ console.log(`desktop_media_preference=${store.snapshot().settings.desktopMediaControls ?? 'auto'}`);
128
+ console.log(`desktop_media_effective=${policy.preference} ${policy.enabled ? 'enabled' : 'disabled'}`);
129
+ console.log(`desktop_media_note=${policy.reason}`);
130
+ if (rest[0])
131
+ console.log('Restart active RadioCLI sessions to apply this preference. RADIOCLI_DESKTOP_MEDIA=off overrides the saved preference.');
132
+ return;
133
+ }
104
134
  if (!isKnownCommand(command)) {
105
- throw new Error(`Unknown command: ${command}\nRun radiocli help.`);
135
+ const suggestion = suggestCommand(command);
136
+ throw new Error(`Unknown command: ${command}${suggestion ? `\nDid you mean radiocli ${suggestion}?` : ''}\nRun radiocli help.`);
106
137
  }
107
138
  if (command === 'doctor') {
108
- const backends = detectPlaybackBackends();
109
- const mpvDiagnostic = diagnoseCommand('mpv');
110
- const report = await doctorReport(backends, mpvDiagnostic);
111
- if (rest.includes('--json')) {
112
- console.log(JSON.stringify(report, null, 2));
113
- return;
114
- }
115
- console.log(`backends=${backends.join(',') || 'none'}`);
116
- printMpvDiagnostic(mpvDiagnostic);
117
- printPlaybackBackendStatus(backends);
118
- for (const [name, capability] of Object.entries(report.capabilities)) {
119
- console.log(`capability_${name}=${capability.status} ${capability.message}`);
120
- }
121
- console.log(`network=${report.network.status} ${report.network.message}`);
122
- console.log(`support_tier=${report.support.tier} runtime=${report.support.runtime.status}`);
123
- console.log(`support_scope=${report.support.scope}`);
124
- console.log('support_note=Doctor checks this installation without running packed-install or audio acceptance tests; reference CI results are documented in the platform matrix.');
125
- for (const reason of report.support.reasons)
126
- console.log(`support_note=${reason}`);
139
+ const { runDoctor } = await import('./cli/doctor.js');
140
+ await runDoctor(rest);
127
141
  return;
128
142
  }
129
143
  if (command === 'update') {
130
144
  const unknown = rest.filter(arg => arg !== '--install');
131
145
  if (unknown.length > 0)
132
146
  throw new Error('Usage: radiocli update [--install]');
147
+ const { checkForUpdate, installUpdate, updateCommandForInstall } = await import('./update-check.js');
133
148
  const updateCheck = await checkForUpdate();
134
149
  const updateCommand = updateCommandForInstall();
135
150
  if (updateCheck.error) {
@@ -146,9 +161,11 @@ export async function runCommand(args) {
146
161
  if (!result.ok)
147
162
  throw new Error(`Update install failed. Run manually: ${result.command}${result.output ? `\n${result.output}` : ''}`);
148
163
  console.log('updated=yes');
164
+ const [{ JsonLibraryStore }, { defaultAgentControlSettings }] = await Promise.all([import('./storage/store.js'), import('./types.js')]);
149
165
  const agentControl = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings;
150
166
  if (agentControl.enabled) {
151
167
  try {
168
+ const { configureMcpIntegrations } = await import('./agent/mcp-install.js');
152
169
  const repaired = await configureMcpIntegrations(true, runtime);
153
170
  const failed = repaired.filter(item => item.status === 'failed');
154
171
  console.log(`mcp_repaired=${failed.length ? 'partial' : 'yes'}`);
@@ -164,6 +181,9 @@ export async function runCommand(args) {
164
181
  return;
165
182
  }
166
183
  if (command === 'check') {
184
+ const [{ JsonLibraryStore }, { ProviderManager }, { PlayerController }, { playbackBackendStatusLines }] = await Promise.all([
185
+ import('./storage/store.js'), import('./providers/provider-manager.js'), import('./player/player-controller.js'), import('./player/backend-install.js')
186
+ ]);
167
187
  const store = new JsonLibraryStore();
168
188
  const providers = new ProviderManager();
169
189
  const player = new PlayerController(() => store.snapshot().settings);
@@ -171,40 +191,22 @@ export async function runCommand(args) {
171
191
  const health = await providers.health(store.snapshot().settings);
172
192
  console.log(`store=${store.filePath}`);
173
193
  console.log(`backends=${backends.join(',') || 'none'}`);
174
- printPlaybackBackendStatus(backends);
194
+ for (const line of playbackBackendStatusLines(backends))
195
+ console.log(line);
175
196
  for (const [provider, status] of Object.entries(health)) {
176
197
  console.log(`${provider}=${status}`);
177
198
  }
178
199
  return;
179
200
  }
180
- if (command === 'countries') {
181
- const providers = new ProviderManager();
182
- const countries = await providers.countries(30);
183
- for (const country of countries) {
184
- console.log(`${country.code}\t${country.stationCount}\t${country.name}`);
185
- }
186
- return;
187
- }
188
- if (command === 'search') {
189
- const query = rest.join(' ').trim();
190
- if (!query) {
191
- throw new Error('Usage: radiocli search <query>');
192
- }
193
- const store = new JsonLibraryStore();
194
- const providers = new ProviderManager();
195
- const stations = await providers.search(query, store.snapshot().settings, { limit: 20 });
196
- for (const station of stations) {
197
- console.log(`${station.provider}:${station.id}\t${station.name}\t${station.country ?? ''}\t${station.codec ?? ''}\t${station.bitrate ?? ''}`);
198
- }
199
- return;
200
- }
201
201
  if (command === 'import') {
202
202
  const target = rest[0];
203
203
  if (!target) {
204
204
  throw new Error('Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]');
205
205
  }
206
+ const { JsonLibraryStore } = await import('./storage/store.js');
206
207
  const store = new JsonLibraryStore();
207
208
  if (/^https?:\/\//i.test(target)) {
209
+ const { importStreamUrl } = await import('./streams/import-stream.js');
208
210
  const result = await importStreamUrl(target, rest.slice(1).join(' ') || undefined);
209
211
  store.addImported([result.station]);
210
212
  console.log(`imported=${result.station.name}`);
@@ -212,12 +214,14 @@ export async function runCommand(args) {
212
214
  console.log(`warning=${result.warning}`);
213
215
  return;
214
216
  }
217
+ const { parsePlaylistFile } = await import('./playlists/playlist.js');
215
218
  const stations = parsePlaylistFile(target);
216
219
  store.addImported(stations);
217
220
  console.log(`imported=${stations.length}`);
218
221
  return;
219
222
  }
220
223
  if (command === 'export') {
224
+ const [{ JsonLibraryStore }, { writeM3u }] = await Promise.all([import('./storage/store.js'), import('./playlists/playlist.js')]);
221
225
  const file = rest[0] ?? 'radiocli-favorites.m3u';
222
226
  const store = new JsonLibraryStore();
223
227
  const state = store.snapshot();
@@ -226,25 +230,90 @@ export async function runCommand(args) {
226
230
  return;
227
231
  }
228
232
  }
229
- export function printHelp() {
233
+ export function printHelp(all = false) {
230
234
  console.log(`RadioCLI
231
235
 
232
- Usage:
233
- radiocli Start the TUI
234
- radiocli version Print the installed version
235
- radiocli check Show provider/backend health
236
- radiocli doctor [--json] Show local playback setup guidance
237
- radiocli setup Install and verify native playback tools
238
- radiocli mcp <command> Install, inspect, or run the MCP integration
239
- radiocli agent <command> Scriptable radio controls for local agents
240
- radiocli update Show update availability and install command
241
- radiocli update --install Install the latest release and repair enabled MCP entries
242
- radiocli countries Print top countries
243
- radiocli search <query> Search public stations
236
+ Usage: radiocli [command]
237
+ Run radiocli without a command to open the TUI.
238
+
239
+ Everyday listening:
240
+ radiocli play [station] Start or continue listening; names and presets work
241
+ radiocli pause Pause; use play to continue
242
+ radiocli next Tune the next station in the current queue
243
+ radiocli now See the current station, track, and volume
244
+ radiocli volume <0-100> Set listening volume
245
+ radiocli favorite Save the current station
246
+ radiocli favorites List saved stations; add --pick to choose one
247
+ radiocli stop Stop playback and close the background player
248
+ ${all ? `
249
+ More listening controls:
250
+ radiocli previous Tune the previous station in the queue
251
+ radiocli resume Resume paused playback (play also works)
252
+ radiocli mute | unmute Mute or restore sound
253
+ radiocli now --watch Watch station and track changes
254
+ radiocli status Alias for now
255
+ radiocli play --favorites | --recent | --popular
256
+ Choose a queue; add --random or --pick
257
+
258
+ Discovery and library:
259
+ radiocli search <query> Search stations; --country, --limit, --pick
260
+ radiocli countries List top countries
261
+ radiocli recent List recently played stations
262
+ radiocli unfavorite Remove a favorite (current station by default)
263
+ radiocli preset save <name> Save a shortcut; play with play <name>
264
+ radiocli preset remove <name> Remove a shortcut
265
+ radiocli presets List shortcuts; also: preset list
266
+ radiocli stats Show listening time and streaks
244
267
  radiocli import <target> Import a stream URL or local playlist
245
- radiocli export [file] Export favorites/imports as .m3u
268
+ radiocli export [file] Export favorites/imports as .m3u
246
269
  radiocli alarm <command> Manage alarms and scheduled radio
247
- `);
270
+
271
+ Setup and integrations:
272
+ radiocli setup Install and verify native playback tools
273
+ radiocli doctor [--json] Show local playback setup guidance
274
+ radiocli check Show provider/backend health
275
+ radiocli version Print the installed version
276
+ radiocli update Check for updates; --install to install
277
+ radiocli media-controls [auto|on|off] Configure Linux desktop media controls
278
+ radiocli mcp <command> Install, inspect, or run the MCP integration
279
+ radiocli agent <command> Scriptable radio controls for local agents
280
+ ` : ''}
281
+ Examples:
282
+ radiocli play jazz Find a station and listen
283
+ radiocli play Continue the current or last station
284
+ radiocli play --favorites Listen from your favorites
285
+
286
+ More help:
287
+ radiocli help --all Browse every command by category
288
+ radiocli help play Options and examples for one command
289
+ radiocli completion <shell> Print a Tab completion script
290
+ ${all ? '\nListener commands support --json, --plain, --quiet, and --no-input.\nUse radiocli <command> --help for options and examples.\n' : ''}
291
+ `.trimEnd());
292
+ }
293
+ function legacyCommandHelp() {
294
+ return {
295
+ check: 'Usage: radiocli check\nShow local store paths, playback backends, and provider health.',
296
+ doctor: 'Usage: radiocli doctor [--json] [--playback]\nCheck playback tools without changing settings. Add --playback to rehearse local WAV playback through null/dummy audio.',
297
+ update: 'Usage: radiocli update [--install]\nCheck for updates. Add --install to install and repair enabled MCP entries.',
298
+ import: 'Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]\nExample: radiocli import https://example.com/live.mp3 "Example FM"',
299
+ export: 'Usage: radiocli export [file]\nExport favorites and imports as M3U (default: radiocli-favorites.m3u).',
300
+ 'media-controls': 'Usage: radiocli media-controls [auto|on|off]\nInspect or configure Linux desktop media controls.',
301
+ mcp: 'Usage: radiocli mcp <enable|install|repair|disable|status|config|serve>\nManage the optional local agent integration.'
302
+ };
303
+ }
304
+ function suggestCommand(input) {
305
+ const candidates = [...listenerCommands, ...Object.keys(legacyCommandHelp()), 'setup', 'alarm', 'agent', 'help', 'version'];
306
+ const distance = (word) => {
307
+ let row = Array.from({ length: word.length + 1 }, (_, index) => index);
308
+ for (let i = 1; i <= input.length; i += 1) {
309
+ const next = [i];
310
+ for (let j = 1; j <= word.length; j += 1)
311
+ next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + (input[i - 1] === word[j - 1] ? 0 : 1));
312
+ row = next;
313
+ }
314
+ return row[word.length];
315
+ };
316
+ return candidates.map(word => ({ word, distance: distance(word) })).filter(item => item.distance <= (input.length > 3 ? 2 : 1)).sort((a, b) => a.distance - b.distance)[0]?.word;
248
317
  }
249
318
  export function isDirectRun(entryPath, moduleUrl) {
250
319
  if (!entryPath) {
@@ -260,7 +329,8 @@ export function isDirectRun(entryPath, moduleUrl) {
260
329
  function isKnownCommand(command) {
261
330
  return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'alarm', 'mcp', 'agent'].includes(command);
262
331
  }
263
- function printSetupHelp() {
332
+ async function printSetupHelp() {
333
+ const { isAirPlayPlatformSupported } = await import('./player/airplay-policy.js');
264
334
  console.log(`RadioCLI Setup
265
335
 
266
336
  Usage:
@@ -273,100 +343,9 @@ Usage:
273
343
  radiocli setup --mcp --agent-ui Open a terminal TUI for agent playback (default)
274
344
  radiocli setup --mcp --headless-agent Opt out of external terminal windows
275
345
  radiocli setup --no-mcp Disable and remove agent MCP entries
276
- radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
346
+ ${process.platform === 'linux' && isAirPlayPlatformSupported() ? ' radiocli setup --airplay Enable experimental Linux AirPlay and set up prerequisites\n radiocli setup --no-airplay Disable Linux AirPlay; keep shared services and packages\n' : ''} radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
277
347
  apt, dnf, pacman, apk, zypper,
278
348
  pkg (FreeBSD), pkg_add, pkgin,
279
349
  termux-pkg (Termux), or pkgman (Haiku)
280
350
  `);
281
351
  }
282
- function printPlaybackBackendStatus(backends) {
283
- for (const line of playbackBackendStatusLines(backends)) {
284
- console.log(line);
285
- }
286
- }
287
- async function doctorReport(backends, mpvDiagnostic) {
288
- const commands = Object.fromEntries(['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]));
289
- const airPlay = airPlaySenderHealth();
290
- const host = identifyPlatform();
291
- const terminal = resolveTerminalCapabilities(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
292
- let scheduler;
293
- try {
294
- const adapter = createSchedulerAdapter();
295
- scheduler = await adapter.probeCapabilities?.();
296
- }
297
- catch {
298
- scheduler = { supported: false, exactWake: false, catchUpAfterWake: false, message: 'The optional scheduler probe failed. Run radiocli alarm doctor for repair guidance.' };
299
- }
300
- const integrationCommands = [
301
- 'launchctl', 'schtasks.exe', 'systemctl', 'caffeinate', 'systemd-inhibit',
302
- 'powershell.exe', 'pwsh.exe', 'osascript', 'wpctl', 'pactl', 'amixer',
303
- 'open', 'explorer', 'xdg-open', 'pbcopy', 'clip', 'wl-copy', 'xclip', 'xsel',
304
- 'termux-open-url', 'termux-clipboard-set'
305
- ].filter(command => resolveCommand(command) !== null);
306
- const capabilities = platformCapabilities(host, {
307
- backends,
308
- commands: integrationCommands,
309
- graphicalSession: hasGraphicalSession(host),
310
- packageManager: detectPackageManager(process.platform, host.osRelease),
311
- storageWritable: libraryWritesAvailable(),
312
- scheduler,
313
- unicode: terminal.unicode,
314
- color: terminal.colorLevel > 0,
315
- screenReader: terminal.screenReader
316
- });
317
- return {
318
- radioCliVersion: appVersion(),
319
- nodeVersion: process.version,
320
- platform: process.platform,
321
- architecture: process.arch,
322
- host: { id: host.id, arch: host.arch, armVersion: host.armVersion, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl },
323
- support: { scope: 'current-installation', ...assessPlatformSupport(host) },
324
- capabilities,
325
- terminal,
326
- network: networkDiagnostic(),
327
- backends,
328
- commands,
329
- mpv: redactDiagnostic(mpvDiagnostic),
330
- airPlay: {
331
- available: airPlay.available,
332
- safe: airPlay.safe,
333
- package: airPlay.packageName,
334
- version: airPlay.version ?? null,
335
- vulnerablePackages: airPlay.vulnerablePackages,
336
- warningPackages: airPlay.warningPackages
337
- },
338
- guidance: playbackBackendStatusLines(backends)
339
- };
340
- }
341
- function libraryWritesAvailable() {
342
- try {
343
- return storageReadiness(defaultLibraryPath()).status === 'available';
344
- }
345
- catch {
346
- return false;
347
- }
348
- }
349
- function printMpvDiagnostic(diagnostic) {
350
- console.log(`mpv_path=${redactHome(diagnostic.path) ?? 'not-found'}`);
351
- console.log(`mpv_discovery=${diagnostic.discovery}`);
352
- console.log(`mpv_launch=${diagnostic.launchable ? 'ready' : 'failed'}`);
353
- if (diagnostic.version)
354
- console.log(`mpv_version=${diagnostic.version}`);
355
- if (diagnostic.error)
356
- console.log(`mpv_error=${diagnostic.error}`);
357
- if (diagnostic.launchable && diagnostic.discovery !== 'path') {
358
- console.log('mpv_hint=RadioCLI found mpv outside PATH and can use it directly; no PATH changes are required.');
359
- }
360
- else if (!diagnostic.path && process.platform === 'win32') {
361
- console.log('mpv_hint=Install with winget, then rerun Doctor; RadioCLI also checks the standard MPV Player install directory.');
362
- }
363
- }
364
- function redactDiagnostic(diagnostic) {
365
- return { ...diagnostic, path: redactHome(diagnostic.path) };
366
- }
367
- function redactHome(path) {
368
- if (!path)
369
- return null;
370
- const home = process.env.HOME ?? process.env.USERPROFILE;
371
- return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
372
- }
@@ -48,6 +48,13 @@ export function platformCapabilities(host, evidence) {
48
48
  unicode: evidence.unicode === false ? unavailable('ASCII decoration mode is selected; international station data is preserved.') : available('Unicode decoration is enabled; terminal font coverage determines glyph appearance.'),
49
49
  color: evidence.color === false ? unavailable('Color is disabled for the terminal or by user preference.') : available('Color is enabled at the terminal supported depth.'),
50
50
  screenReader: available(evidence.screenReader ? 'Screen-reader mode is enabled; decorative animation is disabled.' : 'Screen-reader mode is available through INK_SCREEN_READER=true.'),
51
- airPlay: adapters.airPlay && evidence.backends.includes('airplay') ? available('Experimental macOS output; receiver selection and worker readiness are still required.', 'airplay') : unavailable(adapters.airPlay ? 'Experimental AirPlay requires FFmpeg, Bonjour, and the safe optional sender.' : 'AirPlay output is available only on macOS.')
51
+ airPlay: !adapters.airPlay ? unavailable('Experimental AirPlay is supported on macOS and native Linux; this environment is not supported.')
52
+ : evidence.airPlay ? evidence.airPlay.ready
53
+ ? evidence.airPlay.discoveryService === 'unverified' ? unverified(evidence.airPlay.message, 'airplay') : available(evidence.airPlay.message, 'airplay')
54
+ : unavailable(evidence.airPlay.message)
55
+ : evidence.backends.includes('airplay')
56
+ ? host.id === 'linux' ? unverified('Experimental Linux output requires an explicit discovery-service and native-sender check. Select a receiver each session.', 'airplay')
57
+ : available('Experimental macOS output; receiver selection and worker readiness are still required.', 'airplay')
58
+ : unavailable(`Experimental AirPlay requires FFmpeg, ${host.id === 'linux' ? 'Avahi' : 'Bonjour'}, and the safe optional sender.`)
52
59
  };
53
60
  }
@@ -0,0 +1,61 @@
1
+ import { resolve } from 'node:path';
2
+ import { planMpvDesktopMedia } from '../player/mpv-desktop-media.js';
3
+ import { identifyOmarchy } from './omarchy.js';
4
+ import { sessionBusSocket, startMpris } from './mpris.js';
5
+ import { resolveCommandDetails } from './executables.js';
6
+ export function desktopMediaPolicy(settings, options = {}) {
7
+ const platform = options.platform ?? process.platform;
8
+ const env = options.env ?? process.env;
9
+ const override = env.RADIOCLI_DESKTOP_MEDIA;
10
+ const preference = override === 'auto' || override === 'on' || override === 'off' ? override : settings.desktopMediaControls ?? 'auto';
11
+ const disabled = (reason) => ({ enabled: false, preference, reason, mpvArgs: [] });
12
+ if (preference === 'off')
13
+ return disabled('Desktop media controls are disabled.');
14
+ if (platform !== 'linux')
15
+ return disabled('Desktop media controls currently require Linux.');
16
+ if (!sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? ''))
17
+ return disabled('A local Unix session bus is required.');
18
+ if (preference === 'auto') {
19
+ const profile = (options.omarchy ?? (() => identifyOmarchy({ platform, env })))();
20
+ if (profile.kind !== 'quattro' || profile.source !== 'package' || !profile.session.localDesktop)
21
+ return disabled('Automatic desktop media controls require a packaged Omarchy Quattro local desktop.');
22
+ }
23
+ const mpv = (options.mpv ?? (() => planMpvDesktopMedia({ platform, env, executable: resolveCommandDetails('mpv', { platform, env }).path })))();
24
+ if (mpv.status !== 'compatible')
25
+ return disabled(mpv.reason);
26
+ return { enabled: true, preference, reason: mpv.reason, mpvArgs: mpv.args };
27
+ }
28
+ /** State and ICY updates reuse the owning player's event stream. No additional
29
+ * playback polling is needed and the bridge never connects through MCP. */
30
+ export function startDesktopMediaControls(options) {
31
+ if (!options.policy.enabled)
32
+ return { refresh() { }, close() { } };
33
+ let title;
34
+ let previousStream;
35
+ let previousStation;
36
+ let disposed = false;
37
+ const bridge = startMpris({
38
+ identity: resolve(options.identity),
39
+ address: (options.env ?? process.env).DBUS_SESSION_BUS_ADDRESS ?? '',
40
+ command: command => options.command(command, () => !disposed),
41
+ snapshot: () => ({ ...options.status(), title, available: !options.suspended() && options.player.desktopMediaCompatible() })
42
+ });
43
+ const refresh = () => {
44
+ const station = options.status().station;
45
+ const key = station ? `${station.provider}\0${station.id}` : undefined;
46
+ if (key !== previousStation) {
47
+ title = undefined;
48
+ previousStation = key;
49
+ }
50
+ bridge.refresh();
51
+ };
52
+ const removeState = options.player.onChange(state => {
53
+ if (state.streamUrl !== previousStream) {
54
+ title = undefined;
55
+ previousStream = state.streamUrl;
56
+ }
57
+ refresh();
58
+ });
59
+ const removeMetadata = options.player.onMetadata(metadata => { title = metadata.title; refresh(); });
60
+ return { refresh, close() { disposed = true; removeState(); removeMetadata(); bridge.close(); } };
61
+ }