@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.
Files changed (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  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 +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,4 +1,5 @@
1
- import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone, nextOccurrenceForAlarm } from '../alarms/schedule.js';
1
+ import { AlarmOccurrenceCache } from '../alarms/occurrence-cache.js';
2
+ import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone } from '../alarms/schedule.js';
2
3
  import { connectActiveAlarms } from '../alarms/active-session.js';
3
4
  import { createSchedulerService } from '../alarms/scheduler.js';
4
5
  export class AgentAlarmService {
@@ -6,6 +7,7 @@ export class AgentAlarmService {
6
7
  resolveStation;
7
8
  scheduler;
8
9
  handoffToInteractive;
10
+ occurrences = new AlarmOccurrenceCache();
9
11
  constructor(store, resolveStation, scheduler = createSchedulerService(), handoffToInteractive) {
10
12
  this.store = store;
11
13
  this.resolveStation = resolveStation;
@@ -45,7 +47,7 @@ export class AgentAlarmService {
45
47
  keepAwakeUntilAlarm: input.keepAwakeUntilAlarm ?? false
46
48
  }
47
49
  };
48
- const alarm = this.store.addAlarm(alarmInput);
50
+ const alarm = await this.store.addAlarm(alarmInput);
49
51
  await this.syncSaved(alarm);
50
52
  return this.describe(alarm);
51
53
  }
@@ -55,7 +57,7 @@ export class AgentAlarmService {
55
57
  const fallbackStation = input.clearFallback
56
58
  ? undefined
57
59
  : input.fallbackStationId ? await this.requiredStation(input.fallbackStationId) : alarm.playback.fallbackStation;
58
- const updated = this.store.updateAlarm(alarm.id, {
60
+ const updated = await this.store.updateAlarm(alarm.id, {
59
61
  label: input.label === undefined ? alarm.label : cleanLabel(input.label),
60
62
  enabled: input.enabled ?? alarm.enabled,
61
63
  station,
@@ -76,7 +78,7 @@ export class AgentAlarmService {
76
78
  return this.describe(updated);
77
79
  }
78
80
  async setEnabled(id, enabled) {
79
- const alarm = this.store.toggleAlarm(this.requiredAlarm(id).id, enabled);
81
+ const alarm = await this.store.toggleAlarm(this.requiredAlarm(id).id, enabled);
80
82
  await this.syncSaved(alarm);
81
83
  return this.describe(alarm);
82
84
  }
@@ -84,8 +86,9 @@ export class AgentAlarmService {
84
86
  if (!confirm)
85
87
  throw new Error('Alarm removal requires confirm=true. List alarms first and confirm the exact alarm ID with the user.');
86
88
  const alarm = this.requiredAlarm(id);
87
- await this.scheduler.remove(alarm.id);
88
- if (!this.store.removeAlarm(alarm.id))
89
+ const disabled = await this.store.toggleAlarm(alarm.id, false);
90
+ await this.scheduler.remove(disabled.id);
91
+ if (!await this.store.removeAlarm(alarm.id))
89
92
  throw new Error(`Alarm not found: ${id}`);
90
93
  return { ok: true, removed: alarm.id };
91
94
  }
@@ -117,7 +120,7 @@ export class AgentAlarmService {
117
120
  enabled: alarm.enabled,
118
121
  station: { id: `${alarm.station.provider}:${alarm.station.id}`, name: alarm.station.name },
119
122
  schedule: alarm.schedule,
120
- nextOccurrence: nextOccurrenceForAlarm(alarm, new Date())?.toISOString() ?? null,
123
+ nextOccurrence: this.occurrences.next(alarm, new Date())?.toISOString() ?? null,
121
124
  playback: {
122
125
  volume: alarm.playback.volume,
123
126
  fadeSeconds: alarm.playback.fadeSeconds,
package/dist/agent/cli.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { airPlayPolicy } from '../player/airplay-policy.js';
2
+ import { readSettingsWithoutMigration } from '../storage/read-settings.js';
3
+ import { defaultLibraryPath } from '../storage/store.js';
1
4
  import { AgentRadioService, stationForAgent } from './service.js';
2
5
  import { configureMcpIntegrations, mcpIntegrationReport, portableMcpConfig } from './mcp-install.js';
3
6
  import { runMcpServer } from './mcp-server.js';
@@ -64,7 +67,7 @@ export async function runAgentCliCommand(args, runtime) {
64
67
  else if (command === 'done')
65
68
  value = await service.runCompletionPreset();
66
69
  else
67
- throw new Error('Usage: radiocli agent <status|search|browse|play|pause|resume|stop|next|previous|volume|mute|unmute|favorite|unfavorite|stats|appearance|airplay|preset|done>');
70
+ throw new Error(agentCommandUsage());
68
71
  console.log(JSON.stringify(value, null, 2));
69
72
  }
70
73
  function parseBrowseKind(value) {
@@ -191,3 +194,15 @@ function assertMcpInstallSucceeded(results, action) {
191
194
  return;
192
195
  throw new Error(`RadioCLI was ${action} where possible, but ${failed.length} integration${failed.length === 1 ? '' : 's'} failed: ${failed.map(result => `${result.client} (${result.detail})`).join('; ')}`);
193
196
  }
197
+ export function agentCommandUsage() {
198
+ let enabled = airPlayPolicy().enabled;
199
+ if (process.platform === 'linux') {
200
+ try {
201
+ enabled = airPlayPolicy({ linuxAirPlayEnabled: readSettingsWithoutMigration(defaultLibraryPath())?.linuxAirPlayEnabled === true }).enabled;
202
+ }
203
+ catch {
204
+ enabled = false;
205
+ }
206
+ }
207
+ return `Usage: radiocli agent <status|search|browse|play|pause|resume|stop|next|previous|volume|mute|unmute|favorite|unfavorite|stats|appearance${enabled ? '|airplay' : ''}|preset|done>`;
208
+ }
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+ import { receiverStyleNames, themeNames } from '../types.js';
3
+ import { stationSchema } from '../storage/station-schema.js';
4
+ const empty = (type) => z.strictObject({ type: z.literal(type) });
5
+ const adjacent = { preservePause: z.boolean().optional(), preserveStop: z.boolean().optional() };
6
+ // A TUI list can contain many discovery pages, beyond one provider page.
7
+ const stationQueue = z.array(stationSchema).max(10_000);
8
+ const agentControl = z.strictObject({
9
+ enabled: z.boolean(), openUiOnPlay: z.boolean(), focusNowPlaying: z.boolean(),
10
+ completionPreset: z.strictObject({
11
+ action: z.enum(['play', 'pause', 'resume', 'stop']), source: z.enum(['recent', 'favorite', 'popular', 'country']),
12
+ countryCode: z.string().length(2).optional(), ifPlaying: z.enum(['keep', 'replace']), openUi: z.boolean().optional()
13
+ })
14
+ });
15
+ const commandSchema = z.discriminatedUnion('type', [
16
+ empty('status'), empty('pause'), empty('alarm-preempt'), empty('airplay-list'), empty('airplay-local'), empty('tui-cancel'), empty('tui-refresh'), empty('tui-toggle-muted'), empty('tui-toggle-pause'),
17
+ z.strictObject({ type: z.literal('tui-sleep'), until: z.number().int().nonnegative().max(8.64e15).nullable() }),
18
+ z.strictObject({ type: z.literal('tui-adjust-volume'), delta: z.number().min(-100).max(100) }),
19
+ z.strictObject({ type: z.literal('desktop-transport'), action: z.enum(['play', 'pause', 'toggle', 'stop', 'next', 'previous']) }),
20
+ z.strictObject({ type: z.literal('play'), station: stationSchema, queue: stationQueue.optional(), openNowPlaying: z.boolean().optional(), ifPlaying: z.enum(['keep', 'replace']).optional(), startPaused: z.boolean().optional() }),
21
+ z.strictObject({ type: z.literal('tui-attach'), pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), clientId: z.string().uuid() }),
22
+ z.strictObject({ type: z.literal('tui-detach'), clientId: z.string().uuid() }),
23
+ z.strictObject({ type: z.literal('tui-select'), station: stationSchema, queue: stationQueue }),
24
+ z.strictObject({ type: z.literal('tui-output'), deviceId: z.string().min(1).max(4096).nullable() }),
25
+ z.strictObject({ type: z.literal('resume'), restartStopped: z.boolean().optional() }),
26
+ z.strictObject({ type: z.literal('stop'), retainSelection: z.boolean().optional() }),
27
+ z.strictObject({ type: z.literal('next'), ...adjacent }), z.strictObject({ type: z.literal('previous'), ...adjacent }),
28
+ z.strictObject({ type: z.literal('set-volume'), volume: z.number().min(0).max(100), unmute: z.boolean().optional(), allowStopped: z.boolean().optional() }),
29
+ z.strictObject({ type: z.literal('set-muted'), muted: z.boolean(), allowStopped: z.boolean().optional() }),
30
+ z.strictObject({ type: z.literal('set-favorite'), favorite: z.boolean(), station: stationSchema.optional() }),
31
+ z.strictObject({ type: z.literal('airplay-select'), deviceId: z.string().min(1).max(4096) }),
32
+ z.strictObject({ type: z.literal('airplay-passcode'), code: z.string().min(1).max(128) }),
33
+ z.strictObject({ type: z.literal('update-settings'), settings: z.strictObject({ theme: z.enum(themeNames).optional(), receiverStyle: z.enum(receiverStyleNames).optional(), agentControl: agentControl.optional() }) })
34
+ ]);
35
+ export function parseRadioCommand(value) {
36
+ const parsed = commandSchema.safeParse(value);
37
+ if (!parsed.success)
38
+ throw new Error(`Invalid radio command: ${parsed.error.issues.map(issue => `${issue.path.join('.') || 'command'}: ${issue.message}`).slice(0, 3).join('; ')}`);
39
+ return parsed.data;
40
+ }
41
+ export function parseRadioRequest(value) {
42
+ if (value && typeof value === 'object' && 'version' in value) {
43
+ const envelope = z.strictObject({ version: z.literal(2), requestId: z.string().uuid(), command: z.unknown() }).parse(value);
44
+ return { command: parseRadioCommand(envelope.command), requestId: envelope.requestId };
45
+ }
46
+ return { command: parseRadioCommand(value) };
47
+ }
@@ -1,74 +1,196 @@
1
1
  import { PlayerController } from '../player/player-controller.js';
2
2
  import { ProviderManager } from '../providers/provider-manager.js';
3
3
  import { JsonLibraryStore, stationKey } from '../storage/store.js';
4
- import { startRadioSession } from './session.js';
4
+ import { LibraryRepository } from '../storage/repository.js';
5
+ import { RadioEngine } from '../player/radio-engine.js';
6
+ import { allowedRadioSessionSettings, resolveDesktopTransport, serializeRadioCommands, startRadioSession } from './session.js';
7
+ import { desktopMediaPolicy, startDesktopMediaControls } from '../platform/desktop-media.js';
5
8
  import { detectPlaybackBackends } from '../player/backend-install.js';
6
- export async function runHeadlessAgentHost() {
7
- const store = new JsonLibraryStore();
9
+ import { networkPolicy } from '../platform/network.js';
10
+ export async function runHeadlessAgentHost(options = {}) {
11
+ const store = new LibraryRepository(options.store ?? new JsonLibraryStore(), { worker: options.worker });
8
12
  const providers = new ProviderManager();
9
- const player = new PlayerController(() => store.snapshot().settings);
13
+ const player = new PlayerController(() => store.settings());
10
14
  player.refreshDetectedBackends();
11
- let station = null;
12
- let queue = [];
13
- const status = () => ({ owner: 'headless', playback: player.getState(), station, queue, output: {
14
- preferredBackend: store.snapshot().settings.preferredBackend,
15
- preferredAirPlayDevice: store.snapshot().settings.preferredAirPlayDevice
16
- } });
15
+ const engine = new RadioEngine(player, providers, store, () => store.settings());
16
+ let persistenceWarning;
17
+ let desktopSuspended = false;
18
+ let shuttingDown = false;
19
+ let media;
20
+ let tui;
21
+ let terminalProtocol = false;
22
+ let focusNowPlaying = 0;
23
+ let sleepUntil = null;
24
+ let sleepTimer;
25
+ const scheduleSleep = () => {
26
+ if (sleepUntil === null)
27
+ return;
28
+ const remaining = sleepUntil - Date.now();
29
+ if (remaining <= 0) {
30
+ sleepUntil = null;
31
+ void dispatch({ type: 'stop', retainSelection: true }).catch(() => undefined);
32
+ }
33
+ else {
34
+ sleepTimer = setTimeout(scheduleSleep, Math.min(2_147_483_647, remaining));
35
+ sleepTimer.unref();
36
+ }
37
+ };
38
+ const tuiPresent = () => {
39
+ if (!tui)
40
+ return false;
41
+ try {
42
+ process.kill(tui.pid, 0);
43
+ return true;
44
+ }
45
+ catch (error) {
46
+ return error.code !== 'ESRCH';
47
+ }
48
+ };
49
+ const saveLibrary = async (write) => {
50
+ await write();
51
+ persistenceWarning = undefined;
52
+ engine.clearWarning();
53
+ };
54
+ const status = () => {
55
+ const current = engine.snapshot();
56
+ const settings = store.settings();
57
+ return { owner: tuiPresent() ? 'tui' : 'headless', playback: player.getState(), station: current.station, nowPlaying: current.nowPlaying,
58
+ queue: current.queue, persistenceWarning: persistenceWarning ?? current.warning, output: {
59
+ preferredBackend: settings.preferredBackend === 'airplay' && player.airPlaySelectionRequired() ? 'auto' : settings.preferredBackend,
60
+ preferredAirPlayDevice: settings.preferredAirPlayDevice
61
+ }, ...(terminalProtocol ? { tui: { tuningStation: current.tuningStation, message: current.message, focusNowPlaying, sleepUntil, diagnostics: player.diagnostics(),
62
+ devices: player.detectedAirPlayDevices(), selectionRequired: player.airPlaySelectionRequired(), desktopMediaCompatible: player.desktopMediaCompatible() } } : {}) };
63
+ };
17
64
  const result = (message, ok = true, data) => ({ ok, message, status: status(), ...(data ? { data } : {}) });
18
- const play = async (next, nextQueue = [next]) => {
19
- store.finishActiveListeningSession();
20
- await player.stop();
21
- const resolved = await providers.resolve(next);
22
- await player.play(next, resolved.url);
23
- station = next;
24
- queue = nextQueue.length ? nextQueue : [next];
25
- store.addRecent(next);
26
- store.startListeningSession(next);
27
- return result(`Playing ${next.name}.`);
65
+ const play = async (next, nextQueue = [next], startPaused = false, signal) => {
66
+ signal?.throwIfAborted();
67
+ // Earlier accepted output saves must reach their durable acknowledgement
68
+ // before this tune chooses a backend. Stop can still cancel the wait.
69
+ await store.readFresh();
70
+ signal?.throwIfAborted();
71
+ desktopSuspended = false;
72
+ const tuned = await engine.tune(next, { queue: nextQueue, startPaused, signal });
73
+ if (shuttingDown)
74
+ return result('RadioCLI is shutting down.', false);
75
+ media?.refresh();
76
+ return result(`${startPaused ? 'Selected' : 'Playing'} ${tuned.name}.`);
28
77
  };
29
- const handle = async (command) => {
78
+ const handle = async (command, signal) => {
79
+ if (shuttingDown)
80
+ return result('RadioCLI is shutting down.', false);
81
+ signal?.throwIfAborted();
82
+ const { station, queue } = engine.snapshot();
83
+ if (command.type === 'tui-attach') {
84
+ if (tuiPresent() && tui?.clientId !== command.clientId)
85
+ return result('RadioCLI is already open in another terminal.', false);
86
+ tui = { pid: command.pid, clientId: command.clientId };
87
+ terminalProtocol = true;
88
+ return result(station ? `Reconnected to ${station.name}.` : 'RadioCLI connected.');
89
+ }
90
+ if (command.type === 'tui-detach') {
91
+ if (tui?.clientId !== command.clientId)
92
+ return result('This terminal no longer owns the RadioCLI view.', false);
93
+ tui = undefined;
94
+ shuttingDown = true;
95
+ await engine.stop();
96
+ // Let the command response finish, then close our own resources directly.
97
+ // Sending SIGTERM to ourselves terminates Windows without running cleanup.
98
+ setTimeout(onShutdown, 50).unref();
99
+ return result('Closed RadioCLI.');
100
+ }
101
+ if (command.type === 'tui-cancel') {
102
+ engine.cancel();
103
+ return result('Canceled pending tune.');
104
+ }
105
+ if (command.type === 'tui-sleep') {
106
+ clearTimeout(sleepTimer);
107
+ sleepUntil = command.until;
108
+ scheduleSleep();
109
+ return result('Sleep timer updated.');
110
+ }
111
+ if (command.type === 'tui-adjust-volume')
112
+ return handle({ type: 'set-volume', volume: Math.max(0, Math.min(100, player.getState().volume + command.delta)) }, signal);
113
+ if (command.type === 'tui-toggle-muted')
114
+ return handle({ type: 'set-muted', muted: !player.getState().muted }, signal);
115
+ if (command.type === 'tui-toggle-pause') {
116
+ const control = await player.togglePause();
117
+ await engine.flushHistory();
118
+ return result(control.message ?? 'Playback updated.', control.ok);
119
+ }
120
+ if (command.type === 'tui-select') {
121
+ engine.select(command.station, command.queue);
122
+ media?.refresh();
123
+ return result(`Selected ${command.station.name}.`);
124
+ }
125
+ if (command.type === 'tui-refresh') {
126
+ await store.readFresh();
127
+ player.refreshDetectedBackends();
128
+ return result('Playback settings refreshed.');
129
+ }
130
+ if (command.type === 'tui-output') {
131
+ await store.readFresh();
132
+ const selected = await player.selectAirPlayDevice(command.deviceId);
133
+ return result(selected ? 'Audio output selected.' : 'Audio output is unavailable.', selected);
134
+ }
135
+ if (command.type === 'desktop-transport') {
136
+ const resolved = resolveDesktopTransport(command.action, status());
137
+ return resolved ? handle(resolved, signal) : result('Playback state unchanged.');
138
+ }
139
+ if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !player.airPlayPolicy().enabled) {
140
+ return result(player.airPlayPolicy().message, false, command.type === 'airplay-list' ? [] : undefined);
141
+ }
142
+ if (['airplay-list', 'airplay-select'].includes(command.type) && networkPolicy().offline)
143
+ return result('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []);
30
144
  if (command.type === 'status')
31
145
  return result(station ? `${player.getState().state}: ${station.name}` : 'RadioCLI is idle.');
32
146
  if (command.type === 'play') {
33
147
  if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state))
34
148
  return result(`Kept current station ${station?.name ?? ''}.`);
35
- return play(command.station, command.queue);
149
+ if (command.openNowPlaying)
150
+ focusNowPlaying++;
151
+ return play(command.station, command.queue, command.startPaused, signal);
36
152
  }
37
153
  if (command.type === 'pause') {
38
- const control = await player.pause();
39
- if (control.ok)
40
- store.finishActiveListeningSession();
154
+ const control = await engine.pause();
41
155
  return result(control.message ?? 'Paused.', control.ok);
42
156
  }
43
157
  if (command.type === 'resume') {
44
- const control = await player.resume();
45
- if (control.ok && station)
46
- store.startListeningSession(station);
158
+ if (command.restartStopped && station && ['idle', 'stopped'].includes(player.getState().state))
159
+ return play(station, queue, false, signal);
160
+ const control = await engine.resume();
47
161
  return result(control.message ?? 'Resumed.', control.ok);
48
162
  }
49
163
  if (command.type === 'stop') {
50
- store.finishActiveListeningSession();
51
- await player.stop();
52
- station = null;
53
- queue = [];
54
- setTimeout(() => process.kill(process.pid, 'SIGTERM'), 50).unref();
164
+ await engine.stop({ retainSelection: command.retainSelection });
165
+ if (command.retainSelection) {
166
+ media?.refresh();
167
+ return result('Stopped playback.');
168
+ }
169
+ if (!tuiPresent()) {
170
+ shuttingDown = true;
171
+ setTimeout(onShutdown, 50).unref();
172
+ }
55
173
  return result('Stopped RadioCLI.');
56
174
  }
57
175
  if (command.type === 'alarm-preempt') {
58
- store.finishActiveListeningSession();
59
- await player.stop();
60
- station = null;
61
- queue = [];
176
+ desktopSuspended = true;
177
+ media?.refresh();
178
+ await engine.stop();
62
179
  return result('Interactive playback yielded to the alarm.');
63
180
  }
64
181
  if (command.type === 'set-volume') {
65
- const control = await player.setVolume(command.volume);
182
+ const control = command.unmute || command.allowStopped ? await player.setVolume(command.volume, { allowStopped: true }) : await player.setVolume(command.volume);
66
183
  if (control.ok)
67
- store.updateSettings({ volume: player.getState().volume });
184
+ await saveLibrary(() => store.updateSettings({ volume: player.getState().volume }));
185
+ if (control.ok && command.unmute && command.volume > 0) {
186
+ const unmuted = await player.setMuted(false, { allowStopped: true });
187
+ if (!unmuted.ok)
188
+ return result(unmuted.message ?? 'The output could not be unmuted.', false);
189
+ }
68
190
  return result(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
69
191
  }
70
192
  if (command.type === 'set-muted') {
71
- const control = await player.setMuted(command.muted);
193
+ const control = await player.setMuted(command.muted, { allowStopped: command.allowStopped });
72
194
  return result(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok);
73
195
  }
74
196
  if (command.type === 'set-favorite') {
@@ -77,8 +199,8 @@ export async function runHeadlessAgentHost() {
77
199
  return result('No active station to favorite.', false);
78
200
  const current = store.isFavorite(target);
79
201
  if (current !== command.favorite)
80
- store.toggleFavorite(target);
81
- if (command.favorite && !current && store.snapshot().settings.shareDirectoryVotes)
202
+ await saveLibrary(() => store.toggleFavorite(target));
203
+ if (command.favorite && !current && store.settings().shareDirectoryVotes)
82
204
  void providers.vote(target);
83
205
  return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`);
84
206
  }
@@ -88,56 +210,101 @@ export async function runHeadlessAgentHost() {
88
210
  }
89
211
  if (command.type === 'airplay-select') {
90
212
  const devices = await player.refreshAirPlayDevices();
213
+ signal?.throwIfAborted();
91
214
  const device = devices.find(item => item.id === command.deviceId);
92
215
  if (!device)
93
216
  return result('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
94
217
  if (device.local)
95
- return switchToLocal();
96
- if (!detectPlaybackBackends().includes('airplay'))
218
+ return switchToLocal(signal);
219
+ if (!player.refreshDetectedBackends().includes('airplay'))
97
220
  return result('AirPlay playback is unavailable; run radiocli doctor.', false);
98
- store.updateSettings({ preferredAirPlayDevice: device.id });
99
- store.updateSettings({ preferredBackend: 'airplay' });
221
+ await saveLibrary(() => store.updateSettings({ preferredAirPlayDevice: device.id, preferredBackend: 'airplay' }));
222
+ await player.selectAirPlayDevice(device.id);
223
+ signal?.throwIfAborted();
100
224
  if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
101
- return play(station, queue);
225
+ return play(station, queue, false, signal);
102
226
  return result(`Audio output set to AirPlay receiver ${device.name}.`);
103
227
  }
104
228
  if (command.type === 'airplay-local')
105
- return switchToLocal();
229
+ return switchToLocal(signal);
106
230
  if (command.type === 'airplay-passcode') {
107
- const control = player.submitAirPlayPasscode(command.code);
231
+ const control = await player.submitAirPlayPasscode(command.code);
108
232
  return result(control.message ?? 'AirPlay code sent.', control.ok);
109
233
  }
110
234
  if (command.type === 'update-settings') {
111
- store.updateSettings(command.settings);
235
+ if (!allowedRadioSessionSettings(command.settings))
236
+ return result('Session settings only accept appearance and agent control. Use radiocli setup --airplay to enable Linux AirPlay.', false);
237
+ await saveLibrary(() => store.updateSettings(command.settings));
112
238
  return result('RadioCLI settings updated.');
113
239
  }
240
+ if (command.type !== 'next' && command.type !== 'previous')
241
+ throw new Error('Unsupported radio command.');
114
242
  const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station)) : -1;
115
243
  const delta = command.type === 'next' ? 1 : -1;
116
244
  const next = queue.length ? queue[(Math.max(0, currentIndex) + delta + queue.length) % queue.length] : undefined;
117
- return next ? play(next, queue) : result('No playback queue is available.', false);
245
+ if (next && command.preserveStop && ['idle', 'stopped'].includes(player.getState().state)) {
246
+ engine.select(next, queue);
247
+ media?.refresh();
248
+ return result(`Selected ${next.name}.`);
249
+ }
250
+ return next ? play(next, queue, Boolean(command.preservePause && player.getState().state === 'paused'), signal) : result('No playback queue is available.', false);
118
251
  };
119
- const switchToLocal = async () => {
252
+ const switchToLocal = async (signal) => {
253
+ signal?.throwIfAborted();
120
254
  const backends = detectPlaybackBackends();
121
255
  const backend = backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null;
122
256
  if (!backend)
123
257
  return result('No local playback backend is available. Run radiocli setup to install mpv.', false);
124
- store.updateSettings({ preferredBackend: backend });
258
+ await saveLibrary(() => store.updateSettings({ preferredBackend: backend }));
259
+ await player.selectAirPlayDevice(null);
260
+ signal?.throwIfAborted();
261
+ const { station, queue } = engine.snapshot();
125
262
  if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
126
- return play(station, queue);
263
+ return play(station, queue, false, signal);
127
264
  return result(`Audio output set to this device (${backend}).`);
128
265
  };
129
- const session = await startRadioSession(handle);
130
- const checkpoint = setInterval(() => {
131
- if (player.getState().state === 'playing')
132
- store.checkpointActiveListeningSession();
133
- }, 30_000);
134
- const shutdown = async () => {
135
- clearInterval(checkpoint);
136
- store.finishActiveListeningSession();
137
- await player.stop();
138
- await session.close();
139
- };
140
- process.once('SIGTERM', () => { void shutdown().finally(() => process.exit(0)); });
141
- process.once('SIGINT', () => { void shutdown().finally(() => process.exit(0)); });
266
+ const dispatch = serializeRadioCommands(handle, { responsive: true });
267
+ const session = await startRadioSession((command, source) => dispatch(command, async () => {
268
+ if (command.type.startsWith('tui-') && source !== 'cli')
269
+ throw new Error('Terminal attachment requires a local CLI client.');
270
+ if (source !== 'cli' && command.type !== 'status' && command.type !== 'alarm-preempt') {
271
+ await store.readFresh();
272
+ if (!store.settings().agentControl?.enabled)
273
+ throw new Error('Agent controls are disabled.');
274
+ }
275
+ return true;
276
+ }), undefined, { queue: 'owner' }).catch(async (error) => {
277
+ try {
278
+ await engine.close();
279
+ }
280
+ finally {
281
+ await store.close();
282
+ }
283
+ throw error;
284
+ });
285
+ media = startDesktopMediaControls({ player, identity: store.filePath, policy: desktopMediaPolicy(store.settings()), status,
286
+ command: (command, permitted) => dispatch(command, () => permitted?.() !== false && !desktopSuspended), suspended: () => desktopSuspended });
287
+ const unsubscribe = engine.onChange(() => media?.refresh());
288
+ let shutdownPromise;
289
+ const signals = ['SIGTERM', 'SIGINT', 'SIGHUP'];
290
+ const shutdown = () => shutdownPromise ??= (async () => {
291
+ shuttingDown = true;
292
+ clearTimeout(sleepTimer);
293
+ desktopSuspended = true;
294
+ for (const signal of signals)
295
+ process.removeListener(signal, onShutdown);
296
+ media?.close();
297
+ unsubscribe();
298
+ try {
299
+ await engine.close();
300
+ }
301
+ finally {
302
+ await session.close();
303
+ await store.close();
304
+ }
305
+ })();
306
+ const onShutdown = () => { void shutdown().finally(() => process.exit(0)); };
307
+ for (const signal of signals)
308
+ process.once(signal, onShutdown);
142
309
  await new Promise(() => undefined);
143
310
  }
@@ -1,71 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import { basename, join } from 'node:path';
4
- import { detectAlarmTerminal } from '../alarms/terminal-launcher.js';
5
- const linuxTerminals = new Set(['alacritty', 'foot', 'ghostty', 'gnome-terminal', 'kitty', 'konsole', 'mate-terminal', 'qterminal', 'terminator', 'tilix', 'wezterm', 'xfce4-terminal', 'x-terminal-emulator']);
6
- export async function launchRadioTui(nodePath, cliPath, encodedCommand) {
7
- const terminal = detectAlarmTerminal();
8
- const direct = environmentCommand(nodePath, cliPath, ['agent-ui', encodedCommand]);
9
- const command = `${direct.map(shellQuote).join(' ')}`;
10
- if (terminal === 'darwin:apple-terminal')
11
- await launched(spawnDetached('/usr/bin/osascript', appleTerminalScript(command)));
12
- else if (terminal === 'darwin:iterm')
13
- await launched(spawnDetached('/usr/bin/osascript', iTermScript(command)));
14
- else if (terminal === 'darwin:wezterm')
15
- await launched(spawnDetached('/usr/bin/open', ['-na', 'WezTerm', '--args', 'start', '--always-new-process', '--', ...direct]));
16
- else if (terminal === 'darwin:ghostty')
17
- await launched(spawnDetached('/usr/bin/open', ['-na', 'Ghostty', '--args', '-e', ...direct]));
18
- else if (terminal === 'darwin:kitty')
19
- await launched(spawnDetached('/usr/bin/open', ['-na', 'kitty', '--args', '--detach', ...direct]));
20
- else if (terminal === 'win32:windows-terminal')
21
- await launched(spawnDetached('wt.exe', ['-w', 'new', 'new-tab', '--title', 'RadioCLI', ...direct]));
22
- else if (terminal === 'win32:console')
23
- await launched(spawnDetached('cmd.exe', ['/d', '/c', 'start', 'RadioCLI', 'cmd.exe', '/k', windowsCommand(direct)]));
24
- else if (terminal.startsWith('linux:')) {
25
- const executable = terminal.slice('linux:'.length);
26
- if (!linuxTerminals.has(basename(executable)))
27
- throw new Error('Saved Linux terminal is not supported.');
28
- const name = basename(executable);
29
- const prefix = name === 'gnome-terminal' || name === 'mate-terminal' || name === 'xfce4-terminal'
30
- ? ['--']
31
- : name === 'wezterm' ? ['start', '--always-new-process', '--'] : ['-e'];
32
- await launched(spawnDetached(executable, [...prefix, ...direct]));
33
- }
34
- else {
35
- throw new Error('No supported graphical terminal was found. Set agentControl.openUiOnPlay to false or open radiocli manually.');
36
- }
2
+ import { launchTerminalCommand } from '../platform/terminals.js';
3
+ import { waitForLaunch } from '../platform/launch-command.js';
4
+ /** The agent service verifies the authenticated session after this request. */
5
+ export async function launchRadioTui(nodePath, cliPath, encodedCommand, options = {}) {
6
+ await launchTerminalCommand({ ...options, nodePath, args: [cliPath, 'agent-ui', encodedCommand], title: 'RadioCLI' });
37
7
  }
38
8
  export async function launchHeadlessHost(nodePath, cliPath) {
39
- await launched(spawnDetached(nodePath, [cliPath, 'agent-host']));
40
- }
41
- function environmentCommand(nodePath, cliPath, args) {
42
- const command = [nodePath, cliPath, ...args];
43
- const radioCliHome = process.env.RADIOCLI_HOME;
44
- if (!radioCliHome)
45
- return command;
46
- if (process.platform === 'win32') {
47
- return ['cmd.exe', '/d', '/c', `set "RADIOCLI_HOME=${cmdEscape(radioCliHome)}" && ${windowsCommand(command)}`];
48
- }
49
- return ['/usr/bin/env', `RADIOCLI_HOME=${radioCliHome}`, ...command];
50
- }
51
- function shellQuote(value) { return `'${value.replaceAll("'", `'\\''`)}'`; }
52
- function windowsCommand(values) { return values.map(value => `"${value.replaceAll('"', '""')}"`).join(' '); }
53
- function cmdEscape(value) { return value.replaceAll('%', '%%').replaceAll('"', '""').replaceAll('^', '^^').replaceAll('&', '^&').replaceAll('|', '^|').replaceAll('<', '^<').replaceAll('>', '^>'); }
54
- function appleTerminalScript(command) { return ['-e', 'on run argv', '-e', 'tell application "Terminal"', '-e', 'activate', '-e', 'do script (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
55
- function iTermScript(command) { return ['-e', 'on run argv', '-e', 'tell application "iTerm"', '-e', 'activate', '-e', 'set w to (create window with default profile)', '-e', 'tell current session of w to write text (item 1 of argv)', '-e', 'end tell', '-e', 'end run', command]; }
56
- function spawnDetached(command, args) { return spawn(command, [...args], { detached: true, stdio: 'ignore', windowsHide: false }); }
57
- function launched(child) { return new Promise((resolve, reject) => { child.once('error', reject); child.once('spawn', () => { child.unref(); resolve(); }); }); }
58
- export function resolveExecutable(input, env = process.env, platform = process.platform) {
59
- if ((input.includes('/') || input.includes('\\')) && existsSync(input))
60
- return input;
61
- const suffixes = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
62
- const pathDelimiter = platform === 'win32' ? ';' : ':';
63
- for (const directory of (env.PATH ?? '').split(pathDelimiter)) {
64
- for (const suffix of suffixes) {
65
- const path = join(directory, `${input}${suffix}`);
66
- if (existsSync(path))
67
- return path;
68
- }
69
- }
70
- return undefined;
9
+ const loader = cliPath.endsWith('.tsx') ? ['--import', import.meta.resolve('tsx')] : [];
10
+ await waitForLaunch(spawn(nodePath, [...loader, cliPath, 'agent-host'], { detached: true, stdio: 'ignore', windowsHide: true }));
71
11
  }