@ciphore/radiocli 0.2.4 → 0.2.6
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 +103 -2
- package/CONTRIBUTING.md +28 -0
- package/README.md +108 -253
- 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 +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- 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 +182 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- 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 +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +130 -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 +314 -76
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +21 -24
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/components/ReceiverVisualRow.js +39 -0
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +2 -21
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/theme.js +46 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +2 -2
- package/dist/ui/visualizers/receiver-style-registry.js +3 -1
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +36 -12
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AlarmOccurrenceCache } from '../alarms/occurrence-cache.js';
|
|
2
|
+
import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone } from '../alarms/schedule.js';
|
|
2
3
|
import { connectActiveAlarms } from '../alarms/active-session.js';
|
|
3
4
|
import { createSchedulerService } from '../alarms/scheduler.js';
|
|
4
5
|
export class AgentAlarmService {
|
|
@@ -6,6 +7,7 @@ export class AgentAlarmService {
|
|
|
6
7
|
resolveStation;
|
|
7
8
|
scheduler;
|
|
8
9
|
handoffToInteractive;
|
|
10
|
+
occurrences = new AlarmOccurrenceCache();
|
|
9
11
|
constructor(store, resolveStation, scheduler = createSchedulerService(), handoffToInteractive) {
|
|
10
12
|
this.store = store;
|
|
11
13
|
this.resolveStation = resolveStation;
|
|
@@ -45,7 +47,7 @@ export class AgentAlarmService {
|
|
|
45
47
|
keepAwakeUntilAlarm: input.keepAwakeUntilAlarm ?? false
|
|
46
48
|
}
|
|
47
49
|
};
|
|
48
|
-
const alarm = this.store.addAlarm(alarmInput);
|
|
50
|
+
const alarm = await this.store.addAlarm(alarmInput);
|
|
49
51
|
await this.syncSaved(alarm);
|
|
50
52
|
return this.describe(alarm);
|
|
51
53
|
}
|
|
@@ -55,7 +57,7 @@ export class AgentAlarmService {
|
|
|
55
57
|
const fallbackStation = input.clearFallback
|
|
56
58
|
? undefined
|
|
57
59
|
: input.fallbackStationId ? await this.requiredStation(input.fallbackStationId) : alarm.playback.fallbackStation;
|
|
58
|
-
const updated = this.store.updateAlarm(alarm.id, {
|
|
60
|
+
const updated = await this.store.updateAlarm(alarm.id, {
|
|
59
61
|
label: input.label === undefined ? alarm.label : cleanLabel(input.label),
|
|
60
62
|
enabled: input.enabled ?? alarm.enabled,
|
|
61
63
|
station,
|
|
@@ -76,7 +78,7 @@ export class AgentAlarmService {
|
|
|
76
78
|
return this.describe(updated);
|
|
77
79
|
}
|
|
78
80
|
async setEnabled(id, enabled) {
|
|
79
|
-
const alarm = this.store.toggleAlarm(this.requiredAlarm(id).id, enabled);
|
|
81
|
+
const alarm = await this.store.toggleAlarm(this.requiredAlarm(id).id, enabled);
|
|
80
82
|
await this.syncSaved(alarm);
|
|
81
83
|
return this.describe(alarm);
|
|
82
84
|
}
|
|
@@ -84,8 +86,9 @@ export class AgentAlarmService {
|
|
|
84
86
|
if (!confirm)
|
|
85
87
|
throw new Error('Alarm removal requires confirm=true. List alarms first and confirm the exact alarm ID with the user.');
|
|
86
88
|
const alarm = this.requiredAlarm(id);
|
|
87
|
-
await this.
|
|
88
|
-
|
|
89
|
+
const disabled = await this.store.toggleAlarm(alarm.id, false);
|
|
90
|
+
await this.scheduler.remove(disabled.id);
|
|
91
|
+
if (!await this.store.removeAlarm(alarm.id))
|
|
89
92
|
throw new Error(`Alarm not found: ${id}`);
|
|
90
93
|
return { ok: true, removed: alarm.id };
|
|
91
94
|
}
|
|
@@ -117,7 +120,7 @@ export class AgentAlarmService {
|
|
|
117
120
|
enabled: alarm.enabled,
|
|
118
121
|
station: { id: `${alarm.station.provider}:${alarm.station.id}`, name: alarm.station.name },
|
|
119
122
|
schedule: alarm.schedule,
|
|
120
|
-
nextOccurrence:
|
|
123
|
+
nextOccurrence: this.occurrences.next(alarm, new Date())?.toISOString() ?? null,
|
|
121
124
|
playback: {
|
|
122
125
|
volume: alarm.playback.volume,
|
|
123
126
|
fadeSeconds: alarm.playback.fadeSeconds,
|
package/dist/agent/cli.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { airPlayPolicy } from '../player/airplay-policy.js';
|
|
2
|
+
import { readSettingsWithoutMigration } from '../storage/read-settings.js';
|
|
3
|
+
import { defaultLibraryPath } from '../storage/store.js';
|
|
1
4
|
import { AgentRadioService, stationForAgent } from './service.js';
|
|
2
5
|
import { configureMcpIntegrations, mcpIntegrationReport, portableMcpConfig } from './mcp-install.js';
|
|
3
6
|
import { runMcpServer } from './mcp-server.js';
|
|
@@ -64,7 +67,7 @@ export async function runAgentCliCommand(args, runtime) {
|
|
|
64
67
|
else if (command === 'done')
|
|
65
68
|
value = await service.runCompletionPreset();
|
|
66
69
|
else
|
|
67
|
-
throw new Error(
|
|
70
|
+
throw new Error(agentCommandUsage());
|
|
68
71
|
console.log(JSON.stringify(value, null, 2));
|
|
69
72
|
}
|
|
70
73
|
function parseBrowseKind(value) {
|
|
@@ -191,3 +194,15 @@ function assertMcpInstallSucceeded(results, action) {
|
|
|
191
194
|
return;
|
|
192
195
|
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
196
|
}
|
|
197
|
+
export function agentCommandUsage() {
|
|
198
|
+
let enabled = airPlayPolicy().enabled;
|
|
199
|
+
if (process.platform === 'linux') {
|
|
200
|
+
try {
|
|
201
|
+
enabled = airPlayPolicy({ linuxAirPlayEnabled: readSettingsWithoutMigration(defaultLibraryPath())?.linuxAirPlayEnabled === true }).enabled;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
enabled = false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return `Usage: radiocli agent <status|search|browse|play|pause|resume|stop|next|previous|volume|mute|unmute|favorite|unfavorite|stats|appearance${enabled ? '|airplay' : ''}|preset|done>`;
|
|
208
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { receiverStyleNames, themeNames } from '../types.js';
|
|
3
|
+
import { stationSchema } from '../storage/station-schema.js';
|
|
4
|
+
const empty = (type) => z.strictObject({ type: z.literal(type) });
|
|
5
|
+
const adjacent = { preservePause: z.boolean().optional(), preserveStop: z.boolean().optional() };
|
|
6
|
+
// A TUI list can contain many discovery pages, beyond one provider page.
|
|
7
|
+
const stationQueue = z.array(stationSchema).max(10_000);
|
|
8
|
+
const agentControl = z.strictObject({
|
|
9
|
+
enabled: z.boolean(), openUiOnPlay: z.boolean(), focusNowPlaying: z.boolean(),
|
|
10
|
+
completionPreset: z.strictObject({
|
|
11
|
+
action: z.enum(['play', 'pause', 'resume', 'stop']), source: z.enum(['recent', 'favorite', 'popular', 'country']),
|
|
12
|
+
countryCode: z.string().length(2).optional(), ifPlaying: z.enum(['keep', 'replace']), openUi: z.boolean().optional()
|
|
13
|
+
})
|
|
14
|
+
});
|
|
15
|
+
const commandSchema = z.discriminatedUnion('type', [
|
|
16
|
+
empty('status'), empty('pause'), empty('alarm-preempt'), empty('airplay-list'), empty('airplay-local'), empty('tui-cancel'), empty('tui-refresh'), empty('tui-toggle-muted'), empty('tui-toggle-pause'),
|
|
17
|
+
z.strictObject({ type: z.literal('tui-sleep'), until: z.number().int().nonnegative().max(8.64e15).nullable() }),
|
|
18
|
+
z.strictObject({ type: z.literal('tui-adjust-volume'), delta: z.number().min(-100).max(100) }),
|
|
19
|
+
z.strictObject({ type: z.literal('desktop-transport'), action: z.enum(['play', 'pause', 'toggle', 'stop', 'next', 'previous']) }),
|
|
20
|
+
z.strictObject({ type: z.literal('play'), station: stationSchema, queue: stationQueue.optional(), openNowPlaying: z.boolean().optional(), ifPlaying: z.enum(['keep', 'replace']).optional(), startPaused: z.boolean().optional() }),
|
|
21
|
+
z.strictObject({ type: z.literal('tui-attach'), pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), clientId: z.string().uuid() }),
|
|
22
|
+
z.strictObject({ type: z.literal('tui-detach'), clientId: z.string().uuid() }),
|
|
23
|
+
z.strictObject({ type: z.literal('tui-select'), station: stationSchema, queue: stationQueue }),
|
|
24
|
+
z.strictObject({ type: z.literal('tui-output'), deviceId: z.string().min(1).max(4096).nullable() }),
|
|
25
|
+
z.strictObject({ type: z.literal('resume'), restartStopped: z.boolean().optional() }),
|
|
26
|
+
z.strictObject({ type: z.literal('stop'), retainSelection: z.boolean().optional() }),
|
|
27
|
+
z.strictObject({ type: z.literal('next'), ...adjacent }), z.strictObject({ type: z.literal('previous'), ...adjacent }),
|
|
28
|
+
z.strictObject({ type: z.literal('set-volume'), volume: z.number().min(0).max(100), unmute: z.boolean().optional(), allowStopped: z.boolean().optional() }),
|
|
29
|
+
z.strictObject({ type: z.literal('set-muted'), muted: z.boolean(), allowStopped: z.boolean().optional() }),
|
|
30
|
+
z.strictObject({ type: z.literal('set-favorite'), favorite: z.boolean(), station: stationSchema.optional() }),
|
|
31
|
+
z.strictObject({ type: z.literal('airplay-select'), deviceId: z.string().min(1).max(4096) }),
|
|
32
|
+
z.strictObject({ type: z.literal('airplay-passcode'), code: z.string().min(1).max(128) }),
|
|
33
|
+
z.strictObject({ type: z.literal('update-settings'), settings: z.strictObject({ theme: z.enum(themeNames).optional(), receiverStyle: z.enum(receiverStyleNames).optional(), agentControl: agentControl.optional() }) })
|
|
34
|
+
]);
|
|
35
|
+
export function parseRadioCommand(value) {
|
|
36
|
+
const parsed = commandSchema.safeParse(value);
|
|
37
|
+
if (!parsed.success)
|
|
38
|
+
throw new Error(`Invalid radio command: ${parsed.error.issues.map(issue => `${issue.path.join('.') || 'command'}: ${issue.message}`).slice(0, 3).join('; ')}`);
|
|
39
|
+
return parsed.data;
|
|
40
|
+
}
|
|
41
|
+
export function parseRadioRequest(value) {
|
|
42
|
+
if (value && typeof value === 'object' && 'version' in value) {
|
|
43
|
+
const envelope = z.strictObject({ version: z.literal(2), requestId: z.string().uuid(), command: z.unknown() }).parse(value);
|
|
44
|
+
return { command: parseRadioCommand(envelope.command), requestId: envelope.requestId };
|
|
45
|
+
}
|
|
46
|
+
return { command: parseRadioCommand(value) };
|
|
47
|
+
}
|
|
@@ -1,94 +1,196 @@
|
|
|
1
1
|
import { PlayerController } from '../player/player-controller.js';
|
|
2
2
|
import { ProviderManager } from '../providers/provider-manager.js';
|
|
3
3
|
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { LibraryRepository } from '../storage/repository.js';
|
|
5
|
+
import { RadioEngine } from '../player/radio-engine.js';
|
|
6
|
+
import { allowedRadioSessionSettings, resolveDesktopTransport, serializeRadioCommands, startRadioSession } from './session.js';
|
|
7
|
+
import { desktopMediaPolicy, startDesktopMediaControls } from '../platform/desktop-media.js';
|
|
8
|
+
import { detectPlaybackBackends } from '../player/backend-install.js';
|
|
9
|
+
import { networkPolicy } from '../platform/network.js';
|
|
10
|
+
export async function runHeadlessAgentHost(options = {}) {
|
|
11
|
+
const store = new LibraryRepository(options.store ?? new JsonLibraryStore(), { worker: options.worker });
|
|
8
12
|
const providers = new ProviderManager();
|
|
9
|
-
const player = new PlayerController(() => store.
|
|
13
|
+
const player = new PlayerController(() => store.settings());
|
|
10
14
|
player.refreshDetectedBackends();
|
|
11
|
-
|
|
12
|
-
let queue = [];
|
|
15
|
+
const engine = new RadioEngine(player, providers, store, () => store.settings());
|
|
13
16
|
let persistenceWarning;
|
|
14
|
-
|
|
17
|
+
let desktopSuspended = false;
|
|
18
|
+
let shuttingDown = false;
|
|
19
|
+
let media;
|
|
20
|
+
let tui;
|
|
21
|
+
let terminalProtocol = false;
|
|
22
|
+
let focusNowPlaying = 0;
|
|
23
|
+
let sleepUntil = null;
|
|
24
|
+
let sleepTimer;
|
|
25
|
+
const scheduleSleep = () => {
|
|
26
|
+
if (sleepUntil === null)
|
|
27
|
+
return;
|
|
28
|
+
const remaining = sleepUntil - Date.now();
|
|
29
|
+
if (remaining <= 0) {
|
|
30
|
+
sleepUntil = null;
|
|
31
|
+
void dispatch({ type: 'stop', retainSelection: true }).catch(() => undefined);
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
sleepTimer = setTimeout(scheduleSleep, Math.min(2_147_483_647, remaining));
|
|
35
|
+
sleepTimer.unref();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const tuiPresent = () => {
|
|
39
|
+
if (!tui)
|
|
40
|
+
return false;
|
|
15
41
|
try {
|
|
16
|
-
|
|
42
|
+
process.kill(tui.pid, 0);
|
|
43
|
+
return true;
|
|
17
44
|
}
|
|
18
45
|
catch (error) {
|
|
19
|
-
|
|
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.`;
|
|
46
|
+
return error.code !== 'ESRCH';
|
|
22
47
|
}
|
|
23
48
|
};
|
|
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.
|
|
49
|
+
const saveLibrary = async (write) => {
|
|
50
|
+
await write();
|
|
28
51
|
persistenceWarning = undefined;
|
|
52
|
+
engine.clearWarning();
|
|
53
|
+
};
|
|
54
|
+
const status = () => {
|
|
55
|
+
const current = engine.snapshot();
|
|
56
|
+
const settings = store.settings();
|
|
57
|
+
return { owner: tuiPresent() ? 'tui' : 'headless', playback: player.getState(), station: current.station, nowPlaying: current.nowPlaying,
|
|
58
|
+
queue: current.queue, persistenceWarning: persistenceWarning ?? current.warning, output: {
|
|
59
|
+
preferredBackend: settings.preferredBackend === 'airplay' && player.airPlaySelectionRequired() ? 'auto' : settings.preferredBackend,
|
|
60
|
+
preferredAirPlayDevice: settings.preferredAirPlayDevice
|
|
61
|
+
}, ...(terminalProtocol ? { tui: { tuningStation: current.tuningStation, message: current.message, focusNowPlaying, sleepUntil, diagnostics: player.diagnostics(),
|
|
62
|
+
devices: player.detectedAirPlayDevices(), selectionRequired: player.airPlaySelectionRequired(), desktopMediaCompatible: player.desktopMediaCompatible() } } : {}) };
|
|
29
63
|
};
|
|
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
64
|
const result = (message, ok = true, data) => ({ ok, message, status: status(), ...(data ? { data } : {}) });
|
|
35
|
-
const play = async (next, nextQueue = [next]) => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
await
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
65
|
+
const play = async (next, nextQueue = [next], startPaused = false, signal) => {
|
|
66
|
+
signal?.throwIfAborted();
|
|
67
|
+
// Earlier accepted output saves must reach their durable acknowledgement
|
|
68
|
+
// before this tune chooses a backend. Stop can still cancel the wait.
|
|
69
|
+
await store.readFresh();
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
desktopSuspended = false;
|
|
72
|
+
const tuned = await engine.tune(next, { queue: nextQueue, startPaused, signal });
|
|
73
|
+
if (shuttingDown)
|
|
74
|
+
return result('RadioCLI is shutting down.', false);
|
|
75
|
+
media?.refresh();
|
|
76
|
+
return result(`${startPaused ? 'Selected' : 'Playing'} ${tuned.name}.`);
|
|
45
77
|
};
|
|
46
|
-
const handle = async (command) => {
|
|
47
|
-
if (
|
|
48
|
-
return result(
|
|
78
|
+
const handle = async (command, signal) => {
|
|
79
|
+
if (shuttingDown)
|
|
80
|
+
return result('RadioCLI is shutting down.', false);
|
|
81
|
+
signal?.throwIfAborted();
|
|
82
|
+
const { station, queue } = engine.snapshot();
|
|
83
|
+
if (command.type === 'tui-attach') {
|
|
84
|
+
if (tuiPresent() && tui?.clientId !== command.clientId)
|
|
85
|
+
return result('RadioCLI is already open in another terminal.', false);
|
|
86
|
+
tui = { pid: command.pid, clientId: command.clientId };
|
|
87
|
+
terminalProtocol = true;
|
|
88
|
+
return result(station ? `Reconnected to ${station.name}.` : 'RadioCLI connected.');
|
|
89
|
+
}
|
|
90
|
+
if (command.type === 'tui-detach') {
|
|
91
|
+
if (tui?.clientId !== command.clientId)
|
|
92
|
+
return result('This terminal no longer owns the RadioCLI view.', false);
|
|
93
|
+
tui = undefined;
|
|
94
|
+
shuttingDown = true;
|
|
95
|
+
await engine.stop();
|
|
96
|
+
// Let the command response finish, then close our own resources directly.
|
|
97
|
+
// Sending SIGTERM to ourselves terminates Windows without running cleanup.
|
|
98
|
+
setTimeout(onShutdown, 50).unref();
|
|
99
|
+
return result('Closed RadioCLI.');
|
|
100
|
+
}
|
|
101
|
+
if (command.type === 'tui-cancel') {
|
|
102
|
+
engine.cancel();
|
|
103
|
+
return result('Canceled pending tune.');
|
|
104
|
+
}
|
|
105
|
+
if (command.type === 'tui-sleep') {
|
|
106
|
+
clearTimeout(sleepTimer);
|
|
107
|
+
sleepUntil = command.until;
|
|
108
|
+
scheduleSleep();
|
|
109
|
+
return result('Sleep timer updated.');
|
|
110
|
+
}
|
|
111
|
+
if (command.type === 'tui-adjust-volume')
|
|
112
|
+
return handle({ type: 'set-volume', volume: Math.max(0, Math.min(100, player.getState().volume + command.delta)) }, signal);
|
|
113
|
+
if (command.type === 'tui-toggle-muted')
|
|
114
|
+
return handle({ type: 'set-muted', muted: !player.getState().muted }, signal);
|
|
115
|
+
if (command.type === 'tui-toggle-pause') {
|
|
116
|
+
const control = await player.togglePause();
|
|
117
|
+
await engine.flushHistory();
|
|
118
|
+
return result(control.message ?? 'Playback updated.', control.ok);
|
|
119
|
+
}
|
|
120
|
+
if (command.type === 'tui-select') {
|
|
121
|
+
engine.select(command.station, command.queue);
|
|
122
|
+
media?.refresh();
|
|
123
|
+
return result(`Selected ${command.station.name}.`);
|
|
124
|
+
}
|
|
125
|
+
if (command.type === 'tui-refresh') {
|
|
126
|
+
await store.readFresh();
|
|
127
|
+
player.refreshDetectedBackends();
|
|
128
|
+
return result('Playback settings refreshed.');
|
|
129
|
+
}
|
|
130
|
+
if (command.type === 'tui-output') {
|
|
131
|
+
await store.readFresh();
|
|
132
|
+
const selected = await player.selectAirPlayDevice(command.deviceId);
|
|
133
|
+
return result(selected ? 'Audio output selected.' : 'Audio output is unavailable.', selected);
|
|
134
|
+
}
|
|
135
|
+
if (command.type === 'desktop-transport') {
|
|
136
|
+
const resolved = resolveDesktopTransport(command.action, status());
|
|
137
|
+
return resolved ? handle(resolved, signal) : result('Playback state unchanged.');
|
|
49
138
|
}
|
|
139
|
+
if (['airplay-list', 'airplay-select', 'airplay-passcode'].includes(command.type) && !player.airPlayPolicy().enabled) {
|
|
140
|
+
return result(player.airPlayPolicy().message, false, command.type === 'airplay-list' ? [] : undefined);
|
|
141
|
+
}
|
|
142
|
+
if (['airplay-list', 'airplay-select'].includes(command.type) && networkPolicy().offline)
|
|
143
|
+
return result('AirPlay discovery is disabled by RADIOCLI_OFFLINE=1.', false, []);
|
|
50
144
|
if (command.type === 'status')
|
|
51
145
|
return result(station ? `${player.getState().state}: ${station.name}` : 'RadioCLI is idle.');
|
|
52
146
|
if (command.type === 'play') {
|
|
53
147
|
if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state))
|
|
54
148
|
return result(`Kept current station ${station?.name ?? ''}.`);
|
|
55
|
-
|
|
149
|
+
if (command.openNowPlaying)
|
|
150
|
+
focusNowPlaying++;
|
|
151
|
+
return play(command.station, command.queue, command.startPaused, signal);
|
|
56
152
|
}
|
|
57
153
|
if (command.type === 'pause') {
|
|
58
|
-
const control = await
|
|
59
|
-
if (control.ok)
|
|
60
|
-
persistHistory(() => store.finishActiveListeningSession());
|
|
154
|
+
const control = await engine.pause();
|
|
61
155
|
return result(control.message ?? 'Paused.', control.ok);
|
|
62
156
|
}
|
|
63
157
|
if (command.type === 'resume') {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
158
|
+
if (command.restartStopped && station && ['idle', 'stopped'].includes(player.getState().state))
|
|
159
|
+
return play(station, queue, false, signal);
|
|
160
|
+
const control = await engine.resume();
|
|
67
161
|
return result(control.message ?? 'Resumed.', control.ok);
|
|
68
162
|
}
|
|
69
163
|
if (command.type === 'stop') {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
164
|
+
await engine.stop({ retainSelection: command.retainSelection });
|
|
165
|
+
if (command.retainSelection) {
|
|
166
|
+
media?.refresh();
|
|
167
|
+
return result('Stopped playback.');
|
|
168
|
+
}
|
|
169
|
+
if (!tuiPresent()) {
|
|
170
|
+
shuttingDown = true;
|
|
171
|
+
setTimeout(onShutdown, 50).unref();
|
|
172
|
+
}
|
|
75
173
|
return result('Stopped RadioCLI.');
|
|
76
174
|
}
|
|
77
175
|
if (command.type === 'alarm-preempt') {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
queue = [];
|
|
176
|
+
desktopSuspended = true;
|
|
177
|
+
media?.refresh();
|
|
178
|
+
await engine.stop();
|
|
82
179
|
return result('Interactive playback yielded to the alarm.');
|
|
83
180
|
}
|
|
84
181
|
if (command.type === 'set-volume') {
|
|
85
|
-
const control = await player.setVolume(command.volume);
|
|
182
|
+
const control = command.unmute || command.allowStopped ? await player.setVolume(command.volume, { allowStopped: true }) : await player.setVolume(command.volume);
|
|
86
183
|
if (control.ok)
|
|
87
|
-
saveLibrary(() => store.updateSettings({ volume: player.getState().volume }));
|
|
184
|
+
await saveLibrary(() => store.updateSettings({ volume: player.getState().volume }));
|
|
185
|
+
if (control.ok && command.unmute && command.volume > 0) {
|
|
186
|
+
const unmuted = await player.setMuted(false, { allowStopped: true });
|
|
187
|
+
if (!unmuted.ok)
|
|
188
|
+
return result(unmuted.message ?? 'The output could not be unmuted.', false);
|
|
189
|
+
}
|
|
88
190
|
return result(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
|
|
89
191
|
}
|
|
90
192
|
if (command.type === 'set-muted') {
|
|
91
|
-
const control = await player.setMuted(command.muted);
|
|
193
|
+
const control = await player.setMuted(command.muted, { allowStopped: command.allowStopped });
|
|
92
194
|
return result(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok);
|
|
93
195
|
}
|
|
94
196
|
if (command.type === 'set-favorite') {
|
|
@@ -97,8 +199,8 @@ export async function runHeadlessAgentHost() {
|
|
|
97
199
|
return result('No active station to favorite.', false);
|
|
98
200
|
const current = store.isFavorite(target);
|
|
99
201
|
if (current !== command.favorite)
|
|
100
|
-
saveLibrary(() => store.toggleFavorite(target));
|
|
101
|
-
if (command.favorite && !current && store.
|
|
202
|
+
await saveLibrary(() => store.toggleFavorite(target));
|
|
203
|
+
if (command.favorite && !current && store.settings().shareDirectoryVotes)
|
|
102
204
|
void providers.vote(target);
|
|
103
205
|
return result(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${target.name}.`);
|
|
104
206
|
}
|
|
@@ -108,58 +210,101 @@ export async function runHeadlessAgentHost() {
|
|
|
108
210
|
}
|
|
109
211
|
if (command.type === 'airplay-select') {
|
|
110
212
|
const devices = await player.refreshAirPlayDevices();
|
|
213
|
+
signal?.throwIfAborted();
|
|
111
214
|
const device = devices.find(item => item.id === command.deviceId);
|
|
112
215
|
if (!device)
|
|
113
216
|
return result('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
|
|
114
217
|
if (device.local)
|
|
115
|
-
return switchToLocal();
|
|
116
|
-
if (!
|
|
218
|
+
return switchToLocal(signal);
|
|
219
|
+
if (!player.refreshDetectedBackends().includes('airplay'))
|
|
117
220
|
return result('AirPlay playback is unavailable; run radiocli doctor.', false);
|
|
118
|
-
saveLibrary(() => {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
});
|
|
221
|
+
await saveLibrary(() => store.updateSettings({ preferredAirPlayDevice: device.id, preferredBackend: 'airplay' }));
|
|
222
|
+
await player.selectAirPlayDevice(device.id);
|
|
223
|
+
signal?.throwIfAborted();
|
|
122
224
|
if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
|
|
123
|
-
return play(station, queue);
|
|
225
|
+
return play(station, queue, false, signal);
|
|
124
226
|
return result(`Audio output set to AirPlay receiver ${device.name}.`);
|
|
125
227
|
}
|
|
126
228
|
if (command.type === 'airplay-local')
|
|
127
|
-
return switchToLocal();
|
|
229
|
+
return switchToLocal(signal);
|
|
128
230
|
if (command.type === 'airplay-passcode') {
|
|
129
|
-
const control = player.submitAirPlayPasscode(command.code);
|
|
231
|
+
const control = await player.submitAirPlayPasscode(command.code);
|
|
130
232
|
return result(control.message ?? 'AirPlay code sent.', control.ok);
|
|
131
233
|
}
|
|
132
234
|
if (command.type === 'update-settings') {
|
|
133
|
-
|
|
235
|
+
if (!allowedRadioSessionSettings(command.settings))
|
|
236
|
+
return result('Session settings only accept appearance and agent control. Use radiocli setup --airplay to enable Linux AirPlay.', false);
|
|
237
|
+
await saveLibrary(() => store.updateSettings(command.settings));
|
|
134
238
|
return result('RadioCLI settings updated.');
|
|
135
239
|
}
|
|
240
|
+
if (command.type !== 'next' && command.type !== 'previous')
|
|
241
|
+
throw new Error('Unsupported radio command.');
|
|
136
242
|
const currentIndex = station ? queue.findIndex(item => stationKey(item) === stationKey(station)) : -1;
|
|
137
243
|
const delta = command.type === 'next' ? 1 : -1;
|
|
138
244
|
const next = queue.length ? queue[(Math.max(0, currentIndex) + delta + queue.length) % queue.length] : undefined;
|
|
139
|
-
|
|
245
|
+
if (next && command.preserveStop && ['idle', 'stopped'].includes(player.getState().state)) {
|
|
246
|
+
engine.select(next, queue);
|
|
247
|
+
media?.refresh();
|
|
248
|
+
return result(`Selected ${next.name}.`);
|
|
249
|
+
}
|
|
250
|
+
return next ? play(next, queue, Boolean(command.preservePause && player.getState().state === 'paused'), signal) : result('No playback queue is available.', false);
|
|
140
251
|
};
|
|
141
|
-
const switchToLocal = async () => {
|
|
252
|
+
const switchToLocal = async (signal) => {
|
|
253
|
+
signal?.throwIfAborted();
|
|
142
254
|
const backends = detectPlaybackBackends();
|
|
143
255
|
const backend = backends.includes('mpv') ? 'mpv' : backends.includes('ffplay') ? 'ffplay' : backends.includes('vlc') ? 'vlc' : null;
|
|
144
256
|
if (!backend)
|
|
145
257
|
return result('No local playback backend is available. Run radiocli setup to install mpv.', false);
|
|
146
|
-
saveLibrary(() => store.updateSettings({ preferredBackend: backend }));
|
|
258
|
+
await saveLibrary(() => store.updateSettings({ preferredBackend: backend }));
|
|
259
|
+
await player.selectAirPlayDevice(null);
|
|
260
|
+
signal?.throwIfAborted();
|
|
261
|
+
const { station, queue } = engine.snapshot();
|
|
147
262
|
if (station && ['playing', 'paused', 'loading'].includes(player.getState().state))
|
|
148
|
-
return play(station, queue);
|
|
263
|
+
return play(station, queue, false, signal);
|
|
149
264
|
return result(`Audio output set to this device (${backend}).`);
|
|
150
265
|
};
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
|
|
266
|
+
const dispatch = serializeRadioCommands(handle, { responsive: true });
|
|
267
|
+
const session = await startRadioSession((command, source) => dispatch(command, async () => {
|
|
268
|
+
if (command.type.startsWith('tui-') && source !== 'cli')
|
|
269
|
+
throw new Error('Terminal attachment requires a local CLI client.');
|
|
270
|
+
if (source !== 'cli' && command.type !== 'status' && command.type !== 'alarm-preempt') {
|
|
271
|
+
await store.readFresh();
|
|
272
|
+
if (!store.settings().agentControl?.enabled)
|
|
273
|
+
throw new Error('Agent controls are disabled.');
|
|
274
|
+
}
|
|
275
|
+
return true;
|
|
276
|
+
}), undefined, { queue: 'owner' }).catch(async (error) => {
|
|
277
|
+
try {
|
|
278
|
+
await engine.close();
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
await store.close();
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
});
|
|
285
|
+
media = startDesktopMediaControls({ player, identity: store.filePath, policy: desktopMediaPolicy(store.settings()), status,
|
|
286
|
+
command: (command, permitted) => dispatch(command, () => permitted?.() !== false && !desktopSuspended), suspended: () => desktopSuspended });
|
|
287
|
+
const unsubscribe = engine.onChange(() => media?.refresh());
|
|
288
|
+
let shutdownPromise;
|
|
289
|
+
const signals = ['SIGTERM', 'SIGINT', 'SIGHUP'];
|
|
290
|
+
const shutdown = () => shutdownPromise ??= (async () => {
|
|
291
|
+
shuttingDown = true;
|
|
292
|
+
clearTimeout(sleepTimer);
|
|
293
|
+
desktopSuspended = true;
|
|
294
|
+
for (const signal of signals)
|
|
295
|
+
process.removeListener(signal, onShutdown);
|
|
296
|
+
media?.close();
|
|
297
|
+
unsubscribe();
|
|
298
|
+
try {
|
|
299
|
+
await engine.close();
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
await session.close();
|
|
303
|
+
await store.close();
|
|
304
|
+
}
|
|
305
|
+
})();
|
|
306
|
+
const onShutdown = () => { void shutdown().finally(() => process.exit(0)); };
|
|
307
|
+
for (const signal of signals)
|
|
308
|
+
process.once(signal, onShutdown);
|
|
164
309
|
await new Promise(() => undefined);
|
|
165
310
|
}
|
package/dist/agent/launcher.js
CHANGED
|
@@ -6,5 +6,6 @@ export async function launchRadioTui(nodePath, cliPath, encodedCommand, options
|
|
|
6
6
|
await launchTerminalCommand({ ...options, nodePath, args: [cliPath, 'agent-ui', encodedCommand], title: 'RadioCLI' });
|
|
7
7
|
}
|
|
8
8
|
export async function launchHeadlessHost(nodePath, cliPath) {
|
|
9
|
-
|
|
9
|
+
const loader = cliPath.endsWith('.tsx') ? ['--import', import.meta.resolve('tsx')] : [];
|
|
10
|
+
await waitForLaunch(spawn(nodePath, [...loader, cliPath, 'agent-host'], { detached: true, stdio: 'ignore', windowsHide: true }));
|
|
10
11
|
}
|
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
|
}
|