@ciphore/radiocli 0.2.2 → 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 +47 -1
- package/README.md +30 -0
- 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/cli.js +4 -1
- package/dist/alarms/runner.js +43 -26
- package/dist/cli.js +60 -3
- package/dist/player/player-controller.js +19 -0
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +4 -0
- package/dist/setup.js +71 -2
- package/dist/storage/store.js +33 -1
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +24 -9
- package/dist/ui/App.js +299 -18
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/components/StationList.js +3 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/page-footer.js +4 -2
- package/dist/ui/screen-items.js +40 -9
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/use-alarm-tui.js +4 -0
- package/dist/ui/use-app-input.js +29 -3
- 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 +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- 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 +4 -1
|
@@ -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
|
+
}
|
package/dist/alarms/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { AlarmPowerGuardStore } from './power-guard-store.js';
|
|
|
14
14
|
import { openAlarmControls } from './terminal-launcher.js';
|
|
15
15
|
import { hasLiveTui } from './tui-presence.js';
|
|
16
16
|
import { createSystemVolumeController } from './system-volume.js';
|
|
17
|
+
import { connectRadioSession } from '../agent/session.js';
|
|
17
18
|
export async function runAlarmCommand(args, dependencies = {}) {
|
|
18
19
|
const store = dependencies.store ?? new JsonLibraryStore();
|
|
19
20
|
const scheduler = dependencies.scheduler ?? createSchedulerService();
|
|
@@ -173,7 +174,9 @@ export async function runAlarmCommand(args, dependencies = {}) {
|
|
|
173
174
|
}
|
|
174
175
|
throw new Error(alarmUsage());
|
|
175
176
|
}
|
|
176
|
-
function createRuntimeDeps(store, scheduler, alarmId) { const providers = new ProviderManager(); const alarm = store.getAlarm(alarmId); const detected = detectPlaybackBackends(); const settings = alarm ? alarmRuntimeSettings(store.snapshot().settings, alarm, detected) : { ...store.snapshot().settings, preferredBackend: 'auto', preferredAirPlayDevice: undefined }; const player = new PlayerController(() => settings); player.refreshDetectedBackends(); const cliPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js'); return { now: () => new Date(), store, providers, player, scheduler, inhibitor: createPowerInhibitor(), systemVolume: createSystemVolumeController(),
|
|
177
|
+
function createRuntimeDeps(store, scheduler, alarmId) { const providers = new ProviderManager(); const alarm = store.getAlarm(alarmId); const detected = detectPlaybackBackends(); const settings = alarm ? alarmRuntimeSettings(store.snapshot().settings, alarm, detected) : { ...store.snapshot().settings, preferredBackend: 'auto', preferredAirPlayDevice: undefined }; const player = new PlayerController(() => settings); player.refreshDetectedBackends(); const cliPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js'); return { now: () => new Date(), store, providers, player, scheduler, inhibitor: createPowerInhibitor(), systemVolume: createSystemVolumeController(), preemptInteractivePlayback: async () => { const client = await connectRadioSession(); if (!client)
|
|
178
|
+
return; const result = await client.call({ type: 'alarm-preempt' }); if (!result.ok)
|
|
179
|
+
throw new Error(result.message); }, acquireLock: acquireOccurrenceLock, createSession: defaultRunnerUtilities.createSession, openControls: () => openAlarmControls({ nodePath: process.execPath, cliPath, hasLiveTui }), wait: defaultRunnerUtilities.wait, subscribeSignals: defaultRunnerUtilities.subscribeSignals, health: scheduler.health }; }
|
|
177
180
|
export function alarmRuntimeSettings(settings, alarm, backends) { const mpvFade = alarm.playback.fadeSeconds > 0 && backends.includes('mpv'); return { ...settings, volume: mpvFade ? 0 : alarm.playback.volume, preferredBackend: mpvFade ? 'mpv' : 'auto', preferredAirPlayDevice: undefined }; }
|
|
178
181
|
async function testAlarm(alarm, store) {
|
|
179
182
|
const scheduledAt = new Date(Math.floor(Date.now() / 60_000) * 60_000);
|
package/dist/alarms/runner.js
CHANGED
|
@@ -35,6 +35,10 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
35
35
|
const earlySignal = new Promise(resolve => { resolveEarlySignal = resolve; });
|
|
36
36
|
const unsubscribeSignals = deps.subscribeSignals?.(() => { signalReceived = true; resolveEarlySignal(); onPlaybackSignal(); void deps.player.stop().catch(() => undefined); });
|
|
37
37
|
const finish = (status, message) => ({ status, scheduledAt: scheduledAt.toISOString(), ...(firedAt ? { firedAt: firedAt.toISOString() } : {}), finishedAt: deps.now().toISOString(), ...(message ? { message } : {}) });
|
|
38
|
+
let action;
|
|
39
|
+
let keepPlaying = false;
|
|
40
|
+
let resolveAction = () => { };
|
|
41
|
+
const actionPromise = new Promise(resolve => { resolveAction = resolve; });
|
|
38
42
|
try {
|
|
39
43
|
if (!alarm || !alarm.enabled)
|
|
40
44
|
return { message: 'Alarm is missing or disabled.' };
|
|
@@ -53,6 +57,22 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
53
57
|
outcome = finish('missed', 'Alarm was outside its missed-run grace window.');
|
|
54
58
|
return { status: outcome.status, message: outcome.message };
|
|
55
59
|
}
|
|
60
|
+
const claimingStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: alarm.station.name, station: alarm.station, startedAt: deps.now().toISOString(), state: 'starting' };
|
|
61
|
+
const creatingSession = deps.createSession(claimingStatus, {
|
|
62
|
+
onDismiss: () => { action = 'dismissed'; resolveAction('dismissed'); resolveEarlySignal(); },
|
|
63
|
+
onSnooze: minutes => { deps.store.snoozeAlarm(alarmId, new Date(deps.now().getTime() + minutes * 60_000)); preserveNextOverride = true; action = 'snoozed'; resolveAction('snoozed'); resolveEarlySignal(); },
|
|
64
|
+
onKeepPlaying: () => { keepPlaying = true; session?.update({ keepPlaying: true }); },
|
|
65
|
+
onHandoff: () => { if (!listening)
|
|
66
|
+
throw new Error('Alarm playback is still starting.'); preserveSystemVolume = true; action = 'handoff'; resolveAction('handoff'); }
|
|
67
|
+
});
|
|
68
|
+
void creatingSession.then(created => { if (signalReceived)
|
|
69
|
+
void created.close().catch(() => undefined); }).catch(() => { });
|
|
70
|
+
const created = await Promise.race([creatingSession.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
|
|
71
|
+
if ('signal' in created) {
|
|
72
|
+
outcome = finish('dismissed', 'Alarm interrupted while local controls were starting.');
|
|
73
|
+
return { status: 'dismissed', message: outcome.message };
|
|
74
|
+
}
|
|
75
|
+
session = created.value;
|
|
56
76
|
const claimed = deps.store.getAlarm(alarmId);
|
|
57
77
|
if (claimed?.enabled && claimed.schedule.type === 'recurring') {
|
|
58
78
|
if (deps.scheduler.syncClaimed)
|
|
@@ -60,16 +80,10 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
60
80
|
else
|
|
61
81
|
await deps.scheduler.sync(claimed);
|
|
62
82
|
}
|
|
63
|
-
if (deps.systemVolume)
|
|
64
|
-
try {
|
|
65
|
-
systemVolumeLease = await deps.systemVolume.acquireMinimum(alarm.playback.volume);
|
|
66
|
-
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: true, active: true, message: systemVolumeLease.message });
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: false, message: `Alarm will use player volume, but system output could not be raised: ${errorMessage(error)}` });
|
|
70
|
-
}
|
|
71
83
|
let resolvedStation = alarm.station;
|
|
72
84
|
let lastError;
|
|
85
|
+
let interactivePreempted = false;
|
|
86
|
+
let outputPrepared = false;
|
|
73
87
|
const candidates = [alarm.station, alarm.station, alarm.playback.fallbackStation].filter((item) => Boolean(item));
|
|
74
88
|
for (const [candidateIndex, candidate] of candidates.entries()) {
|
|
75
89
|
try {
|
|
@@ -78,6 +92,26 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
78
92
|
outcome = finish('dismissed', 'Alarm interrupted before playback started.');
|
|
79
93
|
return { status: 'dismissed', message: outcome.message };
|
|
80
94
|
}
|
|
95
|
+
if (!interactivePreempted) {
|
|
96
|
+
interactivePreempted = true;
|
|
97
|
+
try {
|
|
98
|
+
await deps.preemptInteractivePlayback?.();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: true, message: `Alarm continued after interactive playback could not be stopped cleanly: ${errorMessage(error)}` });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!outputPrepared) {
|
|
105
|
+
outputPrepared = true;
|
|
106
|
+
if (deps.systemVolume)
|
|
107
|
+
try {
|
|
108
|
+
systemVolumeLease = await deps.systemVolume.acquireMinimum(alarm.playback.volume);
|
|
109
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: true, active: true, message: systemVolumeLease.message });
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: false, message: `Alarm will use player volume, but system output could not be raised: ${errorMessage(error)}` });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
81
115
|
const playPromise = deps.player.play(candidate, resolved.stream.url);
|
|
82
116
|
const tuned = await Promise.race([playPromise.then(() => ({ played: true })), earlySignal.then(() => ({ signal: true }))]);
|
|
83
117
|
if ('signal' in tuned || signalReceived) {
|
|
@@ -129,25 +163,8 @@ export async function runAlarm(alarmId, scheduledAtText, deps) {
|
|
|
129
163
|
deps.store.addRecent(resolvedStation);
|
|
130
164
|
deps.store.startListeningSession(resolvedStation, startedAt);
|
|
131
165
|
listening = true;
|
|
132
|
-
let action;
|
|
133
|
-
let keepPlaying = false;
|
|
134
|
-
let resolveAction = () => { };
|
|
135
|
-
const actionPromise = new Promise(resolve => { resolveAction = resolve; });
|
|
136
166
|
const activeStatus = { alarmId, scheduledAt: scheduledAt.toISOString(), stationName: resolvedStation.name, station: resolvedStation, startedAt: startedAt.toISOString(), state: 'playing' };
|
|
137
|
-
|
|
138
|
-
onDismiss: () => { action = 'dismissed'; resolveAction('dismissed'); },
|
|
139
|
-
onSnooze: minutes => { deps.store.snoozeAlarm(alarmId, new Date(deps.now().getTime() + minutes * 60_000)); preserveNextOverride = true; action = 'snoozed'; resolveAction('snoozed'); },
|
|
140
|
-
onKeepPlaying: () => { keepPlaying = true; session?.update({ keepPlaying: true }); },
|
|
141
|
-
onHandoff: () => { preserveSystemVolume = true; action = 'handoff'; resolveAction('handoff'); }
|
|
142
|
-
});
|
|
143
|
-
void creatingSession.then(created => { if (signalReceived)
|
|
144
|
-
void created.close().catch(() => undefined); }).catch(() => { });
|
|
145
|
-
const created = await Promise.race([creatingSession.then(value => ({ value })), earlySignal.then(() => ({ signal: true }))]);
|
|
146
|
-
if ('signal' in created) {
|
|
147
|
-
outcome = finish('dismissed', 'Alarm interrupted while local controls were starting.');
|
|
148
|
-
return { status: 'dismissed', message: outcome.message };
|
|
149
|
-
}
|
|
150
|
-
session = created.value;
|
|
167
|
+
session.update(activeStatus);
|
|
151
168
|
void deps.openControls?.(activeStatus).catch(error => { try {
|
|
152
169
|
deps.health?.record({ alarmId, occurrenceAt: scheduledAt.toISOString(), component: 'runner', healthy: false, active: true, message: `Alarm is playing, but RadioCLI controls could not open automatically: ${errorMessage(error)}` });
|
|
153
170
|
}
|
package/dist/cli.js
CHANGED
|
@@ -12,12 +12,31 @@ import { resolveCommand } from './player/command.js';
|
|
|
12
12
|
import { diagnoseCommand } from './player/command-diagnostics.js';
|
|
13
13
|
import { airPlaySenderHealth } from './player/airplay-sender-health.js';
|
|
14
14
|
import { appVersion } from './version.js';
|
|
15
|
-
import { checkForUpdate, updateCommandForInstall } from './update-check.js';
|
|
15
|
+
import { checkForUpdate, installUpdate, updateCommandForInstall } from './update-check.js';
|
|
16
16
|
import { runAlarmCommand } from './alarms/cli.js';
|
|
17
17
|
import { runSetup } from './setup.js';
|
|
18
|
+
import { runAgentCliCommand, runMcpCommand } from './agent/cli.js';
|
|
19
|
+
import { decodeAgentCommand } from './agent/service.js';
|
|
20
|
+
import { runHeadlessAgentHost } from './agent/headless-host.js';
|
|
21
|
+
import { configureMcpIntegrations } from './agent/mcp-install.js';
|
|
22
|
+
import { defaultAgentControlSettings } from './types.js';
|
|
23
|
+
const runtime = { nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url) };
|
|
18
24
|
if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
19
25
|
const args = process.argv.slice(2);
|
|
20
|
-
if (args
|
|
26
|
+
if (args[0] === 'agent-ui') {
|
|
27
|
+
const encoded = args[1];
|
|
28
|
+
if (!encoded)
|
|
29
|
+
throw new Error('Missing RadioCLI agent startup request.');
|
|
30
|
+
const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
|
|
31
|
+
render(_jsx(App, { initialAgentCommand: decodeAgentCommand(encoded) }), {
|
|
32
|
+
exitOnCtrlC: false,
|
|
33
|
+
kittyKeyboard: { mode: 'auto', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
else if (args[0] === 'agent-host') {
|
|
37
|
+
await runHeadlessAgentHost();
|
|
38
|
+
}
|
|
39
|
+
else if (args.length > 0) {
|
|
21
40
|
await runCommand(args).catch(error => {
|
|
22
41
|
console.error(error instanceof Error ? error.message : String(error));
|
|
23
42
|
process.exitCode = 1;
|
|
@@ -49,6 +68,14 @@ export async function runCommand(args) {
|
|
|
49
68
|
await runAlarmCommand(rest);
|
|
50
69
|
return;
|
|
51
70
|
}
|
|
71
|
+
if (command === 'mcp') {
|
|
72
|
+
await runMcpCommand(rest, runtime);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (command === 'agent') {
|
|
76
|
+
await runAgentCliCommand(rest, runtime);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
52
79
|
if (command === 'setup') {
|
|
53
80
|
if (rest.includes('--help') || rest.includes('-h')) {
|
|
54
81
|
printSetupHelp();
|
|
@@ -73,6 +100,9 @@ export async function runCommand(args) {
|
|
|
73
100
|
return;
|
|
74
101
|
}
|
|
75
102
|
if (command === 'update') {
|
|
103
|
+
const unknown = rest.filter(arg => arg !== '--install');
|
|
104
|
+
if (unknown.length > 0)
|
|
105
|
+
throw new Error('Usage: radiocli update [--install]');
|
|
76
106
|
const updateCheck = await checkForUpdate();
|
|
77
107
|
const updateCommand = updateCommandForInstall();
|
|
78
108
|
if (updateCheck.error) {
|
|
@@ -84,6 +114,26 @@ export async function runCommand(args) {
|
|
|
84
114
|
console.log(`available=${updateCheck.updateAvailable ? 'yes' : 'no'}`);
|
|
85
115
|
}
|
|
86
116
|
console.log(`command=${updateCommand.command}`);
|
|
117
|
+
if (rest.includes('--install')) {
|
|
118
|
+
const result = await installUpdate(updateCommand.command);
|
|
119
|
+
if (!result.ok)
|
|
120
|
+
throw new Error(`Update install failed. Run manually: ${result.command}${result.output ? `\n${result.output}` : ''}`);
|
|
121
|
+
console.log('updated=yes');
|
|
122
|
+
const agentControl = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings;
|
|
123
|
+
if (agentControl.enabled) {
|
|
124
|
+
try {
|
|
125
|
+
const repaired = await configureMcpIntegrations(true, runtime);
|
|
126
|
+
const failed = repaired.filter(item => item.status === 'failed');
|
|
127
|
+
console.log(`mcp_repaired=${failed.length ? 'partial' : 'yes'}`);
|
|
128
|
+
if (failed.length)
|
|
129
|
+
console.log(`mcp_failures=${failed.map(item => `${item.client}: ${item.detail}`).join('; ')}`);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
console.log(`mcp_repaired=failed ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
console.log('restart_required=yes');
|
|
136
|
+
}
|
|
87
137
|
return;
|
|
88
138
|
}
|
|
89
139
|
if (command === 'check') {
|
|
@@ -161,7 +211,10 @@ Usage:
|
|
|
161
211
|
radiocli check Show provider/backend health
|
|
162
212
|
radiocli doctor [--json] Show local playback setup guidance
|
|
163
213
|
radiocli setup Install and verify native playback tools
|
|
214
|
+
radiocli mcp <command> Install, inspect, or run the MCP integration
|
|
215
|
+
radiocli agent <command> Scriptable radio controls for local agents
|
|
164
216
|
radiocli update Show update availability and install command
|
|
217
|
+
radiocli update --install Install the latest release and repair enabled MCP entries
|
|
165
218
|
radiocli countries Print top countries
|
|
166
219
|
radiocli search <query> Search public stations
|
|
167
220
|
radiocli import <file> Import .m3u, .pls, or .xspf streams
|
|
@@ -182,7 +235,7 @@ export function isDirectRun(entryPath, moduleUrl) {
|
|
|
182
235
|
}
|
|
183
236
|
}
|
|
184
237
|
function isKnownCommand(command) {
|
|
185
|
-
return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'add-url', 'alarm'].includes(command);
|
|
238
|
+
return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'add-url', 'alarm', 'mcp', 'agent'].includes(command);
|
|
186
239
|
}
|
|
187
240
|
function printSetupHelp() {
|
|
188
241
|
console.log(`RadioCLI Setup
|
|
@@ -193,6 +246,10 @@ Usage:
|
|
|
193
246
|
radiocli setup --all --yes Install mpv, FFmpeg, and VLC
|
|
194
247
|
radiocli setup --only mpv,ffmpeg Select specific components
|
|
195
248
|
radiocli setup --dry-run Show commands without installing
|
|
249
|
+
radiocli setup --mcp Enable and configure agent MCP clients
|
|
250
|
+
radiocli setup --mcp --agent-ui Open a terminal TUI for agent playback (default)
|
|
251
|
+
radiocli setup --mcp --headless-agent Opt out of external terminal windows
|
|
252
|
+
radiocli setup --no-mcp Disable and remove agent MCP entries
|
|
196
253
|
radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
|
|
197
254
|
apt, dnf, pacman, apk, or zypper
|
|
198
255
|
`);
|
|
@@ -230,6 +230,20 @@ export class PlayerController {
|
|
|
230
230
|
});
|
|
231
231
|
return { ok: true };
|
|
232
232
|
}
|
|
233
|
+
async pause() {
|
|
234
|
+
if (this.state.state === 'paused')
|
|
235
|
+
return { ok: true };
|
|
236
|
+
if (this.state.state !== 'playing')
|
|
237
|
+
return { ok: false, message: 'RadioCLI is not currently playing.' };
|
|
238
|
+
return this.togglePause();
|
|
239
|
+
}
|
|
240
|
+
async resume() {
|
|
241
|
+
if (this.state.state === 'playing')
|
|
242
|
+
return { ok: true };
|
|
243
|
+
if (this.state.state !== 'paused')
|
|
244
|
+
return { ok: false, message: 'RadioCLI has no paused station to resume.' };
|
|
245
|
+
return this.togglePause();
|
|
246
|
+
}
|
|
233
247
|
setVolume(volume) {
|
|
234
248
|
const clamped = clampVolume(volume);
|
|
235
249
|
const unsupported = this.unsupportedFfplayControl();
|
|
@@ -272,6 +286,11 @@ export class PlayerController {
|
|
|
272
286
|
this.setState({ ...this.state, muted });
|
|
273
287
|
return { ok: true };
|
|
274
288
|
}
|
|
289
|
+
async setMuted(muted) {
|
|
290
|
+
if (this.state.muted === muted)
|
|
291
|
+
return { ok: true };
|
|
292
|
+
return this.toggleMute();
|
|
293
|
+
}
|
|
275
294
|
stop() {
|
|
276
295
|
if (this.stopPromise)
|
|
277
296
|
return this.stopPromise;
|
|
@@ -16,6 +16,11 @@ export class ProviderManager {
|
|
|
16
16
|
byCountry(countryCode, limit, offset) {
|
|
17
17
|
return this.radioBrowser.byCountry(countryCode, limit, offset);
|
|
18
18
|
}
|
|
19
|
+
byId(provider, id) {
|
|
20
|
+
if (provider !== 'radio-browser')
|
|
21
|
+
return Promise.resolve(null);
|
|
22
|
+
return this.radioBrowser.byId(id);
|
|
23
|
+
}
|
|
19
24
|
nearby(location, limit) {
|
|
20
25
|
return this.radioBrowser.nearby(location, limit);
|
|
21
26
|
}
|
|
@@ -115,6 +115,10 @@ export class RadioBrowserProvider {
|
|
|
115
115
|
}, { maxAgeMs: 30 * 60 * 1000 });
|
|
116
116
|
return this.normalizeStations(rows);
|
|
117
117
|
}
|
|
118
|
+
async byId(id) {
|
|
119
|
+
const rows = await this.request(`/json/stations/byuuid/${encodeURIComponent(id)}`, {}, { maxAgeMs: 10 * 60 * 1000 });
|
|
120
|
+
return this.normalizeStations(rows)[0] ?? null;
|
|
121
|
+
}
|
|
118
122
|
async search(query, options = {}) {
|
|
119
123
|
const trimmed = query.trim();
|
|
120
124
|
if (!trimmed) {
|