@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,152 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { resolveCommand } from './platform/executables.js';
6
+ /** Exercise local decoders without sending audio to an output device or receiver. */
7
+ export async function verifyLocalPlayback(backends, options = {}) {
8
+ options.signal?.throwIfAborted();
9
+ const local = [...new Set(backends)].filter((value) => ['mpv', 'ffplay', 'vlc'].includes(value));
10
+ if (!local.length)
11
+ return [];
12
+ const result = (backend, status, message) => ({
13
+ backend, status, source: 'local-wav', output: backend === 'mpv' ? 'null' : 'dummy', physicalOutput: 'unverified', message
14
+ });
15
+ if (options.platform && options.platform !== process.platform && !options.execute) {
16
+ return local.map(backend => result(backend, 'unverified', 'The requested platform differs from the current runtime. Run the rehearsal on that device.'));
17
+ }
18
+ const directory = await mkdtemp(join(tmpdir(), 'radiocli-setup-'));
19
+ try {
20
+ const fixture = join(directory, 'local silence.wav');
21
+ await writeFile(fixture, silentWav(), { mode: 0o600 });
22
+ const resolve = options.resolve ?? resolveCommand;
23
+ const execute = options.execute ?? runDecoderProcess;
24
+ const results = [];
25
+ for (const backend of local) {
26
+ options.signal?.throwIfAborted();
27
+ const command = backend === 'vlc' ? resolve('cvlc') ?? resolve('vlc') : resolve(backend);
28
+ if (!command) {
29
+ results.push(result(backend, 'failed', 'The executable was detected earlier but could not be resolved for the rehearsal.'));
30
+ continue;
31
+ }
32
+ const args = backend === 'mpv'
33
+ ? ['--no-config', '--no-video', '--ao=null', '--ao-null-untimed', '--idle=no', '--term-status-msg=', '--term-playing-msg=RADIOCLI_DECODER_READY', fixture]
34
+ : backend === 'ffplay'
35
+ ? ['-nodisp', '-autoexit', '-hide_banner', '-loglevel', 'info', fixture]
36
+ : ['--ignore-config', '--intf', 'dummy', '--no-video', '--aout=dummy', '--no-one-instance', '--no-media-library', '--no-repeat', '--no-loop', '--play-and-exit', '--verbose=2', fixture];
37
+ let completed;
38
+ try {
39
+ completed = await execute(command, args, {
40
+ env: { ...options.env ?? process.env, LC_ALL: 'C', ...(backend === 'ffplay' ? { SDL_AUDIODRIVER: 'dummy', SDL_AUDIO_DRIVER: 'dummy', SDL_VIDEODRIVER: 'dummy', SDL_VIDEO_DRIVER: 'dummy' } : {}) },
41
+ timeoutMs: Math.max(250, Math.min(options.timeoutMs ?? 8000, 30_000)), signal: options.signal
42
+ });
43
+ }
44
+ catch {
45
+ options.signal?.throwIfAborted();
46
+ completed = { code: null, output: '', failure: 'The playback process could not be started.' };
47
+ }
48
+ options.signal?.throwIfAborted();
49
+ const fatal = hasPlaybackFailure(backend, completed.output);
50
+ const observed = backend === 'mpv' ? completed.output.includes('RADIOCLI_DECODER_READY')
51
+ : backend === 'ffplay' ? /Audio:\s*pcm_s16le/i.test(completed.output)
52
+ : /using audio output module "(?:adummy|dummy)"/i.test(completed.output) && /(?:end of file|EOF)/i.test(completed.output);
53
+ results.push(completed.failure || completed.code !== 0 || fatal
54
+ ? result(backend, 'failed', completed.failure ?? `Local WAV playback failed${completed.code !== null ? ` (exit ${completed.code})` : ''}. Run radiocli doctor and check this backend's native dependencies.`)
55
+ : observed ? result(backend, 'passed', 'Local WAV playback completed with a null or dummy audio output.')
56
+ : result(backend, 'unverified', 'The process exited successfully, but it did not expose the expected playback evidence.'));
57
+ }
58
+ return results;
59
+ }
60
+ finally {
61
+ await rm(directory, { force: true, recursive: true });
62
+ }
63
+ }
64
+ function hasPlaybackFailure(backend, output) {
65
+ return output.split(/\r?\n/).some(line => {
66
+ if (!/\b(?:error|failed|could not|cannot|invalid data)\b/i.test(line))
67
+ return false;
68
+ // The null-output rehearsal does not render OSD text. Minimal Linux installs
69
+ // can lack fonts while the audio decoder and output complete successfully.
70
+ if (backend === 'mpv' && /^\[osd(?:\/[^\]]+)?\]/i.test(line))
71
+ return false;
72
+ if (backend === 'vlc') {
73
+ // VLC logs rejected optional modules and build flags at debug level, and
74
+ // starts desktop interfaces even with a dummy playback interface. Neither
75
+ // proves failure of the isolated audio pipeline; readiness is checked below.
76
+ if (/\bdebug:/i.test(line))
77
+ return false;
78
+ if (/\b(?:dbus|interface)\b/i.test(line) && !/\b(?:audio output|decoder|demux|input|access|stream)\b/i.test(line))
79
+ return false;
80
+ }
81
+ return true;
82
+ });
83
+ }
84
+ function runDecoderProcess(command, args, options) {
85
+ return new Promise((resolve, reject) => {
86
+ options.signal?.throwIfAborted();
87
+ const child = spawn(command, args, { env: options.env, stdio: ['ignore', 'pipe', 'pipe'], shell: false, windowsHide: true });
88
+ let output = '';
89
+ let failure;
90
+ let force;
91
+ let deadline;
92
+ let settled = false;
93
+ const finish = (code) => {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ clearTimeout(timeout);
98
+ clearTimeout(force);
99
+ clearTimeout(deadline);
100
+ options.signal?.removeEventListener('abort', abort);
101
+ options.signal?.aborted ? reject(options.signal.reason) : resolve({ code, output, failure });
102
+ };
103
+ const stop = (message) => {
104
+ if (settled || failure)
105
+ return;
106
+ failure = message;
107
+ try {
108
+ child.kill();
109
+ }
110
+ catch { /* The force deadline also covers failed termination. */ }
111
+ force = setTimeout(() => { try {
112
+ child.kill('SIGKILL');
113
+ }
114
+ catch { /* Report through the cleanup deadline. */ } }, 250);
115
+ deadline = setTimeout(() => {
116
+ // A failed native termination must not hang setup or retain stdio handles.
117
+ child.stdout?.destroy();
118
+ child.stderr?.destroy();
119
+ child.unref();
120
+ failure = 'The playback process did not acknowledge termination. Check for a remaining native player process.';
121
+ finish(null);
122
+ }, 1500);
123
+ };
124
+ const abort = () => stop('The rehearsal was cancelled.');
125
+ const timeout = setTimeout(() => stop('Local WAV playback exceeded its deadline.'), options.timeoutMs);
126
+ const collect = (chunk) => { output = `${output}${chunk.toString()}`.slice(-64 * 1024); };
127
+ child.stdout?.on('data', collect);
128
+ child.stderr?.on('data', collect);
129
+ child.once('error', () => { failure = 'The playback process could not be started.'; finish(null); });
130
+ child.once('close', code => finish(code));
131
+ options.signal?.addEventListener('abort', abort, { once: true });
132
+ if (options.signal?.aborted)
133
+ abort();
134
+ });
135
+ }
136
+ function silentWav() {
137
+ const dataBytes = 44_100; // 250 ms, stereo, signed 16-bit PCM.
138
+ const buffer = Buffer.alloc(44 + dataBytes);
139
+ buffer.write('RIFF');
140
+ buffer.writeUInt32LE(36 + dataBytes, 4);
141
+ buffer.write('WAVEfmt ', 8);
142
+ buffer.writeUInt32LE(16, 16);
143
+ buffer.writeUInt16LE(1, 20);
144
+ buffer.writeUInt16LE(2, 22);
145
+ buffer.writeUInt32LE(44_100, 24);
146
+ buffer.writeUInt32LE(44_100 * 4, 28);
147
+ buffer.writeUInt16LE(4, 32);
148
+ buffer.writeUInt16LE(16, 34);
149
+ buffer.write('data', 36);
150
+ buffer.writeUInt32LE(dataBytes, 40);
151
+ return buffer;
152
+ }
package/dist/setup.js CHANGED
@@ -5,10 +5,15 @@ import { createInterface } from 'node:readline/promises';
5
5
  import { clearCommandCache, commandExists, resolveCommand } from './platform/executables.js';
6
6
  import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
7
7
  import { configureMcpIntegrations } from './agent/mcp-install.js';
8
- import { JsonLibraryStore } from './storage/store.js';
8
+ import { defaultLibraryPath, JsonLibraryStore } from './storage/store.js';
9
9
  import { defaultAgentControlSettings } from './types.js';
10
10
  import { resolveTerminalCapabilities } from './platform/terminal.js';
11
+ import { isAirPlayPlatformSupported } from './player/airplay-policy.js';
12
+ import { probeAirPlayReadiness } from './player/airplay-readiness.js';
13
+ import { airPlaySetupVerification, hasSystemdSystemManager, linuxAirPlaySetupPlan } from './player/airplay-setup.js';
14
+ import { readSettingsWithoutMigration } from './storage/read-settings.js';
11
15
  import { identifyPlatform, readLinuxOsRelease } from './platform/runtime.js';
16
+ import { verifyLocalPlayback } from './setup-playback.js';
12
17
  const components = ['mpv', 'ffmpeg', 'vlc'];
13
18
  export async function runSetup(options = {}) {
14
19
  const platform = options.platform ?? process.platform;
@@ -19,6 +24,12 @@ export async function runSetup(options = {}) {
19
24
  const output = options.output ?? process.stdout;
20
25
  const hasCommand = options.hasCommand ?? commandExists;
21
26
  const parsed = parseSetupArgs(options.args ?? []);
27
+ const linuxAirPlayEligible = platform === 'linux' && isAirPlayPlatformSupported(platform, env, host.release);
28
+ if (parsed.airplay !== null && !linuxAirPlayEligible)
29
+ throw new Error('--airplay and --no-airplay configure experimental native Linux AirPlay only. macOS keeps its existing AirPlay setup; this environment has no Linux opt-in.');
30
+ const prompt = options.prompt ?? ((question, defaultValue) => promptYesNo(input, output, question, defaultValue));
31
+ const libraryPath = () => options.libraryPath ?? defaultLibraryPath({ platform, env, home: env.HOME ?? env.USERPROFILE });
32
+ const probe = options.probeAirPlay ?? ((loadSender) => probeAirPlayReadiness({ platform, env, release: host.release, hasCommand, ...(loadSender ? {} : { sender: async () => ({ loaded: false, message: 'Not probed before approval.' }) }) }));
22
33
  if (parsed.packageManager)
23
34
  validateNativePackageManager(parsed.packageManager, host);
24
35
  const installed = detectInstalledComponents(hasCommand);
@@ -28,36 +39,64 @@ export async function runSetup(options = {}) {
28
39
  output.write(`Manager ${packageManager ?? 'not detected'}\n\n`);
29
40
  let selected = parsed.only ?? defaultComponents(platform, parsed.all);
30
41
  if (!parsed.yes && !parsed.only && isInteractive(input, output)) {
31
- selected = await promptForComponents({ platform, installed, input, output });
42
+ selected = await promptForComponents({ platform, installed, output, prompt });
43
+ }
44
+ let airplay = parsed.airplay;
45
+ if (linuxAirPlayEligible && airplay === null && !parsed.yes && !parsed.only && isInteractive(input, output)) {
46
+ const enabled = readSettingsWithoutMigration(libraryPath())?.linuxAirPlayEnabled === true;
47
+ const choice = await prompt(' AirPlay — experimental (FFmpeg, shared Avahi network discovery)', enabled);
48
+ // Declining the default on a new install leaves the library untouched.
49
+ if (choice || enabled)
50
+ airplay = choice;
32
51
  }
33
52
  let mcp = parsed.mcp;
34
53
  if (mcp === null && !parsed.yes && isInteractive(input, output)) {
35
- mcp = await promptYesNo(input, output, ' Agent control via MCP (detected coding agents)', true);
54
+ mcp = await prompt(' Agent control via MCP (detected coding agents)', true);
36
55
  }
37
56
  let agentUi = parsed.agentUi;
38
57
  if (mcp === true && agentUi === null && !parsed.yes && isInteractive(input, output)) {
39
58
  const note = platform === 'darwin'
40
59
  ? ' (macOS will ask the agent app to control Terminal on first use)'
41
60
  : ' (opens a separate terminal window)';
42
- agentUi = await promptYesNo(input, output, ` Open the RadioCLI TUI for agent playback${note}`, true);
61
+ agentUi = await prompt(` Open the RadioCLI TUI for agent playback${note}`, true);
43
62
  }
44
63
  const isRoot = (options.getUid ?? process.getuid)?.() === 0;
45
64
  const elevation = packageManagerElevation(hasCommand, isRoot);
46
65
  const plan = createSetupPlan({ platform, osRelease, env, packageManager, installed, selected, elevation });
66
+ const airPlayPlan = airplay === true ? linuxAirPlaySetupPlan({ manager: packageManager, elevation, hasCommand,
67
+ systemd: (options.isSystemd ?? hasSystemdSystemManager)(),
68
+ serviceReady: parsed.dryRun ? false : (await probe(false).catch(() => null))?.discoveryService === 'ready', existingCommands: plan.commands }) : null;
69
+ if (airPlayPlan)
70
+ plan.commands.push(...airPlayPlan.commands);
47
71
  printPlan(plan, output);
72
+ if (airplay !== null)
73
+ output.write(` Linux AirPlay: ${airplay ? 'enable for this library after accepted setup; verify sender and discovery' : 'disable for this library; keep installed packages and shared services'}\n`);
74
+ for (const note of airPlayPlan?.notes ?? [])
75
+ output.write(` Note: ${note}\n`);
48
76
  if (packageManager && packageManagerNeedsRoot(packageManager) && !isRoot && !elevation) {
49
77
  output.write(' Note: Run package commands as root; sudo/doas unavailable.\n');
50
78
  }
51
79
  for (const note of packageManagerNotes(packageManager, host))
52
80
  output.write(` Note: ${note}\n`);
81
+ const verify = async () => {
82
+ let linuxAirPlayEnabled = airplay === true;
83
+ if (platform === 'linux' && airplay === null) {
84
+ try {
85
+ linuxAirPlayEnabled = readSettingsWithoutMigration(libraryPath())?.linuxAirPlayEnabled === true;
86
+ }
87
+ catch { /* Unreadable consent stays disabled. */ }
88
+ }
89
+ await printVerification(output, { platform, osRelease, env, release: host.release, hasCommand, getUid: options.getUid, linuxAirPlayEnabled, verifyPlayback: options.verifyPlayback });
90
+ };
53
91
  const missing = plan.selected.filter(component => !plan.installed[component]);
54
- if (missing.length === 0) {
92
+ if (missing.length === 0 && airplay === null) {
55
93
  output.write(plan.selected.length === 0 ? '\nNo components selected.\n' : '\nEverything selected is already installed.\n');
56
94
  await finishMcpSetup(mcp, agentUi, parsed.dryRun, output);
57
- printVerification(output);
95
+ if (!parsed.dryRun)
96
+ await verify();
58
97
  return;
59
98
  }
60
- if (!plan.packageManager) {
99
+ if (!plan.packageManager && missing.length > 0) {
61
100
  if (parsed.dryRun) {
62
101
  output.write('\nDry run complete. Install the missing components manually; no system packages were changed.\n');
63
102
  await finishMcpSetup(mcp, agentUi, true, output);
@@ -75,15 +114,15 @@ export async function runSetup(options = {}) {
75
114
  throw new Error(`No verified automatic installation command for ${manual.join(', ')} with ${plan.packageManager}. Install these components manually or select --only=mpv.`);
76
115
  if (plan.packageManager === 'termux-pkg' && isRoot)
77
116
  throw new Error('Run setup as the normal Termux app user. Termux pkg refuses root execution.');
78
- if (packageManagerNeedsRoot(plan.packageManager) && !isRoot && !elevation) {
117
+ if ((plan.packageManager && packageManagerNeedsRoot(plan.packageManager) || airPlayPlan?.commands.length) && plan.commands.length > 0 && !isRoot && !elevation) {
79
118
  throw new Error('System package installation requires root, sudo, or doas. Review the dry-run plan and install prerequisites with your administrator.');
80
119
  }
81
- if (parsed.packageManager && !hasCommand(packageManagerProgram(parsed.packageManager))) {
120
+ if (plan.commands.length > 0 && parsed.packageManager && !hasCommand(packageManagerProgram(parsed.packageManager))) {
82
121
  const program = packageManagerProgram(parsed.packageManager);
83
122
  throw new Error(`Requested package manager is not available: ${parsed.packageManager}${program !== parsed.packageManager ? ` (${program})` : ''}.`);
84
123
  }
85
124
  if (!parsed.yes && isInteractive(input, output)) {
86
- const confirmed = await promptYesNo(input, output, '\nInstall these components?', true);
125
+ const confirmed = await prompt(airplay === null ? '\nInstall these components?' : '\nApply this setup plan and Linux AirPlay preference?', true);
87
126
  if (!confirmed) {
88
127
  output.write('Setup cancelled. No system packages were changed.\n');
89
128
  return;
@@ -92,19 +131,32 @@ export async function runSetup(options = {}) {
92
131
  else if (!parsed.yes) {
93
132
  throw new Error('Setup needs an interactive terminal. Use --yes to approve the displayed commands or --dry-run to inspect them.');
94
133
  }
95
- if (plan.commands.some(command => command.program === 'sudo')) {
134
+ if (!options.runCommand && plan.commands.some(command => command.program === 'sudo')) {
96
135
  output.write('\nRadioCLI needs administrator approval for the system package manager.\n');
97
136
  await runVisibleCommand(resolveCommand('sudo') ?? 'sudo', ['-v']);
98
137
  }
99
138
  const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform));
100
- output.write('\nInstalling\n');
139
+ if (plan.commands.length)
140
+ output.write('\nInstalling\n');
101
141
  for (const command of plan.commands) {
102
142
  await execute(command, output);
103
143
  }
104
144
  clearCommandCache();
145
+ if (airplay !== null) {
146
+ if (options.saveLinuxAirPlayEnabled)
147
+ options.saveLinuxAirPlayEnabled(airplay);
148
+ else
149
+ new JsonLibraryStore(libraryPath()).updateSettings({ linuxAirPlayEnabled: airplay });
150
+ output.write(`\nLinux AirPlay ${airplay ? 'enabled' : 'disabled'} for this library. Restart active RadioCLI sessions to apply this preference.\n`);
151
+ if (airplay) {
152
+ const readiness = await probe(true).catch(() => ({ eligible: true, discoveryExecutable: false, discoveryService: 'unverified', senderLoaded: false, ffmpeg: false, ready: false, message: 'Readiness could not be verified. Run radiocli doctor.' }));
153
+ for (const line of airPlaySetupVerification(readiness))
154
+ output.write(` ${line}\n`);
155
+ }
156
+ }
105
157
  output.write('\nSetup complete.\n');
106
158
  await finishMcpSetup(mcp, agentUi, false, output);
107
- printVerification(output);
159
+ await verify();
108
160
  }
109
161
  export function createSetupPlan({ platform, osRelease = '', env = process.env, packageManager, installed, selected, elevation = 'sudo' }) {
110
162
  if (packageManager)
@@ -141,6 +193,7 @@ export function parseSetupArgs(args) {
141
193
  let packageManager = null;
142
194
  let mcp = null;
143
195
  let agentUi = null;
196
+ let airplay = null;
144
197
  for (let index = 0; index < args.length; index += 1) {
145
198
  const arg = args[index];
146
199
  if (arg === '--all')
@@ -149,6 +202,12 @@ export function parseSetupArgs(args) {
149
202
  dryRun = true;
150
203
  else if (arg === '--yes' || arg === '-y')
151
204
  yes = true;
205
+ else if (arg === '--airplay' || arg === '--no-airplay') {
206
+ const enabled = arg === '--airplay';
207
+ if (airplay !== null && airplay !== enabled)
208
+ throw new Error('Use either --airplay or --no-airplay, not both.');
209
+ airplay = enabled;
210
+ }
152
211
  else if (arg === '--mcp') {
153
212
  if (mcp === false)
154
213
  throw new Error('Use either --mcp or --no-mcp, not both.');
@@ -184,7 +243,7 @@ export function parseSetupArgs(args) {
184
243
  throw new Error('Use either --all or --only, not both.');
185
244
  if (agentUi !== null && mcp !== true)
186
245
  throw new Error('--agent-ui and --headless-agent require --mcp.');
187
- return { all, dryRun, yes, only, packageManager, mcp, agentUi };
246
+ return { all, dryRun, yes, only, packageManager, mcp, agentUi, airplay };
188
247
  }
189
248
  async function finishMcpSetup(enabled, agentUi, dryRun, output) {
190
249
  if (enabled === null)
@@ -238,14 +297,14 @@ function detectInstalledComponents(hasCommand) {
238
297
  vlc: hasCommand('vlc') || hasCommand('cvlc')
239
298
  };
240
299
  }
241
- async function promptForComponents({ platform, installed, input, output }) {
300
+ async function promptForComponents({ platform, installed, output, prompt }) {
242
301
  output.write('Choose components (installed items will be skipped):\n');
243
302
  const selected = [];
244
- if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true))
303
+ if (await prompt(` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true))
245
304
  selected.push('mpv');
246
- if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ` ${separator(output)} installed` : ''}`, platform === 'darwin'))
305
+ if (await prompt(` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ` ${separator(output)} installed` : ''}`, platform === 'darwin'))
247
306
  selected.push('ffmpeg');
248
- if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ` ${separator(output)} installed` : ''}`, false))
307
+ if (await prompt(` VLC Additional playback fallback${installed.vlc ? ` ${separator(output)} installed` : ''}`, false))
249
308
  selected.push('vlc');
250
309
  return selected;
251
310
  }
@@ -328,13 +387,21 @@ function progressFrame(label, frame, elapsedMs, output) {
328
387
  const bar = Array.from({ length: width }, (_, index) => index >= start && index < start + segment ? (unicode ? '█' : '#') : (unicode ? '░' : '.')).join('');
329
388
  return ` ${accent(output, unicode ? '◒' : '*')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`;
330
389
  }
331
- function printVerification(output) {
390
+ async function printVerification(output, { platform, osRelease, env, release, hasCommand, getUid, linuxAirPlayEnabled, verifyPlayback = verifyLocalPlayback }) {
332
391
  clearCommandCache();
333
- const backends = detectPlaybackBackends();
392
+ const backends = detectPlaybackBackends({ platform, env, release, hasCommand, settings: { linuxAirPlayEnabled } });
334
393
  output.write('\nVerification\n');
335
394
  output.write(` Playback backends: ${backends.join(', ') || 'none'}\n`);
336
- for (const line of playbackBackendStatusLines(backends).slice(0, 3))
395
+ for (const line of playbackBackendStatusLines(backends, platform, osRelease, { hasCommand, getUid }).slice(0, 3))
337
396
  output.write(` ${line}\n`);
397
+ const rehearsals = await verifyPlayback(backends, { platform, env });
398
+ for (const rehearsal of rehearsals)
399
+ output.write(` decoder_${rehearsal.backend}=${rehearsal.status} ${rehearsal.message}\n`);
400
+ if (rehearsals.length === 0)
401
+ output.write(' decoder=unverified No local playback backend is available for a rehearsal.\n');
402
+ output.write(' physical_output=unverified Play a station on your intended speakers, headphones, or AirPlay receiver to verify that path.\n');
403
+ if (rehearsals.some(item => item.status !== 'passed'))
404
+ output.write(' Playback needs attention. Run radiocli doctor --playback for a repeatable local check.\n');
338
405
  output.write('\nRun radiocli to start listening.\n');
339
406
  }
340
407
  function writeHeader(output) {
@@ -0,0 +1,113 @@
1
+ import { mkdirSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { performance } from 'node:perf_hooks';
5
+ /** An owner is never evicted for being slow. Only a positively dead PID or an
6
+ * explicitly released generation can be reclaimed. Unknown/legacy locks fail
7
+ * closed; their age cannot distinguish a crashed writer from a suspended one. */
8
+ export function acquireFileLock(directory, timeoutMs = 1000) {
9
+ const marker = `owner-${process.pid}-${randomUUID()}`;
10
+ const deadline = performance.now() + timeoutMs;
11
+ while (true) {
12
+ let acquired = false;
13
+ try {
14
+ mkdirSync(directory, { mode: 0o700 });
15
+ acquired = true;
16
+ }
17
+ catch (error) {
18
+ if (codeOf(error) !== 'EEXIST')
19
+ throw error;
20
+ }
21
+ if (acquired) {
22
+ try {
23
+ writeFileSync(join(directory, marker), '', { mode: 0o600, flag: 'wx' });
24
+ }
25
+ catch (error) {
26
+ // A failed write can leave its newly created marker behind. Remove only
27
+ // our unique file; never clear a directory belonging to a later writer.
28
+ try {
29
+ unlinkSync(join(directory, marker));
30
+ }
31
+ catch { /* May not have been created. */ }
32
+ try {
33
+ rmdirSync(directory);
34
+ }
35
+ catch { /* Never remove another generation. */ }
36
+ throw error;
37
+ }
38
+ let released = false;
39
+ return () => { if (!released) {
40
+ released = true;
41
+ finishLock(directory, marker);
42
+ } };
43
+ }
44
+ try {
45
+ const entries = readdirSync(directory);
46
+ const owner = entries.length === 1 ? entries[0] : '';
47
+ const match = /^(owner|released)-([1-9]\d*)-([a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12})$/.exec(owner);
48
+ if (match && Number.isSafeInteger(Number(match[2])) && (match[1] === 'released' || isDead(Number(match[2])))) {
49
+ // Compare and claim the exact marker, never rename/delete the lock
50
+ // directory after an observation: a different writer may now own it.
51
+ const reaper = `owner-${process.pid}-${randomUUID()}`;
52
+ renameSync(join(directory, owner), join(directory, reaper));
53
+ finishLock(directory, reaper);
54
+ }
55
+ }
56
+ catch (error) {
57
+ if (codeOf(error) !== 'ENOENT')
58
+ throw error;
59
+ }
60
+ if (performance.now() >= deadline) {
61
+ throw Object.assign(new Error('The library lock has a live or unknown owner.'), { code: 'EBUSY' });
62
+ }
63
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
64
+ }
65
+ }
66
+ function isDead(pid) {
67
+ try {
68
+ process.kill(pid, 0);
69
+ return false;
70
+ }
71
+ catch (error) {
72
+ return codeOf(error) === 'ESRCH';
73
+ }
74
+ }
75
+ function finishLock(directory, marker) {
76
+ const released = `released-${process.pid}-${randomUUID()}`;
77
+ try {
78
+ renameSync(join(directory, marker), join(directory, released));
79
+ }
80
+ catch {
81
+ try {
82
+ removeOwnedLock(directory, marker);
83
+ }
84
+ catch { /* Preserve the transaction result. */ }
85
+ return;
86
+ }
87
+ // Publish completion before cleanup so a failed unlink is recoverable even
88
+ // while this process is alive. Cleanup must not turn a commit into a failure.
89
+ try {
90
+ removeOwnedLock(directory, released);
91
+ }
92
+ catch { /* Recover on the next acquisition. */ }
93
+ }
94
+ function removeOwnedLock(directory, marker) {
95
+ try {
96
+ unlinkSync(join(directory, marker));
97
+ }
98
+ catch (error) {
99
+ if (codeOf(error) === 'ENOENT')
100
+ return;
101
+ throw error;
102
+ }
103
+ // An empty directory can be concurrently replaced only by a legacy writer.
104
+ // rmdir cannot remove a new nonempty generation, unlike recursive rm.
105
+ try {
106
+ rmdirSync(directory);
107
+ }
108
+ catch (error) {
109
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(codeOf(error) ?? ''))
110
+ throw error;
111
+ }
112
+ }
113
+ function codeOf(error) { return error?.code; }
@@ -0,0 +1,33 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync } from 'node:fs';
2
+ /** Bounded, read-only preference inspection. Never creates or repairs a library. */
3
+ export function readSettingsWithoutMigration(path) {
4
+ if (!path)
5
+ return undefined;
6
+ let descriptor;
7
+ const limit = 2 * 1024 * 1024;
8
+ try {
9
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
10
+ const info = fstatSync(descriptor);
11
+ if (!info.isFile() || info.size > limit)
12
+ return undefined;
13
+ const buffer = Buffer.alloc(limit + 1);
14
+ const size = readSync(descriptor, buffer, 0, buffer.length, 0);
15
+ if (size > limit)
16
+ return undefined;
17
+ const data = JSON.parse(buffer.toString('utf8', 0, size));
18
+ if (!record(data))
19
+ return undefined;
20
+ return data.settings === undefined ? {} : record(data.settings) ? data.settings : undefined;
21
+ }
22
+ catch (error) {
23
+ return error.code === 'ENOENT' ? {} : undefined;
24
+ }
25
+ finally {
26
+ if (descriptor !== undefined)
27
+ try {
28
+ closeSync(descriptor);
29
+ }
30
+ catch { /* Read-only best effort. */ }
31
+ }
32
+ }
33
+ function record(value) { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); }
@@ -0,0 +1,20 @@
1
+ import { renameSync } from 'node:fs';
2
+ /** Keep the destination intact until the replacement succeeds. Windows readers
3
+ * and virus scanners can briefly deny replacement even in a writable folder. */
4
+ export function replaceFileSync(source, destination, platform = process.platform) {
5
+ const pause = new Int32Array(new SharedArrayBuffer(4));
6
+ for (let retry = 0;; retry++) {
7
+ try {
8
+ renameSync(source, destination);
9
+ return;
10
+ }
11
+ catch (error) {
12
+ const code = error.code;
13
+ if (platform !== 'win32' || !['EACCES', 'EPERM', 'EBUSY'].includes(code ?? '') || retry >= 10)
14
+ throw error;
15
+ // At most 550 ms in total, while retaining the writer lock and flushed
16
+ // temporary file. Never unlink the destination to work around a lock.
17
+ Atomics.wait(pause, 0, 0, (retry + 1) * 10);
18
+ }
19
+ }
20
+ }