@ciphore/radiocli 0.2.4 → 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 (134) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/CONTRIBUTING.md +10 -0
  3. package/README.md +108 -253
  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 +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +178 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +113 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +249 -76
  71. package/dist/ui/AdaptiveContent.js +18 -10
  72. package/dist/ui/App.js +708 -454
  73. package/dist/ui/AppContent.js +5 -5
  74. package/dist/ui/airplay-settings.js +12 -5
  75. package/dist/ui/bidi-ranges.js +45 -0
  76. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  77. package/dist/ui/format.js +37 -4
  78. package/dist/ui/help-content.js +5 -2
  79. package/dist/ui/rtl-output.js +137 -0
  80. package/dist/ui/screen-items.js +10 -4
  81. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  82. package/dist/ui/screens/AlarmsScreen.js +5 -2
  83. package/dist/ui/screens/HelpScreen.js +3 -3
  84. package/dist/ui/screens/SettingsScreen.js +16 -16
  85. package/dist/ui/screens/StatsScreen.js +1 -1
  86. package/dist/ui/text-direction.js +9 -0
  87. package/dist/ui/use-alarm-tui.js +24 -24
  88. package/dist/ui/use-app-input.js +42 -8
  89. package/dist/ui/use-command-executor.js +21 -14
  90. package/dist/ui/visualizers/gallop.js +1 -1
  91. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  92. package/dist/update-check.js +2 -1
  93. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  94. package/package.json +34 -11
  95. package/scripts/build-airplay-native.mjs +22 -0
  96. package/vendor/node-airtunes2/LICENSE +208 -0
  97. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  98. package/vendor/node-airtunes2/README.md +225 -0
  99. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  100. package/vendor/node-airtunes2/binding.gyp +41 -0
  101. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  102. package/vendor/node-airtunes2/lib/atv.js +307 -0
  103. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  104. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  105. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  106. package/vendor/node-airtunes2/lib/config.js +26 -0
  107. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  108. package/vendor/node-airtunes2/lib/devices.js +266 -0
  109. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  110. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  111. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  112. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  113. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  114. package/vendor/node-airtunes2/lib/http.js +189 -0
  115. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  116. package/vendor/node-airtunes2/lib/index.js +127 -0
  117. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  118. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  119. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  120. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  121. package/vendor/node-airtunes2/lib/srp.js +158 -0
  122. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  123. package/vendor/node-airtunes2/lib/util.js +12 -0
  124. package/vendor/node-airtunes2/package.json +31 -0
  125. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  126. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  127. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  128. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  129. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  130. package/vendor/node-airtunes2/src/base64.c +136 -0
  131. package/vendor/node-airtunes2/src/base64.h +42 -0
  132. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  133. package/vendor/node-airtunes2/src/codec.cc +170 -0
  134. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -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,11 +119,19 @@ export class MpvIpcClient {
102
119
  this.socket = socket;
103
120
  this.connectingSocket = null;
104
121
  this.buffer = '';
122
+ this.bufferBytes = 0;
123
+ this.bufferScanFrom = 0;
105
124
  socket.setEncoding('utf8');
106
- socket.on('data', chunk => this.consume(String(chunk)));
125
+ socket.on('data', chunk => { if (this.socket === socket)
126
+ this.consume(String(chunk)); });
107
127
  socket.on('error', error => this.dropSocket(socket, error));
108
128
  socket.on('close', () => this.dropSocket(socket, new Error('mpv IPC connection closed.')));
109
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;
110
135
  };
111
136
  socket.once('error', failConnection);
112
137
  socket.once('close', closeBeforeConnection);
@@ -127,10 +152,20 @@ export class MpvIpcClient {
127
152
  }
128
153
  consume(chunk) {
129
154
  this.buffer += chunk;
130
- 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);
131
165
  while (newlineIndex !== -1) {
132
166
  const line = this.buffer.slice(0, newlineIndex);
133
167
  this.buffer = this.buffer.slice(newlineIndex + 1);
168
+ this.bufferBytes -= Buffer.byteLength(line) + 1;
134
169
  newlineIndex = this.buffer.indexOf('\n');
135
170
  if (!line.trim()) {
136
171
  continue;
@@ -142,7 +177,18 @@ export class MpvIpcClient {
142
177
  catch {
143
178
  continue;
144
179
  }
180
+ if (!response || typeof response !== 'object' || Array.isArray(response))
181
+ continue;
145
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
+ }
146
192
  continue;
147
193
  }
148
194
  if (response.error && response.error !== 'success') {
@@ -152,6 +198,7 @@ export class MpvIpcClient {
152
198
  this.settleRequest(response.request_id, pending => pending.resolve(response.data));
153
199
  }
154
200
  }
201
+ this.bufferScanFrom = this.buffer.length;
155
202
  }
156
203
  dropSocket(socket, error) {
157
204
  if (this.socket !== socket) {
@@ -159,6 +206,8 @@ export class MpvIpcClient {
159
206
  }
160
207
  this.socket = null;
161
208
  this.buffer = '';
209
+ this.bufferBytes = 0;
210
+ this.bufferScanFrom = 0;
162
211
  this.rejectPending(error);
163
212
  }
164
213
  rejectPending(error) {