@ciphore/radiocli 0.2.3 → 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 +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- 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 +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- 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 +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- 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 +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- 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 +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- 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
package/dist/agent/session.js
CHANGED
|
@@ -1,13 +1,123 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import {
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { parseRadioCommand, parseRadioRequest } from './command-schema.js';
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { createServer, request } from 'node:http';
|
|
4
|
-
import {
|
|
5
|
+
import { platformPaths } from '../platform/paths.js';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
6
|
-
|
|
7
|
+
import { isLoopbackHost, listenLoopback } from '../platform/loopback.js';
|
|
8
|
+
/** The wire protocol accepts appearance/MCP preferences, never experimental-output consent. */
|
|
9
|
+
export function allowedRadioSessionSettings(value) {
|
|
10
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
&& Object.keys(value).every(key => ['theme', 'receiverStyle', 'agentControl'].includes(key));
|
|
12
|
+
}
|
|
13
|
+
/** Resolve desktop intent at the owner queue boundary, after earlier commands
|
|
14
|
+
* have completed. In particular, Play/PlayPause must not use a stale snapshot. */
|
|
15
|
+
export function resolveDesktopTransport(action, status) {
|
|
16
|
+
const { playback, station, queue } = status;
|
|
17
|
+
const stopped = ['idle', 'stopped'].includes(playback.state);
|
|
18
|
+
if (action === 'play') {
|
|
19
|
+
if (playback.state === 'paused')
|
|
20
|
+
return { type: 'resume' };
|
|
21
|
+
return stopped && station ? { type: 'resume', restartStopped: true } : null;
|
|
22
|
+
}
|
|
23
|
+
if (action === 'pause')
|
|
24
|
+
return playback.state === 'playing' && playback.backend === 'mpv' ? { type: 'pause' } : null;
|
|
25
|
+
if (action === 'toggle') {
|
|
26
|
+
if (!station || playback.backend !== 'mpv')
|
|
27
|
+
throw new Error('This output cannot toggle pause.');
|
|
28
|
+
return playback.state === 'playing' ? { type: 'pause' } : playback.state === 'paused' ? { type: 'resume' } : stopped ? { type: 'resume', restartStopped: true } : null;
|
|
29
|
+
}
|
|
30
|
+
// Even an idle backend can have an unresolved tune or delayed retry to cancel.
|
|
31
|
+
if (action === 'stop')
|
|
32
|
+
return { type: 'stop', retainSelection: true };
|
|
33
|
+
return station && queue.length > 1 && (stopped || playback.state === 'playing' || playback.state === 'paused')
|
|
34
|
+
? { type: action, preservePause: true, preserveStop: true } : null;
|
|
35
|
+
}
|
|
36
|
+
/** Agent and desktop controls must use the same owner queue. A failed command
|
|
37
|
+
* cannot strand the queue or allow a later command to overtake an active one. */
|
|
38
|
+
export function serializeRadioCommands(handle, options = {}) {
|
|
39
|
+
let pending = Promise.resolve();
|
|
40
|
+
let tuning = Promise.resolve();
|
|
41
|
+
let admitted = 0;
|
|
42
|
+
const tunes = new Set();
|
|
43
|
+
return (input, permitted) => {
|
|
44
|
+
let command;
|
|
45
|
+
try {
|
|
46
|
+
command = parseRadioCommand(input);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return Promise.reject(error);
|
|
50
|
+
}
|
|
51
|
+
const priority = command.type === 'stop' || command.type === 'alarm-preempt' || command.type === 'tui-cancel'
|
|
52
|
+
|| command.type === 'desktop-transport' && command.action === 'stop';
|
|
53
|
+
const checkPermission = async () => { if (permitted && !await permitted())
|
|
54
|
+
throw new Error('Desktop media controls are suspended.'); };
|
|
55
|
+
if (options.responsive && command.type === 'status')
|
|
56
|
+
return Promise.resolve().then(async () => { await checkPermission(); return handle(command); });
|
|
57
|
+
if (options.responsive && priority) {
|
|
58
|
+
const interrupted = [...tunes];
|
|
59
|
+
const priorPending = pending;
|
|
60
|
+
const priorTuning = tuning;
|
|
61
|
+
const work = checkPermission().then(() => {
|
|
62
|
+
for (const controller of interrupted)
|
|
63
|
+
controller.abort(new DOMException('Tuning canceled by Stop or alarm.', 'AbortError'));
|
|
64
|
+
return handle(command);
|
|
65
|
+
});
|
|
66
|
+
pending = work.then(() => undefined, () => priorPending);
|
|
67
|
+
tuning = work.then(() => undefined, () => priorTuning);
|
|
68
|
+
return work;
|
|
69
|
+
}
|
|
70
|
+
if (admitted >= (options.limit ?? 128))
|
|
71
|
+
return Promise.reject(new Error('RadioCLI control queue is busy. Status and Stop remain available.'));
|
|
72
|
+
admitted++;
|
|
73
|
+
const replacesTune = ['play', 'airplay-select', 'airplay-local'].includes(command.type);
|
|
74
|
+
const transport = replacesTune || ['pause', 'resume', 'next', 'previous', 'desktop-transport'].includes(command.type);
|
|
75
|
+
const long = options.responsive && (['play', 'resume', 'next', 'previous', 'airplay-select', 'airplay-local'].includes(command.type) || command.type === 'desktop-transport' && ['play', 'next', 'previous'].includes(command.action));
|
|
76
|
+
const replaced = options.responsive && replacesTune ? [...tunes] : [];
|
|
77
|
+
const priorTuning = tuning;
|
|
78
|
+
let executing = false;
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
if (options.responsive && transport)
|
|
81
|
+
tunes.add(controller);
|
|
82
|
+
const fence = options.responsive && transport && !replacesTune ? Promise.all([pending, tuning]).then(() => undefined) : pending;
|
|
83
|
+
const start = abortableControl(fence, controller.signal).then(async () => {
|
|
84
|
+
await checkPermission();
|
|
85
|
+
controller.signal.throwIfAborted();
|
|
86
|
+
for (const earlier of replaced)
|
|
87
|
+
earlier.abort(new DOMException('Tuning canceled by a replacement.', 'AbortError'));
|
|
88
|
+
executing = true;
|
|
89
|
+
const result = handle(command, controller.signal);
|
|
90
|
+
return { result };
|
|
91
|
+
});
|
|
92
|
+
const result = abortableControl(start.then(({ result }) => result), controller.signal).finally(() => { admitted--; tunes.delete(controller); });
|
|
93
|
+
// Tune completion is separate from command admission. A later tune replaces
|
|
94
|
+
// an active one, while Stop and status bypass both queues entirely.
|
|
95
|
+
pending = (long ? start : result).then(() => undefined, () => undefined);
|
|
96
|
+
if (long)
|
|
97
|
+
tuning = result.then(() => undefined, () => executing ? undefined : priorTuning);
|
|
98
|
+
// Cancellation is an expected control outcome. Keep the rejection available
|
|
99
|
+
// to the caller without leaking it while another transport action is awaited.
|
|
100
|
+
void result.catch(() => undefined);
|
|
101
|
+
return result;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function abortableControl(work, signal) {
|
|
105
|
+
if (signal.aborted)
|
|
106
|
+
return Promise.reject(signal.reason);
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const abort = () => { signal.removeEventListener('abort', abort); reject(signal.reason); };
|
|
109
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
110
|
+
void work.then(value => { signal.removeEventListener('abort', abort); resolve(value); }, error => { signal.removeEventListener('abort', abort); reject(error); });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Use queue: 'owner' only when handle submits directly to the owner's shared
|
|
114
|
+
* serialized dispatcher. Other callers retain the session's own FIFO. */
|
|
115
|
+
export async function startRadioSession(handle, filePath = radioSessionPath(), options = {}) {
|
|
7
116
|
const ownerPath = `${filePath}.owner`;
|
|
8
117
|
acquireOwner(ownerPath);
|
|
9
118
|
const token = randomBytes(32).toString('hex');
|
|
10
119
|
let serial = Promise.resolve();
|
|
120
|
+
const requests = new Map();
|
|
11
121
|
const server = createServer((req, res) => {
|
|
12
122
|
res.setHeader('content-type', 'application/json');
|
|
13
123
|
if (req.headers.authorization !== `Bearer ${token}`) {
|
|
@@ -20,27 +130,51 @@ export async function startRadioSession(handle, filePath = radioSessionPath()) {
|
|
|
20
130
|
res.end('{}');
|
|
21
131
|
return;
|
|
22
132
|
}
|
|
23
|
-
const
|
|
133
|
+
const accept = async () => {
|
|
24
134
|
const body = await readBody(req);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
135
|
+
const { command, requestId } = parseRadioRequest(JSON.parse(body));
|
|
136
|
+
const execute = () => handle(command, req.headers['x-radiocli-client'] === 'cli' ? 'cli' : 'agent');
|
|
137
|
+
if (!requestId || command.type === 'status')
|
|
138
|
+
return execute();
|
|
139
|
+
const fingerprint = createHash('sha256').update(JSON.stringify(command)).digest('hex');
|
|
140
|
+
const existing = requests.get(requestId);
|
|
141
|
+
if (existing) {
|
|
142
|
+
if (existing.fingerprint !== fingerprint)
|
|
143
|
+
throw new Error('This request ID was already used for a different command.');
|
|
144
|
+
return existing.result;
|
|
145
|
+
}
|
|
146
|
+
for (const [id, entry] of requests)
|
|
147
|
+
if (entry.settledAt && (Date.now() - entry.settledAt > 300_000 || requests.size >= 256))
|
|
148
|
+
requests.delete(id);
|
|
149
|
+
if (requests.size >= 256) {
|
|
150
|
+
// Stop/preemption are intrinsically idempotent and must remain available
|
|
151
|
+
// even when ordinary requests occupy the entire bounded outcome cache.
|
|
152
|
+
if (command.type === 'stop' || command.type === 'alarm-preempt' || command.type === 'desktop-transport' && command.action === 'stop')
|
|
153
|
+
return execute();
|
|
154
|
+
throw new Error('RadioCLI has too many active control requests.');
|
|
155
|
+
}
|
|
156
|
+
const entry = { fingerprint, result: Promise.resolve().then(execute) };
|
|
157
|
+
requests.set(requestId, entry);
|
|
158
|
+
void entry.result.then(() => { entry.settledAt = Date.now(); }, () => { entry.settledAt = Date.now(); });
|
|
159
|
+
return entry.result;
|
|
160
|
+
};
|
|
161
|
+
// An owner shared with desktop controls already serializes accepted work.
|
|
162
|
+
// Submit each authenticated, complete request directly to that queue so a
|
|
163
|
+
// later desktop action cannot overtake an earlier HTTP request here.
|
|
164
|
+
const work = options.queue === 'owner' ? accept() : serial.then(accept);
|
|
165
|
+
if (options.queue !== 'owner')
|
|
166
|
+
serial = work.then(() => undefined, () => undefined);
|
|
28
167
|
void work.then(result => res.end(JSON.stringify(result))).catch(error => {
|
|
29
168
|
res.statusCode = 400;
|
|
30
169
|
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Radio control failed.' }));
|
|
31
170
|
});
|
|
32
171
|
});
|
|
33
172
|
try {
|
|
34
|
-
await
|
|
35
|
-
server.once('error', reject);
|
|
36
|
-
server.listen(0, '127.0.0.1', () => resolve());
|
|
37
|
-
});
|
|
38
|
-
const address = server.address();
|
|
39
|
-
if (!address || typeof address === 'string')
|
|
40
|
-
throw new Error('Unable to create RadioCLI control endpoint.');
|
|
173
|
+
const address = await listenLoopback(server);
|
|
41
174
|
const discovery = {
|
|
42
175
|
version: 1,
|
|
43
|
-
|
|
176
|
+
protocol: 2,
|
|
177
|
+
host: address.host,
|
|
44
178
|
port: address.port,
|
|
45
179
|
token,
|
|
46
180
|
pid: process.pid,
|
|
@@ -61,13 +195,14 @@ export async function startRadioSession(handle, filePath = radioSessionPath()) {
|
|
|
61
195
|
throw error;
|
|
62
196
|
}
|
|
63
197
|
}
|
|
64
|
-
export async function connectRadioSession(filePath = radioSessionPath()) {
|
|
198
|
+
export async function connectRadioSession(filePath = radioSessionPath(), options = {}) {
|
|
199
|
+
options.signal?.throwIfAborted();
|
|
65
200
|
if (!existsSync(filePath))
|
|
66
201
|
return null;
|
|
67
202
|
let discovery;
|
|
68
203
|
try {
|
|
69
204
|
discovery = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
70
|
-
if (discovery.version !== 1 || discovery.host
|
|
205
|
+
if (discovery.version !== 1 || !isLoopbackHost(discovery.host) || !Number.isInteger(discovery.port) || !discovery.token) {
|
|
71
206
|
throw new Error('invalid');
|
|
72
207
|
}
|
|
73
208
|
}
|
|
@@ -75,48 +210,72 @@ export async function connectRadioSession(filePath = radioSessionPath()) {
|
|
|
75
210
|
rmSync(filePath, { force: true });
|
|
76
211
|
return null;
|
|
77
212
|
}
|
|
78
|
-
const call = async (command) => {
|
|
79
|
-
const
|
|
213
|
+
const call = async (command, requestId = randomUUID()) => {
|
|
214
|
+
const validated = parseRadioCommand(command);
|
|
215
|
+
const payload = JSON.stringify(discovery.protocol === 2 ? { version: 2, requestId, command: validated } : validated);
|
|
80
216
|
return new Promise((resolve, reject) => {
|
|
81
217
|
const req = request({
|
|
82
|
-
host:
|
|
218
|
+
host: discovery.host,
|
|
219
|
+
// Local control tokens must never be forwarded by an environment proxy.
|
|
220
|
+
agent: false,
|
|
221
|
+
signal: options.signal,
|
|
83
222
|
port: discovery.port,
|
|
84
223
|
path: '/command',
|
|
85
224
|
method: 'POST',
|
|
86
225
|
headers: {
|
|
87
226
|
authorization: `Bearer ${discovery.token}`,
|
|
227
|
+
'x-radiocli-client': options.source ?? 'agent',
|
|
88
228
|
'content-type': 'application/json',
|
|
89
229
|
'content-length': Buffer.byteLength(payload)
|
|
90
230
|
}
|
|
91
231
|
}, res => {
|
|
92
232
|
let text = '';
|
|
93
|
-
res.
|
|
233
|
+
res.setEncoding('utf8');
|
|
234
|
+
res.on('data', value => { text += String(value); if (Buffer.byteLength(text) > 8 * 1024 * 1024)
|
|
235
|
+
res.destroy(new Error('RadioCLI response too large.')); });
|
|
236
|
+
res.once('error', reject);
|
|
94
237
|
res.on('end', () => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
238
|
+
try {
|
|
239
|
+
if (res.statusCode && res.statusCode < 300) {
|
|
240
|
+
const parsed = JSON.parse(text);
|
|
241
|
+
if (typeof parsed.ok !== 'boolean' || typeof parsed.message !== 'string' || !parsed.status?.playback || !Array.isArray(parsed.status.queue))
|
|
242
|
+
throw new Error('Invalid RadioCLI control response.');
|
|
243
|
+
resolve(parsed);
|
|
244
|
+
}
|
|
245
|
+
else
|
|
246
|
+
reject(new Error(parseError(text)));
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
reject(error);
|
|
250
|
+
}
|
|
99
251
|
});
|
|
100
252
|
});
|
|
101
253
|
req.once('error', reject);
|
|
102
|
-
|
|
254
|
+
const timeout = command.type === 'status' ? 5000 : ['stop', 'alarm-preempt'].includes(command.type) ? 15_000 : 120_000;
|
|
255
|
+
req.setTimeout(timeout, () => req.destroy(new Error(`RadioCLI control request timed out. Request ID: ${requestId}. Retry with the same ID to retrieve its outcome.`)));
|
|
103
256
|
req.end(payload);
|
|
104
257
|
});
|
|
105
258
|
};
|
|
106
259
|
try {
|
|
107
260
|
await call({ type: 'status' });
|
|
261
|
+
options.signal?.throwIfAborted();
|
|
108
262
|
return { call, status: async () => (await call({ type: 'status' })).status };
|
|
109
263
|
}
|
|
110
264
|
catch {
|
|
111
|
-
|
|
265
|
+
options.signal?.throwIfAborted();
|
|
266
|
+
// A slow command does not prove that a playback host is dead. Retain both
|
|
267
|
+
// ownership records while its PID is alive so a second host cannot start.
|
|
268
|
+
if (!processAlive(discovery.pid)) {
|
|
112
269
|
removeIfOwned(filePath, discovery);
|
|
113
270
|
removeOwner(`${filePath}.owner`, discovery.pid);
|
|
114
271
|
}
|
|
115
272
|
return null;
|
|
116
273
|
}
|
|
117
274
|
}
|
|
118
|
-
export async function ensureRadioSession(start, timeoutMs = 15_000) {
|
|
119
|
-
|
|
275
|
+
export async function ensureRadioSession(start, timeoutMs = 15_000, options = {}) {
|
|
276
|
+
options.signal?.throwIfAborted();
|
|
277
|
+
const existing = await connectRadioSession(undefined, options);
|
|
278
|
+
options.signal?.throwIfAborted();
|
|
120
279
|
if (existing)
|
|
121
280
|
return existing;
|
|
122
281
|
const lockPath = `${radioSessionPath()}.launch`;
|
|
@@ -124,16 +283,18 @@ export async function ensureRadioSession(start, timeoutMs = 15_000) {
|
|
|
124
283
|
try {
|
|
125
284
|
acquireOwner(lockPath);
|
|
126
285
|
ownsLaunch = true;
|
|
127
|
-
const appeared = await connectRadioSession();
|
|
286
|
+
const appeared = await connectRadioSession(undefined, options);
|
|
287
|
+
options.signal?.throwIfAborted();
|
|
128
288
|
if (appeared)
|
|
129
289
|
return appeared;
|
|
130
290
|
await start();
|
|
131
|
-
|
|
291
|
+
options.signal?.throwIfAborted();
|
|
292
|
+
return await waitForSession(timeoutMs, options);
|
|
132
293
|
}
|
|
133
294
|
catch (error) {
|
|
134
295
|
if (ownsLaunch || !(error instanceof Error) || !error.message.includes('already active'))
|
|
135
296
|
throw error;
|
|
136
|
-
return waitForSession(timeoutMs);
|
|
297
|
+
return waitForSession(timeoutMs, options);
|
|
137
298
|
}
|
|
138
299
|
finally {
|
|
139
300
|
if (ownsLaunch)
|
|
@@ -143,22 +304,45 @@ export async function ensureRadioSession(start, timeoutMs = 15_000) {
|
|
|
143
304
|
function radioSessionPath() {
|
|
144
305
|
return join(runtimeDirectory(), 'agent-session.json');
|
|
145
306
|
}
|
|
146
|
-
|
|
307
|
+
/** Optional terminal recovery may fall back to inline playback only when no
|
|
308
|
+
* live or unknown owner could already be using this library's audio session. */
|
|
309
|
+
export function radioSessionMayBeActive() {
|
|
310
|
+
for (const path of [`${radioSessionPath()}.owner`, `${radioSessionPath()}.launch`, radioSessionPath()]) {
|
|
311
|
+
try {
|
|
312
|
+
const text = readFileSync(path, 'utf8');
|
|
313
|
+
const pid = path.endsWith('.json') ? JSON.parse(text).pid : Number(text.trim());
|
|
314
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || processAlive(pid))
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
if (error.code !== 'ENOENT')
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
async function waitForSession(timeoutMs, options) {
|
|
147
325
|
const deadline = Date.now() + timeoutMs;
|
|
148
326
|
while (Date.now() < deadline) {
|
|
149
|
-
|
|
327
|
+
options.signal?.throwIfAborted();
|
|
328
|
+
const client = await connectRadioSession(undefined, options);
|
|
329
|
+
options.signal?.throwIfAborted();
|
|
150
330
|
if (client)
|
|
151
331
|
return client;
|
|
152
|
-
await
|
|
332
|
+
await controlPollDelay(options.signal);
|
|
153
333
|
}
|
|
154
334
|
throw new Error('RadioCLI was launched but its control session did not become ready.');
|
|
155
335
|
}
|
|
336
|
+
function controlPollDelay(signal) {
|
|
337
|
+
signal?.throwIfAborted();
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
const abort = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); reject(signal?.reason); };
|
|
340
|
+
const timer = setTimeout(() => { signal?.removeEventListener('abort', abort); resolve(); }, 100);
|
|
341
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
342
|
+
});
|
|
343
|
+
}
|
|
156
344
|
function runtimeDirectory() {
|
|
157
|
-
|
|
158
|
-
return join(process.env.RADIOCLI_HOME, 'runtime');
|
|
159
|
-
if (process.platform === 'win32')
|
|
160
|
-
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime');
|
|
161
|
-
return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli');
|
|
345
|
+
return platformPaths().runtime;
|
|
162
346
|
}
|
|
163
347
|
function acquireOwner(path) {
|
|
164
348
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -214,23 +398,13 @@ function processAlive(pid) {
|
|
|
214
398
|
return error.code === 'EPERM';
|
|
215
399
|
}
|
|
216
400
|
}
|
|
217
|
-
function discoveryAgeMs(path, discovery) {
|
|
218
|
-
const created = Date.parse(discovery.createdAt);
|
|
219
|
-
if (Number.isFinite(created))
|
|
220
|
-
return Math.max(0, Date.now() - created);
|
|
221
|
-
try {
|
|
222
|
-
return Math.max(0, Date.now() - statSync(path).mtimeMs);
|
|
223
|
-
}
|
|
224
|
-
catch {
|
|
225
|
-
return 0;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
401
|
function readBody(req) {
|
|
229
402
|
return new Promise((resolve, reject) => {
|
|
230
403
|
let body = '';
|
|
404
|
+
req.setEncoding('utf8');
|
|
231
405
|
req.on('data', value => {
|
|
232
406
|
body += String(value);
|
|
233
|
-
if (body
|
|
407
|
+
if (Buffer.byteLength(body) > 4 * 1024 * 1024)
|
|
234
408
|
req.destroy(new Error('Request too large.'));
|
|
235
409
|
});
|
|
236
410
|
req.on('end', () => resolve(body));
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { PlayerController } from '../player/player-controller.js';
|
|
6
|
+
import { launchHeadlessHost } from './launcher.js';
|
|
7
|
+
import { platformPaths } from '../platform/paths.js';
|
|
8
|
+
import { storageReadiness } from '../platform/storage.js';
|
|
9
|
+
import { listenLoopback } from '../platform/loopback.js';
|
|
10
|
+
import { ensureRadioSession, radioSessionMayBeActive } from './session.js';
|
|
11
|
+
export async function openTuiSession() {
|
|
12
|
+
if (!radioSessionMayBeActive()) {
|
|
13
|
+
if (process.env.RADIOCLI_TUI_PLAYBACK === 'inline')
|
|
14
|
+
return { warning: 'Playback runs in this terminal. Crash recovery is disabled by RADIOCLI_TUI_PLAYBACK=inline.' };
|
|
15
|
+
if (storageReadiness(join(platformPaths().runtime, 'agent-session.json')).status === 'unavailable') {
|
|
16
|
+
return { warning: 'Playback recovery is unavailable: the runtime directory is not writable. Set RADIOCLI_HOME to a private writable directory.' };
|
|
17
|
+
}
|
|
18
|
+
const probe = createServer();
|
|
19
|
+
try {
|
|
20
|
+
await listenLoopback(probe);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return { warning: 'Playback recovery is unavailable: this environment blocks local loopback control. Playback runs in this terminal.' };
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await new Promise(resolve => probe.close(() => resolve()));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { session: await TuiSession.connect() };
|
|
30
|
+
}
|
|
31
|
+
/** A terminal is a view of the existing playback owner. Its death must never
|
|
32
|
+
* destroy the owner's IPC connection, queue, timers, or listening history. */
|
|
33
|
+
export class TuiSession {
|
|
34
|
+
client;
|
|
35
|
+
clientId;
|
|
36
|
+
status;
|
|
37
|
+
listeners = new Set();
|
|
38
|
+
timer;
|
|
39
|
+
revision = 0;
|
|
40
|
+
closing;
|
|
41
|
+
closed = false;
|
|
42
|
+
constructor(client, clientId, status) {
|
|
43
|
+
this.client = client;
|
|
44
|
+
this.clientId = clientId;
|
|
45
|
+
this.status = status;
|
|
46
|
+
}
|
|
47
|
+
static async connect() {
|
|
48
|
+
const cli = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../cli.tsx' : '../cli.js', import.meta.url));
|
|
49
|
+
const client = await ensureRadioSession(() => launchHeadlessHost(process.execPath, cli), 15_000, { source: 'cli' });
|
|
50
|
+
const clientId = randomUUID();
|
|
51
|
+
let result;
|
|
52
|
+
try {
|
|
53
|
+
result = await client.call({ type: 'tui-attach', pid: process.pid, clientId });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof Error && /Invalid radio command|Unsupported radio command/i.test(error.message)) {
|
|
57
|
+
throw new Error('The running player predates TUI recovery. Run radiocli stop, then reopen radiocli to use the updated player.');
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
if (!result.ok)
|
|
62
|
+
throw new Error(result.message);
|
|
63
|
+
if (!result.status.tui)
|
|
64
|
+
throw new Error('The playback host does not support terminal recovery. Stop the older session and reopen RadioCLI.');
|
|
65
|
+
const session = new TuiSession(client, clientId, result.status);
|
|
66
|
+
session.poll();
|
|
67
|
+
return session;
|
|
68
|
+
}
|
|
69
|
+
subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
70
|
+
async call(command) {
|
|
71
|
+
if (this.closed)
|
|
72
|
+
throw new Error('RadioCLI terminal session is closed.');
|
|
73
|
+
const result = await this.client.call(command);
|
|
74
|
+
this.revision++;
|
|
75
|
+
this.status = result.status;
|
|
76
|
+
this.emit();
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
report(error) {
|
|
80
|
+
this.status = { ...this.status, persistenceWarning: error instanceof Error ? error.message : String(error) };
|
|
81
|
+
this.emit();
|
|
82
|
+
}
|
|
83
|
+
disconnect() {
|
|
84
|
+
clearTimeout(this.timer);
|
|
85
|
+
this.closed = true;
|
|
86
|
+
this.listeners.clear();
|
|
87
|
+
}
|
|
88
|
+
close() {
|
|
89
|
+
return this.closing ??= (async () => {
|
|
90
|
+
clearTimeout(this.timer);
|
|
91
|
+
try {
|
|
92
|
+
const result = await this.call({ type: 'tui-detach', clientId: this.clientId });
|
|
93
|
+
if (!result.ok)
|
|
94
|
+
throw new Error(result.message);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
this.disconnect();
|
|
98
|
+
}
|
|
99
|
+
})();
|
|
100
|
+
}
|
|
101
|
+
poll() {
|
|
102
|
+
this.timer = setTimeout(() => {
|
|
103
|
+
const revision = this.revision;
|
|
104
|
+
void this.client.status().then(status => {
|
|
105
|
+
if (!this.closed && revision === this.revision && JSON.stringify(status) !== JSON.stringify(this.status)) {
|
|
106
|
+
this.status = status;
|
|
107
|
+
this.emit();
|
|
108
|
+
}
|
|
109
|
+
}).catch(() => {
|
|
110
|
+
if (!this.closed && revision === this.revision) {
|
|
111
|
+
this.status = { ...this.status, playback: { ...this.status.playback, state: 'error', ready: false,
|
|
112
|
+
message: 'Playback host disconnected. Reopen RadioCLI to reconnect.' } };
|
|
113
|
+
this.emit();
|
|
114
|
+
}
|
|
115
|
+
}).finally(() => { if (!this.closed && !this.closing)
|
|
116
|
+
this.poll(); });
|
|
117
|
+
}, 250);
|
|
118
|
+
this.timer.unref();
|
|
119
|
+
}
|
|
120
|
+
emit() { for (const listener of this.listeners)
|
|
121
|
+
listener(); }
|
|
122
|
+
}
|
|
123
|
+
/** Keep synchronous presentation queries local, while every audio mutation
|
|
124
|
+
* reaches the same owner used by CLI, agent, alarm, and desktop controls. */
|
|
125
|
+
export class TuiPlayer extends PlayerController {
|
|
126
|
+
session;
|
|
127
|
+
constructor(session, settings) {
|
|
128
|
+
super(settings);
|
|
129
|
+
this.session = session;
|
|
130
|
+
}
|
|
131
|
+
getState() { return { ...this.session.status.playback }; }
|
|
132
|
+
onChange(listener) {
|
|
133
|
+
listener(this.getState());
|
|
134
|
+
return this.session.subscribe(() => listener(this.getState()));
|
|
135
|
+
}
|
|
136
|
+
onMetadata(listener) {
|
|
137
|
+
let previous;
|
|
138
|
+
return this.session.subscribe(() => {
|
|
139
|
+
const metadata = this.session.status.nowPlaying;
|
|
140
|
+
const key = JSON.stringify(metadata);
|
|
141
|
+
if (metadata && key !== previous) {
|
|
142
|
+
previous = key;
|
|
143
|
+
listener(metadata);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
diagnostics() { return { ...this.session.status.tui.diagnostics }; }
|
|
148
|
+
desktopMediaCompatible() { return this.session.status.tui.desktopMediaCompatible; }
|
|
149
|
+
detectedBackends() { return [...this.session.status.tui.diagnostics.availableBackends]; }
|
|
150
|
+
refreshDetectedBackends() {
|
|
151
|
+
void this.session.call({ type: 'tui-refresh' }).catch(error => this.session.report(error));
|
|
152
|
+
return this.detectedBackends();
|
|
153
|
+
}
|
|
154
|
+
detectedAirPlayDevices() { return [...this.session.status.tui.devices]; }
|
|
155
|
+
airPlaySelectionRequired() { return this.session.status.tui.selectionRequired; }
|
|
156
|
+
async refreshAirPlayDevices() {
|
|
157
|
+
const result = await this.session.call({ type: 'airplay-list' });
|
|
158
|
+
if (!result.ok)
|
|
159
|
+
throw new Error(result.message);
|
|
160
|
+
return result.data ?? [];
|
|
161
|
+
}
|
|
162
|
+
async selectAirPlayDevice(deviceId) { return (await this.session.call({ type: 'tui-output', deviceId })).ok; }
|
|
163
|
+
async submitAirPlayPasscode(code) { return this.control({ type: 'airplay-passcode', code }); }
|
|
164
|
+
async pause() { return this.control({ type: 'pause' }); }
|
|
165
|
+
async resume() { return this.control({ type: 'resume' }); }
|
|
166
|
+
async togglePause() { return this.control({ type: 'tui-toggle-pause' }); }
|
|
167
|
+
async setVolume(volume, options = {}) {
|
|
168
|
+
return this.control({ type: 'set-volume', volume: Math.max(0, Math.min(100, Math.round(volume))), allowStopped: options.allowStopped });
|
|
169
|
+
}
|
|
170
|
+
async adjustVolume(delta) { return this.control({ type: 'tui-adjust-volume', delta }); }
|
|
171
|
+
async setMuted(muted, options = {}) { return this.control({ type: 'set-muted', muted, allowStopped: options.allowStopped }); }
|
|
172
|
+
async toggleMute() { return this.control({ type: 'tui-toggle-muted' }); }
|
|
173
|
+
async stop() { await this.session.call({ type: 'stop', retainSelection: true }); }
|
|
174
|
+
async play() { throw new Error('Terminal playback must use the shared radio engine.'); }
|
|
175
|
+
async control(command) {
|
|
176
|
+
const result = await this.session.call(command);
|
|
177
|
+
return { ok: result.ok, message: result.message };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export class TuiRadioEngine {
|
|
181
|
+
session;
|
|
182
|
+
constructor(session) {
|
|
183
|
+
this.session = session;
|
|
184
|
+
}
|
|
185
|
+
snapshot() {
|
|
186
|
+
const status = this.session.status;
|
|
187
|
+
return { station: status.station, tuningStation: status.tui?.tuningStation ?? null, queue: [...status.queue],
|
|
188
|
+
playback: { ...status.playback }, nowPlaying: status.nowPlaying ?? null, warning: status.persistenceWarning, message: status.tui?.message };
|
|
189
|
+
}
|
|
190
|
+
onChange(listener) { listener(this.snapshot()); return this.session.subscribe(() => listener(this.snapshot())); }
|
|
191
|
+
clearWarning() { this.session.status.persistenceWarning = undefined; }
|
|
192
|
+
cancel() { void this.session.call({ type: 'tui-cancel' }).catch(error => this.session.report(error)); }
|
|
193
|
+
async tune(station, options = {}) {
|
|
194
|
+
options.signal?.throwIfAborted();
|
|
195
|
+
const abort = () => this.cancel();
|
|
196
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
197
|
+
try {
|
|
198
|
+
const result = await this.session.call({ type: 'play', station, queue: options.queue, startPaused: options.startPaused });
|
|
199
|
+
options.signal?.throwIfAborted();
|
|
200
|
+
if (!result.ok || !result.status.station)
|
|
201
|
+
throw new Error(result.message);
|
|
202
|
+
return result.status.station;
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
options.signal?.removeEventListener('abort', abort);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async stop(options = {}) { await this.session.call({ type: 'stop', ...options }); }
|
|
209
|
+
async pause() { const result = await this.session.call({ type: 'pause' }); return { ok: result.ok, message: result.message }; }
|
|
210
|
+
async resume() { const result = await this.session.call({ type: 'resume' }); return { ok: result.ok, message: result.message }; }
|
|
211
|
+
select(station, queue) { void this.session.call({ type: 'tui-select', station, queue }).catch(error => this.session.report(error)); }
|
|
212
|
+
close() { return this.session.close(); }
|
|
213
|
+
}
|