@ciphore/radiocli 0.2.3 → 0.2.5
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 +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { applyEdits, modify, parse } from 'jsonc-parser';
|
|
6
6
|
import { JsonLibraryStore } from '../storage/store.js';
|
|
7
7
|
import { defaultAgentControlSettings } from '../types.js';
|
|
8
|
-
import {
|
|
8
|
+
import { resolveCommandDetails } from '../platform/executables.js';
|
|
9
9
|
const formatting = { insertSpaces: true, tabSize: 2, eol: '\n' };
|
|
10
10
|
export async function configureMcpIntegrations(enabled, runtime, output = process.stdout) {
|
|
11
11
|
const store = new JsonLibraryStore();
|
|
@@ -31,7 +31,7 @@ export async function configureMcpIntegrations(enabled, runtime, output = proces
|
|
|
31
31
|
}
|
|
32
32
|
else
|
|
33
33
|
results.push(notFound('Codex'));
|
|
34
|
-
const claude =
|
|
34
|
+
const claude = resolveCommandDetails('claude', { env: process.env }).path;
|
|
35
35
|
if (claude) {
|
|
36
36
|
results.push(await configureCliClient('Claude Code', claude, enabled
|
|
37
37
|
? ['mcp', 'add', '--scope', 'user', 'radiocli', '--', ...command]
|
|
@@ -40,7 +40,7 @@ export async function configureMcpIntegrations(enabled, runtime, output = proces
|
|
|
40
40
|
else
|
|
41
41
|
results.push(notFound('Claude Code'));
|
|
42
42
|
const openCodePath = openCodeConfigPath();
|
|
43
|
-
if (
|
|
43
|
+
if (resolveCommandDetails('opencode', { env: process.env }).path || existsSync(openCodePath)) {
|
|
44
44
|
try {
|
|
45
45
|
updateOpenCodeConfig(openCodePath, enabled, command);
|
|
46
46
|
results.push({ client: 'OpenCode', status: enabled ? 'configured' : 'removed', detail: openCodePath });
|
|
@@ -54,13 +54,13 @@ export async function configureMcpIntegrations(enabled, runtime, output = proces
|
|
|
54
54
|
configureJsonClient(results, 'Cursor', cursorDetected(), join(homedir(), '.cursor', 'mcp.json'), ['mcpServers', 'radiocli'], enabled, {
|
|
55
55
|
command: command[0], args: command.slice(1)
|
|
56
56
|
});
|
|
57
|
-
configureJsonClient(results, 'Gemini CLI', Boolean(
|
|
57
|
+
configureJsonClient(results, 'Gemini CLI', Boolean(resolveCommandDetails('gemini', { env: process.env }).path), join(homedir(), '.gemini', 'settings.json'), ['mcpServers', 'radiocli'], enabled, {
|
|
58
58
|
command: command[0], args: command.slice(1)
|
|
59
59
|
});
|
|
60
60
|
configureJsonClient(results, 'VS Code / Copilot Agent Host', vsCodeDetected(), join(homedir(), '.copilot', 'mcp-config.json'), ['servers', 'radiocli'], enabled, {
|
|
61
61
|
type: 'stdio', command: command[0], args: command.slice(1)
|
|
62
62
|
});
|
|
63
|
-
if (
|
|
63
|
+
if (resolveCommandDetails('orca', { env: process.env }).path) {
|
|
64
64
|
results.push({ client: 'Orca', status: 'inherited', detail: 'Orca exposes MCP integrations through its configured Codex/Claude agent runtimes.' });
|
|
65
65
|
}
|
|
66
66
|
else
|
|
@@ -182,7 +182,7 @@ export function probeMcpServer(command, timeoutMs = 5_000) {
|
|
|
182
182
|
});
|
|
183
183
|
}
|
|
184
184
|
function matchingRadioCliShim(runtime, platform) {
|
|
185
|
-
const candidate =
|
|
185
|
+
const candidate = resolveCommandDetails('radiocli', { env: process.env, platform }).path;
|
|
186
186
|
if (!candidate)
|
|
187
187
|
return undefined;
|
|
188
188
|
if (!usableDirectLauncher(candidate, platform))
|
|
@@ -204,16 +204,16 @@ async function mcpClientStates(command) {
|
|
|
204
204
|
const vscodePath = join(homedir(), '.copilot', 'mcp-config.json');
|
|
205
205
|
return {
|
|
206
206
|
codex: await codexClientState(resolveCodexExecutable(), command),
|
|
207
|
-
claude: await claudeClientState(
|
|
208
|
-
opencode: jsonClientState(Boolean(
|
|
207
|
+
claude: await claudeClientState(resolveCommandDetails('claude', { env: process.env }).path, command),
|
|
208
|
+
opencode: jsonClientState(Boolean(resolveCommandDetails('opencode', { env: process.env }).path || existsSync(openCodePath)), openCodePath, [
|
|
209
209
|
['mcp', 'servers', 'radiocli'], ['mcp', 'radiocli']
|
|
210
210
|
], command),
|
|
211
211
|
cursor: jsonClientState(cursorDetected(), cursorPath, [['mcpServers', 'radiocli']], command),
|
|
212
|
-
gemini: jsonClientState(Boolean(
|
|
212
|
+
gemini: jsonClientState(Boolean(resolveCommandDetails('gemini', { env: process.env }).path), geminiPath, [['mcpServers', 'radiocli']], command),
|
|
213
213
|
vscode: jsonClientState(vsCodeDetected(), vscodePath, [['servers', 'radiocli']], command),
|
|
214
214
|
orca: {
|
|
215
|
-
detected: Boolean(
|
|
216
|
-
state:
|
|
215
|
+
detected: Boolean(resolveCommandDetails('orca', { env: process.env }).path),
|
|
216
|
+
state: resolveCommandDetails('orca', { env: process.env }).path ? 'managed-by-client' : 'not-found',
|
|
217
217
|
detail: 'inherits MCP configuration from its Codex or Claude runtime'
|
|
218
218
|
},
|
|
219
219
|
portable: jsonClientState(true, portableConfigPath(), [['mcpServers', 'radiocli']], command)
|
|
@@ -226,7 +226,7 @@ export function resolveCodexExecutable(env = process.env, platform = process.pla
|
|
|
226
226
|
const configured = env.CODEX_CLI_PATH;
|
|
227
227
|
if (configured && existsSync(configured))
|
|
228
228
|
return configured;
|
|
229
|
-
const pathExecutable =
|
|
229
|
+
const pathExecutable = resolveCommandDetails('codex', { env, platform, home }).path;
|
|
230
230
|
if (pathExecutable)
|
|
231
231
|
return pathExecutable;
|
|
232
232
|
const candidates = platform === 'darwin'
|
|
@@ -243,14 +243,14 @@ export function resolveCodexExecutable(env = process.env, platform = process.pla
|
|
|
243
243
|
return candidates.find(candidate => existsSync(candidate));
|
|
244
244
|
}
|
|
245
245
|
function cursorDetected() {
|
|
246
|
-
return Boolean(
|
|
247
|
-
|
|
246
|
+
return Boolean(resolveCommandDetails('cursor', { env: process.env }).path ||
|
|
247
|
+
resolveCommandDetails('cursor-agent', { env: process.env }).path ||
|
|
248
248
|
existsSync(join(homedir(), '.cursor')) ||
|
|
249
249
|
(process.platform === 'darwin' && existsSync('/Applications/Cursor.app')) ||
|
|
250
250
|
(process.platform === 'win32' && existsSync(join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'Programs', 'cursor', 'Cursor.exe'))));
|
|
251
251
|
}
|
|
252
252
|
function vsCodeDetected() {
|
|
253
|
-
return Boolean(
|
|
253
|
+
return Boolean(resolveCommandDetails('code', { env: process.env }).path ||
|
|
254
254
|
(process.platform === 'darwin' && existsSync('/Applications/Visual Studio Code.app')) ||
|
|
255
255
|
(process.platform === 'win32' && existsSync(join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'Programs', 'Microsoft VS Code', 'Code.exe'))));
|
|
256
256
|
}
|
package/dist/agent/mcp-server.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
+
import { JsonLibraryStore } from '../storage/store.js';
|
|
5
|
+
import { LibraryRepository } from '../storage/repository.js';
|
|
4
6
|
import { appVersion } from '../version.js';
|
|
5
7
|
import { receiverStyleNames, themeNames } from '../types.js';
|
|
6
8
|
import { AgentRadioService, stationForAgent } from './service.js';
|
|
7
9
|
export const radioCliMcpInstructions = `RadioCLI is the authoritative handler for requests about RadioCLI, “the radio”, “my radio”, recent stations, favorites, or stations from a place. Never substitute a web browser, web search, Computer Use, or another radio/music service. “Play my most recent radio station” means call radio_play with source="recent" and index=0. “Play a station from New York” means call radio_search with query="New York", then radio_play with one returned opaque station ID. Call these tools directly; do not delegate a radio-only operation or create a goal merely to monitor it.
|
|
8
10
|
|
|
9
|
-
RadioCLI controls internet radio, alarms, and
|
|
11
|
+
RadioCLI controls internet radio, alarms, and experimental AirPlay locally. Discover unidentified stations with radio_search or radio_browse, then use only returned opaque IDs; never invent IDs or stream URLs. Ordinary stop never dismisses an alarm: list active alarms and use alarm controls, with an exact ID when several ring. Never remove an alarm without explicit user confirmation. List AirPlay receivers before selecting one. Never install an update without explicit approval.
|
|
10
12
|
|
|
11
|
-
For “this station”, omit station_id when favoriting. A request such as “do X, then play or stop the radio” is a normal completion action in the current task: perform X, then call radio_run_completion_preset once X is genuinely complete. Do not create or set a goal, scheduled task, automation, reminder, task record, or separate monitoring task merely to defer or track that radio action. Use one of those mechanisms only when the user explicitly requests it for a separate reason. AirPlay is macOS
|
|
13
|
+
For “this station”, omit station_id when favoriting. A request such as “do X, then play or stop the radio” is a normal completion action in the current task: perform X, then call radio_run_completion_preset once X is genuinely complete. Do not create or set a goal, scheduled task, automation, reminder, task record, or separate monitoring task merely to defer or track that radio action. Use one of those mechanisms only when the user explicitly requests it for a separate reason. AirPlay is experimental on macOS and native Linux; Linux receiver selection is required each session. Submit a receiver code only when the user provides it; codes are never saved. Use radio_update_status for version or upgrade questions, and tell the user to restart their MCP client after an update.`;
|
|
12
14
|
const alarmScheduleSchema = z.discriminatedUnion('type', [
|
|
13
15
|
z.object({ type: z.literal('once'), at: z.string().min(1).describe('Absolute ISO-8601 minute including an offset or Z, with zero seconds.') }),
|
|
14
16
|
z.object({
|
|
@@ -30,7 +32,9 @@ const alarmOptionsSchema = {
|
|
|
30
32
|
keep_awake_until_alarm: z.boolean().optional()
|
|
31
33
|
};
|
|
32
34
|
export async function runMcpServer(runtime) {
|
|
33
|
-
const
|
|
35
|
+
const store = new LibraryRepository(new JsonLibraryStore());
|
|
36
|
+
const service = new AgentRadioService(runtime, store);
|
|
37
|
+
process.once('beforeExit', () => { void store.close().catch(() => undefined); });
|
|
34
38
|
serveStdio(() => createMcpServer(service), { onerror: error => console.error(`RadioCLI MCP: ${error.message}`) });
|
|
35
39
|
}
|
|
36
40
|
function createMcpServer(service) {
|
|
@@ -39,6 +43,7 @@ function createMcpServer(service) {
|
|
|
39
43
|
const config = schema ? { description, inputSchema: schema } : { description };
|
|
40
44
|
server.registerTool(name, config, async (raw) => {
|
|
41
45
|
try {
|
|
46
|
+
await service.refresh();
|
|
42
47
|
const value = await handler(raw);
|
|
43
48
|
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
44
49
|
}
|
|
@@ -123,7 +128,7 @@ function createMcpServer(service) {
|
|
|
123
128
|
occurrence_at: z.string().min(1).optional(),
|
|
124
129
|
snooze_minutes: z.number().int().min(1).max(1440).optional()
|
|
125
130
|
}).refine(value => value.action === 'snooze' || value.snooze_minutes === undefined, { message: 'snooze_minutes is only valid for the snooze action.' }), ({ action, alarm_id, occurrence_at, snooze_minutes }) => service.alarmControl({ action, alarmId: alarm_id, occurrenceAt: occurrence_at, snoozeMinutes: snooze_minutes }));
|
|
126
|
-
tool('radio_airplay_list', 'Discover AirPlay receivers visible to this
|
|
131
|
+
tool('radio_airplay_list', 'Discover AirPlay receivers visible to this device. Returns opaque receiver IDs; always call this before selecting a receiver.', undefined, async () => (await service.listAirPlayDevices()).map(device => ({
|
|
127
132
|
id: device.id,
|
|
128
133
|
name: device.name,
|
|
129
134
|
host: device.host,
|
|
@@ -133,7 +138,7 @@ function createMcpServer(service) {
|
|
|
133
138
|
local: device.local ?? false
|
|
134
139
|
})));
|
|
135
140
|
tool('radio_airplay_select', 'Switch current or future interactive playback to an exact AirPlay receiver ID returned by radio_airplay_list. Opens RadioCLI when needed unless open_ui=false.', z.object({ device_id: z.string().min(1), open_ui: z.boolean().optional() }), ({ device_id, open_ui }) => service.selectAirPlayDevice(device_id, open_ui));
|
|
136
|
-
tool('radio_airplay_use_local', 'Switch interactive playback from AirPlay back to this
|
|
141
|
+
tool('radio_airplay_use_local', 'Switch interactive playback from AirPlay back to this device using the best available local backend.', z.object({ open_ui: z.boolean().optional() }).optional(), args => service.useLocalOutput(args?.open_ui));
|
|
137
142
|
tool('radio_airplay_submit_code', 'Submit a receiver-displayed AirPlay code to the active AirPlay session. Use only a code the user explicitly provides; RadioCLI never saves it.', z.object({ code: z.string().min(1).max(64) }), ({ code }) => service.submitAirPlayPasscode(code));
|
|
138
143
|
return server;
|
|
139
144
|
}
|
package/dist/agent/service.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { randomInt } from 'node:crypto';
|
|
2
2
|
import { defaultAgentControlSettings, themeNames, receiverStyleNames } from '../types.js';
|
|
3
|
+
import { LibraryRepository } from '../storage/repository.js';
|
|
3
4
|
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
5
|
+
import { parseRadioCommand } from './command-schema.js';
|
|
6
|
+
import { matchesStationId } from '../storage/station-identity.js';
|
|
4
7
|
import { ProviderManager } from '../providers/provider-manager.js';
|
|
5
8
|
import { computeListeningStats } from '../activity/stats.js';
|
|
6
9
|
import { connectActiveAlarms } from '../alarms/active-session.js';
|
|
7
10
|
import { connectRadioSession, ensureRadioSession } from './session.js';
|
|
8
11
|
import { launchHeadlessHost, launchRadioTui } from './launcher.js';
|
|
9
12
|
import { PlayerController } from '../player/player-controller.js';
|
|
13
|
+
import { airPlayPolicy } from '../player/airplay-policy.js';
|
|
10
14
|
import { detectPlaybackBackends } from '../player/backend-install.js';
|
|
11
15
|
import { AgentAlarmService } from './alarm-service.js';
|
|
12
16
|
import { appVersion } from '../version.js';
|
|
@@ -15,22 +19,29 @@ export class AgentRadioService {
|
|
|
15
19
|
runtime;
|
|
16
20
|
store;
|
|
17
21
|
providers;
|
|
22
|
+
source;
|
|
18
23
|
stationCache = new Map();
|
|
19
24
|
alarms;
|
|
20
|
-
constructor(runtime, store = new JsonLibraryStore(), providers = new ProviderManager()
|
|
25
|
+
constructor(runtime, store = new JsonLibraryStore(), providers = new ProviderManager(),
|
|
26
|
+
// Local shell commands do not enable or register agent/MCP integrations.
|
|
27
|
+
source = 'agent') {
|
|
21
28
|
this.runtime = runtime;
|
|
22
29
|
this.store = store;
|
|
23
30
|
this.providers = providers;
|
|
31
|
+
this.source = source;
|
|
24
32
|
this.alarms = new AgentAlarmService(this.store, id => this.resolveStationId(id), undefined, status => this.handoffAlarm(status));
|
|
25
33
|
}
|
|
26
|
-
|
|
34
|
+
async refresh() { if (this.store instanceof LibraryRepository)
|
|
35
|
+
await this.store.readFresh(); }
|
|
36
|
+
settings() { return this.store.settings(); }
|
|
37
|
+
connect() { return connectRadioSession(undefined, { source: this.source }); }
|
|
27
38
|
appearance() {
|
|
28
39
|
this.assertEnabled();
|
|
29
40
|
return { themes: themeNames, receiverStyles: receiverStyleNames, current: this.settings() };
|
|
30
41
|
}
|
|
31
42
|
async status() {
|
|
32
43
|
this.assertEnabled();
|
|
33
|
-
const client = await
|
|
44
|
+
const client = await this.connect();
|
|
34
45
|
const state = this.store.snapshot();
|
|
35
46
|
return {
|
|
36
47
|
connected: Boolean(client),
|
|
@@ -106,21 +117,27 @@ export class AgentRadioService {
|
|
|
106
117
|
: queue[input.random ? randomInt(queue.length) : Math.max(0, input.index ?? 0)];
|
|
107
118
|
if (!station)
|
|
108
119
|
throw new Error(input.stationId ? `Unknown station ID: ${input.stationId}. Search or browse first.` : 'No station is available for that request.');
|
|
109
|
-
|
|
120
|
+
return this.playStation(station, queue, input);
|
|
121
|
+
}
|
|
122
|
+
async playStation(station, queue = [station], options = {}) {
|
|
123
|
+
this.assertEnabled();
|
|
124
|
+
if ((await connectActiveAlarms()).length > 0)
|
|
125
|
+
throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.');
|
|
126
|
+
const ordered = queue.some(item => stationKey(item) === stationKey(station)) ? queue : [station, ...queue];
|
|
110
127
|
return this.send({
|
|
111
128
|
type: 'play',
|
|
112
129
|
station,
|
|
113
130
|
queue: ordered,
|
|
114
|
-
openNowPlaying: this.agentSettings().focusNowPlaying,
|
|
115
|
-
ifPlaying:
|
|
116
|
-
},
|
|
131
|
+
openNowPlaying: this.source === 'agent' && this.agentSettings().focusNowPlaying,
|
|
132
|
+
ifPlaying: options.ifPlaying ?? 'replace'
|
|
133
|
+
}, options.openUi);
|
|
117
134
|
}
|
|
118
135
|
async control(command, openUi) {
|
|
119
136
|
this.assertEnabled();
|
|
120
|
-
const client = await
|
|
137
|
+
const client = await this.connect();
|
|
121
138
|
if (!client && command.type === 'set-volume') {
|
|
122
139
|
const volume = Math.min(100, Math.max(0, command.volume));
|
|
123
|
-
this.store.updateSettings({ volume });
|
|
140
|
+
await this.store.updateSettings({ volume });
|
|
124
141
|
return idleResult(`Saved volume ${volume}; no playback session is active.`);
|
|
125
142
|
}
|
|
126
143
|
if (!client && ['stop', 'pause', 'resume', 'next', 'previous', 'set-volume', 'set-muted'].includes(command.type)) {
|
|
@@ -132,17 +149,24 @@ export class AgentRadioService {
|
|
|
132
149
|
}
|
|
133
150
|
async setFavorite(favorite, stationId) {
|
|
134
151
|
this.assertEnabled();
|
|
135
|
-
const client = await
|
|
152
|
+
const client = await this.connect();
|
|
136
153
|
if (!stationId && client)
|
|
137
154
|
return client.call({ type: 'set-favorite', favorite });
|
|
138
155
|
const station = stationId ? await this.findStation(stationId, this.savedStations()) : undefined;
|
|
139
156
|
if (!station)
|
|
140
157
|
throw new Error('No station was supplied and no active station is available.');
|
|
158
|
+
return this.favoriteStation(station, favorite);
|
|
159
|
+
}
|
|
160
|
+
async favoriteStation(station, favorite) {
|
|
161
|
+
this.assertEnabled();
|
|
162
|
+
const client = await this.connect();
|
|
163
|
+
if (client)
|
|
164
|
+
return client.call({ type: 'set-favorite', favorite, station });
|
|
141
165
|
const isFavorite = this.store.isFavorite(station);
|
|
142
166
|
if (isFavorite !== favorite)
|
|
143
|
-
this.store.toggleFavorite(station);
|
|
167
|
+
await this.store.toggleFavorite(station);
|
|
144
168
|
if (favorite && !isFavorite && this.store.snapshot().settings.shareDirectoryVotes)
|
|
145
|
-
void this.providers.vote(station);
|
|
169
|
+
void this.providers.vote(station).catch(() => undefined);
|
|
146
170
|
return { ok: true, message: `${favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}`, station };
|
|
147
171
|
}
|
|
148
172
|
async updateAppearance(input) {
|
|
@@ -151,10 +175,10 @@ export class AgentRadioService {
|
|
|
151
175
|
throw new Error(`Unknown theme: ${input.theme}`);
|
|
152
176
|
if (input.receiverStyle && !receiverStyleNames.includes(input.receiverStyle))
|
|
153
177
|
throw new Error(`Unknown receiver style: ${input.receiverStyle}`);
|
|
154
|
-
const client = await
|
|
178
|
+
const client = await this.connect();
|
|
155
179
|
if (client)
|
|
156
180
|
return client.call({ type: 'update-settings', settings: input });
|
|
157
|
-
return this.store.updateSettings(input).settings;
|
|
181
|
+
return (await this.store.updateSettings(input)).settings;
|
|
158
182
|
}
|
|
159
183
|
async runCompletionPreset() {
|
|
160
184
|
const preset = this.agentSettings().completionPreset;
|
|
@@ -162,7 +186,7 @@ export class AgentRadioService {
|
|
|
162
186
|
return this.play({ source: preset.source, countryCode: preset.countryCode, random: preset.source !== 'recent', openUi: preset.openUi, ifPlaying: preset.ifPlaying });
|
|
163
187
|
return this.control({ type: preset.action }, preset.openUi);
|
|
164
188
|
}
|
|
165
|
-
configureCompletionPreset(input) {
|
|
189
|
+
async configureCompletionPreset(input) {
|
|
166
190
|
this.assertEnabled();
|
|
167
191
|
const agentControl = this.agentSettings();
|
|
168
192
|
const completionPreset = { ...agentControl.completionPreset, ...input };
|
|
@@ -170,15 +194,15 @@ export class AgentRadioService {
|
|
|
170
194
|
throw new Error('A two-letter countryCode is required for the country preset source.');
|
|
171
195
|
if (completionPreset.countryCode)
|
|
172
196
|
completionPreset.countryCode = completionPreset.countryCode.toUpperCase();
|
|
173
|
-
return this.store.updateSettings({ agentControl: { ...agentControl, completionPreset } }).settings.agentControl;
|
|
197
|
+
return (await this.store.updateSettings({ agentControl: { ...agentControl, completionPreset } })).settings.agentControl;
|
|
174
198
|
}
|
|
175
199
|
async updateAgentPreferences(input) {
|
|
176
200
|
this.assertEnabled();
|
|
177
201
|
const agentControl = { ...this.agentSettings(), ...input };
|
|
178
|
-
const client = await
|
|
202
|
+
const client = await this.connect();
|
|
179
203
|
if (client)
|
|
180
204
|
return client.call({ type: 'update-settings', settings: { agentControl } });
|
|
181
|
-
return this.store.updateSettings({ agentControl }).settings.agentControl;
|
|
205
|
+
return (await this.store.updateSettings({ agentControl })).settings.agentControl;
|
|
182
206
|
}
|
|
183
207
|
alarmList() { this.assertEnabled(); return this.alarms.list(); }
|
|
184
208
|
alarmStatus() { this.assertEnabled(); return this.alarms.status(); }
|
|
@@ -191,13 +215,17 @@ export class AgentRadioService {
|
|
|
191
215
|
async listAirPlayDevices() {
|
|
192
216
|
this.assertEnabled();
|
|
193
217
|
this.assertAirPlayPlatform();
|
|
194
|
-
const client = await
|
|
195
|
-
if (client)
|
|
196
|
-
|
|
218
|
+
const client = await this.connect();
|
|
219
|
+
if (client) {
|
|
220
|
+
const result = await client.call({ type: 'airplay-list' });
|
|
221
|
+
if (!result.ok)
|
|
222
|
+
throw new Error(result.message);
|
|
223
|
+
return result.data ?? [];
|
|
224
|
+
}
|
|
197
225
|
const player = new PlayerController(() => this.store.snapshot().settings);
|
|
198
226
|
const backends = player.refreshDetectedBackends();
|
|
199
227
|
if (!backends.includes('airplay'))
|
|
200
|
-
throw new Error('AirPlay is unavailable on this
|
|
228
|
+
throw new Error('AirPlay is unavailable on this device. Run radiocli doctor to check FFmpeg, discovery, and the optional sender.');
|
|
201
229
|
return player.refreshAirPlayDevices();
|
|
202
230
|
}
|
|
203
231
|
async selectAirPlayDevice(deviceId, openUi) {
|
|
@@ -209,17 +237,17 @@ export class AgentRadioService {
|
|
|
209
237
|
throw new Error('Unknown AirPlay receiver ID. Refresh the receiver list and use an exact returned ID.');
|
|
210
238
|
if (device.local)
|
|
211
239
|
return this.useLocalOutput(openUi);
|
|
212
|
-
this.store.updateSettings({ preferredAirPlayDevice: device.id });
|
|
240
|
+
await this.store.updateSettings({ preferredAirPlayDevice: device.id });
|
|
213
241
|
return this.send({ type: 'airplay-select', deviceId: device.id }, openUi);
|
|
214
242
|
}
|
|
215
243
|
async useLocalOutput(openUi) {
|
|
216
244
|
this.assertEnabled();
|
|
217
|
-
const client = await
|
|
245
|
+
const client = await this.connect();
|
|
218
246
|
if (!client) {
|
|
219
247
|
const backend = preferredLocalBackend(detectPlaybackBackends());
|
|
220
248
|
if (!backend)
|
|
221
249
|
throw new Error('No local playback backend is available. Run radiocli setup to install mpv.');
|
|
222
|
-
this.store.updateSettings({ preferredBackend: backend });
|
|
250
|
+
await this.store.updateSettings({ preferredBackend: backend });
|
|
223
251
|
return idleResult(`Audio output set to this device (${backend}).`);
|
|
224
252
|
}
|
|
225
253
|
return this.send({ type: 'airplay-local' }, openUi);
|
|
@@ -227,7 +255,7 @@ export class AgentRadioService {
|
|
|
227
255
|
async submitAirPlayPasscode(code) {
|
|
228
256
|
this.assertEnabled();
|
|
229
257
|
this.assertAirPlayPlatform();
|
|
230
|
-
const client = await
|
|
258
|
+
const client = await this.connect();
|
|
231
259
|
if (!client)
|
|
232
260
|
throw new Error('No active AirPlay session is waiting for a receiver code.');
|
|
233
261
|
return client.call({ type: 'airplay-passcode', code });
|
|
@@ -252,19 +280,18 @@ export class AgentRadioService {
|
|
|
252
280
|
async send(command, openUi, allowActiveAlarm = false) {
|
|
253
281
|
const settings = this.settings();
|
|
254
282
|
const agentControl = settings.agentControl ?? defaultAgentControlSettings;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
let client = await connectRadioSession();
|
|
283
|
+
this.assertEnabled();
|
|
284
|
+
let client = await this.connect();
|
|
258
285
|
if (!client) {
|
|
259
286
|
if (!allowActiveAlarm && (await connectActiveAlarms()).length > 0)
|
|
260
287
|
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;
|
|
288
|
+
const shouldOpen = openUi ?? (this.source === 'agent' && agentControl.openUiOnPlay);
|
|
262
289
|
client = await ensureRadioSession(async () => {
|
|
263
290
|
if (shouldOpen)
|
|
264
291
|
await launchRadioTui(this.runtime.nodePath, this.runtime.cliPath, encodeAgentCommand({ type: 'status' }));
|
|
265
292
|
else
|
|
266
293
|
await launchHeadlessHost(this.runtime.nodePath, this.runtime.cliPath);
|
|
267
|
-
});
|
|
294
|
+
}, undefined, { source: this.source });
|
|
268
295
|
}
|
|
269
296
|
return client.call(command);
|
|
270
297
|
}
|
|
@@ -290,8 +317,10 @@ export class AgentRadioService {
|
|
|
290
317
|
}
|
|
291
318
|
async findStation(id, candidates) {
|
|
292
319
|
const normalized = id.trim();
|
|
293
|
-
const
|
|
294
|
-
|
|
320
|
+
const matches = new Map(candidates.filter(station => matchesStationId(station, normalized)).map(station => [stationKey(station), station]));
|
|
321
|
+
if (matches.size > 1)
|
|
322
|
+
throw new Error('This legacy station ID is ambiguous. Search or browse again and use the current station ID.');
|
|
323
|
+
const local = this.stationCache.get(normalized) ?? [...matches.values()][0];
|
|
295
324
|
if (local)
|
|
296
325
|
return local;
|
|
297
326
|
const separator = normalized.indexOf(':');
|
|
@@ -305,20 +334,26 @@ export class AgentRadioService {
|
|
|
305
334
|
return station ?? undefined;
|
|
306
335
|
}
|
|
307
336
|
remember(stations) {
|
|
308
|
-
for (const station of stations)
|
|
309
|
-
|
|
337
|
+
for (const station of stations) {
|
|
338
|
+
const key = opaqueStationId(station);
|
|
339
|
+
this.stationCache.delete(key);
|
|
340
|
+
this.stationCache.set(key, station);
|
|
341
|
+
}
|
|
342
|
+
while (this.stationCache.size > 1024)
|
|
343
|
+
this.stationCache.delete(this.stationCache.keys().next().value);
|
|
310
344
|
return stations;
|
|
311
345
|
}
|
|
312
346
|
agentSettings() {
|
|
313
347
|
return this.settings().agentControl ?? defaultAgentControlSettings;
|
|
314
348
|
}
|
|
315
349
|
assertEnabled() {
|
|
316
|
-
if (!this.agentSettings().enabled)
|
|
350
|
+
if (this.source === 'agent' && !this.agentSettings().enabled)
|
|
317
351
|
throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.');
|
|
318
352
|
}
|
|
319
353
|
assertAirPlayPlatform() {
|
|
320
|
-
|
|
321
|
-
|
|
354
|
+
const policy = airPlayPolicy(this.settings());
|
|
355
|
+
if (!policy.enabled)
|
|
356
|
+
throw new Error(policy.message);
|
|
322
357
|
}
|
|
323
358
|
}
|
|
324
359
|
function opaqueStationId(station) { return `${station.provider}:${station.id}`; }
|
|
@@ -337,7 +372,7 @@ export function stationForAgent(station) {
|
|
|
337
372
|
};
|
|
338
373
|
}
|
|
339
374
|
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')); }
|
|
375
|
+
export function decodeAgentCommand(value) { return parseRadioCommand(JSON.parse(Buffer.from(value, 'base64url').toString('utf8'))); }
|
|
341
376
|
function clampLimit(value, max = 30) { return Math.min(max, Math.max(1, Math.round(value))); }
|
|
342
377
|
function idleResult(message) {
|
|
343
378
|
return { ok: true, message, status: { owner: 'headless', station: null, queue: [], playback: { backend: 'none', state: 'idle', volume: 70, muted: false, ready: false } } };
|