@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
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { ffplayInstallCommand, mpvInstallCommand } from '../platform/packages.js';
|
|
2
|
+
import { readLinuxOsRelease } from '../platform/runtime.js';
|
|
3
|
+
import { commandExists } from '../platform/executables.js';
|
|
4
|
+
import { airPlayPolicy } from './airplay-policy.js';
|
|
5
|
+
export { isAirPlayPlatformSupported } from './airplay-policy.js';
|
|
3
6
|
import { airPlaySenderHealth } from './airplay-sender-health.js';
|
|
4
7
|
export const ffplayLimitedControlsMessage = 'ffplay fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
|
|
5
8
|
export const vlcLimitedControlsMessage = 'VLC fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
|
|
6
|
-
export function
|
|
9
|
+
export function airPlayLocalDeviceLabel(platform = process.platform) {
|
|
10
|
+
return platform === 'darwin' ? 'this Mac' : 'this device';
|
|
11
|
+
}
|
|
12
|
+
export function detectPlaybackBackends({ platform = process.platform, settings, env, release, hasCommand = commandExists, hasAirPlaySender = hasAirPlaySenderPackage } = {}) {
|
|
7
13
|
const backends = ['mpv', 'ffplay'].filter(hasCommand);
|
|
8
14
|
if (hasCommand('cvlc') || hasCommand('vlc')) {
|
|
9
15
|
backends.push('vlc');
|
|
10
16
|
}
|
|
11
|
-
|
|
17
|
+
// This is only a cheap candidate check. Discovery and explicit diagnostics
|
|
18
|
+
// separately verify the daemon and isolated native sender loadability.
|
|
19
|
+
if (airPlayPolicy(settings, { platform, env, release }).enabled && hasCommand('ffmpeg') && hasCommand(platform === 'linux' ? 'avahi-browse' : 'dns-sd') && hasAirPlaySender()) {
|
|
12
20
|
backends.push('airplay');
|
|
13
21
|
}
|
|
14
22
|
return backends;
|
|
@@ -69,16 +77,16 @@ export function playbackBackendCapabilities(backend) {
|
|
|
69
77
|
export function playbackBackendLabel(backend) {
|
|
70
78
|
return playbackBackendCapabilities(backend).label;
|
|
71
79
|
}
|
|
72
|
-
export function playbackBackendInstallHint(platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
73
|
-
return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease)}), then run radiocli doctor.`;
|
|
80
|
+
export function playbackBackendInstallHint(platform = process.platform, osRelease = readLinuxOsRelease(platform), options = {}) {
|
|
81
|
+
return `Run radiocli setup to install mpv for playback (${mpvInstallCommand(platform, osRelease, process.env, options)}), then run radiocli doctor.`;
|
|
74
82
|
}
|
|
75
|
-
export function playbackBackendStatusLines(backends, platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
83
|
+
export function playbackBackendStatusLines(backends, platform = process.platform, osRelease = readLinuxOsRelease(platform), options = {}) {
|
|
76
84
|
const backendSet = new Set(backends);
|
|
77
85
|
const lines = [
|
|
78
86
|
'npm_install=RadioCLI installs the optional AirPlay sender when native dependencies are available; playback tools come from mpv and FFmpeg',
|
|
79
87
|
'guided_setup=radiocli setup',
|
|
80
|
-
`install_mpv=${mpvInstallCommand(platform, osRelease)}`,
|
|
81
|
-
`optional_ffplay=${ffplayInstallCommand(platform, osRelease)}`
|
|
88
|
+
`install_mpv=${mpvInstallCommand(platform, osRelease, process.env, options)}`,
|
|
89
|
+
`optional_ffplay=${ffplayInstallCommand(platform, osRelease, process.env, options)}`
|
|
82
90
|
];
|
|
83
91
|
if (backendSet.has('mpv')) {
|
|
84
92
|
return [
|
|
@@ -123,89 +131,3 @@ export function playbackBackendStatusLines(backends, platform = process.platform
|
|
|
123
131
|
...lines
|
|
124
132
|
];
|
|
125
133
|
}
|
|
126
|
-
export function mpvInstallCommand(platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
127
|
-
if (platform === 'darwin') {
|
|
128
|
-
return 'brew install mpv';
|
|
129
|
-
}
|
|
130
|
-
if (platform === 'win32') {
|
|
131
|
-
return 'winget install --id shinchiro.mpv -e';
|
|
132
|
-
}
|
|
133
|
-
if (platform !== 'linux') {
|
|
134
|
-
return 'install mpv with your system package manager';
|
|
135
|
-
}
|
|
136
|
-
const ids = linuxReleaseIds(osRelease);
|
|
137
|
-
if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) {
|
|
138
|
-
return 'sudo apt install mpv';
|
|
139
|
-
}
|
|
140
|
-
if (hasAny(ids, ['fedora', 'rhel', 'centos'])) {
|
|
141
|
-
return 'sudo dnf install mpv';
|
|
142
|
-
}
|
|
143
|
-
if (hasAny(ids, ['arch', 'manjaro'])) {
|
|
144
|
-
return 'sudo pacman -S mpv';
|
|
145
|
-
}
|
|
146
|
-
if (hasAny(ids, ['alpine'])) {
|
|
147
|
-
return 'sudo apk add mpv';
|
|
148
|
-
}
|
|
149
|
-
if (hasAny(ids, ['opensuse', 'suse'])) {
|
|
150
|
-
return 'sudo zypper install mpv';
|
|
151
|
-
}
|
|
152
|
-
return 'install mpv with your system package manager';
|
|
153
|
-
}
|
|
154
|
-
function ffplayInstallCommand(platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
155
|
-
if (platform === 'darwin') {
|
|
156
|
-
return 'brew install ffmpeg';
|
|
157
|
-
}
|
|
158
|
-
if (platform === 'win32') {
|
|
159
|
-
return 'winget install --id Gyan.FFmpeg -e';
|
|
160
|
-
}
|
|
161
|
-
if (platform !== 'linux') {
|
|
162
|
-
return 'install FFmpeg with your system package manager';
|
|
163
|
-
}
|
|
164
|
-
const ids = linuxReleaseIds(osRelease);
|
|
165
|
-
if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) {
|
|
166
|
-
return 'sudo apt install ffmpeg';
|
|
167
|
-
}
|
|
168
|
-
if (hasAny(ids, ['fedora', 'rhel', 'centos'])) {
|
|
169
|
-
return 'sudo dnf install ffmpeg';
|
|
170
|
-
}
|
|
171
|
-
if (hasAny(ids, ['arch', 'manjaro'])) {
|
|
172
|
-
return 'sudo pacman -S ffmpeg';
|
|
173
|
-
}
|
|
174
|
-
if (hasAny(ids, ['alpine'])) {
|
|
175
|
-
return 'sudo apk add ffmpeg';
|
|
176
|
-
}
|
|
177
|
-
if (hasAny(ids, ['opensuse', 'suse'])) {
|
|
178
|
-
return 'sudo zypper install ffmpeg';
|
|
179
|
-
}
|
|
180
|
-
return 'install FFmpeg with your system package manager';
|
|
181
|
-
}
|
|
182
|
-
function readLinuxOsRelease() {
|
|
183
|
-
if (process.platform !== 'linux' || !existsSync('/etc/os-release')) {
|
|
184
|
-
return '';
|
|
185
|
-
}
|
|
186
|
-
try {
|
|
187
|
-
return readFileSync('/etc/os-release', 'utf8');
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
return '';
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
function linuxReleaseIds(osRelease) {
|
|
194
|
-
const ids = new Set();
|
|
195
|
-
for (const line of osRelease.split('\n')) {
|
|
196
|
-
const match = /^(ID|ID_LIKE)=(.*)$/.exec(line);
|
|
197
|
-
if (!match) {
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
for (const value of match[2].replaceAll('"', '').split(/\s+/)) {
|
|
201
|
-
const normalized = value.trim().toLowerCase();
|
|
202
|
-
if (normalized) {
|
|
203
|
-
ids.add(normalized);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return ids;
|
|
208
|
-
}
|
|
209
|
-
function hasAny(values, candidates) {
|
|
210
|
-
return candidates.some(candidate => values.has(candidate));
|
|
211
|
-
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { resolveCommandDetails } from '
|
|
2
|
+
import { resolveCommandDetails } from '../platform/executables.js';
|
|
3
3
|
export function diagnoseCommand(command, overrides = {}) {
|
|
4
4
|
const resolution = (overrides.resolve ?? resolveCommandDetails)(command);
|
|
5
5
|
if (!resolution.path) {
|
|
6
|
-
return { ...resolution, launchable: false, version: null, error: 'not found' };
|
|
6
|
+
return { ...resolution, launchable: false, version: null, error: resolution.error ?? 'not found' };
|
|
7
7
|
}
|
|
8
8
|
const execute = overrides.execute ?? ((path, args, options) => spawnSync(path, args, options));
|
|
9
9
|
const result = execute(resolution.path, ['--version'], {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// FFmpeg releases in circulation do not propagate tls_verify to resources
|
|
2
|
+
// opened by HLS/DASH/IMF demuxers. Restrict network fallbacks to self-contained
|
|
3
|
+
// media; mpv supplies verified networking for multi-resource inputs.
|
|
4
|
+
const mediaFormats = 'aac,ac3,aiff,amr,ape,asf,au,avi,caf,eac3,flac,flv,live_flv,matroska,webm,mov,mp4,m4a,3gp,3g2,mj2,mp3,mpeg,mpegts,nut,ogg,w64,wav,wv';
|
|
5
|
+
export function ffmpegInputArguments(target, options = {}) {
|
|
6
|
+
const network = /^https?:\/\//i.test(target);
|
|
7
|
+
const plainHttp = options.plainHttp && /^http:\/\//i.test(target);
|
|
8
|
+
if (!network)
|
|
9
|
+
return ['-protocol_whitelist', 'file,crypto,data'];
|
|
10
|
+
const args = ['-format_whitelist', mediaFormats];
|
|
11
|
+
if (plainHttp)
|
|
12
|
+
args.push('-protocol_blacklist', 'https,tls');
|
|
13
|
+
else
|
|
14
|
+
args.push('-tls_verify', '1');
|
|
15
|
+
if (options.reconnect)
|
|
16
|
+
args.push('-reconnect', '1', '-reconnect_on_network_error', '1', '-reconnect_streamed', '1', '-reconnect_delay_max', '2');
|
|
17
|
+
return args;
|
|
18
|
+
}
|
|
19
|
+
/** First verify any HTTP -> HTTPS redirect. FFmpeg rejects the unused TLS
|
|
20
|
+
* option on ordinary HTTP media; retry only that exact failure, with TLS
|
|
21
|
+
* protocols blocked even if the server changes its redirect. */
|
|
22
|
+
export function canRetryPlainHttp(target, diagnostic) {
|
|
23
|
+
return /^http:\/\//i.test(target) && /(?:^|[\r\n])Option tls_verify not found\.?\s*(?:[\r\n]|$)/.test(diagnostic);
|
|
24
|
+
}
|
|
25
|
+
export function ffmpegInputFailure(diagnostic) {
|
|
26
|
+
if (/not on whitelist|on blacklist/i.test(diagnostic))
|
|
27
|
+
return 'This input is unavailable with FFmpeg. Use a direct audio stream, or select mpv for local playback of HLS/DASH and other network playlists.';
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
export function ffmpegCertificateFailure(diagnostic) {
|
|
31
|
+
// Backends use different TLS libraries. Generic handshake/I/O errors can be
|
|
32
|
+
// transient; only specific certificate failures are permanent here.
|
|
33
|
+
if (/certificate (?:verify|verification) failed|peer certificate failed verification|certificate's owner does not match hostname|unable to verify peer certificate|invalid certificate chain|SNI or certificate check failed|Creating security context failed \(0x800903(?:25|27|28)\)/i.test(diagnostic)) {
|
|
34
|
+
return 'The stream certificate could not be verified. Choose a valid HTTPS stream or repair the native certificate configuration.';
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, opendirSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
const maxConfigBytes = 256 * 1024;
|
|
4
|
+
const maxScripts = 256;
|
|
5
|
+
const unsupported = (reason) => ({ status: 'unsupported', args: [], reason });
|
|
6
|
+
const compatible = (args, reason) => ({ status: 'compatible', args, reason });
|
|
7
|
+
/**
|
|
8
|
+
* mpv-mpris 1.3 has no per-process opt-out. Replace directory autoload with its
|
|
9
|
+
* exact file list, omitting only the packaged plugin. mpv still reads its normal
|
|
10
|
+
* configuration and loads every unrelated script. Nothing is written to disk.
|
|
11
|
+
*
|
|
12
|
+
* Source contracts: mpv player/scripting.c and options/path.c; mpv-mpris Makefile.
|
|
13
|
+
* Configurations whose script-loading semantics cannot be preserved are left
|
|
14
|
+
* completely alone; the application media service must then remain disabled.
|
|
15
|
+
*/
|
|
16
|
+
export function planMpvDesktopMedia(options = {}) {
|
|
17
|
+
if ((options.platform ?? process.platform) !== 'linux')
|
|
18
|
+
return unsupported('Desktop mpv media integration is available on Linux.');
|
|
19
|
+
const env = options.env ?? process.env;
|
|
20
|
+
const executable = options.executable ?? env.RADIOCLI_MPV_PATH ?? '/usr/bin/mpv';
|
|
21
|
+
if (!options.configDirectories && !['/usr/bin/mpv', '/bin/mpv'].includes(executable)) {
|
|
22
|
+
return unsupported('Desktop media controls require the packaged mpv configuration layout; the custom mpv executable was left unchanged.');
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const directories = options.configDirectories ?? configDirectories(env);
|
|
26
|
+
let stockMprisPath = options.stockMprisPath ?? '/usr/lib/mpv-mpris/mpris.so';
|
|
27
|
+
try {
|
|
28
|
+
stockMprisPath = realpathSync(stockMprisPath);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code !== 'ENOENT')
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
let autoload = true;
|
|
35
|
+
let ambiguous = false;
|
|
36
|
+
// mpv loads encoding profiles first, then config/mpv.conf in each search
|
|
37
|
+
// directory. A profile with script options is deliberately not reinterpreted.
|
|
38
|
+
const files = [
|
|
39
|
+
...directories.map(directory => posix.join(directory, 'encoding-profiles.conf')),
|
|
40
|
+
...directories.flatMap(directory => ['config', 'mpv.conf'].map(file => posix.join(directory, file)))
|
|
41
|
+
];
|
|
42
|
+
for (const file of files) {
|
|
43
|
+
const contents = readConfig(file);
|
|
44
|
+
if (contents === null)
|
|
45
|
+
continue;
|
|
46
|
+
let profile = false;
|
|
47
|
+
for (const raw of contents.split(/\r?\n/)) {
|
|
48
|
+
const line = raw.trim();
|
|
49
|
+
if (!line || line.startsWith('#'))
|
|
50
|
+
continue;
|
|
51
|
+
if (line.startsWith('[')) {
|
|
52
|
+
profile = !/^\[default\]\s*(?:#.*)?$/.test(line);
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Includes and script lists can change startup script order or load
|
|
56
|
+
// another media plugin. Ordinary profiles remain intact; script options
|
|
57
|
+
// inside any profile are rejected even when that profile is inactive.
|
|
58
|
+
if (/^(?:--)?(?:include|config(?:-dir)?|use-filedir-conf|scripts?(?:-(?:add|append|clr|del|pre|set|toggle|remove))?)\s*(?:=|$)/i.test(line)) {
|
|
59
|
+
ambiguous = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (/^(?:--)?(?:no-)?load-scripts\b/i.test(line)) {
|
|
63
|
+
const setting = /^(?:--)?load-scripts\s*=\s*(yes|no)\s*(?:#.*)?$/i.exec(line);
|
|
64
|
+
if (profile || !setting)
|
|
65
|
+
ambiguous = true;
|
|
66
|
+
else
|
|
67
|
+
autoload = setting[1].toLowerCase() === 'yes';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (ambiguous)
|
|
72
|
+
return unsupported('Custom mpv script, include, or profile directives require native media handling; mpv configuration and scripts were left unchanged.');
|
|
73
|
+
if (!autoload)
|
|
74
|
+
return compatible([], 'mpv directory scripts are disabled by the existing configuration.');
|
|
75
|
+
const scripts = [];
|
|
76
|
+
let omitted = 0;
|
|
77
|
+
for (const directory of directories) {
|
|
78
|
+
const entries = scriptFiles(posix.join(directory, 'scripts'));
|
|
79
|
+
for (const file of entries) {
|
|
80
|
+
if (scripts.length + omitted >= maxScripts)
|
|
81
|
+
throw new Error('Too many mpv scripts to preserve within the bounded desktop media check.');
|
|
82
|
+
const actual = realpathSync(file);
|
|
83
|
+
if (actual === stockMprisPath) {
|
|
84
|
+
omitted++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (/^(?:mpv[-_])?mpris(?:\.(?:so|lua|js))?$/i.test(posix.basename(file)) || /^(?:mpv[-_])?mpris(?:\.(?:so|lua|js))?$/i.test(posix.basename(actual))) {
|
|
88
|
+
return unsupported('A custom mpv MPRIS plugin needs native media handling; mpv configuration and scripts were left unchanged.');
|
|
89
|
+
}
|
|
90
|
+
scripts.push(file);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!omitted)
|
|
94
|
+
return compatible([], 'No stock mpv media plugin needs suppression.');
|
|
95
|
+
return compatible(['--load-scripts=no', ...scripts.map(file => `--script=${file}`)], 'The stock mpv media plugin is omitted for this process; other scripts and configuration are preserved.');
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return unsupported('mpv script configuration could not be safely inspected; native mpv behavior is preserved.');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function configDirectories(env) {
|
|
102
|
+
if (env.MPV_HOME !== undefined) {
|
|
103
|
+
if (!env.MPV_HOME)
|
|
104
|
+
return [];
|
|
105
|
+
return [absolute(env.MPV_HOME)];
|
|
106
|
+
}
|
|
107
|
+
const result = ['/etc/mpv'];
|
|
108
|
+
const home = env.HOME ? absolute(env.HOME) : null;
|
|
109
|
+
const modern = env.XDG_CONFIG_HOME?.startsWith('/') ? posix.join(absolute(env.XDG_CONFIG_HOME), 'mpv') : home ? posix.join(home, '.config', 'mpv') : null;
|
|
110
|
+
const legacy = home ? posix.join(home, '.mpv') : null;
|
|
111
|
+
if (legacy && isDirectory(legacy))
|
|
112
|
+
result.push(legacy);
|
|
113
|
+
if (modern && (modern !== legacy) && (!legacy || !isDirectory(legacy) || isDirectory(modern)))
|
|
114
|
+
result.push(modern);
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
function absolute(value) {
|
|
118
|
+
if (!posix.isAbsolute(value) || /[\0\r\n]/.test(value))
|
|
119
|
+
throw new Error('Ambiguous mpv configuration path.');
|
|
120
|
+
return posix.normalize(value);
|
|
121
|
+
}
|
|
122
|
+
function isDirectory(path) {
|
|
123
|
+
try {
|
|
124
|
+
return statSync(path).isDirectory();
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error.code === 'ENOENT')
|
|
128
|
+
return false;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function readConfig(path) {
|
|
133
|
+
let descriptor;
|
|
134
|
+
try {
|
|
135
|
+
descriptor = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
136
|
+
const info = fstatSync(descriptor);
|
|
137
|
+
if (!info.isFile() || info.size > maxConfigBytes)
|
|
138
|
+
throw new Error('Unbounded mpv configuration.');
|
|
139
|
+
const buffer = Buffer.alloc(maxConfigBytes + 1);
|
|
140
|
+
const size = readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
141
|
+
if (size > maxConfigBytes)
|
|
142
|
+
throw new Error('Unbounded mpv configuration.');
|
|
143
|
+
return buffer.toString('utf8', 0, size);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error.code === 'ENOENT')
|
|
147
|
+
return null;
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
if (descriptor !== undefined)
|
|
152
|
+
closeSync(descriptor);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function scriptFiles(directory) {
|
|
156
|
+
let handle;
|
|
157
|
+
try {
|
|
158
|
+
handle = opendirSync(directory);
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code === 'ENOENT')
|
|
162
|
+
return [];
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
const files = [];
|
|
166
|
+
let inspected = 0;
|
|
167
|
+
try {
|
|
168
|
+
for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
|
|
169
|
+
if (++inspected > 1024 || files.length >= maxScripts)
|
|
170
|
+
throw new Error('Unbounded mpv script directory.');
|
|
171
|
+
if (entry.name.startsWith('.'))
|
|
172
|
+
continue;
|
|
173
|
+
const path = posix.join(directory, entry.name);
|
|
174
|
+
let info;
|
|
175
|
+
try {
|
|
176
|
+
info = statSync(path);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error.code === 'ENOENT')
|
|
180
|
+
continue;
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
if (info.isFile() || info.isDirectory())
|
|
184
|
+
files.push(path);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
handle.closeSync();
|
|
189
|
+
}
|
|
190
|
+
// Use stable ASCII ordering for explicit scripts, without locale collation.
|
|
191
|
+
// Native mpv autoload order is not a reliable script dependency contract.
|
|
192
|
+
const fold = (value) => Buffer.from(value.replace(/[A-Z]/g, letter => letter.toLowerCase()));
|
|
193
|
+
return files.sort((left, right) => Buffer.compare(fold(left), fold(right)));
|
|
194
|
+
}
|
|
@@ -14,12 +14,25 @@ export class MpvIpcClient {
|
|
|
14
14
|
connecting = null;
|
|
15
15
|
pending = new Map();
|
|
16
16
|
buffer = '';
|
|
17
|
+
bufferBytes = 0;
|
|
18
|
+
bufferScanFrom = 0;
|
|
17
19
|
nextRequestId = 1;
|
|
18
20
|
closed = false;
|
|
21
|
+
connectedOnce = false;
|
|
22
|
+
observers = new Map();
|
|
23
|
+
eventListeners = new Set();
|
|
19
24
|
constructor(path, timeoutMs = 1000) {
|
|
20
25
|
this.path = path;
|
|
21
26
|
this.timeoutMs = timeoutMs;
|
|
22
27
|
}
|
|
28
|
+
onEvent(listener) {
|
|
29
|
+
this.eventListeners.add(listener);
|
|
30
|
+
return () => this.eventListeners.delete(listener);
|
|
31
|
+
}
|
|
32
|
+
async observe(id, property) {
|
|
33
|
+
this.observers.set(id, property);
|
|
34
|
+
await this.query({ command: ['observe_property', id, property] });
|
|
35
|
+
}
|
|
23
36
|
async query(payload) {
|
|
24
37
|
if (this.closed) {
|
|
25
38
|
throw new Error('mpv IPC client is closed.');
|
|
@@ -54,6 +67,8 @@ export class MpvIpcClient {
|
|
|
54
67
|
return;
|
|
55
68
|
}
|
|
56
69
|
this.closed = true;
|
|
70
|
+
this.eventListeners.clear();
|
|
71
|
+
this.observers.clear();
|
|
57
72
|
this.rejectPending(reason);
|
|
58
73
|
this.socket?.destroy();
|
|
59
74
|
this.connectingSocket?.destroy();
|
|
@@ -61,6 +76,8 @@ export class MpvIpcClient {
|
|
|
61
76
|
this.connectingSocket = null;
|
|
62
77
|
this.connecting = null;
|
|
63
78
|
this.buffer = '';
|
|
79
|
+
this.bufferBytes = 0;
|
|
80
|
+
this.bufferScanFrom = 0;
|
|
64
81
|
}
|
|
65
82
|
connect() {
|
|
66
83
|
if (this.socket && !this.socket.destroyed) {
|
|
@@ -102,10 +119,19 @@ export class MpvIpcClient {
|
|
|
102
119
|
this.socket = socket;
|
|
103
120
|
this.connectingSocket = null;
|
|
104
121
|
this.buffer = '';
|
|
105
|
-
|
|
122
|
+
this.bufferBytes = 0;
|
|
123
|
+
this.bufferScanFrom = 0;
|
|
124
|
+
socket.setEncoding('utf8');
|
|
125
|
+
socket.on('data', chunk => { if (this.socket === socket)
|
|
126
|
+
this.consume(String(chunk)); });
|
|
106
127
|
socket.on('error', error => this.dropSocket(socket, error));
|
|
107
128
|
socket.on('close', () => this.dropSocket(socket, new Error('mpv IPC connection closed.')));
|
|
108
129
|
resolve(socket);
|
|
130
|
+
if (this.connectedOnce) {
|
|
131
|
+
for (const [id, property] of this.observers)
|
|
132
|
+
void this.query({ command: ['observe_property', id, property] }).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
this.connectedOnce = true;
|
|
109
135
|
};
|
|
110
136
|
socket.once('error', failConnection);
|
|
111
137
|
socket.once('close', closeBeforeConnection);
|
|
@@ -126,10 +152,20 @@ export class MpvIpcClient {
|
|
|
126
152
|
}
|
|
127
153
|
consume(chunk) {
|
|
128
154
|
this.buffer += chunk;
|
|
129
|
-
|
|
155
|
+
this.bufferBytes += Buffer.byteLength(chunk);
|
|
156
|
+
if (this.bufferBytes > 4 * 1024 * 1024) {
|
|
157
|
+
const socket = this.socket;
|
|
158
|
+
if (socket) {
|
|
159
|
+
this.dropSocket(socket, new Error('mpv IPC message exceeds 4 MiB.'));
|
|
160
|
+
socket.destroy();
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
let newlineIndex = this.buffer.indexOf('\n', this.bufferScanFrom);
|
|
130
165
|
while (newlineIndex !== -1) {
|
|
131
166
|
const line = this.buffer.slice(0, newlineIndex);
|
|
132
167
|
this.buffer = this.buffer.slice(newlineIndex + 1);
|
|
168
|
+
this.bufferBytes -= Buffer.byteLength(line) + 1;
|
|
133
169
|
newlineIndex = this.buffer.indexOf('\n');
|
|
134
170
|
if (!line.trim()) {
|
|
135
171
|
continue;
|
|
@@ -141,7 +177,18 @@ export class MpvIpcClient {
|
|
|
141
177
|
catch {
|
|
142
178
|
continue;
|
|
143
179
|
}
|
|
180
|
+
if (!response || typeof response !== 'object' || Array.isArray(response))
|
|
181
|
+
continue;
|
|
144
182
|
if (typeof response.request_id !== 'number' || !this.pending.has(response.request_id)) {
|
|
183
|
+
const event = response;
|
|
184
|
+
if (typeof event.event === 'string') {
|
|
185
|
+
for (const listener of this.eventListeners) {
|
|
186
|
+
try {
|
|
187
|
+
listener(event);
|
|
188
|
+
}
|
|
189
|
+
catch { /* Isolate event consumers. */ }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
145
192
|
continue;
|
|
146
193
|
}
|
|
147
194
|
if (response.error && response.error !== 'success') {
|
|
@@ -151,6 +198,7 @@ export class MpvIpcClient {
|
|
|
151
198
|
this.settleRequest(response.request_id, pending => pending.resolve(response.data));
|
|
152
199
|
}
|
|
153
200
|
}
|
|
201
|
+
this.bufferScanFrom = this.buffer.length;
|
|
154
202
|
}
|
|
155
203
|
dropSocket(socket, error) {
|
|
156
204
|
if (this.socket !== socket) {
|
|
@@ -158,6 +206,8 @@ export class MpvIpcClient {
|
|
|
158
206
|
}
|
|
159
207
|
this.socket = null;
|
|
160
208
|
this.buffer = '';
|
|
209
|
+
this.bufferBytes = 0;
|
|
210
|
+
this.bufferScanFrom = 0;
|
|
161
211
|
this.rejectPending(error);
|
|
162
212
|
}
|
|
163
213
|
rejectPending(error) {
|