@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,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,94 +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';
5
- import { airPlayMacOSOnlyMessage, detectPlaybackBackends, isAirPlayPlatformSupported } from '../player/backend-install.js';
6
- export async function runHeadlessAgentHost() {
7
- const store = new JsonLibraryStore();
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';
8
+ import { detectPlaybackBackends } from '../player/backend-install.js';
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 = [];
15
+ const engine = new RadioEngine(player, providers, store, () => store.settings());
13
16
  let persistenceWarning;
14
- const persistHistory = (write) => {
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;
15
41
  try {
16
- write();
42
+ process.kill(tui.pid, 0);
43
+ return true;
17
44
  }
18
45
  catch (error) {
19
- const code = error?.code;
20
- const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : '';
21
- persistenceWarning = `Listening history was not saved${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`;
46
+ return error.code !== 'ESRCH';
22
47
  }
23
48
  };
24
- const saveLibrary = (write) => {
25
- write();
26
- // Optional writes can be no-ops, so only an explicit successful save clears
27
- // a prior warning. Explicit failures still reject the control request.
49
+ const saveLibrary = async (write) => {
50
+ await write();
28
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() } } : {}) };
29
63
  };
30
- const status = () => ({ owner: 'headless', playback: player.getState(), station, queue, persistenceWarning, output: {
31
- preferredBackend: store.snapshot().settings.preferredBackend,
32
- preferredAirPlayDevice: store.snapshot().settings.preferredAirPlayDevice
33
- } });
34
64
  const result = (message, ok = true, data) => ({ ok, message, status: status(), ...(data ? { data } : {}) });
35
- const play = async (next, nextQueue = [next]) => {
36
- persistHistory(() => store.finishActiveListeningSession());
37
- await player.stop();
38
- const resolved = await providers.resolve(next);
39
- await player.play(next, resolved.url);
40
- station = next;
41
- queue = nextQueue.length ? nextQueue : [next];
42
- persistHistory(() => store.addRecent(next));
43
- persistHistory(() => store.startListeningSession(next));
44
- 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}.`);
45
77
  };
46
- const handle = async (command) => {
47
- if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !isAirPlayPlatformSupported()) {
48
- return result(airPlayMacOSOnlyMessage, false, command.type === 'airplay-list' ? [] : undefined);
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.');
49
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, []);
50
144
  if (command.type === 'status')
51
145
  return result(station ? `${player.getState().state}: ${station.name}` : 'RadioCLI is idle.');
52
146
  if (command.type === 'play') {
53
147
  if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state))
54
148
  return result(`Kept current station ${station?.name ?? ''}.`);
55
- return play(command.station, command.queue);
149
+ if (command.openNowPlaying)
150
+ focusNowPlaying++;
151
+ return play(command.station, command.queue, command.startPaused, signal);
56
152
  }
57
153
  if (command.type === 'pause') {
58
- const control = await player.pause();
59
- if (control.ok)
60
- persistHistory(() => store.finishActiveListeningSession());
154
+ const control = await engine.pause();
61
155
  return result(control.message ?? 'Paused.', control.ok);
62
156
  }
63
157
  if (command.type === 'resume') {
64
- const control = await player.resume();
65
- if (control.ok && station)
66
- persistHistory(() => 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();
67
161
  return result(control.message ?? 'Resumed.', control.ok);
68
162
  }
69
163
  if (command.type === 'stop') {
70
- persistHistory(() => store.finishActiveListeningSession());
71
- await player.stop();
72
- station = null;
73
- queue = [];
74
- 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
+ }
75
173
  return result('Stopped RadioCLI.');
76
174
  }
77
175
  if (command.type === 'alarm-preempt') {
78
- persistHistory(() => store.finishActiveListeningSession());
79
- await player.stop();
80
- station = null;
81
- queue = [];
176
+ desktopSuspended = true;
177
+ media?.refresh();
178
+ await engine.stop();
82
179
  return result('Interactive playback yielded to the alarm.');
83
180
  }
84
181
  if (command.type === 'set-volume') {
85
- 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);
86
183
  if (control.ok)
87
- saveLibrary(() => 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
+ }
88
190
  return result(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
89
191
  }
90
192
  if (command.type === 'set-muted') {
91
- const control = await player.setMuted(command.muted);
193
+ const control = await player.setMuted(command.muted, { allowStopped: command.allowStopped });
92
194
  return result(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok);
93
195
  }
94
196
  if (command.type === 'set-favorite') {
@@ -97,8 +199,8 @@ export async function runHeadlessAgentHost() {
97
199
  return result('No active station to favorite.', false);
98
200
  const current = store.isFavorite(target);
99
201
  if (current !== command.favorite)
100
- saveLibrary(() => store.toggleFavorite(target));
101
- if (command.favorite && !current && store.snapshot().settings.shareDirectoryVotes)
202
+ await saveLibrary(() => store.toggleFavorite(target));
203
+ if (command.favorite && !current && store.settings().shareDirectoryVotes)
102
204
  void providers.vote(target);
103
205
  return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`);
104
206
  }
@@ -108,58 +210,101 @@ export async function runHeadlessAgentHost() {
108
210
  }
109
211
  if (command.type === 'airplay-select') {
110
212
  const devices = await player.refreshAirPlayDevices();
213
+ signal?.throwIfAborted();
111
214
  const device = devices.find(item => item.id === command.deviceId);
112
215
  if (!device)
113
216
  return result('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
114
217
  if (device.local)
115
- return switchToLocal();
116
- if (!detectPlaybackBackends().includes('airplay'))
218
+ return switchToLocal(signal);
219
+ if (!player.refreshDetectedBackends().includes('airplay'))
117
220
  return result('AirPlay playback is unavailable; run radiocli doctor.', false);
118
- saveLibrary(() => {
119
- store.updateSettings({ preferredAirPlayDevice: device.id });
120
- store.updateSettings({ preferredBackend: 'airplay' });
121
- });
221
+ await saveLibrary(() => store.updateSettings({ preferredAirPlayDevice: device.id, preferredBackend: 'airplay' }));
222
+ await player.selectAirPlayDevice(device.id);
223
+ signal?.throwIfAborted();
122
224
  if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
123
- return play(station, queue);
225
+ return play(station, queue, false, signal);
124
226
  return result(`Audio output set to AirPlay receiver ${device.name}.`);
125
227
  }
126
228
  if (command.type === 'airplay-local')
127
- return switchToLocal();
229
+ return switchToLocal(signal);
128
230
  if (command.type === 'airplay-passcode') {
129
- const control = player.submitAirPlayPasscode(command.code);
231
+ const control = await player.submitAirPlayPasscode(command.code);
130
232
  return result(control.message ?? 'AirPlay code sent.', control.ok);
131
233
  }
132
234
  if (command.type === 'update-settings') {
133
- saveLibrary(() => 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));
134
238
  return result('RadioCLI settings updated.');
135
239
  }
240
+ if (command.type !== 'next' && command.type !== 'previous')
241
+ throw new Error('Unsupported radio command.');
136
242
  const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station)) : -1;
137
243
  const delta = command.type === 'next' ? 1 : -1;
138
244
  const next = queue.length ? queue[(Math.max(0, currentIndex) + delta + queue.length) % queue.length] : undefined;
139
- 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);
140
251
  };
141
- const switchToLocal = async () => {
252
+ const switchToLocal = async (signal) => {
253
+ signal?.throwIfAborted();
142
254
  const backends = detectPlaybackBackends();
143
255
  const backend = backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null;
144
256
  if (!backend)
145
257
  return result('No local playback backend is available. Run radiocli setup to install mpv.', false);
146
- saveLibrary(() => 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();
147
262
  if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
148
- return play(station, queue);
263
+ return play(station, queue, false, signal);
149
264
  return result(`Audio output set to this device (${backend}).`);
150
265
  };
151
- const session = await startRadioSession(handle);
152
- const checkpoint = setInterval(() => {
153
- if (player.getState().state === 'playing')
154
- persistHistory(() => store.checkpointActiveListeningSession());
155
- }, 30_000);
156
- const shutdown = async () => {
157
- clearInterval(checkpoint);
158
- persistHistory(() => store.finishActiveListeningSession());
159
- await player.stop();
160
- await session.close();
161
- };
162
- process.once('SIGTERM', () => { void shutdown().finally(() => process.exit(0)); });
163
- 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);
164
309
  await new Promise(() => undefined);
165
310
  }
@@ -6,5 +6,6 @@ export async function launchRadioTui(nodePath, cliPath, encodedCommand, options
6
6
  await launchTerminalCommand({ ...options, nodePath, args: [cliPath, 'agent-ui', encodedCommand], title: 'RadioCLI' });
7
7
  }
8
8
  export async function launchHeadlessHost(nodePath, cliPath) {
9
- await waitForLaunch(spawn(nodePath, [cliPath, 'agent-host'], { detached: true, stdio: 'ignore', windowsHide: false }));
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 }));
10
11
  }
@@ -1,14 +1,16 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { serveStdio } from '@modelcontextprotocol/server/stdio';
3
3
  import { z } from 'zod';
4
+ import { JsonLibraryStore } from '../storage/store.js';
5
+ import { LibraryRepository } from '../storage/repository.js';
4
6
  import { appVersion } from '../version.js';
5
7
  import { receiverStyleNames, themeNames } from '../types.js';
6
8
  import { AgentRadioService, stationForAgent } from './service.js';
7
9
  export const radioCliMcpInstructions = `RadioCLI is the authoritative handler for requests about RadioCLI, “the radio”, “my radio”, recent stations, favorites, or stations from a place. Never substitute a web browser, web search, Computer Use, or another radio/music service. “Play my most recent radio station” means call radio_play with source="recent" and index=0. “Play a station from New York” means call radio_search with query="New York", then radio_play with one returned opaque station ID. Call these tools directly; do not delegate a radio-only operation or create a goal merely to monitor it.
8
10
 
9
- RadioCLI controls internet radio, alarms, and macOS AirPlay locally. Discover unidentified stations with radio_search or radio_browse, then use only returned opaque IDs; never invent IDs or stream URLs. Ordinary stop never dismisses an alarm: list active alarms and use alarm controls, with an exact ID when several ring. Never remove an alarm without explicit user confirmation. List AirPlay receivers before selecting one. Never install an update without explicit approval.
11
+ RadioCLI controls internet radio, alarms, and experimental AirPlay locally. Discover unidentified stations with radio_search or radio_browse, then use only returned opaque IDs; never invent IDs or stream URLs. Ordinary stop never dismisses an alarm: list active alarms and use alarm controls, with an exact ID when several ring. Never remove an alarm without explicit user confirmation. List AirPlay receivers before selecting one. Never install an update without explicit approval.
10
12
 
11
- For “this station”, omit station_id when favoriting. A request such as “do X, then play or stop the radio” is a normal completion action in the current task: perform X, then call radio_run_completion_preset once X is genuinely complete. Do not create or set a goal, scheduled task, automation, reminder, task record, or separate monitoring task merely to defer or track that radio action. Use one of those mechanisms only when the user explicitly requests it for a separate reason. AirPlay is macOS-only. Submit a receiver code only when the user provides it; codes are never saved. Use radio_update_status for version or upgrade questions, and tell the user to restart their MCP client after an update.`;
13
+ For “this station”, omit station_id when favoriting. A request such as “do X, then play or stop the radio” is a normal completion action in the current task: perform X, then call radio_run_completion_preset once X is genuinely complete. Do not create or set a goal, scheduled task, automation, reminder, task record, or separate monitoring task merely to defer or track that radio action. Use one of those mechanisms only when the user explicitly requests it for a separate reason. AirPlay is experimental on macOS and native Linux; Linux receiver selection is required each session. Submit a receiver code only when the user provides it; codes are never saved. Use radio_update_status for version or upgrade questions, and tell the user to restart their MCP client after an update.`;
12
14
  const alarmScheduleSchema = z.discriminatedUnion('type', [
13
15
  z.object({ type: z.literal('once'), at: z.string().min(1).describe('Absolute ISO-8601 minute including an offset or Z, with zero seconds.') }),
14
16
  z.object({
@@ -30,7 +32,9 @@ const alarmOptionsSchema = {
30
32
  keep_awake_until_alarm: z.boolean().optional()
31
33
  };
32
34
  export async function runMcpServer(runtime) {
33
- const service = new AgentRadioService(runtime);
35
+ const store = new LibraryRepository(new JsonLibraryStore());
36
+ const service = new AgentRadioService(runtime, store);
37
+ process.once('beforeExit', () => { void store.close().catch(() => undefined); });
34
38
  serveStdio(() => createMcpServer(service), { onerror: error => console.error(`RadioCLI MCP: ${error.message}`) });
35
39
  }
36
40
  function createMcpServer(service) {
@@ -39,6 +43,7 @@ function createMcpServer(service) {
39
43
  const config = schema ? { description, inputSchema: schema } : { description };
40
44
  server.registerTool(name, config, async (raw) => {
41
45
  try {
46
+ await service.refresh();
42
47
  const value = await handler(raw);
43
48
  return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
44
49
  }
@@ -123,7 +128,7 @@ function createMcpServer(service) {
123
128
  occurrence_at: z.string().min(1).optional(),
124
129
  snooze_minutes: z.number().int().min(1).max(1440).optional()
125
130
  }).refine(value => value.action === 'snooze' || value.snooze_minutes === undefined, { message: 'snooze_minutes is only valid for the snooze action.' }), ({ action, alarm_id, occurrence_at, snooze_minutes }) => service.alarmControl({ action, alarmId: alarm_id, occurrenceAt: occurrence_at, snoozeMinutes: snooze_minutes }));
126
- tool('radio_airplay_list', 'Discover AirPlay receivers visible to this Mac. Returns opaque receiver IDs; always call this before selecting a receiver.', undefined, async () => (await service.listAirPlayDevices()).map(device => ({
131
+ tool('radio_airplay_list', 'Discover AirPlay receivers visible to this device. Returns opaque receiver IDs; always call this before selecting a receiver.', undefined, async () => (await service.listAirPlayDevices()).map(device => ({
127
132
  id: device.id,
128
133
  name: device.name,
129
134
  host: device.host,
@@ -133,7 +138,7 @@ function createMcpServer(service) {
133
138
  local: device.local ?? false
134
139
  })));
135
140
  tool('radio_airplay_select', 'Switch current or future interactive playback to an exact AirPlay receiver ID returned by radio_airplay_list. Opens RadioCLI when needed unless open_ui=false.', z.object({ device_id: z.string().min(1), open_ui: z.boolean().optional() }), ({ device_id, open_ui }) => service.selectAirPlayDevice(device_id, open_ui));
136
- tool('radio_airplay_use_local', 'Switch interactive playback from AirPlay back to this Mac using the best available local backend.', z.object({ open_ui: z.boolean().optional() }).optional(), args => service.useLocalOutput(args?.open_ui));
141
+ tool('radio_airplay_use_local', 'Switch interactive playback from AirPlay back to this device using the best available local backend.', z.object({ open_ui: z.boolean().optional() }).optional(), args => service.useLocalOutput(args?.open_ui));
137
142
  tool('radio_airplay_submit_code', 'Submit a receiver-displayed AirPlay code to the active AirPlay session. Use only a code the user explicitly provides; RadioCLI never saves it.', z.object({ code: z.string().min(1).max(64) }), ({ code }) => service.submitAirPlayPasscode(code));
138
143
  return server;
139
144
  }