@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
package/dist/agent/service.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { randomInt } from 'node:crypto';
|
|
2
2
|
import { defaultAgentControlSettings, themeNames, receiverStyleNames } from '../types.js';
|
|
3
|
+
import { LibraryRepository } from '../storage/repository.js';
|
|
3
4
|
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
5
|
+
import { parseRadioCommand } from './command-schema.js';
|
|
6
|
+
import { matchesStationId } from '../storage/station-identity.js';
|
|
4
7
|
import { ProviderManager } from '../providers/provider-manager.js';
|
|
5
8
|
import { computeListeningStats } from '../activity/stats.js';
|
|
6
9
|
import { connectActiveAlarms } from '../alarms/active-session.js';
|
|
7
10
|
import { connectRadioSession, ensureRadioSession } from './session.js';
|
|
8
11
|
import { launchHeadlessHost, launchRadioTui } from './launcher.js';
|
|
9
12
|
import { PlayerController } from '../player/player-controller.js';
|
|
10
|
-
import {
|
|
13
|
+
import { airPlayPolicy } from '../player/airplay-policy.js';
|
|
14
|
+
import { detectPlaybackBackends } from '../player/backend-install.js';
|
|
11
15
|
import { AgentAlarmService } from './alarm-service.js';
|
|
12
16
|
import { appVersion } from '../version.js';
|
|
13
17
|
import { checkForUpdate, updateCommandForInstall } from '../update-check.js';
|
|
@@ -15,22 +19,29 @@ export class AgentRadioService {
|
|
|
15
19
|
runtime;
|
|
16
20
|
store;
|
|
17
21
|
providers;
|
|
22
|
+
source;
|
|
18
23
|
stationCache = new Map();
|
|
19
24
|
alarms;
|
|
20
|
-
constructor(runtime, store = new JsonLibraryStore(), providers = new ProviderManager()
|
|
25
|
+
constructor(runtime, store = new JsonLibraryStore(), providers = new ProviderManager(),
|
|
26
|
+
// Local shell commands do not enable or register agent/MCP integrations.
|
|
27
|
+
source = 'agent') {
|
|
21
28
|
this.runtime = runtime;
|
|
22
29
|
this.store = store;
|
|
23
30
|
this.providers = providers;
|
|
31
|
+
this.source = source;
|
|
24
32
|
this.alarms = new AgentAlarmService(this.store, id => this.resolveStationId(id), undefined, status => this.handoffAlarm(status));
|
|
25
33
|
}
|
|
26
|
-
|
|
34
|
+
async refresh() { if (this.store instanceof LibraryRepository)
|
|
35
|
+
await this.store.readFresh(); }
|
|
36
|
+
settings() { return this.store.settings(); }
|
|
37
|
+
connect() { return connectRadioSession(undefined, { source: this.source }); }
|
|
27
38
|
appearance() {
|
|
28
39
|
this.assertEnabled();
|
|
29
40
|
return { themes: themeNames, receiverStyles: receiverStyleNames, current: this.settings() };
|
|
30
41
|
}
|
|
31
42
|
async status() {
|
|
32
43
|
this.assertEnabled();
|
|
33
|
-
const client = await
|
|
44
|
+
const client = await this.connect();
|
|
34
45
|
const state = this.store.snapshot();
|
|
35
46
|
return {
|
|
36
47
|
connected: Boolean(client),
|
|
@@ -106,21 +117,27 @@ export class AgentRadioService {
|
|
|
106
117
|
: queue[input.random ? randomInt(queue.length) : Math.max(0, input.index ?? 0)];
|
|
107
118
|
if (!station)
|
|
108
119
|
throw new Error(input.stationId ? `Unknown station ID: ${input.stationId}. Search or browse first.` : 'No station is available for that request.');
|
|
109
|
-
|
|
120
|
+
return this.playStation(station, queue, input);
|
|
121
|
+
}
|
|
122
|
+
async playStation(station, queue = [station], options = {}) {
|
|
123
|
+
this.assertEnabled();
|
|
124
|
+
if ((await connectActiveAlarms()).length > 0)
|
|
125
|
+
throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.');
|
|
126
|
+
const ordered = queue.some(item => stationKey(item) === stationKey(station)) ? queue : [station, ...queue];
|
|
110
127
|
return this.send({
|
|
111
128
|
type: 'play',
|
|
112
129
|
station,
|
|
113
130
|
queue: ordered,
|
|
114
|
-
openNowPlaying: this.agentSettings().focusNowPlaying,
|
|
115
|
-
ifPlaying:
|
|
116
|
-
},
|
|
131
|
+
openNowPlaying: this.source === 'agent' && this.agentSettings().focusNowPlaying,
|
|
132
|
+
ifPlaying: options.ifPlaying ?? 'replace'
|
|
133
|
+
}, options.openUi);
|
|
117
134
|
}
|
|
118
135
|
async control(command, openUi) {
|
|
119
136
|
this.assertEnabled();
|
|
120
|
-
const client = await
|
|
137
|
+
const client = await this.connect();
|
|
121
138
|
if (!client && command.type === 'set-volume') {
|
|
122
139
|
const volume = Math.min(100, Math.max(0, command.volume));
|
|
123
|
-
this.store.updateSettings({ volume });
|
|
140
|
+
await this.store.updateSettings({ volume });
|
|
124
141
|
return idleResult(`Saved volume ${volume}; no playback session is active.`);
|
|
125
142
|
}
|
|
126
143
|
if (!client && ['stop', 'pause', 'resume', 'next', 'previous', 'set-volume', 'set-muted'].includes(command.type)) {
|
|
@@ -132,17 +149,24 @@ export class AgentRadioService {
|
|
|
132
149
|
}
|
|
133
150
|
async setFavorite(favorite, stationId) {
|
|
134
151
|
this.assertEnabled();
|
|
135
|
-
const client = await
|
|
152
|
+
const client = await this.connect();
|
|
136
153
|
if (!stationId && client)
|
|
137
154
|
return client.call({ type: 'set-favorite', favorite });
|
|
138
155
|
const station = stationId ? await this.findStation(stationId, this.savedStations()) : undefined;
|
|
139
156
|
if (!station)
|
|
140
157
|
throw new Error('No station was supplied and no active station is available.');
|
|
158
|
+
return this.favoriteStation(station, favorite);
|
|
159
|
+
}
|
|
160
|
+
async favoriteStation(station, favorite) {
|
|
161
|
+
this.assertEnabled();
|
|
162
|
+
const client = await this.connect();
|
|
163
|
+
if (client)
|
|
164
|
+
return client.call({ type: 'set-favorite', favorite, station });
|
|
141
165
|
const isFavorite = this.store.isFavorite(station);
|
|
142
166
|
if (isFavorite !== favorite)
|
|
143
|
-
this.store.toggleFavorite(station);
|
|
167
|
+
await this.store.toggleFavorite(station);
|
|
144
168
|
if (favorite && !isFavorite && this.store.snapshot().settings.shareDirectoryVotes)
|
|
145
|
-
void this.providers.vote(station);
|
|
169
|
+
void this.providers.vote(station).catch(() => undefined);
|
|
146
170
|
return { ok: true, message: `${favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}`, station };
|
|
147
171
|
}
|
|
148
172
|
async updateAppearance(input) {
|
|
@@ -151,10 +175,10 @@ export class AgentRadioService {
|
|
|
151
175
|
throw new Error(`Unknown theme: ${input.theme}`);
|
|
152
176
|
if (input.receiverStyle && !receiverStyleNames.includes(input.receiverStyle))
|
|
153
177
|
throw new Error(`Unknown receiver style: ${input.receiverStyle}`);
|
|
154
|
-
const client = await
|
|
178
|
+
const client = await this.connect();
|
|
155
179
|
if (client)
|
|
156
180
|
return client.call({ type: 'update-settings', settings: input });
|
|
157
|
-
return this.store.updateSettings(input).settings;
|
|
181
|
+
return (await this.store.updateSettings(input)).settings;
|
|
158
182
|
}
|
|
159
183
|
async runCompletionPreset() {
|
|
160
184
|
const preset = this.agentSettings().completionPreset;
|
|
@@ -162,7 +186,7 @@ export class AgentRadioService {
|
|
|
162
186
|
return this.play({ source: preset.source, countryCode: preset.countryCode, random: preset.source !== 'recent', openUi: preset.openUi, ifPlaying: preset.ifPlaying });
|
|
163
187
|
return this.control({ type: preset.action }, preset.openUi);
|
|
164
188
|
}
|
|
165
|
-
configureCompletionPreset(input) {
|
|
189
|
+
async configureCompletionPreset(input) {
|
|
166
190
|
this.assertEnabled();
|
|
167
191
|
const agentControl = this.agentSettings();
|
|
168
192
|
const completionPreset = { ...agentControl.completionPreset, ...input };
|
|
@@ -170,15 +194,15 @@ export class AgentRadioService {
|
|
|
170
194
|
throw new Error('A two-letter countryCode is required for the country preset source.');
|
|
171
195
|
if (completionPreset.countryCode)
|
|
172
196
|
completionPreset.countryCode = completionPreset.countryCode.toUpperCase();
|
|
173
|
-
return this.store.updateSettings({ agentControl: { ...agentControl, completionPreset } }).settings.agentControl;
|
|
197
|
+
return (await this.store.updateSettings({ agentControl: { ...agentControl, completionPreset } })).settings.agentControl;
|
|
174
198
|
}
|
|
175
199
|
async updateAgentPreferences(input) {
|
|
176
200
|
this.assertEnabled();
|
|
177
201
|
const agentControl = { ...this.agentSettings(), ...input };
|
|
178
|
-
const client = await
|
|
202
|
+
const client = await this.connect();
|
|
179
203
|
if (client)
|
|
180
204
|
return client.call({ type: 'update-settings', settings: { agentControl } });
|
|
181
|
-
return this.store.updateSettings({ agentControl }).settings.agentControl;
|
|
205
|
+
return (await this.store.updateSettings({ agentControl })).settings.agentControl;
|
|
182
206
|
}
|
|
183
207
|
alarmList() { this.assertEnabled(); return this.alarms.list(); }
|
|
184
208
|
alarmStatus() { this.assertEnabled(); return this.alarms.status(); }
|
|
@@ -191,13 +215,17 @@ export class AgentRadioService {
|
|
|
191
215
|
async listAirPlayDevices() {
|
|
192
216
|
this.assertEnabled();
|
|
193
217
|
this.assertAirPlayPlatform();
|
|
194
|
-
const client = await
|
|
195
|
-
if (client)
|
|
196
|
-
|
|
218
|
+
const client = await this.connect();
|
|
219
|
+
if (client) {
|
|
220
|
+
const result = await client.call({ type: 'airplay-list' });
|
|
221
|
+
if (!result.ok)
|
|
222
|
+
throw new Error(result.message);
|
|
223
|
+
return result.data ?? [];
|
|
224
|
+
}
|
|
197
225
|
const player = new PlayerController(() => this.store.snapshot().settings);
|
|
198
226
|
const backends = player.refreshDetectedBackends();
|
|
199
227
|
if (!backends.includes('airplay'))
|
|
200
|
-
throw new Error('AirPlay is unavailable on this
|
|
228
|
+
throw new Error('AirPlay is unavailable on this device. Run radiocli doctor to check FFmpeg, discovery, and the optional sender.');
|
|
201
229
|
return player.refreshAirPlayDevices();
|
|
202
230
|
}
|
|
203
231
|
async selectAirPlayDevice(deviceId, openUi) {
|
|
@@ -209,17 +237,17 @@ export class AgentRadioService {
|
|
|
209
237
|
throw new Error('Unknown AirPlay receiver ID. Refresh the receiver list and use an exact returned ID.');
|
|
210
238
|
if (device.local)
|
|
211
239
|
return this.useLocalOutput(openUi);
|
|
212
|
-
this.store.updateSettings({ preferredAirPlayDevice: device.id });
|
|
240
|
+
await this.store.updateSettings({ preferredAirPlayDevice: device.id });
|
|
213
241
|
return this.send({ type: 'airplay-select', deviceId: device.id }, openUi);
|
|
214
242
|
}
|
|
215
243
|
async useLocalOutput(openUi) {
|
|
216
244
|
this.assertEnabled();
|
|
217
|
-
const client = await
|
|
245
|
+
const client = await this.connect();
|
|
218
246
|
if (!client) {
|
|
219
247
|
const backend = preferredLocalBackend(detectPlaybackBackends());
|
|
220
248
|
if (!backend)
|
|
221
249
|
throw new Error('No local playback backend is available. Run radiocli setup to install mpv.');
|
|
222
|
-
this.store.updateSettings({ preferredBackend: backend });
|
|
250
|
+
await this.store.updateSettings({ preferredBackend: backend });
|
|
223
251
|
return idleResult(`Audio output set to this device (${backend}).`);
|
|
224
252
|
}
|
|
225
253
|
return this.send({ type: 'airplay-local' }, openUi);
|
|
@@ -227,7 +255,7 @@ export class AgentRadioService {
|
|
|
227
255
|
async submitAirPlayPasscode(code) {
|
|
228
256
|
this.assertEnabled();
|
|
229
257
|
this.assertAirPlayPlatform();
|
|
230
|
-
const client = await
|
|
258
|
+
const client = await this.connect();
|
|
231
259
|
if (!client)
|
|
232
260
|
throw new Error('No active AirPlay session is waiting for a receiver code.');
|
|
233
261
|
return client.call({ type: 'airplay-passcode', code });
|
|
@@ -252,19 +280,18 @@ export class AgentRadioService {
|
|
|
252
280
|
async send(command, openUi, allowActiveAlarm = false) {
|
|
253
281
|
const settings = this.settings();
|
|
254
282
|
const agentControl = settings.agentControl ?? defaultAgentControlSettings;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
let client = await connectRadioSession();
|
|
283
|
+
this.assertEnabled();
|
|
284
|
+
let client = await this.connect();
|
|
258
285
|
if (!client) {
|
|
259
286
|
if (!allowActiveAlarm && (await connectActiveAlarms()).length > 0)
|
|
260
287
|
throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.');
|
|
261
|
-
const shouldOpen = openUi ?? agentControl.openUiOnPlay;
|
|
288
|
+
const shouldOpen = openUi ?? (this.source === 'agent' && agentControl.openUiOnPlay);
|
|
262
289
|
client = await ensureRadioSession(async () => {
|
|
263
290
|
if (shouldOpen)
|
|
264
291
|
await launchRadioTui(this.runtime.nodePath, this.runtime.cliPath, encodeAgentCommand({ type: 'status' }));
|
|
265
292
|
else
|
|
266
293
|
await launchHeadlessHost(this.runtime.nodePath, this.runtime.cliPath);
|
|
267
|
-
});
|
|
294
|
+
}, undefined, { source: this.source });
|
|
268
295
|
}
|
|
269
296
|
return client.call(command);
|
|
270
297
|
}
|
|
@@ -290,8 +317,10 @@ export class AgentRadioService {
|
|
|
290
317
|
}
|
|
291
318
|
async findStation(id, candidates) {
|
|
292
319
|
const normalized = id.trim();
|
|
293
|
-
const
|
|
294
|
-
|
|
320
|
+
const matches = new Map(candidates.filter(station => matchesStationId(station, normalized)).map(station => [stationKey(station), station]));
|
|
321
|
+
if (matches.size > 1)
|
|
322
|
+
throw new Error('This legacy station ID is ambiguous. Search or browse again and use the current station ID.');
|
|
323
|
+
const local = this.stationCache.get(normalized) ?? [...matches.values()][0];
|
|
295
324
|
if (local)
|
|
296
325
|
return local;
|
|
297
326
|
const separator = normalized.indexOf(':');
|
|
@@ -305,20 +334,26 @@ export class AgentRadioService {
|
|
|
305
334
|
return station ?? undefined;
|
|
306
335
|
}
|
|
307
336
|
remember(stations) {
|
|
308
|
-
for (const station of stations)
|
|
309
|
-
|
|
337
|
+
for (const station of stations) {
|
|
338
|
+
const key = opaqueStationId(station);
|
|
339
|
+
this.stationCache.delete(key);
|
|
340
|
+
this.stationCache.set(key, station);
|
|
341
|
+
}
|
|
342
|
+
while (this.stationCache.size > 1024)
|
|
343
|
+
this.stationCache.delete(this.stationCache.keys().next().value);
|
|
310
344
|
return stations;
|
|
311
345
|
}
|
|
312
346
|
agentSettings() {
|
|
313
347
|
return this.settings().agentControl ?? defaultAgentControlSettings;
|
|
314
348
|
}
|
|
315
349
|
assertEnabled() {
|
|
316
|
-
if (!this.agentSettings().enabled)
|
|
350
|
+
if (this.source === 'agent' && !this.agentSettings().enabled)
|
|
317
351
|
throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.');
|
|
318
352
|
}
|
|
319
353
|
assertAirPlayPlatform() {
|
|
320
|
-
|
|
321
|
-
|
|
354
|
+
const policy = airPlayPolicy(this.settings());
|
|
355
|
+
if (!policy.enabled)
|
|
356
|
+
throw new Error(policy.message);
|
|
322
357
|
}
|
|
323
358
|
}
|
|
324
359
|
function opaqueStationId(station) { return `${station.provider}:${station.id}`; }
|
|
@@ -337,7 +372,7 @@ export function stationForAgent(station) {
|
|
|
337
372
|
};
|
|
338
373
|
}
|
|
339
374
|
function encodeAgentCommand(command) { return Buffer.from(JSON.stringify(command)).toString('base64url'); }
|
|
340
|
-
export function decodeAgentCommand(value) { return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); }
|
|
375
|
+
export function decodeAgentCommand(value) { return parseRadioCommand(JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))); }
|
|
341
376
|
function clampLimit(value, max = 30) { return Math.min(max, Math.max(1, Math.round(value))); }
|
|
342
377
|
function idleResult(message) {
|
|
343
378
|
return { ok: true, message, status: { owner: 'headless', station: null, queue: [], playback: { backend: 'none', state: 'idle', volume: 70, muted: false, ready: false } } };
|
package/dist/agent/session.js
CHANGED
|
@@ -1,14 +1,123 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { parseRadioCommand, parseRadioRequest } from './command-schema.js';
|
|
2
3
|
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { createServer, request } from 'node:http';
|
|
4
5
|
import { platformPaths } from '../platform/paths.js';
|
|
5
6
|
import { dirname, join } from 'node:path';
|
|
6
7
|
import { isLoopbackHost, listenLoopback } from '../platform/loopback.js';
|
|
7
|
-
|
|
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 = {}) {
|
|
8
116
|
const ownerPath = `${filePath}.owner`;
|
|
9
117
|
acquireOwner(ownerPath);
|
|
10
118
|
const token = randomBytes(32).toString('hex');
|
|
11
119
|
let serial = Promise.resolve();
|
|
120
|
+
const requests = new Map();
|
|
12
121
|
const server = createServer((req, res) => {
|
|
13
122
|
res.setHeader('content-type', 'application/json');
|
|
14
123
|
if (req.headers.authorization !== `Bearer ${token}`) {
|
|
@@ -21,11 +130,40 @@ export async function startRadioSession(handle, filePath = radioSessionPath()) {
|
|
|
21
130
|
res.end('{}');
|
|
22
131
|
return;
|
|
23
132
|
}
|
|
24
|
-
const
|
|
133
|
+
const accept = async () => {
|
|
25
134
|
const body = await readBody(req);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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);
|
|
29
167
|
void work.then(result => res.end(JSON.stringify(result))).catch(error => {
|
|
30
168
|
res.statusCode = 400;
|
|
31
169
|
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Radio control failed.' }));
|
|
@@ -35,6 +173,7 @@ export async function startRadioSession(handle, filePath = radioSessionPath()) {
|
|
|
35
173
|
const address = await listenLoopback(server);
|
|
36
174
|
const discovery = {
|
|
37
175
|
version: 1,
|
|
176
|
+
protocol: 2,
|
|
38
177
|
host: address.host,
|
|
39
178
|
port: address.port,
|
|
40
179
|
token,
|
|
@@ -56,7 +195,8 @@ export async function startRadioSession(handle, filePath = radioSessionPath()) {
|
|
|
56
195
|
throw error;
|
|
57
196
|
}
|
|
58
197
|
}
|
|
59
|
-
export async function connectRadioSession(filePath = radioSessionPath()) {
|
|
198
|
+
export async function connectRadioSession(filePath = radioSessionPath(), options = {}) {
|
|
199
|
+
options.signal?.throwIfAborted();
|
|
60
200
|
if (!existsSync(filePath))
|
|
61
201
|
return null;
|
|
62
202
|
let discovery;
|
|
@@ -70,41 +210,59 @@ export async function connectRadioSession(filePath = radioSessionPath()) {
|
|
|
70
210
|
rmSync(filePath, { force: true });
|
|
71
211
|
return null;
|
|
72
212
|
}
|
|
73
|
-
const call = async (command) => {
|
|
74
|
-
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);
|
|
75
216
|
return new Promise((resolve, reject) => {
|
|
76
217
|
const req = request({
|
|
77
218
|
host: discovery.host,
|
|
78
219
|
// Local control tokens must never be forwarded by an environment proxy.
|
|
79
220
|
agent: false,
|
|
221
|
+
signal: options.signal,
|
|
80
222
|
port: discovery.port,
|
|
81
223
|
path: '/command',
|
|
82
224
|
method: 'POST',
|
|
83
225
|
headers: {
|
|
84
226
|
authorization: `Bearer ${discovery.token}`,
|
|
227
|
+
'x-radiocli-client': options.source ?? 'agent',
|
|
85
228
|
'content-type': 'application/json',
|
|
86
229
|
'content-length': Buffer.byteLength(payload)
|
|
87
230
|
}
|
|
88
231
|
}, res => {
|
|
89
232
|
let text = '';
|
|
90
|
-
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);
|
|
91
237
|
res.on('end', () => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|
|
96
251
|
});
|
|
97
252
|
});
|
|
98
253
|
req.once('error', reject);
|
|
99
|
-
|
|
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.`)));
|
|
100
256
|
req.end(payload);
|
|
101
257
|
});
|
|
102
258
|
};
|
|
103
259
|
try {
|
|
104
260
|
await call({ type: 'status' });
|
|
261
|
+
options.signal?.throwIfAborted();
|
|
105
262
|
return { call, status: async () => (await call({ type: 'status' })).status };
|
|
106
263
|
}
|
|
107
264
|
catch {
|
|
265
|
+
options.signal?.throwIfAborted();
|
|
108
266
|
// A slow command does not prove that a playback host is dead. Retain both
|
|
109
267
|
// ownership records while its PID is alive so a second host cannot start.
|
|
110
268
|
if (!processAlive(discovery.pid)) {
|
|
@@ -114,8 +272,10 @@ export async function connectRadioSession(filePath = radioSessionPath()) {
|
|
|
114
272
|
return null;
|
|
115
273
|
}
|
|
116
274
|
}
|
|
117
|
-
export async function ensureRadioSession(start, timeoutMs = 15_000) {
|
|
118
|
-
|
|
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();
|
|
119
279
|
if (existing)
|
|
120
280
|
return existing;
|
|
121
281
|
const lockPath = `${radioSessionPath()}.launch`;
|
|
@@ -123,16 +283,18 @@ export async function ensureRadioSession(start, timeoutMs = 15_000) {
|
|
|
123
283
|
try {
|
|
124
284
|
acquireOwner(lockPath);
|
|
125
285
|
ownsLaunch = true;
|
|
126
|
-
const appeared = await connectRadioSession();
|
|
286
|
+
const appeared = await connectRadioSession(undefined, options);
|
|
287
|
+
options.signal?.throwIfAborted();
|
|
127
288
|
if (appeared)
|
|
128
289
|
return appeared;
|
|
129
290
|
await start();
|
|
130
|
-
|
|
291
|
+
options.signal?.throwIfAborted();
|
|
292
|
+
return await waitForSession(timeoutMs, options);
|
|
131
293
|
}
|
|
132
294
|
catch (error) {
|
|
133
295
|
if (ownsLaunch || !(error instanceof Error) || !error.message.includes('already active'))
|
|
134
296
|
throw error;
|
|
135
|
-
return waitForSession(timeoutMs);
|
|
297
|
+
return waitForSession(timeoutMs, options);
|
|
136
298
|
}
|
|
137
299
|
finally {
|
|
138
300
|
if (ownsLaunch)
|
|
@@ -142,16 +304,43 @@ export async function ensureRadioSession(start, timeoutMs = 15_000) {
|
|
|
142
304
|
function radioSessionPath() {
|
|
143
305
|
return join(runtimeDirectory(), 'agent-session.json');
|
|
144
306
|
}
|
|
145
|
-
|
|
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) {
|
|
146
325
|
const deadline = Date.now() + timeoutMs;
|
|
147
326
|
while (Date.now() < deadline) {
|
|
148
|
-
|
|
327
|
+
options.signal?.throwIfAborted();
|
|
328
|
+
const client = await connectRadioSession(undefined, options);
|
|
329
|
+
options.signal?.throwIfAborted();
|
|
149
330
|
if (client)
|
|
150
331
|
return client;
|
|
151
|
-
await
|
|
332
|
+
await controlPollDelay(options.signal);
|
|
152
333
|
}
|
|
153
334
|
throw new Error('RadioCLI was launched but its control session did not become ready.');
|
|
154
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
|
+
}
|
|
155
344
|
function runtimeDirectory() {
|
|
156
345
|
return platformPaths().runtime;
|
|
157
346
|
}
|
|
@@ -212,9 +401,10 @@ function processAlive(pid) {
|
|
|
212
401
|
function readBody(req) {
|
|
213
402
|
return new Promise((resolve, reject) => {
|
|
214
403
|
let body = '';
|
|
404
|
+
req.setEncoding('utf8');
|
|
215
405
|
req.on('data', value => {
|
|
216
406
|
body += String(value);
|
|
217
|
-
if (body
|
|
407
|
+
if (Buffer.byteLength(body) > 4 * 1024 * 1024)
|
|
218
408
|
req.destroy(new Error('Request too large.'));
|
|
219
409
|
});
|
|
220
410
|
req.on('end', () => resolve(body));
|