@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +133 -1
  2. package/README.md +76 -6
  3. package/dist/agent/alarm-service.js +210 -0
  4. package/dist/agent/cli.js +193 -0
  5. package/dist/agent/headless-host.js +143 -0
  6. package/dist/agent/launcher.js +71 -0
  7. package/dist/agent/mcp-install.js +467 -0
  8. package/dist/agent/mcp-server.js +139 -0
  9. package/dist/agent/service.js +347 -0
  10. package/dist/agent/session.js +248 -0
  11. package/dist/alarms/active-session.js +183 -0
  12. package/dist/alarms/cli.js +312 -0
  13. package/dist/alarms/guard.js +343 -0
  14. package/dist/alarms/inhibitor.js +48 -0
  15. package/dist/alarms/power-guard-store.js +169 -0
  16. package/dist/alarms/runner.js +342 -0
  17. package/dist/alarms/runtime-health.js +79 -0
  18. package/dist/alarms/schedule.js +149 -0
  19. package/dist/alarms/scheduler.js +250 -0
  20. package/dist/alarms/setup-verification.js +187 -0
  21. package/dist/alarms/system-volume.js +43 -0
  22. package/dist/alarms/terminal-launcher.js +181 -0
  23. package/dist/alarms/tui-presence.js +38 -0
  24. package/dist/cli.js +113 -5
  25. package/dist/player/backend-install.js +2 -1
  26. package/dist/player/command-diagnostics.js +27 -0
  27. package/dist/player/command.js +123 -62
  28. package/dist/player/player-controller.js +32 -2
  29. package/dist/providers/provider-manager.js +5 -0
  30. package/dist/providers/radio-browser.js +36 -6
  31. package/dist/setup.js +462 -0
  32. package/dist/storage/store.js +262 -2
  33. package/dist/types.js +6 -0
  34. package/dist/ui/AdaptiveContent.js +111 -26
  35. package/dist/ui/App.js +401 -67
  36. package/dist/ui/AppContent.js +24 -7
  37. package/dist/ui/adaptive-explore-layout.js +47 -0
  38. package/dist/ui/alarm-editor.js +174 -0
  39. package/dist/ui/alarm-tui-service.js +84 -0
  40. package/dist/ui/app-state.js +3 -0
  41. package/dist/ui/ascii.js +8 -0
  42. package/dist/ui/components/AdaptiveMarquee.js +28 -0
  43. package/dist/ui/components/StationList.js +10 -5
  44. package/dist/ui/components/VersionIndicator.js +19 -0
  45. package/dist/ui/cosmo-world-map.js +5 -2
  46. package/dist/ui/explore-map-layout.js +18 -6
  47. package/dist/ui/format.js +21 -0
  48. package/dist/ui/help-content.js +14 -2
  49. package/dist/ui/layout.js +1 -1
  50. package/dist/ui/page-footer.js +120 -2
  51. package/dist/ui/receiver-animation.js +68 -0
  52. package/dist/ui/screen-items.js +41 -9
  53. package/dist/ui/screen-meta.js +4 -0
  54. package/dist/ui/screens/AlarmsScreen.js +202 -0
  55. package/dist/ui/screens/CountriesScreen.js +8 -5
  56. package/dist/ui/screens/ExploreScreen.js +8 -3
  57. package/dist/ui/screens/HomeScreen.js +3 -1
  58. package/dist/ui/screens/NowPlayingScreen.js +6 -2
  59. package/dist/ui/screens/SettingsScreen.js +70 -53
  60. package/dist/ui/screens/StationScreen.js +3 -2
  61. package/dist/ui/selection-state.js +10 -0
  62. package/dist/ui/terminal-mouse.js +18 -3
  63. package/dist/ui/use-alarm-tui.js +727 -0
  64. package/dist/ui/use-app-input.js +107 -46
  65. package/dist/ui/visualizers/gallop.js +118 -0
  66. package/dist/ui/visualizers/horse-stride.js +20 -0
  67. package/dist/ui/visualizers/receiver-style-registry.js +14 -7
  68. package/dist/ui/visualizers/receiver-visualizers.js +233 -128
  69. package/dist/ui/visualizers/retro-receivers.js +4 -0
  70. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  71. package/dist/update-check.js +26 -7
  72. package/package.json +6 -1
@@ -0,0 +1,183 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, 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 startActiveAlarmSession(initial, handlers) {
7
+ const filePath = handlers.filePath ?? defaultActiveAlarmPath(initial.alarmId, initial.scheduledAt);
8
+ const token = randomBytes(32).toString('hex');
9
+ let status = { ...initial };
10
+ let terminalStarted = false;
11
+ const server = createServer(async (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
+ try {
19
+ if (req.method === 'GET' && req.url === '/status') {
20
+ res.end(JSON.stringify(status));
21
+ return;
22
+ }
23
+ if (req.method === 'POST' && req.url === '/dismiss') {
24
+ if (terminalStarted)
25
+ throw new Error('A terminal alarm action is already in progress.');
26
+ terminalStarted = true;
27
+ try {
28
+ await handlers.onDismiss();
29
+ }
30
+ catch (error) {
31
+ terminalStarted = false;
32
+ throw error;
33
+ }
34
+ res.end('{}');
35
+ return;
36
+ }
37
+ if (req.method === 'POST' && req.url === '/handoff') {
38
+ if (terminalStarted)
39
+ throw new Error('A terminal alarm action is already in progress.');
40
+ if (!handlers.onHandoff)
41
+ throw new Error('Interactive playback handoff is unavailable for this alarm session.');
42
+ terminalStarted = true;
43
+ try {
44
+ await handlers.onHandoff();
45
+ }
46
+ catch (error) {
47
+ terminalStarted = false;
48
+ throw error;
49
+ }
50
+ res.end('{}');
51
+ return;
52
+ }
53
+ if (req.method === 'POST' && req.url === '/keep-playing') {
54
+ if (terminalStarted)
55
+ throw new Error('The alarm is already stopping.');
56
+ await handlers.onKeepPlaying();
57
+ res.end('{}');
58
+ return;
59
+ }
60
+ if (req.method === 'POST' && req.url === '/snooze') {
61
+ if (terminalStarted)
62
+ throw new Error('A terminal alarm action is already in progress.');
63
+ const body = await readBody(req);
64
+ const minutes = Number(JSON.parse(body).minutes);
65
+ if (!Number.isInteger(minutes) || minutes < 1 || minutes > 1440)
66
+ throw new Error('Snooze must be 1–1440 minutes.');
67
+ if (terminalStarted)
68
+ throw new Error('A terminal alarm action is already in progress.');
69
+ terminalStarted = true;
70
+ try {
71
+ await handlers.onSnooze(minutes);
72
+ }
73
+ catch (error) {
74
+ terminalStarted = false;
75
+ throw error;
76
+ }
77
+ res.end('{}');
78
+ return;
79
+ }
80
+ res.statusCode = 404;
81
+ res.end('{}');
82
+ }
83
+ catch (error) {
84
+ res.statusCode = 400;
85
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'request failed' }));
86
+ }
87
+ });
88
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => resolve()); });
89
+ const address = server.address();
90
+ if (!address || typeof address === 'string')
91
+ throw new Error('Unable to create alarm control endpoint.');
92
+ const discovery = { version: 1, host: '127.0.0.1', port: address.port, token, pid: process.pid, alarmId: initial.alarmId, createdAt: new Date().toISOString() };
93
+ const temp = `${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
94
+ try {
95
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
96
+ writeFileSync(temp, `${JSON.stringify(discovery)}\n`, { mode: 0o600 });
97
+ renameSync(temp, filePath);
98
+ if (process.platform !== 'win32')
99
+ chmodSync(filePath, 0o600);
100
+ }
101
+ catch (error) {
102
+ rmSync(temp, { force: true });
103
+ removeDiscoveryIfOwned(filePath, discovery);
104
+ await new Promise(resolve => server.close(() => resolve()));
105
+ throw error;
106
+ }
107
+ return { update(change) { status = { ...status, ...change }; }, async close() { await new Promise(resolve => server.close(() => resolve())); removeDiscoveryIfOwned(filePath, discovery); } };
108
+ }
109
+ export async function connectActiveAlarm(filePath) {
110
+ if (!filePath) {
111
+ const clients = await connectActiveAlarms();
112
+ return clients[0] ?? null;
113
+ }
114
+ if (!existsSync(filePath))
115
+ return null;
116
+ let discovery;
117
+ try {
118
+ discovery = JSON.parse(readFileSync(filePath, 'utf8'));
119
+ if (discovery.version !== 1 || discovery.host !== '127.0.0.1' || !Number.isInteger(discovery.port) || !discovery.token)
120
+ throw new Error('invalid');
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ const call = async (method, path, body) => { const payload = body === undefined ? '' : JSON.stringify(body); return new Promise((resolve, reject) => { const req = request({ host: '127.0.0.1', port: discovery.port, path, method, headers: { authorization: `Bearer ${discovery.token}`, 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } }, res => { let text = ''; res.on('data', value => text += String(value)); res.on('end', () => res.statusCode && res.statusCode < 300 ? resolve(text ? JSON.parse(text) : {}) : reject(new Error('Alarm control request failed.'))); }); req.once('error', reject); req.setTimeout(1000, () => req.destroy(new Error('Alarm control request timed out.'))); req.end(payload); }); };
126
+ try {
127
+ await call('GET', '/status');
128
+ }
129
+ catch {
130
+ if (!processAlive(discovery.pid) || discoveryAgeMs(filePath, discovery) > 5 * 60_000)
131
+ removeDiscoveryIfOwned(filePath, discovery);
132
+ return null;
133
+ }
134
+ return { status: async () => await call('GET', '/status'), dismiss: async () => { await call('POST', '/dismiss'); }, snooze: async (minutes) => { await call('POST', '/snooze', { minutes }); }, keepPlaying: async () => { await call('POST', '/keep-playing'); }, handoff: async () => { await call('POST', '/handoff'); } };
135
+ }
136
+ export async function connectActiveAlarms(directory = defaultActiveAlarmDirectory()) { if (!existsSync(directory))
137
+ return []; const paths = readdirSync(directory).filter(name => name.endsWith('.json')).map(name => join(directory, name)); const clients = await Promise.all(paths.map(path => connectActiveAlarm(path))); return clients.filter((client) => Boolean(client)); }
138
+ function defaultActiveAlarmDirectory() { if (process.env.RADIOCLI_HOME)
139
+ return join(process.env.RADIOCLI_HOME, 'runtime', 'active-alarms'); if (process.platform === 'darwin')
140
+ return join(homedir(), 'Library', 'Application Support', 'radiocli', 'runtime', 'active-alarms'); if (process.platform === 'win32')
141
+ return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'runtime', 'active-alarms'); return join(process.env.XDG_RUNTIME_DIR ?? join(homedir(), '.local', 'state'), 'radiocli', 'active-alarms'); }
142
+ function defaultActiveAlarmPath(alarmId = 'active', occurrenceAt = 'current') { return join(defaultActiveAlarmDirectory(), `${Buffer.from(`${alarmId}\0${occurrenceAt}`).toString('base64url')}.json`); }
143
+ function readBody(req) { return new Promise((resolve, reject) => { let body = ''; req.on('data', value => { body += String(value); if (body.length > 4096)
144
+ req.destroy(new Error('Request too large.')); }); req.on('end', () => resolve(body)); req.on('error', reject); }); }
145
+ function removeDiscoveryIfOwned(filePath, owner) { let current; try {
146
+ current = JSON.parse(readFileSync(filePath, 'utf8'));
147
+ }
148
+ catch {
149
+ return;
150
+ } if (!sameDiscovery(current, owner))
151
+ return; const quarantine = `${filePath}.closing-${process.pid}-${randomBytes(6).toString('hex')}`; try {
152
+ renameSync(filePath, quarantine);
153
+ const moved = JSON.parse(readFileSync(quarantine, 'utf8'));
154
+ if (sameDiscovery(moved, owner)) {
155
+ rmSync(quarantine, { force: true });
156
+ return;
157
+ }
158
+ if (!existsSync(filePath))
159
+ renameSync(quarantine, filePath);
160
+ }
161
+ catch {
162
+ if (existsSync(quarantine) && !existsSync(filePath))
163
+ try {
164
+ renameSync(quarantine, filePath);
165
+ }
166
+ catch { }
167
+ } }
168
+ function sameDiscovery(left, right) { return left.version === right.version && left.host === right.host && left.port === right.port && left.token === right.token && left.pid === right.pid && left.alarmId === right.alarmId; }
169
+ function processAlive(pid) { if (!Number.isInteger(pid) || pid <= 0)
170
+ return false; try {
171
+ process.kill(pid, 0);
172
+ return true;
173
+ }
174
+ catch (error) {
175
+ return error.code === 'EPERM';
176
+ } }
177
+ function discoveryAgeMs(filePath, discovery) { const created = discovery.createdAt ? Date.parse(discovery.createdAt) : Number.NaN; if (Number.isFinite(created))
178
+ return Math.max(0, Date.now() - created); try {
179
+ return Math.max(0, Date.now() - statSync(filePath).mtimeMs);
180
+ }
181
+ catch {
182
+ return 0;
183
+ } }
@@ -0,0 +1,312 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { JsonLibraryStore, stationKey } from '../storage/store.js';
4
+ import { ProviderManager } from '../providers/provider-manager.js';
5
+ import { PlayerController } from '../player/player-controller.js';
6
+ import { detectPlaybackBackends } from '../player/backend-install.js';
7
+ import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone, nextOccurrenceForAlarm } from './schedule.js';
8
+ import { createSchedulerService, shouldRunLaunchdOccurrence } from './scheduler.js';
9
+ import { connectActiveAlarms } from './active-session.js';
10
+ import { createPowerInhibitor } from './inhibitor.js';
11
+ import { acquireOccurrenceLock, defaultRunnerUtilities, runAlarm } from './runner.js';
12
+ import { AlarmGuardService, runAlarmGuard } from './guard.js';
13
+ import { AlarmPowerGuardStore } from './power-guard-store.js';
14
+ import { openAlarmControls } from './terminal-launcher.js';
15
+ import { hasLiveTui } from './tui-presence.js';
16
+ import { createSystemVolumeController } from './system-volume.js';
17
+ import { connectRadioSession } from '../agent/session.js';
18
+ export async function runAlarmCommand(args, dependencies = {}) {
19
+ const store = dependencies.store ?? new JsonLibraryStore();
20
+ const scheduler = dependencies.scheduler ?? createSchedulerService();
21
+ const [sub = 'list', ...rest] = args;
22
+ if (sub === 'list') {
23
+ const json = rest.includes('--json');
24
+ const alarms = store.listAlarms();
25
+ if (json)
26
+ console.log(JSON.stringify(alarms, null, 2));
27
+ else if (!alarms.length)
28
+ console.log('No alarms.');
29
+ else
30
+ for (const alarm of alarms)
31
+ console.log(formatAlarm(alarm));
32
+ return;
33
+ }
34
+ if (sub === 'show') {
35
+ const alarm = requireAlarm(store, rest[0]);
36
+ console.log(JSON.stringify({ ...alarm, nextOccurrence: nextOccurrenceForAlarm(alarm, new Date())?.toISOString() ?? null }, null, 2));
37
+ return;
38
+ }
39
+ if (sub === 'add') {
40
+ const parsed = parseOptions(rest);
41
+ if (parsed.positionals.length)
42
+ throw new Error('Alarm add does not accept positional arguments.');
43
+ const station = resolveStation(store, required(parsed, 'station'));
44
+ const input = createInput(parsed, station);
45
+ if (parsed.values.has('fallback'))
46
+ input.playback.fallbackStation = resolveStation(store, String(parsed.values.get('fallback')));
47
+ const alarm = store.addAlarm(input);
48
+ await syncSaved(scheduler, alarm);
49
+ if (alarm.reliability.keepAwakeUntilAlarm)
50
+ await reconcileAlarmGuard(alarm);
51
+ console.log(`alarm=${alarm.id}`);
52
+ return;
53
+ }
54
+ if (sub === 'edit') {
55
+ const parsed = parseOptions(rest);
56
+ if (parsed.positionals.length !== 1)
57
+ throw new Error('Usage: radiocli alarm edit <id> [options]');
58
+ const id = parsed.positionals[0];
59
+ const alarm = requireAlarm(store, id);
60
+ const updated = store.updateAlarm(alarm.id, updatesFromOptions(parsed, store, alarm));
61
+ await syncSaved(scheduler, updated);
62
+ if (updated.reliability.keepAwakeUntilAlarm || parsed.values.has('no-guard'))
63
+ await reconcileAlarmGuard(updated);
64
+ console.log(`updated=${updated.id}`);
65
+ return;
66
+ }
67
+ if (sub === 'enable' || sub === 'disable') {
68
+ const alarm = store.toggleAlarm(requiredPos(rest, 0), sub === 'enable');
69
+ await syncSaved(scheduler, alarm);
70
+ await reconcileAlarmGuard(alarm);
71
+ console.log(`${sub}d=${alarm.id}`);
72
+ return;
73
+ }
74
+ if (sub === 'remove') {
75
+ const id = requiredPos(rest, 0);
76
+ requireAlarm(store, id);
77
+ await scheduler.remove(id);
78
+ if (!store.removeAlarm(id))
79
+ throw new Error(`Alarm not found: ${id}`);
80
+ console.log(`removed=${id}`);
81
+ return;
82
+ }
83
+ if (sub === 'sync') {
84
+ const results = await scheduler.syncAll(store.listAlarms());
85
+ for (const item of results)
86
+ console.log(`${item.id}=${item.error ? `degraded ${item.error}` : item.occurrence?.toISOString() ?? 'disabled'}`);
87
+ if (results.some(item => item.error))
88
+ process.exitCode = 1;
89
+ return;
90
+ }
91
+ if (sub === 'doctor') {
92
+ const guards = await new AlarmGuardService(new AlarmPowerGuardStore()).status();
93
+ const active = await statusesOf(await connectActiveAlarms());
94
+ const powerCapability = createPowerInhibitor().status();
95
+ const schedulerStatus = await scheduler.runtimeStatus(store.listAlarms());
96
+ const report = { scheduler: schedulerStatus, power: { ...powerCapability, active: guards.active || runnerPowerActive(active, schedulerStatus.entries) }, guards, active };
97
+ if (rest.includes('--json'))
98
+ console.log(JSON.stringify(report, null, 2));
99
+ else {
100
+ console.log(`scheduler=${report.scheduler.capabilities.supported ? 'ready' : 'unsupported'} ${report.scheduler.capabilities.message}`);
101
+ console.log(`scheduler_degraded=${report.scheduler.entries.filter(item => !item.healthy).length}`);
102
+ console.log(`power_guard=${report.power.supported ? 'ready' : 'unsupported'} ${report.power.message}`);
103
+ console.log(`guards=${guards.guards.length}`);
104
+ console.log(`active=${active.length}`);
105
+ }
106
+ return;
107
+ }
108
+ if (sub === 'status') {
109
+ const active = await selectedActiveStatuses(rest);
110
+ const guards = await new AlarmGuardService(new AlarmPowerGuardStore()).status();
111
+ const powerCapability = createPowerInhibitor().status();
112
+ const schedulerStatus = await scheduler.runtimeStatus(store.listAlarms());
113
+ console.log(JSON.stringify({ active, scheduler: schedulerStatus, guards, power: { ...powerCapability, active: guards.active || runnerPowerActive(active, schedulerStatus.entries) } }, null, 2));
114
+ return;
115
+ }
116
+ if (sub === 'dismiss') {
117
+ const active = await requireActive(rest);
118
+ await active.dismiss();
119
+ console.log('dismissed=yes');
120
+ return;
121
+ }
122
+ if (sub === 'snooze') {
123
+ const minutes = integer(requiredPos(rest, 0), 'snooze minutes', 1, 1440);
124
+ const active = await requireActive(rest.slice(1));
125
+ await active.snooze(minutes);
126
+ console.log(`snoozed=${minutes}`);
127
+ return;
128
+ }
129
+ if (sub === 'keep-playing') {
130
+ const active = await requireActive(rest);
131
+ await active.keepPlaying();
132
+ console.log('keep_playing=yes');
133
+ return;
134
+ }
135
+ if (sub === 'guard') {
136
+ const action = rest[0] ?? 'status';
137
+ const guards = new AlarmGuardService(new AlarmPowerGuardStore());
138
+ if (action === 'start') {
139
+ const alarm = requireAlarm(store, rest[1]);
140
+ const result = await guards.start(alarm);
141
+ console.log(`guard=${result.occurrenceAt}`);
142
+ }
143
+ else if (action === 'stop')
144
+ console.log(`stopped=${await guards.stop(rest[1]) ? 'yes' : 'no'}`);
145
+ else if (action === 'status')
146
+ console.log(JSON.stringify(await guards.status(), null, 2));
147
+ else
148
+ throw new Error('Usage: radiocli alarm guard start <id>|stop [id]|status');
149
+ return;
150
+ }
151
+ if (sub === 'test') {
152
+ const alarm = requireAlarm(store, rest[0]);
153
+ await testAlarm(alarm, store);
154
+ console.log('test=complete');
155
+ return;
156
+ }
157
+ if (sub === 'internal-run') {
158
+ const id = requiredPos(rest, 0);
159
+ const at = requiredPos(rest, 1);
160
+ await runAlarm(id, at, createRuntimeDeps(store, scheduler, id));
161
+ return;
162
+ }
163
+ if (sub === 'internal-launchd') {
164
+ const id = requiredPos(rest, 0);
165
+ const at = requiredPos(rest, 1);
166
+ if (!shouldRunLaunchdOccurrence(at, new Date()))
167
+ return;
168
+ await runAlarm(id, at, createRuntimeDeps(store, scheduler, id));
169
+ return;
170
+ }
171
+ if (sub === 'internal-guard-run') {
172
+ await runAlarmGuard(requiredPos(rest, 0), requiredPos(rest, 1), createPowerInhibitor(), new AlarmPowerGuardStore(), () => new Date(), defaultRunnerUtilities.wait, rest[2], rest[3]);
173
+ return;
174
+ }
175
+ throw new Error(alarmUsage());
176
+ }
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 }; }
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 }; }
181
+ async function testAlarm(alarm, store) {
182
+ const scheduledAt = new Date(Math.floor(Date.now() / 60_000) * 60_000);
183
+ const deadline = Date.now() + 10_000;
184
+ const preview = { ...alarm, id: `test-${process.pid}`, enabled: true, schedule: { type: 'once', at: scheduledAt.toISOString() }, playback: { ...alarm.playback, fadeSeconds: Math.min(alarm.playback.fadeSeconds, 8), stopAfterMinutes: 1 }, reliability: { ...alarm.reliability, missedRunGraceMinutes: Math.max(1, alarm.reliability.missedRunGraceMinutes) } };
185
+ const settings = { ...alarmRuntimeSettings(store.snapshot().settings, preview, detectPlaybackBackends()), tuneTimeoutSeconds: 3 };
186
+ const player = new PlayerController(() => settings);
187
+ player.refreshDetectedBackends();
188
+ const providers = new ProviderManager();
189
+ const boundedProviders = { resolve: (station) => Promise.race([providers.resolve(station), rejectAt(deadline - 3000, 'Alarm preview station resolution timed out.')]) };
190
+ let interrupt = () => { };
191
+ const noOp = () => ({});
192
+ const runner = runAlarm(preview.id, scheduledAt.toISOString(), { now: () => new Date(), store: { getAlarm: () => preview, recordAlarmOutcome: noOp, toggleAlarm: noOp, snoozeAlarm: noOp, addRecent: noOp, startListeningSession: noOp, checkpointActiveListeningSession: noOp, finishActiveListeningSession: noOp }, providers: boundedProviders, player, scheduler: { sync: async () => null }, inhibitor: createPowerInhibitor(), acquireLock: () => () => { }, createSession: defaultRunnerUtilities.createSession, wait: milliseconds => defaultRunnerUtilities.wait(Math.min(milliseconds, Math.max(0, deadline - Date.now()))), subscribeSignals: handler => { interrupt = handler; const unsubscribe = defaultRunnerUtilities.subscribeSignals(handler); return () => { interrupt = () => { }; unsubscribe(); }; } });
193
+ const result = await enforcePreviewDeadline(runner, () => interrupt(), () => player.stop(), Math.max(0, deadline - Date.now()));
194
+ validatePreviewResult(result);
195
+ }
196
+ export function validatePreviewResult(result) { if (result.status !== 'played' && result.status !== 'dismissed')
197
+ throw new Error(result.message ?? `Alarm preview did not play (${result.status ?? 'unknown'}).`); }
198
+ export function rejectAt(deadline, message) { return new Promise((_, reject) => { const timer = setTimeout(() => reject(new Error(message)), Math.max(0, deadline - Date.now())); timer.unref(); }); }
199
+ export async function enforcePreviewDeadline(runner, interrupt, stop, timeoutMs = 10_000, cleanupMs = 1_500) { let timer; const timeout = new Promise(resolve => { timer = setTimeout(() => resolve({ timeout: true }), timeoutMs); }); let result; try {
200
+ result = await Promise.race([runner.then(value => ({ value })), timeout]);
201
+ }
202
+ finally {
203
+ if (timer)
204
+ clearTimeout(timer);
205
+ } if ('value' in result)
206
+ return result.value; interrupt(); const cleanupDeadline = Date.now() + cleanupMs; await settleWithin(Promise.resolve().then(stop).catch(() => undefined), cleanupDeadline); await settleWithin(runner.then(() => undefined, () => undefined), cleanupDeadline); throw new Error(`Alarm preview exceeded its ${Math.round(timeoutMs / 1000)}-second playback safety limit; cleanup was given ${cleanupMs}ms.`); }
207
+ async function settleWithin(promise, deadline) { const remaining = Math.max(0, deadline - Date.now()); if (remaining === 0)
208
+ return; let timer; try {
209
+ await Promise.race([promise, new Promise(resolve => { timer = setTimeout(resolve, remaining); })]);
210
+ }
211
+ finally {
212
+ if (timer)
213
+ clearTimeout(timer);
214
+ } }
215
+ function createInput(options, station) { return { label: String(options.values.get('label') || station.name), enabled: !options.values.has('disabled'), station, schedule: scheduleFrom(options), playback: { volume: integer(String(options.values.get('volume') ?? '40'), 'volume', 0, 100), fadeSeconds: durationSeconds(String(options.values.get('fade') ?? '0')), stopAfterMinutes: durationMinutes(String(options.values.get('stop-after') ?? '60')), fallbackStation: options.values.has('fallback') ? undefined : undefined }, reliability: { missedRunGraceMinutes: integer(String(options.values.get('grace') ?? '10'), 'grace', 0, 10080), wakeIfSupported: options.values.has('wake'), keepAwakeUntilAlarm: options.values.has('guard') } }; }
216
+ function updatesFromOptions(options, store, alarm) { const station = options.values.has('station') ? resolveStation(store, String(options.values.get('station'))) : alarm.station; const playback = { ...alarm.playback }; if (options.values.has('volume'))
217
+ playback.volume = integer(String(options.values.get('volume')), 'volume', 0, 100); if (options.values.has('fade'))
218
+ playback.fadeSeconds = durationSeconds(String(options.values.get('fade'))); if (options.values.has('stop-after'))
219
+ playback.stopAfterMinutes = durationMinutes(String(options.values.get('stop-after'))); if (options.values.has('fallback'))
220
+ playback.fallbackStation = resolveStation(store, String(options.values.get('fallback'))); if (options.values.has('clear-fallback'))
221
+ delete playback.fallbackStation; const reliability = { ...alarm.reliability }; if (options.values.has('grace'))
222
+ reliability.missedRunGraceMinutes = integer(String(options.values.get('grace')), 'grace', 0, 10080); if (options.values.has('wake'))
223
+ reliability.wakeIfSupported = true; if (options.values.has('no-wake'))
224
+ reliability.wakeIfSupported = false; if (options.values.has('guard'))
225
+ reliability.keepAwakeUntilAlarm = true; if (options.values.has('no-guard'))
226
+ reliability.keepAwakeUntilAlarm = false; const changesSchedule = options.values.has('once') || options.values.has('time') || options.values.has('days') || options.values.has('timezone'); return { ...(options.values.has('label') ? { label: String(options.values.get('label')) } : {}), ...(options.values.has('station') ? { station } : {}), ...(changesSchedule ? { schedule: scheduleEdit(options, alarm.schedule) } : {}), playback, reliability }; }
227
+ function scheduleFrom(options) { if (options.values.has('once')) {
228
+ const input = String(options.values.get('once'));
229
+ const instant = new Date(input);
230
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(input) || !Number.isFinite(instant.getTime()) || instant.getUTCSeconds() !== 0 || instant.getUTCMilliseconds() !== 0)
231
+ throw new Error('--once requires an absolute ISO-8601 minute with an offset or Z and zero seconds.');
232
+ return { type: 'once', at: instant.toISOString() };
233
+ } const time = canonicalizeAlarmTime(required(options, 'time')); const days = parseDays(String(options.values.get('days') ?? 'daily')); const timezone = canonicalizeTimeZone(String(options.values.get('timezone') ?? Intl.DateTimeFormat().resolvedOptions().timeZone)); return { type: 'recurring', time, weekdays: days, timezone }; }
234
+ function scheduleEdit(options, current) { if (options.values.has('once'))
235
+ return scheduleFrom(options); if (current.type !== 'recurring' && !options.values.has('time'))
236
+ throw new Error('Changing days or timezone on a one-time alarm also requires --time.'); return { type: 'recurring', time: canonicalizeAlarmTime(String(options.values.get('time') ?? (current.type === 'recurring' ? current.time : ''))), weekdays: options.values.has('days') ? parseDays(String(options.values.get('days'))) : (current.type === 'recurring' ? current.weekdays : [1, 2, 3, 4, 5, 6, 7]), timezone: canonicalizeTimeZone(String(options.values.get('timezone') ?? (current.type === 'recurring' ? current.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone))) }; }
237
+ function parseDays(value) { if (value === 'daily')
238
+ return [1, 2, 3, 4, 5, 6, 7]; if (value === 'weekdays')
239
+ return [1, 2, 3, 4, 5]; if (value === 'weekends')
240
+ return [6, 7]; return canonicalizeIsoWeekdays(value.split(',').map(Number)); }
241
+ function resolveStation(store, key) { const state = store.snapshot(); const stations = [...state.favorites, ...state.imported, ...state.recent.map(item => item.station), ...state.alarms.flatMap(alarm => [alarm.station, ...(alarm.playback.fallbackStation ? [alarm.playback.fallbackStation] : [])])]; const station = stations.find(item => stationKey(item) === key); if (!station)
242
+ throw new Error(`Station not found: ${key}. Add it to favorites/imports/recents first.`); return station; }
243
+ function parseOptions(args) { const allowed = new Set(['station', 'once', 'time', 'days', 'timezone', 'label', 'volume', 'fade', 'stop-after', 'fallback', 'grace', 'disabled', 'wake', 'no-wake', 'guard', 'no-guard', 'clear-fallback']); const values = new Map(); const positionals = []; for (let index = 0; index < args.length; index += 1) {
244
+ const arg = args[index];
245
+ if (!arg.startsWith('--')) {
246
+ positionals.push(arg);
247
+ continue;
248
+ }
249
+ const key = arg.slice(2);
250
+ if (!allowed.has(key))
251
+ throw new Error(`Unknown alarm option: --${key}`);
252
+ if (values.has(key))
253
+ throw new Error(`Duplicate alarm option: --${key}`);
254
+ if (['disabled', 'wake', 'no-wake', 'guard', 'no-guard', 'clear-fallback'].includes(key)) {
255
+ values.set(key, true);
256
+ continue;
257
+ }
258
+ const value = args[++index];
259
+ if (!value || value.startsWith('--'))
260
+ throw new Error(`Missing value for --${key}.`);
261
+ values.set(key, value);
262
+ } if (values.has('once') && values.has('time'))
263
+ throw new Error('Choose either --once or --time, not both.'); if (values.has('wake') && values.has('no-wake'))
264
+ throw new Error('Choose either --wake or --no-wake.'); if (values.has('guard') && values.has('no-guard'))
265
+ throw new Error('Choose either --guard or --no-guard.'); if (values.has('fallback') && values.has('clear-fallback'))
266
+ throw new Error('Choose either --fallback or --clear-fallback.'); return { values, positionals }; }
267
+ function required(options, key) { const value = options.values.get(key); if (typeof value !== 'string' || !value)
268
+ return (() => { throw new Error(`Missing --${key}.`); })(); return value; }
269
+ function requiredPos(values, index) { const value = values[index]; if (!value)
270
+ throw new Error(alarmUsage()); return value; }
271
+ function integer(value, label, min, max) { const number = Number(value); if (!Number.isInteger(number) || number < min || number > max)
272
+ throw new Error(`${label} must be an integer from ${min} to ${max}.`); return number; }
273
+ function durationSeconds(value) { const match = /^(\d+)(s|m)?$/.exec(value); if (!match)
274
+ throw new Error('Fade must be seconds (30s) or minutes (1m).'); return integer(match[1], 'fade', 0, 3600) * (match[2] === 'm' ? 60 : 1); }
275
+ function durationMinutes(value) { const match = /^(\d+)(m|h)?$/.exec(value); if (!match)
276
+ throw new Error('Stop-after must be minutes (60m) or hours (2h).'); const minutes = Number(match[1]) * (match[2] === 'h' ? 60 : 1); return integer(String(minutes), 'stop-after', 1, 10080); }
277
+ function requireAlarm(store, id) { if (!id)
278
+ throw new Error(alarmUsage()); const alarm = store.getAlarm(id); if (!alarm)
279
+ throw new Error(`Alarm not found: ${id}`); return alarm; }
280
+ async function requireActive(args = []) { const selectors = parseActiveSelectors(args); const clients = await connectActiveAlarms(); const matches = []; for (const client of clients) {
281
+ const status = await client.status();
282
+ if ((!selectors.alarmId || status.alarmId === selectors.alarmId) && (!selectors.occurrenceAt || status.scheduledAt === selectors.occurrenceAt))
283
+ matches.push(client);
284
+ } if (!matches.length)
285
+ throw new Error('No matching alarm is currently playing.'); if (matches.length > 1)
286
+ throw new Error('Multiple alarms are playing; select one with --alarm <id> and optionally --occurrence <ISO>.'); return matches[0]; }
287
+ async function syncSaved(scheduler, alarm) { try {
288
+ await scheduler.sync(alarm);
289
+ }
290
+ catch (error) {
291
+ throw new Error(`Alarm ${alarm.id} was saved, but scheduler setup is degraded: ${error instanceof Error ? error.message : String(error)}`);
292
+ } }
293
+ async function reconcileAlarmGuard(alarm, guards = new AlarmGuardService(new AlarmPowerGuardStore())) { if (alarm.enabled && alarm.reliability.keepAwakeUntilAlarm)
294
+ return guards.start(alarm); await guards.stop(alarm.id); return null; }
295
+ function formatAlarm(alarm) { const next = nextOccurrenceForAlarm(alarm, new Date())?.toISOString() ?? 'none'; return `${alarm.enabled ? 'on ' : 'off'}\t${alarm.id}\t${alarm.label}\t${alarm.station.name}\t${next}`; }
296
+ export function alarmUsage() { return 'Usage: radiocli alarm list|show <id>|add --station provider:id (--once ISO | --time HH:mm [--days daily|weekdays|weekends|1,2])|edit <id>|enable <id>|disable <id>|remove <id>|test <id>|sync|doctor|status [--alarm <id> --occurrence <ISO>]|dismiss [--alarm <id> --occurrence <ISO>]|snooze <minutes> [--alarm <id> --occurrence <ISO>]|keep-playing [--alarm <id> --occurrence <ISO>]|guard start <id>|stop|status'; }
297
+ async function statusesOf(clients) { return Promise.all(clients.map(client => client.status())); }
298
+ export function runnerPowerActive(active, entries) { const playing = new Set(active.map(item => `${item.alarmId}\0${new Date(item.scheduledAt).toISOString()}`)); return entries.some(item => item.occurrenceAt && playing.has(`${item.alarmId}\0${new Date(item.occurrenceAt).toISOString()}`) && item.component === 'power' && item.active === true && item.healthy); }
299
+ async function selectedActiveStatuses(args) { const selectors = parseActiveSelectors(args); return (await statusesOf(await connectActiveAlarms())).filter(status => (!selectors.alarmId || status.alarmId === selectors.alarmId) && (!selectors.occurrenceAt || status.scheduledAt === selectors.occurrenceAt)); }
300
+ export function parseActiveSelectors(args) { let alarmId; let occurrenceAt; for (let index = 0; index < args.length; index += 1) {
301
+ const key = args[index];
302
+ const value = args[++index];
303
+ if ((key !== '--alarm' && key !== '--occurrence') || !value)
304
+ throw new Error('Use --alarm <id> and optionally --occurrence <absolute ISO instant>.');
305
+ if (key === '--alarm')
306
+ alarmId = value;
307
+ else {
308
+ if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))
309
+ throw new Error('--occurrence must be an absolute ISO instant.');
310
+ occurrenceAt = new Date(value).toISOString();
311
+ }
312
+ } return { alarmId, occurrenceAt }; }