@ciphore/radiocli 0.2.4 → 0.2.6

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 (145) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/CONTRIBUTING.md +28 -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 +182 -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 +130 -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 +314 -76
  71. package/dist/types.js +22 -1
  72. package/dist/ui/AdaptiveContent.js +21 -24
  73. package/dist/ui/App.js +708 -454
  74. package/dist/ui/AppContent.js +5 -5
  75. package/dist/ui/airplay-settings.js +12 -5
  76. package/dist/ui/bidi-ranges.js +45 -0
  77. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  78. package/dist/ui/components/ReceiverVisualRow.js +39 -0
  79. package/dist/ui/format.js +37 -4
  80. package/dist/ui/help-content.js +5 -2
  81. package/dist/ui/rtl-output.js +137 -0
  82. package/dist/ui/screen-items.js +10 -4
  83. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  84. package/dist/ui/screens/AlarmsScreen.js +5 -2
  85. package/dist/ui/screens/HelpScreen.js +3 -3
  86. package/dist/ui/screens/NowPlayingScreen.js +2 -21
  87. package/dist/ui/screens/SettingsScreen.js +16 -16
  88. package/dist/ui/screens/StatsScreen.js +1 -1
  89. package/dist/ui/text-direction.js +9 -0
  90. package/dist/ui/theme.js +46 -0
  91. package/dist/ui/use-alarm-tui.js +24 -24
  92. package/dist/ui/use-app-input.js +42 -8
  93. package/dist/ui/use-command-executor.js +21 -14
  94. package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
  95. package/dist/ui/visualizers/boat-lake.js +161 -0
  96. package/dist/ui/visualizers/gallop.js +2 -2
  97. package/dist/ui/visualizers/receiver-style-registry.js +3 -1
  98. package/dist/ui/visualizers/receiver-visualizers.js +6 -108
  99. package/dist/ui/visualizers/sumi-ocean.js +183 -0
  100. package/dist/ui/visualizers/terminal-cell.js +45 -0
  101. package/dist/update-check.js +2 -1
  102. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  103. package/docs/boat-lake.md +88 -0
  104. package/docs/sumi-ocean.md +80 -0
  105. package/package.json +36 -12
  106. package/scripts/build-airplay-native.mjs +22 -0
  107. package/vendor/node-airtunes2/LICENSE +208 -0
  108. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  109. package/vendor/node-airtunes2/README.md +225 -0
  110. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  111. package/vendor/node-airtunes2/binding.gyp +41 -0
  112. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  113. package/vendor/node-airtunes2/lib/atv.js +307 -0
  114. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  115. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  116. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  117. package/vendor/node-airtunes2/lib/config.js +26 -0
  118. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  119. package/vendor/node-airtunes2/lib/devices.js +266 -0
  120. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  121. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  122. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  123. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  124. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  125. package/vendor/node-airtunes2/lib/http.js +189 -0
  126. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  127. package/vendor/node-airtunes2/lib/index.js +127 -0
  128. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  129. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  130. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  131. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  132. package/vendor/node-airtunes2/lib/srp.js +158 -0
  133. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  134. package/vendor/node-airtunes2/lib/util.js +12 -0
  135. package/vendor/node-airtunes2/package.json +31 -0
  136. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  137. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  138. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  139. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  140. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  141. package/vendor/node-airtunes2/src/base64.c +136 -0
  142. package/vendor/node-airtunes2/src/base64.h +42 -0
  143. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  144. package/vendor/node-airtunes2/src/codec.cc +170 -0
  145. 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,119 @@ 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
+ // React's development profiler retains per-frame timing entries in Node.
40
+ // Choose the production runtime before loading Ink or JSX; explicit developer
41
+ // environments still take precedence.
42
+ process.env.NODE_ENV ??= 'production';
43
+ const { renderTui: render } = await import('./cli/render-tui.js');
44
+ await render(printHelp, encodedCommand);
73
45
  }
74
46
  export async function runCommand(args) {
75
47
  const [command, ...rest] = args;
76
48
  if (!command || command === 'help' || command === '--help' || command === '-h') {
77
- printHelp();
49
+ if (command === 'help' && rest[0] && rest[0] !== '--all') {
50
+ await runCommand([rest[0], '--help']);
51
+ return;
52
+ }
53
+ printHelp(rest.includes('--all'));
78
54
  return;
79
55
  }
80
56
  if (command === 'version' || command === '--version' || command === '-v') {
81
57
  console.log(appVersion());
82
58
  return;
83
59
  }
60
+ if (command === '_complete') {
61
+ let presets = [];
62
+ if (rest.length > 1 && ['play', 'favorite', 'unfavorite', 'preset'].includes(rest[0])) {
63
+ try {
64
+ // Completion is read-only, including when a library needs repair.
65
+ const { defaultLibraryPath } = await import('./storage/store.js');
66
+ const library = JSON.parse(readFileSync(defaultLibraryPath(), 'utf8'));
67
+ if (Array.isArray(library.presets))
68
+ presets = library.presets.flatMap(preset => typeof preset?.name === 'string' && /^[a-z0-9][a-z0-9_-]{0,39}$/.test(preset.name) ? [preset.name] : []);
69
+ }
70
+ catch { /* Completion must remain quiet when storage is unavailable. */ }
71
+ }
72
+ const candidates = completionCandidates(rest, presets);
73
+ if (candidates.length)
74
+ console.log(candidates.join('\n'));
75
+ return;
76
+ }
77
+ const optionArgs = rest.includes('--') ? rest.slice(0, rest.indexOf('--')) : rest;
78
+ const wantsHelp = optionArgs.includes('--help') || optionArgs.includes('-h');
79
+ if (isListenerCommand(command)) {
80
+ if (wantsHelp)
81
+ console.log(listenerHelp(command));
82
+ else {
83
+ const { runListenerCommand } = await import('./cli/listener.js');
84
+ await runListenerCommand(command, rest, runtime);
85
+ }
86
+ return;
87
+ }
88
+ if (command === 'agent' && wantsHelp) {
89
+ const { agentCommandUsage } = await import('./agent/cli.js');
90
+ console.log(`${agentCommandUsage()}\nJSON controls for local agents. Everyday listening is available directly: radiocli play, now, favorites.`);
91
+ return;
92
+ }
93
+ const commandHelp = legacyCommandHelp()[command];
94
+ if (wantsHelp && commandHelp) {
95
+ console.log(commandHelp);
96
+ return;
97
+ }
84
98
  if (command === 'alarm') {
99
+ const { runAlarmCommand } = await import('./alarms/cli.js');
85
100
  await runAlarmCommand(rest);
86
101
  return;
87
102
  }
88
103
  if (command === 'mcp') {
104
+ const { runMcpCommand } = await import('./agent/cli.js');
89
105
  await runMcpCommand(rest, runtime);
90
106
  return;
91
107
  }
92
108
  if (command === 'agent') {
109
+ const { runAgentCliCommand } = await import('./agent/cli.js');
93
110
  await runAgentCliCommand(rest, runtime);
94
111
  return;
95
112
  }
96
113
  if (command === 'setup') {
97
114
  if (rest.includes('--help') || rest.includes('-h')) {
98
- printSetupHelp();
115
+ await printSetupHelp();
99
116
  return;
100
117
  }
118
+ const { runSetup } = await import('./setup.js');
101
119
  await runSetup({ args: rest });
102
120
  return;
103
121
  }
122
+ if (command === 'media-controls') {
123
+ if (rest.length > 1 || rest[0] !== undefined && !['auto', 'on', 'off'].includes(rest[0])) {
124
+ throw new Error('Usage: radiocli media-controls [auto|on|off]');
125
+ }
126
+ const [{ JsonLibraryStore }, { desktopMediaPolicy }] = await Promise.all([import('./storage/store.js'), import('./platform/desktop-media.js')]);
127
+ const store = new JsonLibraryStore();
128
+ if (rest[0])
129
+ store.updateSettings({ desktopMediaControls: rest[0] });
130
+ const policy = desktopMediaPolicy(store.snapshot().settings);
131
+ console.log(`desktop_media_preference=${store.snapshot().settings.desktopMediaControls ?? 'auto'}`);
132
+ console.log(`desktop_media_effective=${policy.preference} ${policy.enabled ? 'enabled' : 'disabled'}`);
133
+ console.log(`desktop_media_note=${policy.reason}`);
134
+ if (rest[0])
135
+ console.log('Restart active RadioCLI sessions to apply this preference. RADIOCLI_DESKTOP_MEDIA=off overrides the saved preference.');
136
+ return;
137
+ }
104
138
  if (!isKnownCommand(command)) {
105
- throw new Error(`Unknown command: ${command}\nRun radiocli help.`);
139
+ const suggestion = suggestCommand(command);
140
+ throw new Error(`Unknown command: ${command}${suggestion ? `\nDid you mean radiocli ${suggestion}?` : ''}\nRun radiocli help.`);
106
141
  }
107
142
  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}`);
143
+ const { runDoctor } = await import('./cli/doctor.js');
144
+ await runDoctor(rest);
127
145
  return;
128
146
  }
129
147
  if (command === 'update') {
130
148
  const unknown = rest.filter(arg => arg !== '--install');
131
149
  if (unknown.length > 0)
132
150
  throw new Error('Usage: radiocli update [--install]');
151
+ const { checkForUpdate, installUpdate, updateCommandForInstall } = await import('./update-check.js');
133
152
  const updateCheck = await checkForUpdate();
134
153
  const updateCommand = updateCommandForInstall();
135
154
  if (updateCheck.error) {
@@ -146,9 +165,11 @@ export async function runCommand(args) {
146
165
  if (!result.ok)
147
166
  throw new Error(`Update install failed. Run manually: ${result.command}${result.output ? `\n${result.output}` : ''}`);
148
167
  console.log('updated=yes');
168
+ const [{ JsonLibraryStore }, { defaultAgentControlSettings }] = await Promise.all([import('./storage/store.js'), import('./types.js')]);
149
169
  const agentControl = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings;
150
170
  if (agentControl.enabled) {
151
171
  try {
172
+ const { configureMcpIntegrations } = await import('./agent/mcp-install.js');
152
173
  const repaired = await configureMcpIntegrations(true, runtime);
153
174
  const failed = repaired.filter(item => item.status === 'failed');
154
175
  console.log(`mcp_repaired=${failed.length ? 'partial' : 'yes'}`);
@@ -164,6 +185,9 @@ export async function runCommand(args) {
164
185
  return;
165
186
  }
166
187
  if (command === 'check') {
188
+ const [{ JsonLibraryStore }, { ProviderManager }, { PlayerController }, { playbackBackendStatusLines }] = await Promise.all([
189
+ import('./storage/store.js'), import('./providers/provider-manager.js'), import('./player/player-controller.js'), import('./player/backend-install.js')
190
+ ]);
167
191
  const store = new JsonLibraryStore();
168
192
  const providers = new ProviderManager();
169
193
  const player = new PlayerController(() => store.snapshot().settings);
@@ -171,40 +195,22 @@ export async function runCommand(args) {
171
195
  const health = await providers.health(store.snapshot().settings);
172
196
  console.log(`store=${store.filePath}`);
173
197
  console.log(`backends=${backends.join(',') || 'none'}`);
174
- printPlaybackBackendStatus(backends);
198
+ for (const line of playbackBackendStatusLines(backends))
199
+ console.log(line);
175
200
  for (const [provider, status] of Object.entries(health)) {
176
201
  console.log(`${provider}=${status}`);
177
202
  }
178
203
  return;
179
204
  }
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
205
  if (command === 'import') {
202
206
  const target = rest[0];
203
207
  if (!target) {
204
208
  throw new Error('Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]');
205
209
  }
210
+ const { JsonLibraryStore } = await import('./storage/store.js');
206
211
  const store = new JsonLibraryStore();
207
212
  if (/^https?:\/\//i.test(target)) {
213
+ const { importStreamUrl } = await import('./streams/import-stream.js');
208
214
  const result = await importStreamUrl(target, rest.slice(1).join(' ') || undefined);
209
215
  store.addImported([result.station]);
210
216
  console.log(`imported=${result.station.name}`);
@@ -212,12 +218,14 @@ export async function runCommand(args) {
212
218
  console.log(`warning=${result.warning}`);
213
219
  return;
214
220
  }
221
+ const { parsePlaylistFile } = await import('./playlists/playlist.js');
215
222
  const stations = parsePlaylistFile(target);
216
223
  store.addImported(stations);
217
224
  console.log(`imported=${stations.length}`);
218
225
  return;
219
226
  }
220
227
  if (command === 'export') {
228
+ const [{ JsonLibraryStore }, { writeM3u }] = await Promise.all([import('./storage/store.js'), import('./playlists/playlist.js')]);
221
229
  const file = rest[0] ?? 'radiocli-favorites.m3u';
222
230
  const store = new JsonLibraryStore();
223
231
  const state = store.snapshot();
@@ -226,25 +234,90 @@ export async function runCommand(args) {
226
234
  return;
227
235
  }
228
236
  }
229
- export function printHelp() {
237
+ export function printHelp(all = false) {
230
238
  console.log(`RadioCLI
231
239
 
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
240
+ Usage: radiocli [command]
241
+ Run radiocli without a command to open the TUI.
242
+
243
+ Everyday listening:
244
+ radiocli play [station] Start or continue listening; names and presets work
245
+ radiocli pause Pause; use play to continue
246
+ radiocli next Tune the next station in the current queue
247
+ radiocli now See the current station, track, and volume
248
+ radiocli volume <0-100> Set listening volume
249
+ radiocli favorite Save the current station
250
+ radiocli favorites List saved stations; add --pick to choose one
251
+ radiocli stop Stop playback and close the background player
252
+ ${all ? `
253
+ More listening controls:
254
+ radiocli previous Tune the previous station in the queue
255
+ radiocli resume Resume paused playback (play also works)
256
+ radiocli mute | unmute Mute or restore sound
257
+ radiocli now --watch Watch station and track changes
258
+ radiocli status Alias for now
259
+ radiocli play --favorites | --recent | --popular
260
+ Choose a queue; add --random or --pick
261
+
262
+ Discovery and library:
263
+ radiocli search <query> Search stations; --country, --limit, --pick
264
+ radiocli countries List top countries
265
+ radiocli recent List recently played stations
266
+ radiocli unfavorite Remove a favorite (current station by default)
267
+ radiocli preset save <name> Save a shortcut; play with play <name>
268
+ radiocli preset remove <name> Remove a shortcut
269
+ radiocli presets List shortcuts; also: preset list
270
+ radiocli stats Show listening time and streaks
244
271
  radiocli import <target> Import a stream URL or local playlist
245
- radiocli export [file] Export favorites/imports as .m3u
272
+ radiocli export [file] Export favorites/imports as .m3u
246
273
  radiocli alarm <command> Manage alarms and scheduled radio
247
- `);
274
+
275
+ Setup and integrations:
276
+ radiocli setup Install and verify native playback tools
277
+ radiocli doctor [--json] Show local playback setup guidance
278
+ radiocli check Show provider/backend health
279
+ radiocli version Print the installed version
280
+ radiocli update Check for updates; --install to install
281
+ radiocli media-controls [auto|on|off] Configure Linux desktop media controls
282
+ radiocli mcp <command> Install, inspect, or run the MCP integration
283
+ radiocli agent <command> Scriptable radio controls for local agents
284
+ ` : ''}
285
+ Examples:
286
+ radiocli play jazz Find a station and listen
287
+ radiocli play Continue the current or last station
288
+ radiocli play --favorites Listen from your favorites
289
+
290
+ More help:
291
+ radiocli help --all Browse every command by category
292
+ radiocli help play Options and examples for one command
293
+ radiocli completion <shell> Print a Tab completion script
294
+ ${all ? '\nListener commands support --json, --plain, --quiet, and --no-input.\nUse radiocli <command> --help for options and examples.\n' : ''}
295
+ `.trimEnd());
296
+ }
297
+ function legacyCommandHelp() {
298
+ return {
299
+ check: 'Usage: radiocli check\nShow local store paths, playback backends, and provider health.',
300
+ doctor: 'Usage: radiocli doctor [--json] [--playback]\nCheck playback tools without changing settings. Add --playback to rehearse local WAV playback through null/dummy audio.',
301
+ update: 'Usage: radiocli update [--install]\nCheck for updates. Add --install to install and repair enabled MCP entries.',
302
+ import: 'Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]\nExample: radiocli import https://example.com/live.mp3 "Example FM"',
303
+ export: 'Usage: radiocli export [file]\nExport favorites and imports as M3U (default: radiocli-favorites.m3u).',
304
+ 'media-controls': 'Usage: radiocli media-controls [auto|on|off]\nInspect or configure Linux desktop media controls.',
305
+ mcp: 'Usage: radiocli mcp <enable|install|repair|disable|status|config|serve>\nManage the optional local agent integration.'
306
+ };
307
+ }
308
+ function suggestCommand(input) {
309
+ const candidates = [...listenerCommands, ...Object.keys(legacyCommandHelp()), 'setup', 'alarm', 'agent', 'help', 'version'];
310
+ const distance = (word) => {
311
+ let row = Array.from({ length: word.length + 1 }, (_, index) => index);
312
+ for (let i = 1; i <= input.length; i += 1) {
313
+ const next = [i];
314
+ for (let j = 1; j <= word.length; j += 1)
315
+ next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + (input[i - 1] === word[j - 1] ? 0 : 1));
316
+ row = next;
317
+ }
318
+ return row[word.length];
319
+ };
320
+ 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
321
  }
249
322
  export function isDirectRun(entryPath, moduleUrl) {
250
323
  if (!entryPath) {
@@ -260,7 +333,8 @@ export function isDirectRun(entryPath, moduleUrl) {
260
333
  function isKnownCommand(command) {
261
334
  return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'alarm', 'mcp', 'agent'].includes(command);
262
335
  }
263
- function printSetupHelp() {
336
+ async function printSetupHelp() {
337
+ const { isAirPlayPlatformSupported } = await import('./player/airplay-policy.js');
264
338
  console.log(`RadioCLI Setup
265
339
 
266
340
  Usage:
@@ -273,100 +347,9 @@ Usage:
273
347
  radiocli setup --mcp --agent-ui Open a terminal TUI for agent playback (default)
274
348
  radiocli setup --mcp --headless-agent Opt out of external terminal windows
275
349
  radiocli setup --no-mcp Disable and remove agent MCP entries
276
- radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
350
+ ${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
351
  apt, dnf, pacman, apk, zypper,
278
352
  pkg (FreeBSD), pkg_add, pkgin,
279
353
  termux-pkg (Termux), or pkgman (Haiku)
280
354
  `);
281
355
  }
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
+ }