@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.
Files changed (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,14 +1,22 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { commandExists } from './command.js';
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 detectPlaybackBackends({ platform = process.platform, hasCommand = commandExists, hasAirPlaySender = hasAirPlaySenderPackage } = {}) {
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
- if (platform === 'darwin' && hasCommand('ffmpeg') && hasCommand('dns-sd') && hasAirPlaySender()) {
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 './command.js';
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
- socket.on('data', chunk => this.consume(chunk.toString('utf8')));
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
- let newlineIndex = this.buffer.indexOf('\n');
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) {