@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
package/dist/cli.js
CHANGED
|
@@ -1,108 +1,150 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
4
3
|
import { resolve } from 'node:path';
|
|
5
4
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { ProviderManager } from './providers/provider-manager.js';
|
|
7
|
-
import { PlayerController } from './player/player-controller.js';
|
|
8
|
-
import { JsonLibraryStore } from './storage/store.js';
|
|
9
|
-
import { parsePlaylistFile, stationFromUrl, writeM3u } from './playlists/playlist.js';
|
|
10
|
-
import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
|
|
11
|
-
import { resolveCommand } from './player/command.js';
|
|
12
|
-
import { diagnoseCommand } from './player/command-diagnostics.js';
|
|
13
|
-
import { airPlaySenderHealth } from './player/airplay-sender-health.js';
|
|
14
5
|
import { appVersion } from './version.js';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { runSetup } from './setup.js';
|
|
18
|
-
import { runAgentCliCommand, runMcpCommand } from './agent/cli.js';
|
|
19
|
-
import { decodeAgentCommand } from './agent/service.js';
|
|
20
|
-
import { runHeadlessAgentHost } from './agent/headless-host.js';
|
|
21
|
-
import { configureMcpIntegrations } from './agent/mcp-install.js';
|
|
22
|
-
import { defaultAgentControlSettings } from './types.js';
|
|
6
|
+
import { completionCandidates, isListenerCommand, listenerCommands, listenerHelp } from './cli/arguments.js';
|
|
7
|
+
import { sanitizeTerminalText } from './safety.js';
|
|
23
8
|
const runtime = { nodePath: process.execPath, cliPath: fileURLToPath(import.meta.url) };
|
|
24
9
|
if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
25
10
|
const args = process.argv.slice(2);
|
|
11
|
+
process.stdout.on('error', error => {
|
|
12
|
+
if (error.code === 'EPIPE')
|
|
13
|
+
process.exit(0);
|
|
14
|
+
else
|
|
15
|
+
throw error;
|
|
16
|
+
});
|
|
26
17
|
if (args[0] === 'agent-ui') {
|
|
27
18
|
const encoded = args[1];
|
|
28
19
|
if (!encoded)
|
|
29
20
|
throw new Error('Missing RadioCLI agent startup request.');
|
|
30
|
-
|
|
31
|
-
render(_jsx(App, { initialAgentCommand: decodeAgentCommand(encoded) }), {
|
|
32
|
-
exitOnCtrlC: false,
|
|
33
|
-
kittyKeyboard: { mode: 'auto', flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes'] }
|
|
34
|
-
});
|
|
21
|
+
await renderTui(encoded);
|
|
35
22
|
}
|
|
36
23
|
else if (args[0] === 'agent-host') {
|
|
24
|
+
const { runHeadlessAgentHost } = await import('./agent/headless-host.js');
|
|
37
25
|
await runHeadlessAgentHost();
|
|
38
26
|
}
|
|
39
27
|
else if (args.length > 0) {
|
|
40
28
|
await runCommand(args).catch(error => {
|
|
41
|
-
|
|
42
|
-
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
console.error(message.split('\n').map(line => sanitizeTerminalText(line) ?? '').join('\n'));
|
|
31
|
+
process.exitCode = error instanceof Error && error.name === 'AbortError' ? 130 : 1;
|
|
43
32
|
});
|
|
44
33
|
}
|
|
45
34
|
else {
|
|
46
|
-
|
|
47
|
-
render(_jsx(App, {}), {
|
|
48
|
-
// App owns Ctrl+C so it can confirm before performing a clean shutdown.
|
|
49
|
-
exitOnCtrlC: false,
|
|
50
|
-
kittyKeyboard: {
|
|
51
|
-
mode: 'auto',
|
|
52
|
-
flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']
|
|
53
|
-
}
|
|
54
|
-
});
|
|
35
|
+
await renderTui();
|
|
55
36
|
}
|
|
56
37
|
}
|
|
38
|
+
async function renderTui(encodedCommand) {
|
|
39
|
+
const { renderTui: render } = await import('./cli/render-tui.js');
|
|
40
|
+
await render(printHelp, encodedCommand);
|
|
41
|
+
}
|
|
57
42
|
export async function runCommand(args) {
|
|
58
43
|
const [command, ...rest] = args;
|
|
59
44
|
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
60
|
-
|
|
45
|
+
if (command === 'help' && rest[0] && rest[0] !== '--all') {
|
|
46
|
+
await runCommand([rest[0], '--help']);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
printHelp(rest.includes('--all'));
|
|
61
50
|
return;
|
|
62
51
|
}
|
|
63
52
|
if (command === 'version' || command === '--version' || command === '-v') {
|
|
64
53
|
console.log(appVersion());
|
|
65
54
|
return;
|
|
66
55
|
}
|
|
56
|
+
if (command === '_complete') {
|
|
57
|
+
let presets = [];
|
|
58
|
+
if (rest.length > 1 && ['play', 'favorite', 'unfavorite', 'preset'].includes(rest[0])) {
|
|
59
|
+
try {
|
|
60
|
+
// Completion is read-only, including when a library needs repair.
|
|
61
|
+
const { defaultLibraryPath } = await import('./storage/store.js');
|
|
62
|
+
const library = JSON.parse(readFileSync(defaultLibraryPath(), 'utf8'));
|
|
63
|
+
if (Array.isArray(library.presets))
|
|
64
|
+
presets = library.presets.flatMap(preset => typeof preset?.name === 'string' && /^[a-z0-9][a-z0-9_-]{0,39}$/.test(preset.name) ? [preset.name] : []);
|
|
65
|
+
}
|
|
66
|
+
catch { /* Completion must remain quiet when storage is unavailable. */ }
|
|
67
|
+
}
|
|
68
|
+
const candidates = completionCandidates(rest, presets);
|
|
69
|
+
if (candidates.length)
|
|
70
|
+
console.log(candidates.join('\n'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const optionArgs = rest.includes('--') ? rest.slice(0, rest.indexOf('--')) : rest;
|
|
74
|
+
const wantsHelp = optionArgs.includes('--help') || optionArgs.includes('-h');
|
|
75
|
+
if (isListenerCommand(command)) {
|
|
76
|
+
if (wantsHelp)
|
|
77
|
+
console.log(listenerHelp(command));
|
|
78
|
+
else {
|
|
79
|
+
const { runListenerCommand } = await import('./cli/listener.js');
|
|
80
|
+
await runListenerCommand(command, rest, runtime);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (command === 'agent' && wantsHelp) {
|
|
85
|
+
const { agentCommandUsage } = await import('./agent/cli.js');
|
|
86
|
+
console.log(`${agentCommandUsage()}\nJSON controls for local agents. Everyday listening is available directly: radiocli play, now, favorites.`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const commandHelp = legacyCommandHelp()[command];
|
|
90
|
+
if (wantsHelp && commandHelp) {
|
|
91
|
+
console.log(commandHelp);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
67
94
|
if (command === 'alarm') {
|
|
95
|
+
const { runAlarmCommand } = await import('./alarms/cli.js');
|
|
68
96
|
await runAlarmCommand(rest);
|
|
69
97
|
return;
|
|
70
98
|
}
|
|
71
99
|
if (command === 'mcp') {
|
|
100
|
+
const { runMcpCommand } = await import('./agent/cli.js');
|
|
72
101
|
await runMcpCommand(rest, runtime);
|
|
73
102
|
return;
|
|
74
103
|
}
|
|
75
104
|
if (command === 'agent') {
|
|
105
|
+
const { runAgentCliCommand } = await import('./agent/cli.js');
|
|
76
106
|
await runAgentCliCommand(rest, runtime);
|
|
77
107
|
return;
|
|
78
108
|
}
|
|
79
109
|
if (command === 'setup') {
|
|
80
110
|
if (rest.includes('--help') || rest.includes('-h')) {
|
|
81
|
-
printSetupHelp();
|
|
111
|
+
await printSetupHelp();
|
|
82
112
|
return;
|
|
83
113
|
}
|
|
114
|
+
const { runSetup } = await import('./setup.js');
|
|
84
115
|
await runSetup({ args: rest });
|
|
85
116
|
return;
|
|
86
117
|
}
|
|
118
|
+
if (command === 'media-controls') {
|
|
119
|
+
if (rest.length > 1 || rest[0] !== undefined && !['auto', 'on', 'off'].includes(rest[0])) {
|
|
120
|
+
throw new Error('Usage: radiocli media-controls [auto|on|off]');
|
|
121
|
+
}
|
|
122
|
+
const [{ JsonLibraryStore }, { desktopMediaPolicy }] = await Promise.all([import('./storage/store.js'), import('./platform/desktop-media.js')]);
|
|
123
|
+
const store = new JsonLibraryStore();
|
|
124
|
+
if (rest[0])
|
|
125
|
+
store.updateSettings({ desktopMediaControls: rest[0] });
|
|
126
|
+
const policy = desktopMediaPolicy(store.snapshot().settings);
|
|
127
|
+
console.log(`desktop_media_preference=${store.snapshot().settings.desktopMediaControls ?? 'auto'}`);
|
|
128
|
+
console.log(`desktop_media_effective=${policy.preference} ${policy.enabled ? 'enabled' : 'disabled'}`);
|
|
129
|
+
console.log(`desktop_media_note=${policy.reason}`);
|
|
130
|
+
if (rest[0])
|
|
131
|
+
console.log('Restart active RadioCLI sessions to apply this preference. RADIOCLI_DESKTOP_MEDIA=off overrides the saved preference.');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
87
134
|
if (!isKnownCommand(command)) {
|
|
88
|
-
|
|
135
|
+
const suggestion = suggestCommand(command);
|
|
136
|
+
throw new Error(`Unknown command: ${command}${suggestion ? `\nDid you mean radiocli ${suggestion}?` : ''}\nRun radiocli help.`);
|
|
89
137
|
}
|
|
90
138
|
if (command === 'doctor') {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
if (rest.includes('--json')) {
|
|
94
|
-
console.log(JSON.stringify(doctorReport(backends, mpvDiagnostic), null, 2));
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
console.log(`backends=${backends.join(',') || 'none'}`);
|
|
98
|
-
printMpvDiagnostic(mpvDiagnostic);
|
|
99
|
-
printPlaybackBackendStatus(backends);
|
|
139
|
+
const { runDoctor } = await import('./cli/doctor.js');
|
|
140
|
+
await runDoctor(rest);
|
|
100
141
|
return;
|
|
101
142
|
}
|
|
102
143
|
if (command === 'update') {
|
|
103
144
|
const unknown = rest.filter(arg => arg !== '--install');
|
|
104
145
|
if (unknown.length > 0)
|
|
105
146
|
throw new Error('Usage: radiocli update [--install]');
|
|
147
|
+
const { checkForUpdate, installUpdate, updateCommandForInstall } = await import('./update-check.js');
|
|
106
148
|
const updateCheck = await checkForUpdate();
|
|
107
149
|
const updateCommand = updateCommandForInstall();
|
|
108
150
|
if (updateCheck.error) {
|
|
@@ -119,9 +161,11 @@ export async function runCommand(args) {
|
|
|
119
161
|
if (!result.ok)
|
|
120
162
|
throw new Error(`Update install failed. Run manually: ${result.command}${result.output ? `\n${result.output}` : ''}`);
|
|
121
163
|
console.log('updated=yes');
|
|
164
|
+
const [{ JsonLibraryStore }, { defaultAgentControlSettings }] = await Promise.all([import('./storage/store.js'), import('./types.js')]);
|
|
122
165
|
const agentControl = new JsonLibraryStore().snapshot().settings.agentControl ?? defaultAgentControlSettings;
|
|
123
166
|
if (agentControl.enabled) {
|
|
124
167
|
try {
|
|
168
|
+
const { configureMcpIntegrations } = await import('./agent/mcp-install.js');
|
|
125
169
|
const repaired = await configureMcpIntegrations(true, runtime);
|
|
126
170
|
const failed = repaired.filter(item => item.status === 'failed');
|
|
127
171
|
console.log(`mcp_repaired=${failed.length ? 'partial' : 'yes'}`);
|
|
@@ -137,6 +181,9 @@ export async function runCommand(args) {
|
|
|
137
181
|
return;
|
|
138
182
|
}
|
|
139
183
|
if (command === 'check') {
|
|
184
|
+
const [{ JsonLibraryStore }, { ProviderManager }, { PlayerController }, { playbackBackendStatusLines }] = await Promise.all([
|
|
185
|
+
import('./storage/store.js'), import('./providers/provider-manager.js'), import('./player/player-controller.js'), import('./player/backend-install.js')
|
|
186
|
+
]);
|
|
140
187
|
const store = new JsonLibraryStore();
|
|
141
188
|
const providers = new ProviderManager();
|
|
142
189
|
const player = new PlayerController(() => store.snapshot().settings);
|
|
@@ -144,45 +191,37 @@ export async function runCommand(args) {
|
|
|
144
191
|
const health = await providers.health(store.snapshot().settings);
|
|
145
192
|
console.log(`store=${store.filePath}`);
|
|
146
193
|
console.log(`backends=${backends.join(',') || 'none'}`);
|
|
147
|
-
|
|
194
|
+
for (const line of playbackBackendStatusLines(backends))
|
|
195
|
+
console.log(line);
|
|
148
196
|
for (const [provider, status] of Object.entries(health)) {
|
|
149
197
|
console.log(`${provider}=${status}`);
|
|
150
198
|
}
|
|
151
199
|
return;
|
|
152
200
|
}
|
|
153
|
-
if (command === 'countries') {
|
|
154
|
-
const providers = new ProviderManager();
|
|
155
|
-
const countries = await providers.countries(30);
|
|
156
|
-
for (const country of countries) {
|
|
157
|
-
console.log(`${country.code}\t${country.stationCount}\t${country.name}`);
|
|
158
|
-
}
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
if (command === 'search') {
|
|
162
|
-
const query = rest.join(' ').trim();
|
|
163
|
-
if (!query) {
|
|
164
|
-
throw new Error('Usage: radiocli search <query>');
|
|
165
|
-
}
|
|
166
|
-
const store = new JsonLibraryStore();
|
|
167
|
-
const providers = new ProviderManager();
|
|
168
|
-
const stations = await providers.search(query, store.snapshot().settings, { limit: 20 });
|
|
169
|
-
for (const station of stations) {
|
|
170
|
-
console.log(`${station.provider}:${station.id}\t${station.name}\t${station.country ?? ''}\t${station.codec ?? ''}\t${station.bitrate ?? ''}`);
|
|
171
|
-
}
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
201
|
if (command === 'import') {
|
|
175
|
-
const
|
|
176
|
-
if (!
|
|
177
|
-
throw new Error('Usage: radiocli import <playlist.m3u|playlist.pls|playlist.xspf>');
|
|
202
|
+
const target = rest[0];
|
|
203
|
+
if (!target) {
|
|
204
|
+
throw new Error('Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]');
|
|
178
205
|
}
|
|
179
|
-
const
|
|
206
|
+
const { JsonLibraryStore } = await import('./storage/store.js');
|
|
180
207
|
const store = new JsonLibraryStore();
|
|
208
|
+
if (/^https?:\/\//i.test(target)) {
|
|
209
|
+
const { importStreamUrl } = await import('./streams/import-stream.js');
|
|
210
|
+
const result = await importStreamUrl(target, rest.slice(1).join(' ') || undefined);
|
|
211
|
+
store.addImported([result.station]);
|
|
212
|
+
console.log(`imported=${result.station.name}`);
|
|
213
|
+
if (result.warning)
|
|
214
|
+
console.log(`warning=${result.warning}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const { parsePlaylistFile } = await import('./playlists/playlist.js');
|
|
218
|
+
const stations = parsePlaylistFile(target);
|
|
181
219
|
store.addImported(stations);
|
|
182
220
|
console.log(`imported=${stations.length}`);
|
|
183
221
|
return;
|
|
184
222
|
}
|
|
185
223
|
if (command === 'export') {
|
|
224
|
+
const [{ JsonLibraryStore }, { writeM3u }] = await Promise.all([import('./storage/store.js'), import('./playlists/playlist.js')]);
|
|
186
225
|
const file = rest[0] ?? 'radiocli-favorites.m3u';
|
|
187
226
|
const store = new JsonLibraryStore();
|
|
188
227
|
const state = store.snapshot();
|
|
@@ -190,38 +229,91 @@ export async function runCommand(args) {
|
|
|
190
229
|
console.log(`exported=${file}`);
|
|
191
230
|
return;
|
|
192
231
|
}
|
|
193
|
-
if (command === 'add-url') {
|
|
194
|
-
const url = rest[0];
|
|
195
|
-
if (!url || !/^https?:\/\//i.test(url)) {
|
|
196
|
-
throw new Error('Usage: radiocli add-url <stream-url> [station name]');
|
|
197
|
-
}
|
|
198
|
-
const station = stationFromUrl(url, rest.slice(1).join(' ') || url);
|
|
199
|
-
const store = new JsonLibraryStore();
|
|
200
|
-
store.addImported([station]);
|
|
201
|
-
console.log(`added=${station.name}`);
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
232
|
}
|
|
205
|
-
export function printHelp() {
|
|
233
|
+
export function printHelp(all = false) {
|
|
206
234
|
console.log(`RadioCLI
|
|
207
235
|
|
|
208
|
-
Usage:
|
|
209
|
-
radiocli
|
|
210
|
-
|
|
211
|
-
|
|
236
|
+
Usage: radiocli [command]
|
|
237
|
+
Run radiocli without a command to open the TUI.
|
|
238
|
+
|
|
239
|
+
Everyday listening:
|
|
240
|
+
radiocli play [station] Start or continue listening; names and presets work
|
|
241
|
+
radiocli pause Pause; use play to continue
|
|
242
|
+
radiocli next Tune the next station in the current queue
|
|
243
|
+
radiocli now See the current station, track, and volume
|
|
244
|
+
radiocli volume <0-100> Set listening volume
|
|
245
|
+
radiocli favorite Save the current station
|
|
246
|
+
radiocli favorites List saved stations; add --pick to choose one
|
|
247
|
+
radiocli stop Stop playback and close the background player
|
|
248
|
+
${all ? `
|
|
249
|
+
More listening controls:
|
|
250
|
+
radiocli previous Tune the previous station in the queue
|
|
251
|
+
radiocli resume Resume paused playback (play also works)
|
|
252
|
+
radiocli mute | unmute Mute or restore sound
|
|
253
|
+
radiocli now --watch Watch station and track changes
|
|
254
|
+
radiocli status Alias for now
|
|
255
|
+
radiocli play --favorites | --recent | --popular
|
|
256
|
+
Choose a queue; add --random or --pick
|
|
257
|
+
|
|
258
|
+
Discovery and library:
|
|
259
|
+
radiocli search <query> Search stations; --country, --limit, --pick
|
|
260
|
+
radiocli countries List top countries
|
|
261
|
+
radiocli recent List recently played stations
|
|
262
|
+
radiocli unfavorite Remove a favorite (current station by default)
|
|
263
|
+
radiocli preset save <name> Save a shortcut; play with play <name>
|
|
264
|
+
radiocli preset remove <name> Remove a shortcut
|
|
265
|
+
radiocli presets List shortcuts; also: preset list
|
|
266
|
+
radiocli stats Show listening time and streaks
|
|
267
|
+
radiocli import <target> Import a stream URL or local playlist
|
|
268
|
+
radiocli export [file] Export favorites/imports as .m3u
|
|
269
|
+
radiocli alarm <command> Manage alarms and scheduled radio
|
|
270
|
+
|
|
271
|
+
Setup and integrations:
|
|
272
|
+
radiocli setup Install and verify native playback tools
|
|
212
273
|
radiocli doctor [--json] Show local playback setup guidance
|
|
213
|
-
radiocli
|
|
214
|
-
radiocli
|
|
274
|
+
radiocli check Show provider/backend health
|
|
275
|
+
radiocli version Print the installed version
|
|
276
|
+
radiocli update Check for updates; --install to install
|
|
277
|
+
radiocli media-controls [auto|on|off] Configure Linux desktop media controls
|
|
278
|
+
radiocli mcp <command> Install, inspect, or run the MCP integration
|
|
215
279
|
radiocli agent <command> Scriptable radio controls for local agents
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
radiocli
|
|
219
|
-
radiocli
|
|
220
|
-
radiocli
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
radiocli
|
|
224
|
-
|
|
280
|
+
` : ''}
|
|
281
|
+
Examples:
|
|
282
|
+
radiocli play jazz Find a station and listen
|
|
283
|
+
radiocli play Continue the current or last station
|
|
284
|
+
radiocli play --favorites Listen from your favorites
|
|
285
|
+
|
|
286
|
+
More help:
|
|
287
|
+
radiocli help --all Browse every command by category
|
|
288
|
+
radiocli help play Options and examples for one command
|
|
289
|
+
radiocli completion <shell> Print a Tab completion script
|
|
290
|
+
${all ? '\nListener commands support --json, --plain, --quiet, and --no-input.\nUse radiocli <command> --help for options and examples.\n' : ''}
|
|
291
|
+
`.trimEnd());
|
|
292
|
+
}
|
|
293
|
+
function legacyCommandHelp() {
|
|
294
|
+
return {
|
|
295
|
+
check: 'Usage: radiocli check\nShow local store paths, playback backends, and provider health.',
|
|
296
|
+
doctor: 'Usage: radiocli doctor [--json] [--playback]\nCheck playback tools without changing settings. Add --playback to rehearse local WAV playback through null/dummy audio.',
|
|
297
|
+
update: 'Usage: radiocli update [--install]\nCheck for updates. Add --install to install and repair enabled MCP entries.',
|
|
298
|
+
import: 'Usage: radiocli import <stream-url|playlist.m3u|playlist.pls|playlist.xspf> [station name]\nExample: radiocli import https://example.com/live.mp3 "Example FM"',
|
|
299
|
+
export: 'Usage: radiocli export [file]\nExport favorites and imports as M3U (default: radiocli-favorites.m3u).',
|
|
300
|
+
'media-controls': 'Usage: radiocli media-controls [auto|on|off]\nInspect or configure Linux desktop media controls.',
|
|
301
|
+
mcp: 'Usage: radiocli mcp <enable|install|repair|disable|status|config|serve>\nManage the optional local agent integration.'
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function suggestCommand(input) {
|
|
305
|
+
const candidates = [...listenerCommands, ...Object.keys(legacyCommandHelp()), 'setup', 'alarm', 'agent', 'help', 'version'];
|
|
306
|
+
const distance = (word) => {
|
|
307
|
+
let row = Array.from({ length: word.length + 1 }, (_, index) => index);
|
|
308
|
+
for (let i = 1; i <= input.length; i += 1) {
|
|
309
|
+
const next = [i];
|
|
310
|
+
for (let j = 1; j <= word.length; j += 1)
|
|
311
|
+
next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + (input[i - 1] === word[j - 1] ? 0 : 1));
|
|
312
|
+
row = next;
|
|
313
|
+
}
|
|
314
|
+
return row[word.length];
|
|
315
|
+
};
|
|
316
|
+
return candidates.map(word => ({ word, distance: distance(word) })).filter(item => item.distance <= (input.length > 3 ? 2 : 1)).sort((a, b) => a.distance - b.distance)[0]?.word;
|
|
225
317
|
}
|
|
226
318
|
export function isDirectRun(entryPath, moduleUrl) {
|
|
227
319
|
if (!entryPath) {
|
|
@@ -235,9 +327,10 @@ export function isDirectRun(entryPath, moduleUrl) {
|
|
|
235
327
|
}
|
|
236
328
|
}
|
|
237
329
|
function isKnownCommand(command) {
|
|
238
|
-
return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', '
|
|
330
|
+
return ['check', 'doctor', 'setup', 'update', 'countries', 'search', 'import', 'export', 'alarm', 'mcp', 'agent'].includes(command);
|
|
239
331
|
}
|
|
240
|
-
function printSetupHelp() {
|
|
332
|
+
async function printSetupHelp() {
|
|
333
|
+
const { isAirPlayPlatformSupported } = await import('./player/airplay-policy.js');
|
|
241
334
|
console.log(`RadioCLI Setup
|
|
242
335
|
|
|
243
336
|
Usage:
|
|
@@ -250,58 +343,9 @@ Usage:
|
|
|
250
343
|
radiocli setup --mcp --agent-ui Open a terminal TUI for agent playback (default)
|
|
251
344
|
radiocli setup --mcp --headless-agent Opt out of external terminal windows
|
|
252
345
|
radiocli setup --no-mcp Disable and remove agent MCP entries
|
|
253
|
-
radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
|
|
254
|
-
apt, dnf, pacman, apk,
|
|
346
|
+
${process.platform === 'linux' && isAirPlayPlatformSupported() ? ' radiocli setup --airplay Enable experimental Linux AirPlay and set up prerequisites\n radiocli setup --no-airplay Disable Linux AirPlay; keep shared services and packages\n' : ''} radiocli setup --package-manager <pm> Use brew, winget, scoop, choco,
|
|
347
|
+
apt, dnf, pacman, apk, zypper,
|
|
348
|
+
pkg (FreeBSD), pkg_add, pkgin,
|
|
349
|
+
termux-pkg (Termux), or pkgman (Haiku)
|
|
255
350
|
`);
|
|
256
351
|
}
|
|
257
|
-
function printPlaybackBackendStatus(backends) {
|
|
258
|
-
for (const line of playbackBackendStatusLines(backends)) {
|
|
259
|
-
console.log(line);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
function doctorReport(backends, mpvDiagnostic) {
|
|
263
|
-
const commands = Object.fromEntries(['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]));
|
|
264
|
-
const airPlay = airPlaySenderHealth();
|
|
265
|
-
return {
|
|
266
|
-
radioCliVersion: appVersion(),
|
|
267
|
-
nodeVersion: process.version,
|
|
268
|
-
platform: process.platform,
|
|
269
|
-
architecture: process.arch,
|
|
270
|
-
backends,
|
|
271
|
-
commands,
|
|
272
|
-
mpv: redactDiagnostic(mpvDiagnostic),
|
|
273
|
-
airPlay: {
|
|
274
|
-
available: airPlay.available,
|
|
275
|
-
safe: airPlay.safe,
|
|
276
|
-
package: airPlay.packageName,
|
|
277
|
-
version: airPlay.version ?? null,
|
|
278
|
-
vulnerablePackages: airPlay.vulnerablePackages,
|
|
279
|
-
warningPackages: airPlay.warningPackages
|
|
280
|
-
},
|
|
281
|
-
guidance: playbackBackendStatusLines(backends)
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
function printMpvDiagnostic(diagnostic) {
|
|
285
|
-
console.log(`mpv_path=${redactHome(diagnostic.path) ?? 'not-found'}`);
|
|
286
|
-
console.log(`mpv_discovery=${diagnostic.discovery}`);
|
|
287
|
-
console.log(`mpv_launch=${diagnostic.launchable ? 'ready' : 'failed'}`);
|
|
288
|
-
if (diagnostic.version)
|
|
289
|
-
console.log(`mpv_version=${diagnostic.version}`);
|
|
290
|
-
if (diagnostic.error)
|
|
291
|
-
console.log(`mpv_error=${diagnostic.error}`);
|
|
292
|
-
if (diagnostic.launchable && diagnostic.discovery !== 'path') {
|
|
293
|
-
console.log('mpv_hint=RadioCLI found mpv outside PATH and can use it directly; no PATH changes are required.');
|
|
294
|
-
}
|
|
295
|
-
else if (!diagnostic.path && process.platform === 'win32') {
|
|
296
|
-
console.log('mpv_hint=Install with winget, then rerun Doctor; RadioCLI also checks the standard MPV Player install directory.');
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
function redactDiagnostic(diagnostic) {
|
|
300
|
-
return { ...diagnostic, path: redactHome(diagnostic.path) };
|
|
301
|
-
}
|
|
302
|
-
function redactHome(path) {
|
|
303
|
-
if (!path)
|
|
304
|
-
return null;
|
|
305
|
-
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
306
|
-
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
307
|
-
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { browserCommands, clipboardCandidates } from './desktop.js';
|
|
2
|
+
import { nativeAdapters } from './runtime.js';
|
|
3
|
+
const available = (message, adapter) => ({ status: 'available', message, ...(adapter ? { adapter } : {}) });
|
|
4
|
+
const unavailable = (message) => ({ status: 'unavailable', message });
|
|
5
|
+
const unverified = (message, adapter) => ({ status: 'unverified', message, ...(adapter ? { adapter } : {}) });
|
|
6
|
+
/**
|
|
7
|
+
* Availability is independent per feature. Tool discovery never substitutes for
|
|
8
|
+
* desktop, scheduler, audio-device, permission, or upstream runtime verification.
|
|
9
|
+
*/
|
|
10
|
+
export function platformCapabilities(host, evidence) {
|
|
11
|
+
const adapters = nativeAdapters(host);
|
|
12
|
+
const commands = new Set(evidence.commands);
|
|
13
|
+
const has = (name) => commands.has(name);
|
|
14
|
+
const backend = ['mpv', 'ffplay', 'vlc', 'airplay'].find(name => evidence.backends.includes(name));
|
|
15
|
+
const desktop = evidence.graphicalSession;
|
|
16
|
+
const opener = browserCommands(host).find(item => has(item.command));
|
|
17
|
+
const clipboard = clipboardCandidates(host).find(item => has(item.command));
|
|
18
|
+
const schedulerTool = adapters.scheduler === 'launchd' ? 'launchctl'
|
|
19
|
+
: adapters.scheduler === 'task-scheduler' ? 'schtasks.exe' : adapters.scheduler === 'systemd' ? 'systemctl' : null;
|
|
20
|
+
const backgroundScheduling = evidence.scheduler
|
|
21
|
+
? evidence.scheduler.supported ? available(evidence.scheduler.message, adapters.scheduler ?? undefined) : unavailable(evidence.scheduler.message)
|
|
22
|
+
: schedulerTool && has(schedulerTool) ? unverified('Native scheduler command found; verify the per-user session with radiocli alarm doctor.', adapters.scheduler ?? undefined)
|
|
23
|
+
: unavailable(adapters.scheduler ? `The ${schedulerTool} scheduler command is unavailable.` : `Reliable background scheduling has no verified adapter on ${host.id}.`);
|
|
24
|
+
const sleepTool = adapters.inhibitor === 'caffeinate' ? 'caffeinate' : adapters.inhibitor === 'logind' ? 'systemd-inhibit' : adapters.inhibitor === 'windows' ? 'powershell.exe' : null;
|
|
25
|
+
const volumeTool = adapters.volume === 'macos' ? has('osascript') ? 'osascript' : null
|
|
26
|
+
: adapters.volume === 'windows' ? ['powershell.exe', 'pwsh.exe'].find(has)
|
|
27
|
+
: adapters.volume === 'unix-audio' ? ['wpctl', 'pactl', 'amixer'].find(has) : null;
|
|
28
|
+
const storage = evidence.storageWritable === true ? available('The local data directory is writable; operations still verify each write.')
|
|
29
|
+
: evidence.storageWritable === false ? unavailable('Local data is not writable. Set RADIOCLI_HOME to a private writable directory; existing readable data can still be inspected.')
|
|
30
|
+
: unverified('Local JSON data uses the existing private user paths; writability is checked when used.');
|
|
31
|
+
return {
|
|
32
|
+
playback: backend ? available(`${backend} executable detected; stream and audio-device readiness are checked when tuning.`, backend) : unavailable('No playback backend detected. Run radiocli setup or configure a native player path.'),
|
|
33
|
+
playbackControls: backend === 'mpv' ? available('mpv IPC provides pause, mute, volume, metadata, and media keys.', 'mpv')
|
|
34
|
+
: unavailable(backend === 'airplay' ? 'AirPlay provides volume and mute; pause and media-key playback controls require local mpv.' : 'Pause, mute, volume, metadata, and media-key playback controls require mpv.'),
|
|
35
|
+
packageInstallation: evidence.packageManager ? available('Explicit setup plans require confirmation or --yes; --dry-run never installs.', evidence.packageManager) : unavailable('No supported package manager detected; install playback prerequisites manually.'),
|
|
36
|
+
storage,
|
|
37
|
+
filePermissions: adapters.posixPermissions ? available('Private files use mode 0600 and private directories use mode 0700; the filesystem must enforce POSIX permissions.') : available('Private user directories inherit Windows ACLs; POSIX modes do not configure Windows ACLs.'),
|
|
38
|
+
atomicWrites: evidence.storageWritable === false ? unavailable('Atomic replacement requires a writable destination directory.') : available('Temporary files are renamed within the destination directory; write failures are reported.'),
|
|
39
|
+
externalUrl: desktop && opener ? available('A desktop URL handler is available; execution is checked when used.', opener.command) : unavailable('No usable desktop URL handler/session detected. Open the station homepage manually.'),
|
|
40
|
+
clipboard: desktop && clipboard ? available('A clipboard command is available; execution is checked when used.', clipboard.command) : unavailable('No usable clipboard command/session detected. RadioCLI can display the text for manual copying.'),
|
|
41
|
+
terminalReopening: desktop && adapters.terminal ? unverified('A supported installed terminal must be found and its launch verified.', adapters.terminal) : unavailable('No supported graphical terminal session. Use the current TUI or explicitly select headless agent playback.'),
|
|
42
|
+
backgroundScheduling,
|
|
43
|
+
catchUp: backgroundScheduling.status === 'available' && evidence.scheduler?.catchUpAfterWake ? available('The native scheduler can deliver a missed occurrence; RadioCLI still enforces the configured grace window.')
|
|
44
|
+
: backgroundScheduling.status === 'unverified' ? unverified('Catch-up depends on an accessible native user scheduler and its session.') : unavailable('Catch-up delivery is not verified in this session.'),
|
|
45
|
+
wakeRequests: adapters.scheduler === 'task-scheduler' ? unverified('Task Scheduler can request WakeToRun; hardware, login state, and power policy decide delivery.') : unavailable('No privileged or exact wake request is made on this platform. Catch-up and idle-sleep inhibition are separate capabilities.'),
|
|
46
|
+
sleepInhibition: sleepTool && has(sleepTool) ? unverified('A sleep-inhibitor tool is installed; its lease must remain alive to provide protection.', sleepTool) : unavailable('No usable per-process sleep inhibitor. Normal playback remains available; sleep may interrupt it.'),
|
|
47
|
+
systemVolume: volumeTool ? unverified('The system mixer must respond before alarm volume can be raised and restored.', volumeTool) : unavailable('System output volume cannot be adjusted. Set the device volume manually; player volume remains independent.'),
|
|
48
|
+
unicode: evidence.unicode === false ? unavailable('ASCII decoration mode is selected; international station data is preserved.') : available('Unicode decoration is enabled; terminal font coverage determines glyph appearance.'),
|
|
49
|
+
color: evidence.color === false ? unavailable('Color is disabled for the terminal or by user preference.') : available('Color is enabled at the terminal supported depth.'),
|
|
50
|
+
screenReader: available(evidence.screenReader ? 'Screen-reader mode is enabled; decorative animation is disabled.' : 'Screen-reader mode is available through INK_SCREEN_READER=true.'),
|
|
51
|
+
airPlay: !adapters.airPlay ? unavailable('Experimental AirPlay is supported on macOS and native Linux; this environment is not supported.')
|
|
52
|
+
: evidence.airPlay ? evidence.airPlay.ready
|
|
53
|
+
? evidence.airPlay.discoveryService === 'unverified' ? unverified(evidence.airPlay.message, 'airplay') : available(evidence.airPlay.message, 'airplay')
|
|
54
|
+
: unavailable(evidence.airPlay.message)
|
|
55
|
+
: evidence.backends.includes('airplay')
|
|
56
|
+
? host.id === 'linux' ? unverified('Experimental Linux output requires an explicit discovery-service and native-sender check. Select a receiver each session.', 'airplay')
|
|
57
|
+
: available('Experimental macOS output; receiver selection and worker readiness are still required.', 'airplay')
|
|
58
|
+
: unavailable(`Experimental AirPlay requires FFmpeg, ${host.id === 'linux' ? 'Avahi' : 'Bonjour'}, and the safe optional sender.`)
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { planMpvDesktopMedia } from '../player/mpv-desktop-media.js';
|
|
3
|
+
import { identifyOmarchy } from './omarchy.js';
|
|
4
|
+
import { sessionBusSocket, startMpris } from './mpris.js';
|
|
5
|
+
import { resolveCommandDetails } from './executables.js';
|
|
6
|
+
export function desktopMediaPolicy(settings, options = {}) {
|
|
7
|
+
const platform = options.platform ?? process.platform;
|
|
8
|
+
const env = options.env ?? process.env;
|
|
9
|
+
const override = env.RADIOCLI_DESKTOP_MEDIA;
|
|
10
|
+
const preference = override === 'auto' || override === 'on' || override === 'off' ? override : settings.desktopMediaControls ?? 'auto';
|
|
11
|
+
const disabled = (reason) => ({ enabled: false, preference, reason, mpvArgs: [] });
|
|
12
|
+
if (preference === 'off')
|
|
13
|
+
return disabled('Desktop media controls are disabled.');
|
|
14
|
+
if (platform !== 'linux')
|
|
15
|
+
return disabled('Desktop media controls currently require Linux.');
|
|
16
|
+
if (!sessionBusSocket(env.DBUS_SESSION_BUS_ADDRESS ?? ''))
|
|
17
|
+
return disabled('A local Unix session bus is required.');
|
|
18
|
+
if (preference === 'auto') {
|
|
19
|
+
const profile = (options.omarchy ?? (() => identifyOmarchy({ platform, env })))();
|
|
20
|
+
if (profile.kind !== 'quattro' || profile.source !== 'package' || !profile.session.localDesktop)
|
|
21
|
+
return disabled('Automatic desktop media controls require a packaged Omarchy Quattro local desktop.');
|
|
22
|
+
}
|
|
23
|
+
const mpv = (options.mpv ?? (() => planMpvDesktopMedia({ platform, env, executable: resolveCommandDetails('mpv', { platform, env }).path })))();
|
|
24
|
+
if (mpv.status !== 'compatible')
|
|
25
|
+
return disabled(mpv.reason);
|
|
26
|
+
return { enabled: true, preference, reason: mpv.reason, mpvArgs: mpv.args };
|
|
27
|
+
}
|
|
28
|
+
/** State and ICY updates reuse the owning player's event stream. No additional
|
|
29
|
+
* playback polling is needed and the bridge never connects through MCP. */
|
|
30
|
+
export function startDesktopMediaControls(options) {
|
|
31
|
+
if (!options.policy.enabled)
|
|
32
|
+
return { refresh() { }, close() { } };
|
|
33
|
+
let title;
|
|
34
|
+
let previousStream;
|
|
35
|
+
let previousStation;
|
|
36
|
+
let disposed = false;
|
|
37
|
+
const bridge = startMpris({
|
|
38
|
+
identity: resolve(options.identity),
|
|
39
|
+
address: (options.env ?? process.env).DBUS_SESSION_BUS_ADDRESS ?? '',
|
|
40
|
+
command: command => options.command(command, () => !disposed),
|
|
41
|
+
snapshot: () => ({ ...options.status(), title, available: !options.suspended() && options.player.desktopMediaCompatible() })
|
|
42
|
+
});
|
|
43
|
+
const refresh = () => {
|
|
44
|
+
const station = options.status().station;
|
|
45
|
+
const key = station ? `${station.provider}\0${station.id}` : undefined;
|
|
46
|
+
if (key !== previousStation) {
|
|
47
|
+
title = undefined;
|
|
48
|
+
previousStation = key;
|
|
49
|
+
}
|
|
50
|
+
bridge.refresh();
|
|
51
|
+
};
|
|
52
|
+
const removeState = options.player.onChange(state => {
|
|
53
|
+
if (state.streamUrl !== previousStream) {
|
|
54
|
+
title = undefined;
|
|
55
|
+
previousStream = state.streamUrl;
|
|
56
|
+
}
|
|
57
|
+
refresh();
|
|
58
|
+
});
|
|
59
|
+
const removeMetadata = options.player.onMetadata(metadata => { title = metadata.title; refresh(); });
|
|
60
|
+
return { refresh, close() { disposed = true; removeState(); removeMetadata(); bridge.close(); } };
|
|
61
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { identifyPlatform } from './runtime.js';
|
|
2
|
+
/** Command plans only. Callers must check the session and observe execution. */
|
|
3
|
+
export function browserCommands(host = identifyPlatform()) {
|
|
4
|
+
// termux-tools opens one URL through Android's VIEW intent.
|
|
5
|
+
if (host.id === 'termux')
|
|
6
|
+
return [{ command: 'termux-open-url', args: [] }];
|
|
7
|
+
if (host.id === 'darwin')
|
|
8
|
+
return [{ command: 'open', args: [] }];
|
|
9
|
+
if (host.id === 'win32')
|
|
10
|
+
return [{ command: 'explorer', args: [] }];
|
|
11
|
+
if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id))
|
|
12
|
+
return [{ command: 'xdg-open', args: [] }];
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
export function clipboardCandidates(host = identifyPlatform()) {
|
|
16
|
+
// No arguments makes termux-clipboard-set read stdin. Its matching Android
|
|
17
|
+
// API app and permissions are still required; the caller checks execution.
|
|
18
|
+
if (host.id === 'termux')
|
|
19
|
+
return [{ command: 'termux-clipboard-set', args: [] }];
|
|
20
|
+
if (host.id === 'darwin')
|
|
21
|
+
return [{ command: 'pbcopy', args: [] }];
|
|
22
|
+
if (host.id === 'win32')
|
|
23
|
+
return [{ command: 'clip', args: [] }];
|
|
24
|
+
if (['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id))
|
|
25
|
+
return [
|
|
26
|
+
{ command: 'wl-copy', args: [] },
|
|
27
|
+
{ command: 'xclip', args: ['-selection', 'clipboard'] },
|
|
28
|
+
{ command: 'xsel', args: ['--clipboard', '--input'] }
|
|
29
|
+
];
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
export function hasGraphicalSession(host, env = process.env) {
|
|
33
|
+
if (host.id === 'darwin' || host.id === 'win32' || host.id === 'termux')
|
|
34
|
+
return !env.SSH_CONNECTION && !env.SSH_TTY;
|
|
35
|
+
return ['linux', 'freebsd', 'openbsd', 'netbsd'].includes(host.id) && Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
|
|
36
|
+
}
|