@ciphore/radiocli 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/CONTRIBUTING.md +28 -0
  3. package/README.md +108 -253
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +182 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +130 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +314 -76
  71. package/dist/types.js +22 -1
  72. package/dist/ui/AdaptiveContent.js +21 -24
  73. package/dist/ui/App.js +708 -454
  74. package/dist/ui/AppContent.js +5 -5
  75. package/dist/ui/airplay-settings.js +12 -5
  76. package/dist/ui/bidi-ranges.js +45 -0
  77. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  78. package/dist/ui/components/ReceiverVisualRow.js +39 -0
  79. package/dist/ui/format.js +37 -4
  80. package/dist/ui/help-content.js +5 -2
  81. package/dist/ui/rtl-output.js +137 -0
  82. package/dist/ui/screen-items.js +10 -4
  83. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  84. package/dist/ui/screens/AlarmsScreen.js +5 -2
  85. package/dist/ui/screens/HelpScreen.js +3 -3
  86. package/dist/ui/screens/NowPlayingScreen.js +2 -21
  87. package/dist/ui/screens/SettingsScreen.js +16 -16
  88. package/dist/ui/screens/StatsScreen.js +1 -1
  89. package/dist/ui/text-direction.js +9 -0
  90. package/dist/ui/theme.js +46 -0
  91. package/dist/ui/use-alarm-tui.js +24 -24
  92. package/dist/ui/use-app-input.js +42 -8
  93. package/dist/ui/use-command-executor.js +21 -14
  94. package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
  95. package/dist/ui/visualizers/boat-lake.js +161 -0
  96. package/dist/ui/visualizers/gallop.js +2 -2
  97. package/dist/ui/visualizers/receiver-style-registry.js +3 -1
  98. package/dist/ui/visualizers/receiver-visualizers.js +6 -108
  99. package/dist/ui/visualizers/sumi-ocean.js +183 -0
  100. package/dist/ui/visualizers/terminal-cell.js +45 -0
  101. package/dist/update-check.js +2 -1
  102. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  103. package/docs/boat-lake.md +88 -0
  104. package/docs/sumi-ocean.md +80 -0
  105. package/package.json +36 -12
  106. package/scripts/build-airplay-native.mjs +22 -0
  107. package/vendor/node-airtunes2/LICENSE +208 -0
  108. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  109. package/vendor/node-airtunes2/README.md +225 -0
  110. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  111. package/vendor/node-airtunes2/binding.gyp +41 -0
  112. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  113. package/vendor/node-airtunes2/lib/atv.js +307 -0
  114. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  115. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  116. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  117. package/vendor/node-airtunes2/lib/config.js +26 -0
  118. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  119. package/vendor/node-airtunes2/lib/devices.js +266 -0
  120. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  121. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  122. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  123. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  124. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  125. package/vendor/node-airtunes2/lib/http.js +189 -0
  126. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  127. package/vendor/node-airtunes2/lib/index.js +127 -0
  128. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  129. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  130. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  131. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  132. package/vendor/node-airtunes2/lib/srp.js +158 -0
  133. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  134. package/vendor/node-airtunes2/lib/util.js +12 -0
  135. package/vendor/node-airtunes2/package.json +31 -0
  136. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  137. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  138. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  139. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  140. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  141. package/vendor/node-airtunes2/src/base64.c +136 -0
  142. package/vendor/node-airtunes2/src/base64.h +42 -0
  143. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  144. package/vendor/node-airtunes2/src/codec.cc +170 -0
  145. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -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 { airPlayMacOSOnlyMessage, detectPlaybackBackends, isAirPlayPlatformSupported } from '../player/backend-install.js';
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
- settings() { return this.store.snapshot().settings; }
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 connectRadioSession();
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
- const ordered = [station, ...queue.filter(item => stationKey(item) !== stationKey(station))];
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: input.ifPlaying ?? 'replace'
116
- }, input.openUi);
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 connectRadioSession();
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 connectRadioSession();
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 connectRadioSession();
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 connectRadioSession();
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 connectRadioSession();
195
- if (client)
196
- return (await client.call({ type: 'airplay-list' })).data ?? [];
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 Mac. Run radiocli doctor to check ffmpeg, dns-sd, and the sender package.');
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 connectRadioSession();
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 connectRadioSession();
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
- if (!agentControl.enabled)
256
- throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.');
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 local = this.stationCache.get(normalized)
294
- ?? candidates.find(station => opaqueStationId(station) === normalized || station.id === normalized);
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
- this.stationCache.set(opaqueStationId(station), station);
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
- if (!isAirPlayPlatformSupported())
321
- throw new Error(airPlayMacOSOnlyMessage);
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 } } };
@@ -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
- export async function startRadioSession(handle, filePath = radioSessionPath()) {
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 work = serial.then(async () => {
133
+ const accept = async () => {
25
134
  const body = await readBody(req);
26
- return handle(JSON.parse(body));
27
- });
28
- serial = work.then(() => undefined, () => undefined);
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 payload = JSON.stringify(command);
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.on('data', value => { text += String(value); });
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
- if (res.statusCode && res.statusCode < 300)
93
- resolve(JSON.parse(text));
94
- else
95
- reject(new Error(parseError(text)));
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
- req.setTimeout(15_000, () => req.destroy(new Error('RadioCLI control request timed out.')));
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
- const existing = await connectRadioSession();
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
- return await waitForSession(timeoutMs);
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
- async function waitForSession(timeoutMs) {
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
- const client = await connectRadioSession();
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 new Promise(resolve => setTimeout(resolve, 100));
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.length > 1_000_000)
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));