@ciphore/radiocli 0.2.2 → 0.2.4

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 (96) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +84 -10
  4. package/dist/agent/alarm-service.js +210 -0
  5. package/dist/agent/cli.js +193 -0
  6. package/dist/agent/headless-host.js +165 -0
  7. package/dist/agent/launcher.js +10 -0
  8. package/dist/agent/mcp-install.js +467 -0
  9. package/dist/agent/mcp-server.js +139 -0
  10. package/dist/agent/service.js +347 -0
  11. package/dist/agent/session.js +232 -0
  12. package/dist/alarms/active-session.js +9 -12
  13. package/dist/alarms/cli.js +4 -1
  14. package/dist/alarms/guard.js +79 -36
  15. package/dist/alarms/inhibitor.js +27 -22
  16. package/dist/alarms/power-guard-store.js +2 -10
  17. package/dist/alarms/runner.js +96 -48
  18. package/dist/alarms/schedule.js +9 -2
  19. package/dist/alarms/scheduler.js +194 -52
  20. package/dist/alarms/setup-verification.js +1 -2
  21. package/dist/alarms/system-volume-ownership.js +267 -0
  22. package/dist/alarms/system-volume.js +155 -14
  23. package/dist/alarms/terminal-launcher.js +76 -136
  24. package/dist/alarms/tui-presence.js +2 -4
  25. package/dist/cli.js +157 -35
  26. package/dist/platform/capabilities.js +53 -0
  27. package/dist/platform/desktop.js +36 -0
  28. package/dist/{player/command.js → platform/executables.js} +43 -9
  29. package/dist/platform/ipc.js +14 -0
  30. package/dist/platform/launch-command.js +135 -0
  31. package/dist/platform/loopback.js +44 -0
  32. package/dist/platform/network.js +312 -0
  33. package/dist/platform/packages.js +216 -0
  34. package/dist/platform/paths.js +40 -0
  35. package/dist/platform/runtime.js +67 -0
  36. package/dist/platform/shell.js +24 -0
  37. package/dist/platform/storage.js +48 -0
  38. package/dist/platform/support.js +214 -0
  39. package/dist/platform/terminal.js +63 -0
  40. package/dist/platform/terminals.js +203 -0
  41. package/dist/player/airplay-discovery.js +4 -2
  42. package/dist/player/backend-install.js +13 -94
  43. package/dist/player/command-diagnostics.js +2 -2
  44. package/dist/player/mpv-ipc-client.js +2 -1
  45. package/dist/player/player-controller.js +220 -31
  46. package/dist/providers/cache.js +4 -26
  47. package/dist/providers/provider-manager.js +5 -0
  48. package/dist/providers/radio-browser.js +156 -54
  49. package/dist/providers/radio-garden.js +15 -22
  50. package/dist/setup.js +149 -152
  51. package/dist/storage/store.js +138 -53
  52. package/dist/streams/import-stream.js +163 -0
  53. package/dist/types.js +6 -0
  54. package/dist/ui/AdaptiveContent.js +54 -30
  55. package/dist/ui/App.js +466 -98
  56. package/dist/ui/AppContent.js +4 -4
  57. package/dist/ui/app-state.js +8 -1
  58. package/dist/ui/ascii.js +11 -2
  59. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  60. package/dist/ui/components/Logo.js +5 -2
  61. package/dist/ui/components/Menu.js +2 -2
  62. package/dist/ui/components/ScreenHeader.js +1 -1
  63. package/dist/ui/components/StationList.js +8 -8
  64. package/dist/ui/components/TopTabs.js +1 -1
  65. package/dist/ui/components/VersionIndicator.js +19 -0
  66. package/dist/ui/display-context.js +8 -11
  67. package/dist/ui/help-content.js +5 -5
  68. package/dist/ui/layout.js +5 -2
  69. package/dist/ui/page-footer.js +5 -3
  70. package/dist/ui/screen-items.js +42 -11
  71. package/dist/ui/screen-meta.js +1 -1
  72. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  73. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  74. package/dist/ui/screens/AlarmsScreen.js +2 -1
  75. package/dist/ui/screens/CountriesScreen.js +8 -5
  76. package/dist/ui/screens/ExploreScreen.js +2 -1
  77. package/dist/ui/screens/HelpScreen.js +5 -1
  78. package/dist/ui/screens/HomeScreen.js +7 -1
  79. package/dist/ui/screens/MapScreen.js +1 -1
  80. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  81. package/dist/ui/screens/SearchScreen.js +3 -1
  82. package/dist/ui/screens/SettingsScreen.js +80 -56
  83. package/dist/ui/screens/StatsScreen.js +3 -1
  84. package/dist/ui/system-actions.js +80 -52
  85. package/dist/ui/terminal-renderer.js +16 -0
  86. package/dist/ui/use-alarm-tui.js +40 -21
  87. package/dist/ui/use-app-input.js +69 -23
  88. package/dist/ui/use-command-executor.js +31 -7
  89. package/dist/ui/visualizers/gallop.js +118 -0
  90. package/dist/ui/visualizers/horse-stride.js +20 -0
  91. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  92. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  93. package/dist/ui/visualizers/retro-receivers.js +4 -0
  94. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  95. package/dist/update-check.js +33 -23
  96. package/package.json +4 -1
@@ -0,0 +1,193 @@
1
+ import { AgentRadioService, stationForAgent } from './service.js';
2
+ import { configureMcpIntegrations, mcpIntegrationReport, portableMcpConfig } from './mcp-install.js';
3
+ import { runMcpServer } from './mcp-server.js';
4
+ export async function runMcpCommand(args, runtime) {
5
+ const command = args[0] ?? 'status';
6
+ if (command === 'serve')
7
+ return runMcpServer(runtime);
8
+ if (command === 'enable' || command === 'install' || command === 'repair') {
9
+ assertMcpInstallSucceeded(await configureMcpIntegrations(true, runtime), 'configured');
10
+ return;
11
+ }
12
+ if (command === 'disable' || command === 'remove') {
13
+ assertMcpInstallSucceeded(await configureMcpIntegrations(false, runtime), 'removed');
14
+ return;
15
+ }
16
+ if (command === 'config') {
17
+ console.log(JSON.stringify(portableMcpConfig(runtime), null, 2));
18
+ return;
19
+ }
20
+ if (command === 'status' || command === 'doctor') {
21
+ console.log(JSON.stringify(await mcpIntegrationReport(runtime), null, 2));
22
+ return;
23
+ }
24
+ throw new Error('Usage: radiocli mcp <enable|install|repair|disable|status|config|serve>');
25
+ }
26
+ export async function runAgentCliCommand(args, runtime) {
27
+ const service = new AgentRadioService(runtime);
28
+ const [command = 'status', ...rest] = args;
29
+ let value;
30
+ if (command === 'status')
31
+ value = await service.status();
32
+ else if (command === 'search')
33
+ value = (await service.search(requiredText(rest, 'Usage: radiocli agent search <query>'))).map(stationForAgent);
34
+ else if (command === 'browse') {
35
+ const kind = parseBrowseKind(rest[0]);
36
+ if (rest.length > 2)
37
+ throw new Error('Usage: radiocli agent browse [favorites|recent|popular|nearby|countries|track-history] [1-50]');
38
+ const items = await service.browse(kind, rest[1] === undefined ? 20 : integerInRange(rest[1], 1, 50, 'Browse limit'));
39
+ value = ['favorites', 'recent', 'popular', 'nearby'].includes(kind)
40
+ ? items.map(stationForAgent)
41
+ : items;
42
+ }
43
+ else if (command === 'play')
44
+ value = await service.play(parsePlayArgs(rest));
45
+ else if (command === 'pause' || command === 'resume' || command === 'stop' || command === 'next' || command === 'previous')
46
+ value = await service.control({ type: command });
47
+ else if (command === 'volume') {
48
+ if (rest.length !== 1)
49
+ throw new Error('Usage: radiocli agent volume <0-100>');
50
+ value = await service.control({ type: 'set-volume', volume: numberInRange(rest[0], 0, 100, 'Volume') });
51
+ }
52
+ else if (command === 'mute' || command === 'unmute')
53
+ value = await service.control({ type: 'set-muted', muted: command === 'mute' });
54
+ else if (command === 'favorite' || command === 'unfavorite')
55
+ value = await service.setFavorite(command === 'favorite', rest[0]);
56
+ else if (command === 'stats')
57
+ value = service.stats();
58
+ else if (command === 'appearance')
59
+ value = await parseAppearance(rest, service);
60
+ else if (command === 'airplay')
61
+ value = await runAirPlayCommand(rest, service);
62
+ else if (command === 'preset')
63
+ value = service.configureCompletionPreset(parsePreset(rest));
64
+ else if (command === 'done')
65
+ value = await service.runCompletionPreset();
66
+ else
67
+ throw new Error('Usage: radiocli agent <status|search|browse|play|pause|resume|stop|next|previous|volume|mute|unmute|favorite|unfavorite|stats|appearance|airplay|preset|done>');
68
+ console.log(JSON.stringify(value, null, 2));
69
+ }
70
+ function parseBrowseKind(value) {
71
+ const kind = value ?? 'recent';
72
+ if (!['favorites', 'recent', 'popular', 'nearby', 'countries', 'track-history'].includes(kind))
73
+ throw new Error('Browse kind must be favorites, recent, popular, nearby, countries, or track-history.');
74
+ return kind;
75
+ }
76
+ function parsePlayArgs(args) {
77
+ const result = {};
78
+ for (let index = 0; index < args.length; index += 1) {
79
+ const arg = args[index];
80
+ if (arg === '--source')
81
+ result.source = stationSource(required(args[++index], '--source requires recent, favorite, popular, or country.'));
82
+ else if (arg === '--country') {
83
+ result.source = 'country';
84
+ result.countryCode = countryCode(required(args[++index], '--country requires a two-letter code.'));
85
+ }
86
+ else if (arg === '--random')
87
+ result.random = true;
88
+ else if (arg === '--no-ui')
89
+ result.openUi = false;
90
+ else if (arg === '--keep-playing')
91
+ result.ifPlaying = 'keep';
92
+ else if (!result.stationId)
93
+ result.stationId = arg;
94
+ else
95
+ throw new Error(`Unknown play option: ${arg}`);
96
+ }
97
+ return result;
98
+ }
99
+ function parseAppearance(args, service) {
100
+ if (args.length === 0)
101
+ return service.appearance();
102
+ const input = {};
103
+ for (let index = 0; index < args.length; index += 1) {
104
+ const arg = args[index];
105
+ if (arg === '--theme')
106
+ input.theme = required(args[++index], '--theme requires a theme name.');
107
+ else if (arg === '--receiver-style')
108
+ input.receiverStyle = required(args[++index], '--receiver-style requires a style name.');
109
+ else
110
+ throw new Error(`Unknown appearance option: ${arg}`);
111
+ }
112
+ return service.updateAppearance(input);
113
+ }
114
+ function parsePreset(args) {
115
+ const input = {};
116
+ for (let index = 0; index < args.length; index += 1) {
117
+ const arg = args[index];
118
+ if (arg === '--action')
119
+ input.action = presetAction(required(args[++index], '--action requires play, pause, resume, or stop.'));
120
+ else if (arg === '--source')
121
+ input.source = stationSource(required(args[++index], '--source requires recent, favorite, popular, or country.'));
122
+ else if (arg === '--country') {
123
+ input.source = 'country';
124
+ input.countryCode = countryCode(required(args[++index], '--country requires a two-letter code.'));
125
+ }
126
+ else if (arg === '--keep-playing')
127
+ input.ifPlaying = 'keep';
128
+ else if (arg === '--replace')
129
+ input.ifPlaying = 'replace';
130
+ else if (arg === '--open-ui')
131
+ input.openUi = true;
132
+ else if (arg === '--no-ui')
133
+ input.openUi = false;
134
+ else
135
+ throw new Error(`Unknown preset option: ${arg}`);
136
+ }
137
+ return input;
138
+ }
139
+ async function runAirPlayCommand(args, service) {
140
+ const [action = 'list', ...rest] = args;
141
+ if (action === 'list')
142
+ return service.listAirPlayDevices();
143
+ if (action === 'select')
144
+ return service.selectAirPlayDevice(required(rest[0], 'Usage: radiocli agent airplay select <receiver-id>'), !rest.includes('--no-ui'));
145
+ if (action === 'local')
146
+ return service.useLocalOutput();
147
+ if (action === 'code')
148
+ return service.submitAirPlayPasscode(required(rest[0], 'Usage: radiocli agent airplay code <receiver-code>'));
149
+ throw new Error('Usage: radiocli agent airplay <list|select|local|code>');
150
+ }
151
+ function required(value, error) { if (!value)
152
+ throw new Error(error); return value; }
153
+ function requiredText(values, error) {
154
+ const value = values.join(' ').trim();
155
+ if (!value)
156
+ throw new Error(error);
157
+ return value;
158
+ }
159
+ function numberInRange(value, minimum, maximum, label) {
160
+ const parsed = Number(value);
161
+ if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) {
162
+ throw new Error(`${label} must be a number from ${minimum} through ${maximum}.`);
163
+ }
164
+ return parsed;
165
+ }
166
+ function integerInRange(value, minimum, maximum, label) {
167
+ const parsed = numberInRange(value, minimum, maximum, label);
168
+ if (!Number.isInteger(parsed))
169
+ throw new Error(`${label} must be a whole number from ${minimum} through ${maximum}.`);
170
+ return parsed;
171
+ }
172
+ function stationSource(value) {
173
+ if (!['recent', 'favorite', 'popular', 'country'].includes(value)) {
174
+ throw new Error('Source must be recent, favorite, popular, or country.');
175
+ }
176
+ return value;
177
+ }
178
+ function presetAction(value) {
179
+ if (!['play', 'pause', 'resume', 'stop'].includes(value))
180
+ throw new Error('Action must be play, pause, resume, or stop.');
181
+ return value;
182
+ }
183
+ function countryCode(value) {
184
+ if (!/^[a-z]{2}$/i.test(value))
185
+ throw new Error('Country must be a two-letter ISO country code.');
186
+ return value.toUpperCase();
187
+ }
188
+ function assertMcpInstallSucceeded(results, action) {
189
+ const failed = results.filter(result => result.status === 'failed');
190
+ if (failed.length === 0)
191
+ return;
192
+ throw new Error(`RadioCLI was ${action} where possible, but ${failed.length} integration${failed.length === 1 ? '' : 's'} failed: ${failed.map(result => `${result.client} (${result.detail})`).join('; ')}`);
193
+ }
@@ -0,0 +1,165 @@
1
+ import { PlayerController } from '../player/player-controller.js';
2
+ import { ProviderManager } from '../providers/provider-manager.js';
3
+ import { JsonLibraryStore, stationKey } from '../storage/store.js';
4
+ import { startRadioSession } from './session.js';
5
+ import { airPlayMacOSOnlyMessage, detectPlaybackBackends, isAirPlayPlatformSupported } from '../player/backend-install.js';
6
+ export async function runHeadlessAgentHost() {
7
+ const store = new JsonLibraryStore();
8
+ const providers = new ProviderManager();
9
+ const player = new PlayerController(() => store.snapshot().settings);
10
+ player.refreshDetectedBackends();
11
+ let station = null;
12
+ let queue = [];
13
+ let persistenceWarning;
14
+ const persistHistory = (write) => {
15
+ try {
16
+ write();
17
+ }
18
+ catch (error) {
19
+ const code = error?.code;
20
+ const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : '';
21
+ persistenceWarning = `Listening history was not saved${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`;
22
+ }
23
+ };
24
+ const saveLibrary = (write) => {
25
+ write();
26
+ // Optional writes can be no-ops, so only an explicit successful save clears
27
+ // a prior warning. Explicit failures still reject the control request.
28
+ persistenceWarning = undefined;
29
+ };
30
+ const status = () => ({ owner: 'headless', playback: player.getState(), station, queue, persistenceWarning, output: {
31
+ preferredBackend: store.snapshot().settings.preferredBackend,
32
+ preferredAirPlayDevice: store.snapshot().settings.preferredAirPlayDevice
33
+ } });
34
+ const result = (message, ok = true, data) => ({ ok, message, status: status(), ...(data ? { data } : {}) });
35
+ const play = async (next, nextQueue = [next]) => {
36
+ persistHistory(() => store.finishActiveListeningSession());
37
+ await player.stop();
38
+ const resolved = await providers.resolve(next);
39
+ await player.play(next, resolved.url);
40
+ station = next;
41
+ queue = nextQueue.length ? nextQueue : [next];
42
+ persistHistory(() => store.addRecent(next));
43
+ persistHistory(() => store.startListeningSession(next));
44
+ return result(`Playing ${next.name}.`);
45
+ };
46
+ const handle = async (command) => {
47
+ if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !isAirPlayPlatformSupported()) {
48
+ return result(airPlayMacOSOnlyMessage, false, command.type === 'airplay-list' ? [] : undefined);
49
+ }
50
+ if (command.type === 'status')
51
+ return result(station ? `${player.getState().state}: ${station.name}` : 'RadioCLI is idle.');
52
+ if (command.type === 'play') {
53
+ if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state))
54
+ return result(`Kept current station ${station?.name ?? ''}.`);
55
+ return play(command.station, command.queue);
56
+ }
57
+ if (command.type === 'pause') {
58
+ const control = await player.pause();
59
+ if (control.ok)
60
+ persistHistory(() => store.finishActiveListeningSession());
61
+ return result(control.message ?? 'Paused.', control.ok);
62
+ }
63
+ if (command.type === 'resume') {
64
+ const control = await player.resume();
65
+ if (control.ok && station)
66
+ persistHistory(() => store.startListeningSession(station));
67
+ return result(control.message ?? 'Resumed.', control.ok);
68
+ }
69
+ if (command.type === 'stop') {
70
+ persistHistory(() => store.finishActiveListeningSession());
71
+ await player.stop();
72
+ station = null;
73
+ queue = [];
74
+ setTimeout(() => process.kill(process.pid, 'SIGTERM'), 50).unref();
75
+ return result('Stopped RadioCLI.');
76
+ }
77
+ if (command.type === 'alarm-preempt') {
78
+ persistHistory(() => store.finishActiveListeningSession());
79
+ await player.stop();
80
+ station = null;
81
+ queue = [];
82
+ return result('Interactive playback yielded to the alarm.');
83
+ }
84
+ if (command.type === 'set-volume') {
85
+ const control = await player.setVolume(command.volume);
86
+ if (control.ok)
87
+ saveLibrary(() => store.updateSettings({ volume: player.getState().volume }));
88
+ return result(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
89
+ }
90
+ if (command.type === 'set-muted') {
91
+ const control = await player.setMuted(command.muted);
92
+ return result(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok);
93
+ }
94
+ if (command.type === 'set-favorite') {
95
+ const target = command.station ?? station;
96
+ if (!target)
97
+ return result('No active station to favorite.', false);
98
+ const current = store.isFavorite(target);
99
+ if (current !== command.favorite)
100
+ saveLibrary(() => store.toggleFavorite(target));
101
+ if (command.favorite && !current && store.snapshot().settings.shareDirectoryVotes)
102
+ void providers.vote(target);
103
+ return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`);
104
+ }
105
+ if (command.type === 'airplay-list') {
106
+ const devices = await player.refreshAirPlayDevices();
107
+ return result(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
108
+ }
109
+ if (command.type === 'airplay-select') {
110
+ const devices = await player.refreshAirPlayDevices();
111
+ const device = devices.find(item => item.id === command.deviceId);
112
+ if (!device)
113
+ return result('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
114
+ if (device.local)
115
+ return switchToLocal();
116
+ if (!detectPlaybackBackends().includes('airplay'))
117
+ return result('AirPlay playback is unavailable; run radiocli doctor.', false);
118
+ saveLibrary(() => {
119
+ store.updateSettings({ preferredAirPlayDevice: device.id });
120
+ store.updateSettings({ preferredBackend: 'airplay' });
121
+ });
122
+ if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
123
+ return play(station, queue);
124
+ return result(`Audio output set to AirPlay receiver ${device.name}.`);
125
+ }
126
+ if (command.type === 'airplay-local')
127
+ return switchToLocal();
128
+ if (command.type === 'airplay-passcode') {
129
+ const control = player.submitAirPlayPasscode(command.code);
130
+ return result(control.message ?? 'AirPlay code sent.', control.ok);
131
+ }
132
+ if (command.type === 'update-settings') {
133
+ saveLibrary(() => store.updateSettings(command.settings));
134
+ return result('RadioCLI settings updated.');
135
+ }
136
+ const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station)) : -1;
137
+ const delta = command.type === 'next' ? 1 : -1;
138
+ const next = queue.length ? queue[(Math.max(0, currentIndex) + delta + queue.length) % queue.length] : undefined;
139
+ return next ? play(next, queue) : result('No playback queue is available.', false);
140
+ };
141
+ const switchToLocal = async () => {
142
+ const backends = detectPlaybackBackends();
143
+ const backend = backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null;
144
+ if (!backend)
145
+ return result('No local playback backend is available. Run radiocli setup to install mpv.', false);
146
+ saveLibrary(() => store.updateSettings({ preferredBackend: backend }));
147
+ if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
148
+ return play(station, queue);
149
+ return result(`Audio output set to this device (${backend}).`);
150
+ };
151
+ const session = await startRadioSession(handle);
152
+ const checkpoint = setInterval(() => {
153
+ if (player.getState().state === 'playing')
154
+ persistHistory(() => store.checkpointActiveListeningSession());
155
+ }, 30_000);
156
+ const shutdown = async () => {
157
+ clearInterval(checkpoint);
158
+ persistHistory(() => store.finishActiveListeningSession());
159
+ await player.stop();
160
+ await session.close();
161
+ };
162
+ process.once('SIGTERM', () => { void shutdown().finally(() => process.exit(0)); });
163
+ process.once('SIGINT', () => { void shutdown().finally(() => process.exit(0)); });
164
+ await new Promise(() => undefined);
165
+ }
@@ -0,0 +1,10 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { launchTerminalCommand } from '../platform/terminals.js';
3
+ import { waitForLaunch } from '../platform/launch-command.js';
4
+ /** The agent service verifies the authenticated session after this request. */
5
+ export async function launchRadioTui(nodePath, cliPath, encodedCommand, options = {}) {
6
+ await launchTerminalCommand({ ...options, nodePath, args: [cliPath, 'agent-ui', encodedCommand], title: 'RadioCLI' });
7
+ }
8
+ export async function launchHeadlessHost(nodePath, cliPath) {
9
+ await waitForLaunch(spawn(nodePath, [cliPath, 'agent-host'], { detached: true, stdio: 'ignore', windowsHide: false }));
10
+ }