@ciphore/radiocli 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -1
- package/README.md +76 -6
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +248 -0
- package/dist/alarms/active-session.js +183 -0
- package/dist/alarms/cli.js +312 -0
- package/dist/alarms/guard.js +343 -0
- package/dist/alarms/inhibitor.js +48 -0
- package/dist/alarms/power-guard-store.js +169 -0
- package/dist/alarms/runner.js +342 -0
- package/dist/alarms/runtime-health.js +79 -0
- package/dist/alarms/schedule.js +149 -0
- package/dist/alarms/scheduler.js +250 -0
- package/dist/alarms/setup-verification.js +187 -0
- package/dist/alarms/system-volume.js +43 -0
- package/dist/alarms/terminal-launcher.js +181 -0
- package/dist/alarms/tui-presence.js +38 -0
- package/dist/cli.js +113 -5
- package/dist/player/backend-install.js +2 -1
- package/dist/player/command-diagnostics.js +27 -0
- package/dist/player/command.js +123 -62
- package/dist/player/player-controller.js +32 -2
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +36 -6
- package/dist/setup.js +462 -0
- package/dist/storage/store.js +262 -2
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +111 -26
- package/dist/ui/App.js +401 -67
- package/dist/ui/AppContent.js +24 -7
- package/dist/ui/adaptive-explore-layout.js +47 -0
- package/dist/ui/alarm-editor.js +174 -0
- package/dist/ui/alarm-tui-service.js +84 -0
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/ascii.js +8 -0
- package/dist/ui/components/AdaptiveMarquee.js +28 -0
- package/dist/ui/components/StationList.js +10 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/cosmo-world-map.js +5 -2
- package/dist/ui/explore-map-layout.js +18 -6
- package/dist/ui/format.js +21 -0
- package/dist/ui/help-content.js +14 -2
- package/dist/ui/layout.js +1 -1
- package/dist/ui/page-footer.js +120 -2
- package/dist/ui/receiver-animation.js +68 -0
- package/dist/ui/screen-items.js +41 -9
- package/dist/ui/screen-meta.js +4 -0
- package/dist/ui/screens/AlarmsScreen.js +202 -0
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +8 -3
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/NowPlayingScreen.js +6 -2
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/screens/StationScreen.js +3 -2
- package/dist/ui/selection-state.js +10 -0
- package/dist/ui/terminal-mouse.js +18 -3
- package/dist/ui/use-alarm-tui.js +727 -0
- package/dist/ui/use-app-input.js +107 -46
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +14 -7
- package/dist/ui/visualizers/receiver-visualizers.js +233 -128
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +26 -7
- package/package.json +6 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { defaultAgentControlSettings, themeNames, receiverStyleNames } from '../types.js';
|
|
3
|
+
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
4
|
+
import { ProviderManager } from '../providers/provider-manager.js';
|
|
5
|
+
import { computeListeningStats } from '../activity/stats.js';
|
|
6
|
+
import { connectActiveAlarms } from '../alarms/active-session.js';
|
|
7
|
+
import { connectRadioSession, ensureRadioSession } from './session.js';
|
|
8
|
+
import { launchHeadlessHost, launchRadioTui } from './launcher.js';
|
|
9
|
+
import { PlayerController } from '../player/player-controller.js';
|
|
10
|
+
import { detectPlaybackBackends } from '../player/backend-install.js';
|
|
11
|
+
import { AgentAlarmService } from './alarm-service.js';
|
|
12
|
+
import { appVersion } from '../version.js';
|
|
13
|
+
import { checkForUpdate, updateCommandForInstall } from '../update-check.js';
|
|
14
|
+
export class AgentRadioService {
|
|
15
|
+
runtime;
|
|
16
|
+
store;
|
|
17
|
+
providers;
|
|
18
|
+
stationCache = new Map();
|
|
19
|
+
alarms;
|
|
20
|
+
constructor(runtime, store = new JsonLibraryStore(), providers = new ProviderManager()) {
|
|
21
|
+
this.runtime = runtime;
|
|
22
|
+
this.store = store;
|
|
23
|
+
this.providers = providers;
|
|
24
|
+
this.alarms = new AgentAlarmService(this.store, id => this.resolveStationId(id), undefined, status => this.handoffAlarm(status));
|
|
25
|
+
}
|
|
26
|
+
settings() { return this.store.snapshot().settings; }
|
|
27
|
+
appearance() {
|
|
28
|
+
this.assertEnabled();
|
|
29
|
+
return { themes: themeNames, receiverStyles: receiverStyleNames, current: this.settings() };
|
|
30
|
+
}
|
|
31
|
+
async status() {
|
|
32
|
+
this.assertEnabled();
|
|
33
|
+
const client = await connectRadioSession();
|
|
34
|
+
const state = this.store.snapshot();
|
|
35
|
+
return {
|
|
36
|
+
connected: Boolean(client),
|
|
37
|
+
session: client ? await client.status() : null,
|
|
38
|
+
agentControl: this.agentSettings(),
|
|
39
|
+
favoriteCount: state.favorites.length,
|
|
40
|
+
recentCount: state.recent.length,
|
|
41
|
+
alarmActive: (await connectActiveAlarms()).length > 0,
|
|
42
|
+
audioOutput: {
|
|
43
|
+
preferredBackend: state.settings.preferredBackend,
|
|
44
|
+
preferredAirPlayDevice: state.settings.preferredAirPlayDevice
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async updateStatus() {
|
|
49
|
+
this.assertEnabled();
|
|
50
|
+
const update = await checkForUpdate();
|
|
51
|
+
const install = updateCommandForInstall();
|
|
52
|
+
return {
|
|
53
|
+
installedVersion: appVersion(),
|
|
54
|
+
latestVersion: update.latestVersion ?? null,
|
|
55
|
+
updateAvailable: update.updateAvailable,
|
|
56
|
+
checkedAt: update.checkedAt,
|
|
57
|
+
error: update.error ?? null,
|
|
58
|
+
installMethod: install.method,
|
|
59
|
+
command: install.command,
|
|
60
|
+
note: 'RadioCLI does not install updates through MCP. Ask the user to approve the command or use Settings, then restart the MCP client.'
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async search(query, limit = 10, countryCode) {
|
|
64
|
+
this.assertEnabled();
|
|
65
|
+
const stations = await this.providers.search(query, this.store.snapshot().settings, {
|
|
66
|
+
limit: clampLimit(limit),
|
|
67
|
+
countryCode
|
|
68
|
+
});
|
|
69
|
+
this.remember(stations);
|
|
70
|
+
return stations;
|
|
71
|
+
}
|
|
72
|
+
async browse(kind, limit = 20) {
|
|
73
|
+
this.assertEnabled();
|
|
74
|
+
const state = this.store.snapshot();
|
|
75
|
+
const bounded = clampLimit(limit, 50);
|
|
76
|
+
if (kind === 'favorites')
|
|
77
|
+
return this.remember(state.favorites.slice(0, bounded));
|
|
78
|
+
if (kind === 'recent')
|
|
79
|
+
return this.remember(state.recent.slice(0, bounded).map(item => item.station));
|
|
80
|
+
if (kind === 'track-history')
|
|
81
|
+
return state.trackHistory.slice(0, bounded);
|
|
82
|
+
if (kind === 'countries')
|
|
83
|
+
return this.providers.countries(bounded);
|
|
84
|
+
if (kind === 'popular')
|
|
85
|
+
return this.remember(await this.providers.popular(bounded));
|
|
86
|
+
if (!state.settings.enableNearbyLocation)
|
|
87
|
+
throw new Error('Nearby location lookup is disabled in RadioCLI settings.');
|
|
88
|
+
const location = await this.providers.detectLocation();
|
|
89
|
+
if (!location)
|
|
90
|
+
throw new Error('RadioCLI could not determine an approximate location.');
|
|
91
|
+
return this.remember(await this.providers.nearby(location, bounded));
|
|
92
|
+
}
|
|
93
|
+
stats() {
|
|
94
|
+
this.assertEnabled();
|
|
95
|
+
return computeListeningStats(this.store.snapshot().activity.sessions);
|
|
96
|
+
}
|
|
97
|
+
async play(input) {
|
|
98
|
+
this.assertEnabled();
|
|
99
|
+
if ((await connectActiveAlarms()).length > 0)
|
|
100
|
+
throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.');
|
|
101
|
+
const queue = await this.stationQueue(input.source ?? 'recent', input.countryCode);
|
|
102
|
+
if (!input.stationId && queue.length === 0)
|
|
103
|
+
throw new Error('No station is available for that request.');
|
|
104
|
+
const station = input.stationId
|
|
105
|
+
? await this.findStation(input.stationId, queue)
|
|
106
|
+
: queue[input.random ? randomInt(queue.length) : Math.max(0, input.index ?? 0)];
|
|
107
|
+
if (!station)
|
|
108
|
+
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))];
|
|
110
|
+
return this.send({
|
|
111
|
+
type: 'play',
|
|
112
|
+
station,
|
|
113
|
+
queue: ordered,
|
|
114
|
+
openNowPlaying: this.agentSettings().focusNowPlaying,
|
|
115
|
+
ifPlaying: input.ifPlaying ?? 'replace'
|
|
116
|
+
}, input.openUi);
|
|
117
|
+
}
|
|
118
|
+
async control(command, openUi) {
|
|
119
|
+
this.assertEnabled();
|
|
120
|
+
const client = await connectRadioSession();
|
|
121
|
+
if (!client && command.type === 'set-volume') {
|
|
122
|
+
const volume = Math.min(100, Math.max(0, command.volume));
|
|
123
|
+
this.store.updateSettings({ volume });
|
|
124
|
+
return idleResult(`Saved volume ${volume}; no playback session is active.`);
|
|
125
|
+
}
|
|
126
|
+
if (!client && ['stop', 'pause', 'resume', 'next', 'previous', 'set-volume', 'set-muted'].includes(command.type)) {
|
|
127
|
+
if (command.type === 'stop' || command.type === 'pause')
|
|
128
|
+
return idleResult(command.type === 'stop' ? 'RadioCLI is already stopped.' : 'No playback session is active; pause is already satisfied.');
|
|
129
|
+
throw new Error('No interactive RadioCLI playback session is active.');
|
|
130
|
+
}
|
|
131
|
+
return this.send(command, openUi);
|
|
132
|
+
}
|
|
133
|
+
async setFavorite(favorite, stationId) {
|
|
134
|
+
this.assertEnabled();
|
|
135
|
+
const client = await connectRadioSession();
|
|
136
|
+
if (!stationId && client)
|
|
137
|
+
return client.call({ type: 'set-favorite', favorite });
|
|
138
|
+
const station = stationId ? await this.findStation(stationId, this.savedStations()) : undefined;
|
|
139
|
+
if (!station)
|
|
140
|
+
throw new Error('No station was supplied and no active station is available.');
|
|
141
|
+
const isFavorite = this.store.isFavorite(station);
|
|
142
|
+
if (isFavorite !== favorite)
|
|
143
|
+
this.store.toggleFavorite(station);
|
|
144
|
+
if (favorite && !isFavorite && this.store.snapshot().settings.shareDirectoryVotes)
|
|
145
|
+
void this.providers.vote(station);
|
|
146
|
+
return { ok: true, message: `${favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}`, station };
|
|
147
|
+
}
|
|
148
|
+
async updateAppearance(input) {
|
|
149
|
+
this.assertEnabled();
|
|
150
|
+
if (input.theme && !themeNames.includes(input.theme))
|
|
151
|
+
throw new Error(`Unknown theme: ${input.theme}`);
|
|
152
|
+
if (input.receiverStyle && !receiverStyleNames.includes(input.receiverStyle))
|
|
153
|
+
throw new Error(`Unknown receiver style: ${input.receiverStyle}`);
|
|
154
|
+
const client = await connectRadioSession();
|
|
155
|
+
if (client)
|
|
156
|
+
return client.call({ type: 'update-settings', settings: input });
|
|
157
|
+
return this.store.updateSettings(input).settings;
|
|
158
|
+
}
|
|
159
|
+
async runCompletionPreset() {
|
|
160
|
+
const preset = this.agentSettings().completionPreset;
|
|
161
|
+
if (preset.action === 'play')
|
|
162
|
+
return this.play({ source: preset.source, countryCode: preset.countryCode, random: preset.source !== 'recent', openUi: preset.openUi, ifPlaying: preset.ifPlaying });
|
|
163
|
+
return this.control({ type: preset.action }, preset.openUi);
|
|
164
|
+
}
|
|
165
|
+
configureCompletionPreset(input) {
|
|
166
|
+
this.assertEnabled();
|
|
167
|
+
const agentControl = this.agentSettings();
|
|
168
|
+
const completionPreset = { ...agentControl.completionPreset, ...input };
|
|
169
|
+
if (completionPreset.source === 'country' && !completionPreset.countryCode)
|
|
170
|
+
throw new Error('A two-letter countryCode is required for the country preset source.');
|
|
171
|
+
if (completionPreset.countryCode)
|
|
172
|
+
completionPreset.countryCode = completionPreset.countryCode.toUpperCase();
|
|
173
|
+
return this.store.updateSettings({ agentControl: { ...agentControl, completionPreset } }).settings.agentControl;
|
|
174
|
+
}
|
|
175
|
+
async updateAgentPreferences(input) {
|
|
176
|
+
this.assertEnabled();
|
|
177
|
+
const agentControl = { ...this.agentSettings(), ...input };
|
|
178
|
+
const client = await connectRadioSession();
|
|
179
|
+
if (client)
|
|
180
|
+
return client.call({ type: 'update-settings', settings: { agentControl } });
|
|
181
|
+
return this.store.updateSettings({ agentControl }).settings.agentControl;
|
|
182
|
+
}
|
|
183
|
+
alarmList() { this.assertEnabled(); return this.alarms.list(); }
|
|
184
|
+
alarmStatus() { this.assertEnabled(); return this.alarms.status(); }
|
|
185
|
+
alarmCreate(input) { this.assertEnabled(); return this.alarms.create(input); }
|
|
186
|
+
alarmUpdate(id, input) { this.assertEnabled(); return this.alarms.update(id, input); }
|
|
187
|
+
alarmSetEnabled(id, enabled) { this.assertEnabled(); return this.alarms.setEnabled(id, enabled); }
|
|
188
|
+
alarmRemove(id, confirm) { this.assertEnabled(); return this.alarms.remove(id, confirm); }
|
|
189
|
+
alarmSync() { this.assertEnabled(); return this.alarms.sync(); }
|
|
190
|
+
alarmControl(input) { this.assertEnabled(); return this.alarms.controlActive(input); }
|
|
191
|
+
async listAirPlayDevices() {
|
|
192
|
+
this.assertEnabled();
|
|
193
|
+
this.assertAirPlayPlatform();
|
|
194
|
+
const client = await connectRadioSession();
|
|
195
|
+
if (client)
|
|
196
|
+
return (await client.call({ type: 'airplay-list' })).data ?? [];
|
|
197
|
+
const player = new PlayerController(() => this.store.snapshot().settings);
|
|
198
|
+
const backends = player.refreshDetectedBackends();
|
|
199
|
+
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.');
|
|
201
|
+
return player.refreshAirPlayDevices();
|
|
202
|
+
}
|
|
203
|
+
async selectAirPlayDevice(deviceId, openUi) {
|
|
204
|
+
this.assertEnabled();
|
|
205
|
+
this.assertAirPlayPlatform();
|
|
206
|
+
const devices = await this.listAirPlayDevices();
|
|
207
|
+
const device = devices.find(item => item.id === deviceId);
|
|
208
|
+
if (!device)
|
|
209
|
+
throw new Error('Unknown AirPlay receiver ID. Refresh the receiver list and use an exact returned ID.');
|
|
210
|
+
if (device.local)
|
|
211
|
+
return this.useLocalOutput(openUi);
|
|
212
|
+
this.store.updateSettings({ preferredAirPlayDevice: device.id });
|
|
213
|
+
return this.send({ type: 'airplay-select', deviceId: device.id }, openUi);
|
|
214
|
+
}
|
|
215
|
+
async useLocalOutput(openUi) {
|
|
216
|
+
this.assertEnabled();
|
|
217
|
+
const client = await connectRadioSession();
|
|
218
|
+
if (!client) {
|
|
219
|
+
const backend = preferredLocalBackend(detectPlaybackBackends());
|
|
220
|
+
if (!backend)
|
|
221
|
+
throw new Error('No local playback backend is available. Run radiocli setup to install mpv.');
|
|
222
|
+
this.store.updateSettings({ preferredBackend: backend });
|
|
223
|
+
return idleResult(`Audio output set to this device (${backend}).`);
|
|
224
|
+
}
|
|
225
|
+
return this.send({ type: 'airplay-local' }, openUi);
|
|
226
|
+
}
|
|
227
|
+
async submitAirPlayPasscode(code) {
|
|
228
|
+
this.assertEnabled();
|
|
229
|
+
this.assertAirPlayPlatform();
|
|
230
|
+
const client = await connectRadioSession();
|
|
231
|
+
if (!client)
|
|
232
|
+
throw new Error('No active AirPlay session is waiting for a receiver code.');
|
|
233
|
+
return client.call({ type: 'airplay-passcode', code });
|
|
234
|
+
}
|
|
235
|
+
async resolveStationId(id) {
|
|
236
|
+
return this.findStation(id, this.savedStations());
|
|
237
|
+
}
|
|
238
|
+
async handoffAlarm(status) {
|
|
239
|
+
if (!status.station)
|
|
240
|
+
throw new Error('This alarm session does not expose a station for interactive handoff.');
|
|
241
|
+
const result = await this.send({
|
|
242
|
+
type: 'play',
|
|
243
|
+
station: status.station,
|
|
244
|
+
queue: [status.station],
|
|
245
|
+
openNowPlaying: true,
|
|
246
|
+
ifPlaying: 'replace'
|
|
247
|
+
}, undefined, true);
|
|
248
|
+
if (!result.ok || result.status.playback.state !== 'playing' || !result.status.playback.ready) {
|
|
249
|
+
throw new Error(result.message || 'Interactive playback did not become ready; the alarm is still playing.');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async send(command, openUi, allowActiveAlarm = false) {
|
|
253
|
+
const settings = this.settings();
|
|
254
|
+
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();
|
|
258
|
+
if (!client) {
|
|
259
|
+
if (!allowActiveAlarm && (await connectActiveAlarms()).length > 0)
|
|
260
|
+
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;
|
|
262
|
+
client = await ensureRadioSession(async () => {
|
|
263
|
+
if (shouldOpen)
|
|
264
|
+
await launchRadioTui(this.runtime.nodePath, this.runtime.cliPath, encodeAgentCommand({ type: 'status' }));
|
|
265
|
+
else
|
|
266
|
+
await launchHeadlessHost(this.runtime.nodePath, this.runtime.cliPath);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return client.call(command);
|
|
270
|
+
}
|
|
271
|
+
async stationQueue(source, countryCode) {
|
|
272
|
+
const state = this.store.snapshot();
|
|
273
|
+
let stations;
|
|
274
|
+
if (source === 'recent')
|
|
275
|
+
stations = state.recent.map(item => item.station);
|
|
276
|
+
else if (source === 'favorite')
|
|
277
|
+
stations = state.favorites;
|
|
278
|
+
else if (source === 'popular')
|
|
279
|
+
stations = await this.providers.popular(50);
|
|
280
|
+
else {
|
|
281
|
+
if (!countryCode || !/^[a-z]{2}$/i.test(countryCode))
|
|
282
|
+
throw new Error('countryCode must be a two-letter ISO country code.');
|
|
283
|
+
stations = await this.providers.byCountry(countryCode.toUpperCase(), 100);
|
|
284
|
+
}
|
|
285
|
+
return this.remember(stations);
|
|
286
|
+
}
|
|
287
|
+
savedStations() {
|
|
288
|
+
const state = this.store.snapshot();
|
|
289
|
+
return [...state.favorites, ...state.recent.map(item => item.station), ...state.imported, ...this.stationCache.values()];
|
|
290
|
+
}
|
|
291
|
+
async findStation(id, candidates) {
|
|
292
|
+
const normalized = id.trim();
|
|
293
|
+
const local = this.stationCache.get(normalized)
|
|
294
|
+
?? candidates.find(station => opaqueStationId(station) === normalized || station.id === normalized);
|
|
295
|
+
if (local)
|
|
296
|
+
return local;
|
|
297
|
+
const separator = normalized.indexOf(':');
|
|
298
|
+
const provider = separator > 0 ? normalized.slice(0, separator) : 'radio-browser';
|
|
299
|
+
const providerId = separator > 0 ? normalized.slice(separator + 1) : normalized;
|
|
300
|
+
if (!['radio-browser', 'radio-garden', 'playlist'].includes(provider))
|
|
301
|
+
return undefined;
|
|
302
|
+
const station = await this.providers.byId(provider, providerId);
|
|
303
|
+
if (station)
|
|
304
|
+
this.remember([station]);
|
|
305
|
+
return station ?? undefined;
|
|
306
|
+
}
|
|
307
|
+
remember(stations) {
|
|
308
|
+
for (const station of stations)
|
|
309
|
+
this.stationCache.set(opaqueStationId(station), station);
|
|
310
|
+
return stations;
|
|
311
|
+
}
|
|
312
|
+
agentSettings() {
|
|
313
|
+
return this.settings().agentControl ?? defaultAgentControlSettings;
|
|
314
|
+
}
|
|
315
|
+
assertEnabled() {
|
|
316
|
+
if (!this.agentSettings().enabled)
|
|
317
|
+
throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.');
|
|
318
|
+
}
|
|
319
|
+
assertAirPlayPlatform() {
|
|
320
|
+
if (process.platform !== 'darwin')
|
|
321
|
+
throw new Error('AirPlay control is available only on macOS.');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function opaqueStationId(station) { return `${station.provider}:${station.id}`; }
|
|
325
|
+
export function stationForAgent(station) {
|
|
326
|
+
return {
|
|
327
|
+
id: opaqueStationId(station),
|
|
328
|
+
name: station.name,
|
|
329
|
+
country: station.country,
|
|
330
|
+
countryCode: station.countryCode,
|
|
331
|
+
city: station.city,
|
|
332
|
+
language: station.language,
|
|
333
|
+
tags: station.tags.slice(0, 8),
|
|
334
|
+
codec: station.codec,
|
|
335
|
+
bitrate: station.bitrate,
|
|
336
|
+
distanceKm: station.distanceKm
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
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')); }
|
|
341
|
+
function clampLimit(value, max = 30) { return Math.min(max, Math.max(1, Math.round(value))); }
|
|
342
|
+
function idleResult(message) {
|
|
343
|
+
return { ok: true, message, status: { owner: 'headless', station: null, queue: [], playback: { backend: 'none', state: 'idle', volume: 70, muted: false, ready: false } } };
|
|
344
|
+
}
|
|
345
|
+
function preferredLocalBackend(backends) {
|
|
346
|
+
return backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null;
|
|
347
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createServer, request } from 'node:http';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
export async function startRadioSession(handle, filePath = radioSessionPath()) {
|
|
7
|
+
const ownerPath = `${filePath}.owner`;
|
|
8
|
+
acquireOwner(ownerPath);
|
|
9
|
+
const token = randomBytes(32).toString('hex');
|
|
10
|
+
let serial = Promise.resolve();
|
|
11
|
+
const server = createServer((req, res) => {
|
|
12
|
+
res.setHeader('content-type', 'application/json');
|
|
13
|
+
if (req.headers.authorization !== `Bearer ${token}`) {
|
|
14
|
+
res.statusCode = 401;
|
|
15
|
+
res.end('{}');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (req.method !== 'POST' || req.url !== '/command') {
|
|
19
|
+
res.statusCode = 404;
|
|
20
|
+
res.end('{}');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const work = serial.then(async () => {
|
|
24
|
+
const body = await readBody(req);
|
|
25
|
+
return handle(JSON.parse(body));
|
|
26
|
+
});
|
|
27
|
+
serial = work.then(() => undefined, () => undefined);
|
|
28
|
+
void work.then(result => res.end(JSON.stringify(result))).catch(error => {
|
|
29
|
+
res.statusCode = 400;
|
|
30
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'Radio control failed.' }));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
await new Promise((resolve, reject) => {
|
|
35
|
+
server.once('error', reject);
|
|
36
|
+
server.listen(0, '127.0.0.1', () => resolve());
|
|
37
|
+
});
|
|
38
|
+
const address = server.address();
|
|
39
|
+
if (!address || typeof address === 'string')
|
|
40
|
+
throw new Error('Unable to create RadioCLI control endpoint.');
|
|
41
|
+
const discovery = {
|
|
42
|
+
version: 1,
|
|
43
|
+
host: '127.0.0.1',
|
|
44
|
+
port: address.port,
|
|
45
|
+
token,
|
|
46
|
+
pid: process.pid,
|
|
47
|
+
createdAt: new Date().toISOString()
|
|
48
|
+
};
|
|
49
|
+
writePrivateJson(filePath, discovery);
|
|
50
|
+
return {
|
|
51
|
+
async close() {
|
|
52
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
53
|
+
removeIfOwned(filePath, discovery);
|
|
54
|
+
removeOwner(ownerPath, process.pid);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
removeOwner(ownerPath, process.pid);
|
|
60
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function connectRadioSession(filePath = radioSessionPath()) {
|
|
65
|
+
if (!existsSync(filePath))
|
|
66
|
+
return null;
|
|
67
|
+
let discovery;
|
|
68
|
+
try {
|
|
69
|
+
discovery = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
70
|
+
if (discovery.version !== 1 || discovery.host !== '127.0.0.1' || !Number.isInteger(discovery.port) || !discovery.token) {
|
|
71
|
+
throw new Error('invalid');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
rmSync(filePath, { force: true });
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const call = async (command) => {
|
|
79
|
+
const payload = JSON.stringify(command);
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const req = request({
|
|
82
|
+
host: '127.0.0.1',
|
|
83
|
+
port: discovery.port,
|
|
84
|
+
path: '/command',
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
authorization: `Bearer ${discovery.token}`,
|
|
88
|
+
'content-type': 'application/json',
|
|
89
|
+
'content-length': Buffer.byteLength(payload)
|
|
90
|
+
}
|
|
91
|
+
}, res => {
|
|
92
|
+
let text = '';
|
|
93
|
+
res.on('data', value => { text += String(value); });
|
|
94
|
+
res.on('end', () => {
|
|
95
|
+
if (res.statusCode && res.statusCode < 300)
|
|
96
|
+
resolve(JSON.parse(text));
|
|
97
|
+
else
|
|
98
|
+
reject(new Error(parseError(text)));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
req.once('error', reject);
|
|
102
|
+
req.setTimeout(15_000, () => req.destroy(new Error('RadioCLI control request timed out.')));
|
|
103
|
+
req.end(payload);
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
try {
|
|
107
|
+
await call({ type: 'status' });
|
|
108
|
+
return { call, status: async () => (await call({ type: 'status' })).status };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
if (!processAlive(discovery.pid) || discoveryAgeMs(filePath, discovery) > 60_000) {
|
|
112
|
+
removeIfOwned(filePath, discovery);
|
|
113
|
+
removeOwner(`${filePath}.owner`, discovery.pid);
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export async function ensureRadioSession(start, timeoutMs = 15_000) {
|
|
119
|
+
const existing = await connectRadioSession();
|
|
120
|
+
if (existing)
|
|
121
|
+
return existing;
|
|
122
|
+
const lockPath = `${radioSessionPath()}.launch`;
|
|
123
|
+
let ownsLaunch = false;
|
|
124
|
+
try {
|
|
125
|
+
acquireOwner(lockPath);
|
|
126
|
+
ownsLaunch = true;
|
|
127
|
+
const appeared = await connectRadioSession();
|
|
128
|
+
if (appeared)
|
|
129
|
+
return appeared;
|
|
130
|
+
await start();
|
|
131
|
+
return await waitForSession(timeoutMs);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (ownsLaunch || !(error instanceof Error) || !error.message.includes('already active'))
|
|
135
|
+
throw error;
|
|
136
|
+
return waitForSession(timeoutMs);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
if (ownsLaunch)
|
|
140
|
+
removeOwner(lockPath, process.pid);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function radioSessionPath() {
|
|
144
|
+
return join(runtimeDirectory(), 'agent-session.json');
|
|
145
|
+
}
|
|
146
|
+
async function waitForSession(timeoutMs) {
|
|
147
|
+
const deadline = Date.now() + timeoutMs;
|
|
148
|
+
while (Date.now() < deadline) {
|
|
149
|
+
const client = await connectRadioSession();
|
|
150
|
+
if (client)
|
|
151
|
+
return client;
|
|
152
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
153
|
+
}
|
|
154
|
+
throw new Error('RadioCLI was launched but its control session did not become ready.');
|
|
155
|
+
}
|
|
156
|
+
function runtimeDirectory() {
|
|
157
|
+
if (process.env.RADIOCLI_HOME)
|
|
158
|
+
return join(process.env.RADIOCLI_HOME, 'runtime');
|
|
159
|
+
if (process.platform === 'win32')
|
|
160
|
+
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime');
|
|
161
|
+
return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli');
|
|
162
|
+
}
|
|
163
|
+
function acquireOwner(path) {
|
|
164
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
165
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
166
|
+
try {
|
|
167
|
+
const fd = openSync(path, 'wx', 0o600);
|
|
168
|
+
writeFileSync(fd, `${process.pid}\n`);
|
|
169
|
+
closeSync(fd);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error.code !== 'EEXIST')
|
|
174
|
+
throw error;
|
|
175
|
+
const pid = Number(readFileSync(path, 'utf8').trim());
|
|
176
|
+
if (processAlive(pid))
|
|
177
|
+
throw new Error('Another RadioCLI playback session is already active.');
|
|
178
|
+
rmSync(path, { force: true });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
throw new Error('Could not claim the RadioCLI playback session.');
|
|
182
|
+
}
|
|
183
|
+
function removeOwner(path, pid) {
|
|
184
|
+
try {
|
|
185
|
+
if (Number(readFileSync(path, 'utf8').trim()) === pid)
|
|
186
|
+
rmSync(path, { force: true });
|
|
187
|
+
}
|
|
188
|
+
catch { }
|
|
189
|
+
}
|
|
190
|
+
function writePrivateJson(path, value) {
|
|
191
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
192
|
+
const temp = `${path}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
193
|
+
writeFileSync(temp, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
194
|
+
renameSync(temp, path);
|
|
195
|
+
if (process.platform !== 'win32')
|
|
196
|
+
chmodSync(path, 0o600);
|
|
197
|
+
}
|
|
198
|
+
function removeIfOwned(path, owner) {
|
|
199
|
+
try {
|
|
200
|
+
const current = JSON.parse(readFileSync(path, 'utf8'));
|
|
201
|
+
if (current.pid === owner.pid && current.token === owner.token && current.port === owner.port)
|
|
202
|
+
rmSync(path, { force: true });
|
|
203
|
+
}
|
|
204
|
+
catch { }
|
|
205
|
+
}
|
|
206
|
+
function processAlive(pid) {
|
|
207
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
208
|
+
return false;
|
|
209
|
+
try {
|
|
210
|
+
process.kill(pid, 0);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
return error.code === 'EPERM';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function discoveryAgeMs(path, discovery) {
|
|
218
|
+
const created = Date.parse(discovery.createdAt);
|
|
219
|
+
if (Number.isFinite(created))
|
|
220
|
+
return Math.max(0, Date.now() - created);
|
|
221
|
+
try {
|
|
222
|
+
return Math.max(0, Date.now() - statSync(path).mtimeMs);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function readBody(req) {
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
let body = '';
|
|
231
|
+
req.on('data', value => {
|
|
232
|
+
body += String(value);
|
|
233
|
+
if (body.length > 1_000_000)
|
|
234
|
+
req.destroy(new Error('Request too large.'));
|
|
235
|
+
});
|
|
236
|
+
req.on('end', () => resolve(body));
|
|
237
|
+
req.on('error', reject);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function parseError(text) {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(text);
|
|
243
|
+
return typeof parsed.error === 'string' ? parsed.error : 'RadioCLI control request failed.';
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return 'RadioCLI control request failed.';
|
|
247
|
+
}
|
|
248
|
+
}
|