@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.
- package/CHANGELOG.md +70 -2
- package/CONTRIBUTING.md +10 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +178 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +249 -76
- package/dist/ui/AdaptiveContent.js +18 -10
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- 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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
303
|
+
if (await prompt(` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true))
|
|
245
304
|
selected.push('mpv');
|
|
246
|
-
if (await
|
|
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
|
|
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
|
+
}
|