@ciphore/radiocli 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -1
- package/README.md +30 -0
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +248 -0
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/runner.js +43 -26
- package/dist/cli.js +60 -3
- package/dist/player/player-controller.js +19 -0
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +4 -0
- package/dist/setup.js +71 -2
- package/dist/storage/store.js +33 -1
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +24 -9
- package/dist/ui/App.js +299 -18
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/components/StationList.js +3 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/page-footer.js +4 -2
- package/dist/ui/screen-items.js +40 -9
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/use-alarm-tui.js +4 -0
- package/dist/ui/use-app-input.js +29 -3
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +26 -7
- package/package.json +4 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { appVersion } from '../version.js';
|
|
5
|
+
import { receiverStyleNames, themeNames } from '../types.js';
|
|
6
|
+
import { AgentRadioService, stationForAgent } from './service.js';
|
|
7
|
+
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
|
+
|
|
9
|
+
RadioCLI controls internet radio, alarms, and macOS 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
|
+
|
|
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-only. 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
|
+
const alarmScheduleSchema = z.discriminatedUnion('type', [
|
|
13
|
+
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
|
+
z.object({
|
|
15
|
+
type: z.literal('recurring'),
|
|
16
|
+
time: z.string().regex(/^\d{2}:\d{2}$/).describe('24-hour local civil time, HH:mm.'),
|
|
17
|
+
weekdays: z.array(z.number().int().min(1).max(7)).min(1).describe('ISO weekdays, Monday=1 through Sunday=7.'),
|
|
18
|
+
timezone: z.string().min(1).describe('IANA timezone such as America/Los_Angeles.')
|
|
19
|
+
})
|
|
20
|
+
]);
|
|
21
|
+
const alarmOptionsSchema = {
|
|
22
|
+
label: z.string().min(1).max(120).optional(),
|
|
23
|
+
enabled: z.boolean().optional(),
|
|
24
|
+
volume: z.number().int().min(0).max(100).optional(),
|
|
25
|
+
fade_seconds: z.number().int().min(0).max(3600).optional(),
|
|
26
|
+
stop_after_minutes: z.number().int().min(1).max(10080).optional(),
|
|
27
|
+
fallback_station_id: z.string().min(1).optional(),
|
|
28
|
+
missed_run_grace_minutes: z.number().int().min(0).max(10080).optional(),
|
|
29
|
+
wake_if_supported: z.boolean().optional(),
|
|
30
|
+
keep_awake_until_alarm: z.boolean().optional()
|
|
31
|
+
};
|
|
32
|
+
export async function runMcpServer(runtime) {
|
|
33
|
+
const service = new AgentRadioService(runtime);
|
|
34
|
+
serveStdio(() => createMcpServer(service), { onerror: error => console.error(`RadioCLI MCP: ${error.message}`) });
|
|
35
|
+
}
|
|
36
|
+
function createMcpServer(service) {
|
|
37
|
+
const server = new McpServer({ name: 'radiocli', version: appVersion() }, { instructions: radioCliMcpInstructions });
|
|
38
|
+
const tool = (name, description, schema, handler) => {
|
|
39
|
+
const config = schema ? { description, inputSchema: schema } : { description };
|
|
40
|
+
server.registerTool(name, config, async (raw) => {
|
|
41
|
+
try {
|
|
42
|
+
const value = await handler(raw);
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return { content: [{ type: 'text', text: error instanceof Error ? error.message : 'RadioCLI request failed.' }], isError: true };
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
tool('radio_status', 'Get current playback, active station, queue, agent preferences, and whether alarm playback is active.', undefined, () => service.status());
|
|
51
|
+
tool('radio_update_status', 'Check the installed and latest RadioCLI versions and return the appropriate update command. This is read-only and never installs an update.', undefined, () => service.updateStatus());
|
|
52
|
+
tool('radio_search', 'Search RadioCLI’s public station directory by name, genre, language, city, region, or country. Use this—not web search or a browser—for requests such as “play a station from New York”, then pass a returned opaque id to radio_play.', z.object({
|
|
53
|
+
query: z.string().min(1).max(200),
|
|
54
|
+
limit: z.number().int().min(1).max(30).default(10),
|
|
55
|
+
country_code: z.string().length(2).optional()
|
|
56
|
+
}), async ({ query, limit, country_code }) => (await service.search(query, limit, country_code)).map(stationForAgent));
|
|
57
|
+
tool('radio_browse', 'List saved favorites, recent stations, popular stations, nearby stations, countries, or recent track titles.', z.object({
|
|
58
|
+
kind: z.enum(['favorites', 'recent', 'popular', 'nearby', 'countries', 'track-history']),
|
|
59
|
+
limit: z.number().int().min(1).max(50).default(20)
|
|
60
|
+
}), async ({ kind, limit }) => {
|
|
61
|
+
const values = await service.browse(kind, limit);
|
|
62
|
+
return kind === 'favorites' || kind === 'recent' || kind === 'popular' || kind === 'nearby'
|
|
63
|
+
? values.map(stationForAgent)
|
|
64
|
+
: values;
|
|
65
|
+
});
|
|
66
|
+
tool('radio_play', 'Play through RadioCLI using a searched/saved station id, or choose from recent, favorites, popular, or a country. For “play my most recent radio station”, call this directly with source="recent" and index=0. By default this opens the normal RadioCLI TUI in a terminal window; set open_ui=false only when the user requests headless playback.', z.object({
|
|
67
|
+
station_id: z.string().min(1).optional(),
|
|
68
|
+
source: z.enum(['recent', 'favorite', 'popular', 'country']).default('recent'),
|
|
69
|
+
country_code: z.string().length(2).optional(),
|
|
70
|
+
index: z.number().int().min(0).optional(),
|
|
71
|
+
random: z.boolean().default(false),
|
|
72
|
+
open_ui: z.boolean().optional(),
|
|
73
|
+
if_playing: z.enum(['keep', 'replace']).default('replace')
|
|
74
|
+
}), ({ station_id, source, country_code, index, random, open_ui, if_playing }) => service.play({ stationId: station_id, source, countryCode: country_code, index, random, openUi: open_ui, ifPlaying: if_playing }));
|
|
75
|
+
tool('radio_pause', 'Pause playback. Safe to call repeatedly.', undefined, () => service.control({ type: 'pause' }));
|
|
76
|
+
tool('radio_resume', 'Resume paused playback without changing stations.', undefined, () => service.control({ type: 'resume' }));
|
|
77
|
+
tool('radio_stop', 'Stop ordinary interactive/headless playback. This never dismisses or modifies an alarm.', undefined, () => service.control({ type: 'stop' }));
|
|
78
|
+
tool('radio_next', 'Play the next station in the queue created by the last radio_play request.', undefined, () => service.control({ type: 'next' }));
|
|
79
|
+
tool('radio_previous', 'Play the previous station in the current queue.', undefined, () => service.control({ type: 'previous' }));
|
|
80
|
+
tool('radio_set_volume', 'Set playback volume from 0 through 100 and save it as the RadioCLI preference.', z.object({ volume: z.number().min(0).max(100) }), ({ volume }) => service.control({ type: 'set-volume', volume }));
|
|
81
|
+
tool('radio_set_muted', 'Explicitly mute or unmute playback; this is not a toggle.', z.object({ muted: z.boolean() }), ({ muted }) => service.control({ type: 'set-muted', muted }));
|
|
82
|
+
tool('radio_set_favorite', 'Explicitly add or remove a favorite. Omit station_id to act on the current station, as in “favorite this”.', z.object({ favorite: z.boolean().default(true), station_id: z.string().min(1).optional() }), ({ favorite, station_id }) => service.setFavorite(favorite, station_id));
|
|
83
|
+
tool('radio_stats', 'Get local listening time, stations heard, active days, and streak statistics.', undefined, () => service.stats());
|
|
84
|
+
tool('radio_get_appearance', 'List available display themes and receiver styles and show the current selections.', undefined, () => service.appearance());
|
|
85
|
+
tool('radio_set_appearance', 'Change the RadioCLI display color theme or visual receiver style. An open TUI updates immediately.', z.object({ theme: z.enum(themeNames).optional(), receiver_style: z.enum(receiverStyleNames).optional() }), ({ theme, receiver_style }) => service.updateAppearance({ theme, receiverStyle: receiver_style }));
|
|
86
|
+
tool('radio_set_agent_preferences', 'Choose whether agent-started playback opens the normal TUI and whether it switches an open TUI to Now Playing.', z.object({ open_ui_on_play: z.boolean().optional(), focus_now_playing: z.boolean().optional() }), ({ open_ui_on_play, focus_now_playing }) => service.updateAgentPreferences({ openUiOnPlay: open_ui_on_play, focusNowPlaying: focus_now_playing }));
|
|
87
|
+
tool('radio_run_completion_preset', 'Run the user-configured completion action immediately after the current task completes. This is a one-shot radio action, not a reason to create a goal, automation, reminder, task record, or monitoring task.', undefined, () => service.runCompletionPreset());
|
|
88
|
+
tool('radio_configure_completion_preset', 'Configure what radio_run_completion_preset does: play/stop/pause/resume, station source, conflict behavior, and optional UI override.', z.object({
|
|
89
|
+
action: z.enum(['play', 'pause', 'resume', 'stop']).optional(),
|
|
90
|
+
source: z.enum(['recent', 'favorite', 'popular', 'country']).optional(),
|
|
91
|
+
country_code: z.string().length(2).optional(),
|
|
92
|
+
if_playing: z.enum(['keep', 'replace']).optional(),
|
|
93
|
+
open_ui: z.boolean().optional()
|
|
94
|
+
}), ({ action, source, country_code, if_playing, open_ui }) => service.configureCompletionPreset({ action, source, countryCode: country_code, ifPlaying: if_playing, openUi: open_ui }));
|
|
95
|
+
tool('radio_alarm_list', 'List saved alarms with exact IDs, schedules, stations, next occurrences, playback settings, and last outcomes.', undefined, () => service.alarmList());
|
|
96
|
+
tool('radio_alarm_status', 'Get saved alarms, native scheduler health, and every currently ringing alarm occurrence.', undefined, () => service.alarmStatus());
|
|
97
|
+
tool('radio_alarm_create', 'Create and natively schedule a one-time or recurring radio alarm. Resolve the station with radio_search or radio_browse first.', z.object({
|
|
98
|
+
station_id: z.string().min(1),
|
|
99
|
+
schedule: alarmScheduleSchema,
|
|
100
|
+
...alarmOptionsSchema
|
|
101
|
+
}), ({ station_id, schedule, label, enabled, volume, fade_seconds, stop_after_minutes, fallback_station_id, missed_run_grace_minutes, wake_if_supported, keep_awake_until_alarm }) => service.alarmCreate({
|
|
102
|
+
stationId: station_id, schedule: schedule, label, enabled, volume,
|
|
103
|
+
fadeSeconds: fade_seconds, stopAfterMinutes: stop_after_minutes, fallbackStationId: fallback_station_id,
|
|
104
|
+
missedRunGraceMinutes: missed_run_grace_minutes, wakeIfSupported: wake_if_supported, keepAwakeUntilAlarm: keep_awake_until_alarm
|
|
105
|
+
}));
|
|
106
|
+
tool('radio_alarm_update', 'Edit an existing alarm by exact ID. Omitted fields remain unchanged; pass the complete replacement schedule when changing time or recurrence.', z.object({
|
|
107
|
+
alarm_id: z.string().min(1),
|
|
108
|
+
station_id: z.string().min(1).optional(),
|
|
109
|
+
schedule: alarmScheduleSchema.optional(),
|
|
110
|
+
clear_fallback: z.boolean().optional(),
|
|
111
|
+
...alarmOptionsSchema
|
|
112
|
+
}).refine(value => !(value.clear_fallback && value.fallback_station_id), { message: 'Choose either clear_fallback or fallback_station_id.' }), ({ alarm_id, station_id, schedule, clear_fallback, label, enabled, volume, fade_seconds, stop_after_minutes, fallback_station_id, missed_run_grace_minutes, wake_if_supported, keep_awake_until_alarm }) => service.alarmUpdate(alarm_id, {
|
|
113
|
+
stationId: station_id, schedule: schedule, clearFallback: clear_fallback,
|
|
114
|
+
label, enabled, volume, fadeSeconds: fade_seconds, stopAfterMinutes: stop_after_minutes, fallbackStationId: fallback_station_id,
|
|
115
|
+
missedRunGraceMinutes: missed_run_grace_minutes, wakeIfSupported: wake_if_supported, keepAwakeUntilAlarm: keep_awake_until_alarm
|
|
116
|
+
}));
|
|
117
|
+
tool('radio_alarm_set_enabled', 'Explicitly enable or disable an alarm by exact ID and reconcile its native scheduler job.', z.object({ alarm_id: z.string().min(1), enabled: z.boolean() }), ({ alarm_id, enabled }) => service.alarmSetEnabled(alarm_id, enabled));
|
|
118
|
+
tool('radio_alarm_remove', 'Permanently remove an alarm and its native scheduler job. First list alarms and obtain explicit user confirmation for the exact ID, then pass confirm=true.', z.object({ alarm_id: z.string().min(1), confirm: z.boolean() }), ({ alarm_id, confirm }) => service.alarmRemove(alarm_id, confirm));
|
|
119
|
+
tool('radio_alarm_sync', 'Reconcile every saved alarm with the operating system scheduler after setup, upgrades, timezone changes, or repair.', undefined, () => service.alarmSync());
|
|
120
|
+
tool('radio_alarm_control', 'Control one currently ringing alarm: dismiss it, snooze it, keep playing past its automatic stop, or hand it off to normal interactive playback. If multiple alarms ring, supply an exact alarm_id and optionally occurrence_at from radio_alarm_status.', z.object({
|
|
121
|
+
action: z.enum(['dismiss', 'snooze', 'keep-playing', 'handoff']),
|
|
122
|
+
alarm_id: z.string().min(1).optional(),
|
|
123
|
+
occurrence_at: z.string().min(1).optional(),
|
|
124
|
+
snooze_minutes: z.number().int().min(1).max(1440).optional()
|
|
125
|
+
}).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 Mac. Returns opaque receiver IDs; always call this before selecting a receiver.', undefined, async () => (await service.listAirPlayDevices()).map(device => ({
|
|
127
|
+
id: device.id,
|
|
128
|
+
name: device.name,
|
|
129
|
+
host: device.host,
|
|
130
|
+
port: device.port,
|
|
131
|
+
requiresPassword: device.requiresPassword,
|
|
132
|
+
airplay2: device.airplay2,
|
|
133
|
+
local: device.local ?? false
|
|
134
|
+
})));
|
|
135
|
+
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 Mac using the best available local backend.', z.object({ open_ui: z.boolean().optional() }).optional(), args => service.useLocalOutput(args?.open_ui));
|
|
137
|
+
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
|
+
return server;
|
|
139
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { defaultAgentControlSettings, themeNames, receiverStyleNames } from '../types.js';
|
|
3
|
+
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
4
|
+
import { ProviderManager } from '../providers/provider-manager.js';
|
|
5
|
+
import { computeListeningStats } from '../activity/stats.js';
|
|
6
|
+
import { connectActiveAlarms } from '../alarms/active-session.js';
|
|
7
|
+
import { connectRadioSession, ensureRadioSession } from './session.js';
|
|
8
|
+
import { launchHeadlessHost, launchRadioTui } from './launcher.js';
|
|
9
|
+
import { PlayerController } from '../player/player-controller.js';
|
|
10
|
+
import { detectPlaybackBackends } from '../player/backend-install.js';
|
|
11
|
+
import { AgentAlarmService } from './alarm-service.js';
|
|
12
|
+
import { appVersion } from '../version.js';
|
|
13
|
+
import { checkForUpdate, updateCommandForInstall } from '../update-check.js';
|
|
14
|
+
export class AgentRadioService {
|
|
15
|
+
runtime;
|
|
16
|
+
store;
|
|
17
|
+
providers;
|
|
18
|
+
stationCache = new Map();
|
|
19
|
+
alarms;
|
|
20
|
+
constructor(runtime, store = new JsonLibraryStore(), providers = new ProviderManager()) {
|
|
21
|
+
this.runtime = runtime;
|
|
22
|
+
this.store = store;
|
|
23
|
+
this.providers = providers;
|
|
24
|
+
this.alarms = new AgentAlarmService(this.store, id => this.resolveStationId(id), undefined, status => this.handoffAlarm(status));
|
|
25
|
+
}
|
|
26
|
+
settings() { return this.store.snapshot().settings; }
|
|
27
|
+
appearance() {
|
|
28
|
+
this.assertEnabled();
|
|
29
|
+
return { themes: themeNames, receiverStyles: receiverStyleNames, current: this.settings() };
|
|
30
|
+
}
|
|
31
|
+
async status() {
|
|
32
|
+
this.assertEnabled();
|
|
33
|
+
const client = await connectRadioSession();
|
|
34
|
+
const state = this.store.snapshot();
|
|
35
|
+
return {
|
|
36
|
+
connected: Boolean(client),
|
|
37
|
+
session: client ? await client.status() : null,
|
|
38
|
+
agentControl: this.agentSettings(),
|
|
39
|
+
favoriteCount: state.favorites.length,
|
|
40
|
+
recentCount: state.recent.length,
|
|
41
|
+
alarmActive: (await connectActiveAlarms()).length > 0,
|
|
42
|
+
audioOutput: {
|
|
43
|
+
preferredBackend: state.settings.preferredBackend,
|
|
44
|
+
preferredAirPlayDevice: state.settings.preferredAirPlayDevice
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async updateStatus() {
|
|
49
|
+
this.assertEnabled();
|
|
50
|
+
const update = await checkForUpdate();
|
|
51
|
+
const install = updateCommandForInstall();
|
|
52
|
+
return {
|
|
53
|
+
installedVersion: appVersion(),
|
|
54
|
+
latestVersion: update.latestVersion ?? null,
|
|
55
|
+
updateAvailable: update.updateAvailable,
|
|
56
|
+
checkedAt: update.checkedAt,
|
|
57
|
+
error: update.error ?? null,
|
|
58
|
+
installMethod: install.method,
|
|
59
|
+
command: install.command,
|
|
60
|
+
note: 'RadioCLI does not install updates through MCP. Ask the user to approve the command or use Settings, then restart the MCP client.'
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async search(query, limit = 10, countryCode) {
|
|
64
|
+
this.assertEnabled();
|
|
65
|
+
const stations = await this.providers.search(query, this.store.snapshot().settings, {
|
|
66
|
+
limit: clampLimit(limit),
|
|
67
|
+
countryCode
|
|
68
|
+
});
|
|
69
|
+
this.remember(stations);
|
|
70
|
+
return stations;
|
|
71
|
+
}
|
|
72
|
+
async browse(kind, limit = 20) {
|
|
73
|
+
this.assertEnabled();
|
|
74
|
+
const state = this.store.snapshot();
|
|
75
|
+
const bounded = clampLimit(limit, 50);
|
|
76
|
+
if (kind === 'favorites')
|
|
77
|
+
return this.remember(state.favorites.slice(0, bounded));
|
|
78
|
+
if (kind === 'recent')
|
|
79
|
+
return this.remember(state.recent.slice(0, bounded).map(item => item.station));
|
|
80
|
+
if (kind === 'track-history')
|
|
81
|
+
return state.trackHistory.slice(0, bounded);
|
|
82
|
+
if (kind === 'countries')
|
|
83
|
+
return this.providers.countries(bounded);
|
|
84
|
+
if (kind === 'popular')
|
|
85
|
+
return this.remember(await this.providers.popular(bounded));
|
|
86
|
+
if (!state.settings.enableNearbyLocation)
|
|
87
|
+
throw new Error('Nearby location lookup is disabled in RadioCLI settings.');
|
|
88
|
+
const location = await this.providers.detectLocation();
|
|
89
|
+
if (!location)
|
|
90
|
+
throw new Error('RadioCLI could not determine an approximate location.');
|
|
91
|
+
return this.remember(await this.providers.nearby(location, bounded));
|
|
92
|
+
}
|
|
93
|
+
stats() {
|
|
94
|
+
this.assertEnabled();
|
|
95
|
+
return computeListeningStats(this.store.snapshot().activity.sessions);
|
|
96
|
+
}
|
|
97
|
+
async play(input) {
|
|
98
|
+
this.assertEnabled();
|
|
99
|
+
if ((await connectActiveAlarms()).length > 0)
|
|
100
|
+
throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.');
|
|
101
|
+
const queue = await this.stationQueue(input.source ?? 'recent', input.countryCode);
|
|
102
|
+
if (!input.stationId && queue.length === 0)
|
|
103
|
+
throw new Error('No station is available for that request.');
|
|
104
|
+
const station = input.stationId
|
|
105
|
+
? await this.findStation(input.stationId, queue)
|
|
106
|
+
: queue[input.random ? randomInt(queue.length) : Math.max(0, input.index ?? 0)];
|
|
107
|
+
if (!station)
|
|
108
|
+
throw new Error(input.stationId ? `Unknown station ID: ${input.stationId}. Search or browse first.` : 'No station is available for that request.');
|
|
109
|
+
const ordered = [station, ...queue.filter(item => stationKey(item) !== stationKey(station))];
|
|
110
|
+
return this.send({
|
|
111
|
+
type: 'play',
|
|
112
|
+
station,
|
|
113
|
+
queue: ordered,
|
|
114
|
+
openNowPlaying: this.agentSettings().focusNowPlaying,
|
|
115
|
+
ifPlaying: input.ifPlaying ?? 'replace'
|
|
116
|
+
}, input.openUi);
|
|
117
|
+
}
|
|
118
|
+
async control(command, openUi) {
|
|
119
|
+
this.assertEnabled();
|
|
120
|
+
const client = await connectRadioSession();
|
|
121
|
+
if (!client && command.type === 'set-volume') {
|
|
122
|
+
const volume = Math.min(100, Math.max(0, command.volume));
|
|
123
|
+
this.store.updateSettings({ volume });
|
|
124
|
+
return idleResult(`Saved volume ${volume}; no playback session is active.`);
|
|
125
|
+
}
|
|
126
|
+
if (!client && ['stop', 'pause', 'resume', 'next', 'previous', 'set-volume', 'set-muted'].includes(command.type)) {
|
|
127
|
+
if (command.type === 'stop' || command.type === 'pause')
|
|
128
|
+
return idleResult(command.type === 'stop' ? 'RadioCLI is already stopped.' : 'No playback session is active; pause is already satisfied.');
|
|
129
|
+
throw new Error('No interactive RadioCLI playback session is active.');
|
|
130
|
+
}
|
|
131
|
+
return this.send(command, openUi);
|
|
132
|
+
}
|
|
133
|
+
async setFavorite(favorite, stationId) {
|
|
134
|
+
this.assertEnabled();
|
|
135
|
+
const client = await connectRadioSession();
|
|
136
|
+
if (!stationId && client)
|
|
137
|
+
return client.call({ type: 'set-favorite', favorite });
|
|
138
|
+
const station = stationId ? await this.findStation(stationId, this.savedStations()) : undefined;
|
|
139
|
+
if (!station)
|
|
140
|
+
throw new Error('No station was supplied and no active station is available.');
|
|
141
|
+
const isFavorite = this.store.isFavorite(station);
|
|
142
|
+
if (isFavorite !== favorite)
|
|
143
|
+
this.store.toggleFavorite(station);
|
|
144
|
+
if (favorite && !isFavorite && this.store.snapshot().settings.shareDirectoryVotes)
|
|
145
|
+
void this.providers.vote(station);
|
|
146
|
+
return { ok: true, message: `${favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}`, station };
|
|
147
|
+
}
|
|
148
|
+
async updateAppearance(input) {
|
|
149
|
+
this.assertEnabled();
|
|
150
|
+
if (input.theme && !themeNames.includes(input.theme))
|
|
151
|
+
throw new Error(`Unknown theme: ${input.theme}`);
|
|
152
|
+
if (input.receiverStyle && !receiverStyleNames.includes(input.receiverStyle))
|
|
153
|
+
throw new Error(`Unknown receiver style: ${input.receiverStyle}`);
|
|
154
|
+
const client = await connectRadioSession();
|
|
155
|
+
if (client)
|
|
156
|
+
return client.call({ type: 'update-settings', settings: input });
|
|
157
|
+
return this.store.updateSettings(input).settings;
|
|
158
|
+
}
|
|
159
|
+
async runCompletionPreset() {
|
|
160
|
+
const preset = this.agentSettings().completionPreset;
|
|
161
|
+
if (preset.action === 'play')
|
|
162
|
+
return this.play({ source: preset.source, countryCode: preset.countryCode, random: preset.source !== 'recent', openUi: preset.openUi, ifPlaying: preset.ifPlaying });
|
|
163
|
+
return this.control({ type: preset.action }, preset.openUi);
|
|
164
|
+
}
|
|
165
|
+
configureCompletionPreset(input) {
|
|
166
|
+
this.assertEnabled();
|
|
167
|
+
const agentControl = this.agentSettings();
|
|
168
|
+
const completionPreset = { ...agentControl.completionPreset, ...input };
|
|
169
|
+
if (completionPreset.source === 'country' && !completionPreset.countryCode)
|
|
170
|
+
throw new Error('A two-letter countryCode is required for the country preset source.');
|
|
171
|
+
if (completionPreset.countryCode)
|
|
172
|
+
completionPreset.countryCode = completionPreset.countryCode.toUpperCase();
|
|
173
|
+
return this.store.updateSettings({ agentControl: { ...agentControl, completionPreset } }).settings.agentControl;
|
|
174
|
+
}
|
|
175
|
+
async updateAgentPreferences(input) {
|
|
176
|
+
this.assertEnabled();
|
|
177
|
+
const agentControl = { ...this.agentSettings(), ...input };
|
|
178
|
+
const client = await connectRadioSession();
|
|
179
|
+
if (client)
|
|
180
|
+
return client.call({ type: 'update-settings', settings: { agentControl } });
|
|
181
|
+
return this.store.updateSettings({ agentControl }).settings.agentControl;
|
|
182
|
+
}
|
|
183
|
+
alarmList() { this.assertEnabled(); return this.alarms.list(); }
|
|
184
|
+
alarmStatus() { this.assertEnabled(); return this.alarms.status(); }
|
|
185
|
+
alarmCreate(input) { this.assertEnabled(); return this.alarms.create(input); }
|
|
186
|
+
alarmUpdate(id, input) { this.assertEnabled(); return this.alarms.update(id, input); }
|
|
187
|
+
alarmSetEnabled(id, enabled) { this.assertEnabled(); return this.alarms.setEnabled(id, enabled); }
|
|
188
|
+
alarmRemove(id, confirm) { this.assertEnabled(); return this.alarms.remove(id, confirm); }
|
|
189
|
+
alarmSync() { this.assertEnabled(); return this.alarms.sync(); }
|
|
190
|
+
alarmControl(input) { this.assertEnabled(); return this.alarms.controlActive(input); }
|
|
191
|
+
async listAirPlayDevices() {
|
|
192
|
+
this.assertEnabled();
|
|
193
|
+
this.assertAirPlayPlatform();
|
|
194
|
+
const client = await connectRadioSession();
|
|
195
|
+
if (client)
|
|
196
|
+
return (await client.call({ type: 'airplay-list' })).data ?? [];
|
|
197
|
+
const player = new PlayerController(() => this.store.snapshot().settings);
|
|
198
|
+
const backends = player.refreshDetectedBackends();
|
|
199
|
+
if (!backends.includes('airplay'))
|
|
200
|
+
throw new Error('AirPlay is unavailable on this Mac. Run radiocli doctor to check ffmpeg, dns-sd, and the sender package.');
|
|
201
|
+
return player.refreshAirPlayDevices();
|
|
202
|
+
}
|
|
203
|
+
async selectAirPlayDevice(deviceId, openUi) {
|
|
204
|
+
this.assertEnabled();
|
|
205
|
+
this.assertAirPlayPlatform();
|
|
206
|
+
const devices = await this.listAirPlayDevices();
|
|
207
|
+
const device = devices.find(item => item.id === deviceId);
|
|
208
|
+
if (!device)
|
|
209
|
+
throw new Error('Unknown AirPlay receiver ID. Refresh the receiver list and use an exact returned ID.');
|
|
210
|
+
if (device.local)
|
|
211
|
+
return this.useLocalOutput(openUi);
|
|
212
|
+
this.store.updateSettings({ preferredAirPlayDevice: device.id });
|
|
213
|
+
return this.send({ type: 'airplay-select', deviceId: device.id }, openUi);
|
|
214
|
+
}
|
|
215
|
+
async useLocalOutput(openUi) {
|
|
216
|
+
this.assertEnabled();
|
|
217
|
+
const client = await connectRadioSession();
|
|
218
|
+
if (!client) {
|
|
219
|
+
const backend = preferredLocalBackend(detectPlaybackBackends());
|
|
220
|
+
if (!backend)
|
|
221
|
+
throw new Error('No local playback backend is available. Run radiocli setup to install mpv.');
|
|
222
|
+
this.store.updateSettings({ preferredBackend: backend });
|
|
223
|
+
return idleResult(`Audio output set to this device (${backend}).`);
|
|
224
|
+
}
|
|
225
|
+
return this.send({ type: 'airplay-local' }, openUi);
|
|
226
|
+
}
|
|
227
|
+
async submitAirPlayPasscode(code) {
|
|
228
|
+
this.assertEnabled();
|
|
229
|
+
this.assertAirPlayPlatform();
|
|
230
|
+
const client = await connectRadioSession();
|
|
231
|
+
if (!client)
|
|
232
|
+
throw new Error('No active AirPlay session is waiting for a receiver code.');
|
|
233
|
+
return client.call({ type: 'airplay-passcode', code });
|
|
234
|
+
}
|
|
235
|
+
async resolveStationId(id) {
|
|
236
|
+
return this.findStation(id, this.savedStations());
|
|
237
|
+
}
|
|
238
|
+
async handoffAlarm(status) {
|
|
239
|
+
if (!status.station)
|
|
240
|
+
throw new Error('This alarm session does not expose a station for interactive handoff.');
|
|
241
|
+
const result = await this.send({
|
|
242
|
+
type: 'play',
|
|
243
|
+
station: status.station,
|
|
244
|
+
queue: [status.station],
|
|
245
|
+
openNowPlaying: true,
|
|
246
|
+
ifPlaying: 'replace'
|
|
247
|
+
}, undefined, true);
|
|
248
|
+
if (!result.ok || result.status.playback.state !== 'playing' || !result.status.playback.ready) {
|
|
249
|
+
throw new Error(result.message || 'Interactive playback did not become ready; the alarm is still playing.');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async send(command, openUi, allowActiveAlarm = false) {
|
|
253
|
+
const settings = this.settings();
|
|
254
|
+
const agentControl = settings.agentControl ?? defaultAgentControlSettings;
|
|
255
|
+
if (!agentControl.enabled)
|
|
256
|
+
throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.');
|
|
257
|
+
let client = await connectRadioSession();
|
|
258
|
+
if (!client) {
|
|
259
|
+
if (!allowActiveAlarm && (await connectActiveAlarms()).length > 0)
|
|
260
|
+
throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.');
|
|
261
|
+
const shouldOpen = openUi ?? agentControl.openUiOnPlay;
|
|
262
|
+
client = await ensureRadioSession(async () => {
|
|
263
|
+
if (shouldOpen)
|
|
264
|
+
await launchRadioTui(this.runtime.nodePath, this.runtime.cliPath, encodeAgentCommand({ type: 'status' }));
|
|
265
|
+
else
|
|
266
|
+
await launchHeadlessHost(this.runtime.nodePath, this.runtime.cliPath);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return client.call(command);
|
|
270
|
+
}
|
|
271
|
+
async stationQueue(source, countryCode) {
|
|
272
|
+
const state = this.store.snapshot();
|
|
273
|
+
let stations;
|
|
274
|
+
if (source === 'recent')
|
|
275
|
+
stations = state.recent.map(item => item.station);
|
|
276
|
+
else if (source === 'favorite')
|
|
277
|
+
stations = state.favorites;
|
|
278
|
+
else if (source === 'popular')
|
|
279
|
+
stations = await this.providers.popular(50);
|
|
280
|
+
else {
|
|
281
|
+
if (!countryCode || !/^[a-z]{2}$/i.test(countryCode))
|
|
282
|
+
throw new Error('countryCode must be a two-letter ISO country code.');
|
|
283
|
+
stations = await this.providers.byCountry(countryCode.toUpperCase(), 100);
|
|
284
|
+
}
|
|
285
|
+
return this.remember(stations);
|
|
286
|
+
}
|
|
287
|
+
savedStations() {
|
|
288
|
+
const state = this.store.snapshot();
|
|
289
|
+
return [...state.favorites, ...state.recent.map(item => item.station), ...state.imported, ...this.stationCache.values()];
|
|
290
|
+
}
|
|
291
|
+
async findStation(id, candidates) {
|
|
292
|
+
const normalized = id.trim();
|
|
293
|
+
const local = this.stationCache.get(normalized)
|
|
294
|
+
?? candidates.find(station => opaqueStationId(station) === normalized || station.id === normalized);
|
|
295
|
+
if (local)
|
|
296
|
+
return local;
|
|
297
|
+
const separator = normalized.indexOf(':');
|
|
298
|
+
const provider = separator > 0 ? normalized.slice(0, separator) : 'radio-browser';
|
|
299
|
+
const providerId = separator > 0 ? normalized.slice(separator + 1) : normalized;
|
|
300
|
+
if (!['radio-browser', 'radio-garden', 'playlist'].includes(provider))
|
|
301
|
+
return undefined;
|
|
302
|
+
const station = await this.providers.byId(provider, providerId);
|
|
303
|
+
if (station)
|
|
304
|
+
this.remember([station]);
|
|
305
|
+
return station ?? undefined;
|
|
306
|
+
}
|
|
307
|
+
remember(stations) {
|
|
308
|
+
for (const station of stations)
|
|
309
|
+
this.stationCache.set(opaqueStationId(station), station);
|
|
310
|
+
return stations;
|
|
311
|
+
}
|
|
312
|
+
agentSettings() {
|
|
313
|
+
return this.settings().agentControl ?? defaultAgentControlSettings;
|
|
314
|
+
}
|
|
315
|
+
assertEnabled() {
|
|
316
|
+
if (!this.agentSettings().enabled)
|
|
317
|
+
throw new Error('Agent control is disabled. Run radiocli mcp enable or enable it in Settings.');
|
|
318
|
+
}
|
|
319
|
+
assertAirPlayPlatform() {
|
|
320
|
+
if (process.platform !== 'darwin')
|
|
321
|
+
throw new Error('AirPlay control is available only on macOS.');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function opaqueStationId(station) { return `${station.provider}:${station.id}`; }
|
|
325
|
+
export function stationForAgent(station) {
|
|
326
|
+
return {
|
|
327
|
+
id: opaqueStationId(station),
|
|
328
|
+
name: station.name,
|
|
329
|
+
country: station.country,
|
|
330
|
+
countryCode: station.countryCode,
|
|
331
|
+
city: station.city,
|
|
332
|
+
language: station.language,
|
|
333
|
+
tags: station.tags.slice(0, 8),
|
|
334
|
+
codec: station.codec,
|
|
335
|
+
bitrate: station.bitrate,
|
|
336
|
+
distanceKm: station.distanceKm
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function encodeAgentCommand(command) { return Buffer.from(JSON.stringify(command)).toString('base64url'); }
|
|
340
|
+
export function decodeAgentCommand(value) { return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); }
|
|
341
|
+
function clampLimit(value, max = 30) { return Math.min(max, Math.max(1, Math.round(value))); }
|
|
342
|
+
function idleResult(message) {
|
|
343
|
+
return { ok: true, message, status: { owner: 'headless', station: null, queue: [], playback: { backend: 'none', state: 'idle', volume: 70, muted: false, ready: false } } };
|
|
344
|
+
}
|
|
345
|
+
function preferredLocalBackend(backends) {
|
|
346
|
+
return backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null;
|
|
347
|
+
}
|