@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
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
+ import { airPlaySenderRoot } from './airplay-sender-package.js';
3
4
  const require = createRequire(import.meta.url);
4
5
  const setupAp2DigestGuard = 'this.status === SETUP_AP2_1 && response.code === 401';
5
6
  const responseHeadersMarker = ' headers = response.headers';
@@ -58,6 +59,10 @@ const bufferedSocketDataHandler = ` var blob = Buffer.alloc(0)
58
59
  self.clearTimeout()
59
60
 
60
61
  blob = Buffer.concat([blob, Buffer.from(data)])
62
+ if (blob.length > 1024 * 1024) {
63
+ self.cleanup('invalid_response')
64
+ return
65
+ }
61
66
  while (blob.length > 0) {
62
67
  var endIndex = blob.indexOf('\\r\\n\\r\\n')
63
68
 
@@ -69,6 +74,10 @@ const bufferedSocketDataHandler = ` var blob = Buffer.alloc(0)
69
74
  var headers = blob.slice(0, endIndex).toString()
70
75
  var lengthMatch = /(?:^|\\r\\n)Content-Length:\\s*(\\d+)/i.exec(headers)
71
76
  var contentLength = lengthMatch ? parseInt(lengthMatch[1], 10) : 0
77
+ if (!Number.isSafeInteger(contentLength) || contentLength > 1024 * 1024) {
78
+ self.cleanup('invalid_response')
79
+ return
80
+ }
72
81
  var messageLength = endIndex + contentLength
73
82
 
74
83
  if (blob.length < messageLength) {
@@ -82,13 +91,15 @@ const bufferedSocketDataHandler = ` var blob = Buffer.alloc(0)
82
91
  }
83
92
  })`;
84
93
  let installed = false;
85
- export function installAirPlaySenderPatch(packageName = 'node-airtunes2') {
94
+ export function installAirPlaySenderPatch(packageName = airPlaySenderRoot) {
86
95
  if (installed) {
87
96
  return true;
88
97
  }
89
98
  let rtspPath;
99
+ let devicePath;
90
100
  try {
91
101
  rtspPath = require.resolve(`${packageName}/lib/rtsp.js`);
102
+ devicePath = require.resolve(`${packageName}/lib/device_airtunes.js`);
92
103
  }
93
104
  catch {
94
105
  return false;
@@ -99,17 +110,18 @@ export function installAirPlaySenderPatch(packageName = 'node-airtunes2') {
99
110
  return false;
100
111
  }
101
112
  moduleLoader._extensions['.js'] = (module, filename) => {
102
- if (filename !== rtspPath) {
113
+ if (filename !== rtspPath && filename !== devicePath) {
103
114
  originalLoader(module, filename);
104
115
  return;
105
116
  }
106
- module._compile(patchAirTunesRtspSource(readFileSync(filename, 'utf8')), filename);
117
+ const source = readFileSync(filename, 'utf8');
118
+ module._compile(filename === rtspPath ? patchAirTunesRtspSource(source) : patchAirTunesDeviceSource(source), filename);
107
119
  };
108
120
  installed = true;
109
121
  return true;
110
122
  }
111
123
  export function patchAirTunesRtspSource(source) {
112
- let patched = source;
124
+ let patched = patchQueuedControls(patchLegacyDigest(source));
113
125
  if (patched.includes(socketDataHandlerMarker) && !patched.includes('Buffer.concat([blob, Buffer.from(data)])')) {
114
126
  patched = patched.replace(socketDataHandlerMarker, bufferedSocketDataHandler);
115
127
  }
@@ -136,3 +148,89 @@ export function patchAirTunesRtspSource(source) {
136
148
  `;
137
149
  return patched.replace(processDataResponseMarker, `${setupAp2DigestRetry}${processDataResponseMarker}`);
138
150
  }
151
+ /** A zero starting volume is intentional, including when a session starts muted. */
152
+ export function patchAirTunesDeviceSource(source) {
153
+ return source.replaceAll('options.volume || 50', 'options.volume ?? 50').replaceAll('this.options.volume || 30', 'this.options.volume ?? 30');
154
+ }
155
+ function patchLegacyDigest(source) {
156
+ if (source.includes('// RadioCLI legacy Digest'))
157
+ return source;
158
+ return source.replace(' this.status = this.airplay2 ? PAIR_SETUP_1 : PAIR_PIN_SETUP_1\n this.sendNextRequest()', ` if (!this.airplay2 && this.mode === 0 && (this.radioCliDigest || this.status === OPTIONS)) {
159
+ if (this.radioCliDigest) {
160
+ this.radioCliDigest.password = passcode
161
+ this.radioCliDigestTried = true
162
+ this.sendNextRequest(this.radioCliDigest)
163
+ }
164
+ return
165
+ }
166
+ this.status = this.airplay2 ? PAIR_SETUP_1 : PAIR_PIN_SETUP_1
167
+ this.sendNextRequest()`)
168
+ .replace(" var head = method + ' ' + uri + ' RTSP/1.0'", " if (!this.airplay2 && this.mode === 0) di = di || this.radioCliDigest\n var head = method + ' ' + uri + ' RTSP/1.0'")
169
+ .replace(" if (dimode == 'airplay2') {", " if (dimode == 'airplay2' || (!this.airplay2 && this.mode === 0)) {")
170
+ .replace(" diResponse = md5(ha1 + ':' + di.nonce + ':' + ha2)", " diResponse = (!this.airplay2 && this.mode === 0 ? md5norm : md5)(ha1 + ':' + di.nonce + ':' + ha2)")
171
+ .replace(processDataResponseMarker, ` // RadioCLI legacy Digest: keep password authentication separate from PIN pairing.
172
+ if (!this.airplay2 && this.mode === 0 && response.code === 401 &&
173
+ (this.status === OPTIONS || /^Digest\\s/i.test(headers['WWW-Authenticate'] || headers['www-authenticate'] || ''))) {
174
+ const auth = headers['WWW-Authenticate'] || headers['www-authenticate']
175
+ if (this.radioCliDigestTried || typeof auth !== 'string' || !/^Digest\\s/i.test(auth)) {
176
+ this.emit('pair_failed')
177
+ this.cleanup('bad_password')
178
+ return
179
+ }
180
+ const realm = parseAuthenticate(auth, 'realm')
181
+ const nonce = parseAuthenticate(auth, 'nonce')
182
+ if (!realm || !nonce || /[\\r\\n"]/.test(realm + nonce)) {
183
+ this.emit('pair_failed')
184
+ this.cleanup('bad_password')
185
+ return
186
+ }
187
+ this.radioCliDigest = {realm, nonce, username: 'iTunes', password: this.password}
188
+ if (!this.password) {
189
+ this.emit('need_password')
190
+ return
191
+ }
192
+ this.radioCliDigestTried = true
193
+ this.sendNextRequest(this.radioCliDigest)
194
+ return
195
+ }
196
+ if (response.code === 200) this.radioCliDigestTried = false
197
+ ${processDataResponseMarker}`);
198
+ }
199
+ /** The upstream sender silently drops controls while another RTSP request is in flight. */
200
+ function patchQueuedControls(source) {
201
+ if (source.includes('this.radioCliPendingVolume'))
202
+ return source;
203
+ return source.replace(`Client.prototype.setVolume = function (volume, callback) {
204
+ if (this.status !== PLAYING)
205
+ return`, `Client.prototype.setVolume = function (volume, callback) {
206
+ if (this.status !== PLAYING) {
207
+ if (this.status !== CLOSED && this.status !== TEARDOWN) this.radioCliPendingVolume = [volume, callback]
208
+ return
209
+ }`)
210
+ .replace(`Client.prototype.setTrackInfo = function (name, artist, album, callback) {
211
+ if (this.status !== PLAYING)
212
+ return`, `Client.prototype.setTrackInfo = function (name, artist, album, callback) {
213
+ if (this.status !== PLAYING) {
214
+ if (this.status !== CLOSED && this.status !== TEARDOWN) this.radioCliPendingTrack = [name, artist, album, callback]
215
+ return
216
+ }`)
217
+ .replace(` this.sendNextRequest()
218
+ }
219
+
220
+ Client.prototype.parseObject`, ` if (this.status === PLAYING && this.radioCliPendingVolume) {
221
+ const pending = this.radioCliPendingVolume
222
+ this.radioCliPendingVolume = null
223
+ this.setVolume(...pending)
224
+ return
225
+ }
226
+ if (this.status === PLAYING && this.radioCliPendingTrack) {
227
+ const pending = this.radioCliPendingTrack
228
+ this.radioCliPendingTrack = null
229
+ this.setTrackInfo(...pending)
230
+ return
231
+ }
232
+ this.sendNextRequest()
233
+ }
234
+
235
+ Client.prototype.parseObject`);
236
+ }
@@ -0,0 +1,51 @@
1
+ import { statSync } from 'node:fs';
2
+ import { packageInstallCommand } from '../platform/packages.js';
3
+ const airPlaySenderInstallGuidance = 'Reinstall RadioCLI with optional dependencies enabled (npm install -g @ciphore/radiocli --include=optional), then rerun radiocli doctor. Use the same installation method and prefix as the current install.';
4
+ export function hasSystemdSystemManager() {
5
+ try {
6
+ return process.platform === 'linux' && statSync('/run/systemd/system').isDirectory();
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ /** Only apt and Arch have verified Avahi package recipes here. */
13
+ export function linuxAirPlaySetupPlan({ manager, elevation, hasCommand, systemd, serviceReady, existingCommands }) {
14
+ const commands = [];
15
+ const notes = ['Experimental Linux AirPlay is optional. Avahi supplies shared local-network discovery; enabling its system service affects the whole machine.'];
16
+ if (!hasCommand('ffmpeg') && !existingCommands.some(command => command.component === 'ffmpeg')) {
17
+ const command = manager ? packageInstallCommand(manager, 'ffmpeg', { elevation }) : null;
18
+ if (command)
19
+ commands.push({ ...command, component: null, label: 'AirPlay FFmpeg' });
20
+ else
21
+ notes.push('Install FFmpeg manually for AirPlay, then rerun radiocli setup --airplay.');
22
+ }
23
+ if (!hasCommand('avahi-browse') || !serviceReady && !hasCommand('avahi-daemon')) {
24
+ const recipe = manager === 'apt' ? ['apt-get', 'install', '-y', 'avahi-daemon', 'avahi-utils']
25
+ : manager === 'pacman' ? ['pacman', '-S', '--needed', '--noconfirm', 'avahi'] : null;
26
+ if (recipe)
27
+ commands.push(setupAction('Avahi discovery', recipe, elevation));
28
+ else
29
+ notes.push('Install Avahi daemon and avahi-browse with your distribution package manager; no verified automatic Avahi recipe is available for this manager.');
30
+ }
31
+ if (!serviceReady) {
32
+ if (systemd && hasCommand('systemctl') && (hasCommand('avahi-daemon') || manager === 'apt' || manager === 'pacman')) {
33
+ commands.push(setupAction('Enable and start shared Avahi service', ['systemctl', 'enable', '--now', 'avahi-daemon.service'], elevation));
34
+ }
35
+ else
36
+ notes.push('Start the Avahi daemon with your system service manager, then rerun radiocli doctor. RadioCLI will not guess or change this service configuration.');
37
+ }
38
+ return { commands, notes };
39
+ }
40
+ export function airPlaySetupVerification(readiness) {
41
+ const ready = readiness.ready && readiness.discoveryService === 'ready';
42
+ return [
43
+ ready ? 'AirPlay: experimental prerequisites ready. Select a receiver to verify playback.' : `AirPlay: Setup required. ${readiness.message}`,
44
+ ...(!readiness.senderLoaded ? [airPlaySenderInstallGuidance] : []),
45
+ ...(readiness.discoveryService === 'unverified' ? ['Avahi service readiness is unverified. Install busctl or verify the running daemon with your system administrator; receiver discovery remains available explicitly.'] : [])
46
+ ];
47
+ }
48
+ function setupAction(label, invocation, elevation) {
49
+ const [program, ...args] = elevation ? [elevation, ...invocation] : invocation;
50
+ return { component: null, label, program: program, args, display: [program, ...args].join(' ') };
51
+ }
@@ -2,12 +2,13 @@ import { spawn } from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
3
  import { airPlaySenderHealth } from './airplay-sender-health.js';
4
4
  import { installAirPlaySenderPatch } from './airplay-sender-patch.js';
5
+ import { airPlaySenderRoot } from './airplay-sender-package.js';
6
+ import { canRetryPlainHttp, ffmpegCertificateFailure, ffmpegInputArguments, ffmpegInputFailure } from './ffmpeg-input.js';
5
7
  import { parseWorkerMessage, decodeWorkerStart, maxWorkerMessageBytes, serializeWorkerMessage } from './airplay-worker-protocol.js';
6
8
  const require = createRequire(import.meta.url);
7
- // node-airtunes2 writes diagnostics with console.log; keep stdout reserved for JSON events.
8
- console.log = (...args) => {
9
- process.stderr.write(`${args.map(String).join(' ')}\n`);
10
- };
9
+ // The optional sender logs raw authentication exchanges, including receiver codes.
10
+ // Only our bounded protocol events may leave this isolated process.
11
+ console.log = console.info = console.debug = console.warn = console.error = () => undefined;
11
12
  const encodedStart = process.argv[2];
12
13
  if (!encodedStart) {
13
14
  emit({ type: 'error', message: 'Missing AirPlay worker start payload.' });
@@ -18,15 +19,21 @@ const start = decodeStartPayload(encodedStart);
18
19
  const airtunes = new AirTunes();
19
20
  let deviceKey = '';
20
21
  let ffmpeg = null;
22
+ const decoders = new Set();
21
23
  let muted = start.muted;
22
24
  let volume = start.volume;
25
+ let stationName = start.stationName;
23
26
  let retuning = false;
24
27
  let stopping = false;
28
+ let finishStop;
25
29
  airtunes.on('device', (key, status) => {
30
+ if (stopping)
31
+ return;
26
32
  if (typeof key === 'string') {
27
33
  deviceKey = key;
28
34
  }
29
35
  if (status === 'ready') {
36
+ publishStation();
30
37
  emit({ type: 'ready' });
31
38
  }
32
39
  else if (status === 'playing' || status === 'pair_success') {
@@ -36,13 +43,18 @@ airtunes.on('device', (key, status) => {
36
43
  emit({ type: 'password-required' });
37
44
  }
38
45
  else if (status === 'stopped') {
39
- emit({ type: 'stopped' });
46
+ fail('AirPlay receiver disconnected. Select the receiver again to reconnect.');
47
+ }
48
+ else if (status === 'pair_failed') {
49
+ fail('AirPlay receiver rejected the code or pairing request.');
40
50
  }
41
51
  else if (status === 'error') {
42
- emit({ type: 'error', message: 'AirPlay receiver reported an error.' });
52
+ fail('AirPlay receiver reported an error.');
43
53
  }
44
54
  });
45
55
  airtunes.on('buffer', status => {
56
+ if (stopping)
57
+ return;
46
58
  const statusText = String(status);
47
59
  emit({ type: 'buffer', status: statusText });
48
60
  if (retuning && statusText === 'playing') {
@@ -50,9 +62,9 @@ airtunes.on('buffer', status => {
50
62
  emit({ type: 'retuned' });
51
63
  }
52
64
  });
53
- airtunes.on('error', error => {
54
- emit({ type: 'error', message: error instanceof Error ? error.message : String(error) });
55
- });
65
+ airtunes.on('error', () => fail('AirPlay sender failed. Select the receiver again to reconnect.'));
66
+ airtunes.on('drain', () => { if (!stopping)
67
+ ffmpeg?.stdout.resume(); });
56
68
  const device = airtunes.add(start.device.host, {
57
69
  port: start.device.port,
58
70
  volume: muted ? 0 : volume,
@@ -85,7 +97,13 @@ process.stdin.on('data', chunk => {
85
97
  });
86
98
  process.once('SIGTERM', () => stop(0));
87
99
  process.once('SIGINT', () => stop(0));
100
+ process.once('SIGHUP', () => stop(0));
101
+ process.stdin.once('end', () => stop(0));
102
+ process.once('uncaughtException', () => fail('AirPlay sender failed.'));
103
+ process.once('unhandledRejection', () => fail('AirPlay sender failed.'));
88
104
  function handleCommand(command) {
105
+ if (stopping)
106
+ return;
89
107
  if (command.type === 'stop') {
90
108
  stop(0);
91
109
  }
@@ -99,30 +117,26 @@ function handleCommand(command) {
99
117
  }
100
118
  else if (command.type === 'retune') {
101
119
  retuning = true;
120
+ stationName = command.stationName;
102
121
  airtunes.reset();
103
122
  startFfmpeg(command.streamUrl);
123
+ publishStation();
104
124
  }
105
125
  else if (command.type === 'passcode') {
106
126
  device.setPasscode?.(command.code);
107
127
  }
108
128
  }
109
- function startFfmpeg(streamUrl) {
129
+ function startFfmpeg(streamUrl, plainHttp = false) {
130
+ if (stopping)
131
+ return;
110
132
  const previous = ffmpeg;
111
133
  const child = spawn(start.ffmpegPath, [
112
134
  '-hide_banner',
113
135
  '-loglevel',
114
136
  'error',
115
- // Recover from transient source drops without tearing down the AirPlay session.
116
- // These are input options and must precede -i. -reconnect_streamed is essential for
117
- // non-seekable radio; with no retry cap ffmpeg keeps trying until the station returns.
118
- '-reconnect',
119
- '1',
120
- '-reconnect_on_network_error',
121
- '1',
122
- '-reconnect_streamed',
123
- '1',
124
- '-reconnect_delay_max',
125
- '2',
137
+ // Reconnect only network inputs, retaining the receiver session across
138
+ // transient source drops without breaking local file playback.
139
+ ...ffmpegInputArguments(streamUrl, { plainHttp, reconnect: true }),
126
140
  '-i',
127
141
  streamUrl,
128
142
  '-f',
@@ -136,33 +150,44 @@ function startFfmpeg(streamUrl) {
136
150
  stdio: ['ignore', 'pipe', 'pipe']
137
151
  });
138
152
  ffmpeg = child;
139
- if (previous && !previous.killed) {
140
- previous.kill('SIGTERM');
141
- }
153
+ decoders.add(child);
154
+ child.once('close', () => { decoders.delete(child); finishStop?.(); });
155
+ if (previous)
156
+ stopDecoder(previous);
157
+ let pcmStarted = false;
158
+ let diagnostic = '';
142
159
  child.stdout.on('data', chunk => {
143
160
  if (ffmpeg === child && !stopping) {
144
- airtunes.write(chunk);
161
+ pcmStarted ||= chunk.length > 0;
162
+ if (!airtunes.write(chunk))
163
+ child.stdout.pause();
145
164
  }
146
165
  });
166
+ // Inspect only a bounded tail; never forward stream URLs or credentials.
147
167
  child.stderr.on('data', chunk => {
148
- if (ffmpeg === child) {
149
- process.stderr.write(chunk);
150
- }
168
+ diagnostic = (diagnostic + String(chunk)).slice(-8192);
169
+ const failure = ffmpegInputFailure(diagnostic) ?? ffmpegCertificateFailure(diagnostic);
170
+ if (failure && ffmpeg === child && !stopping)
171
+ fail(failure);
151
172
  });
152
- child.once('error', error => {
173
+ child.stderr.resume();
174
+ child.once('error', () => {
153
175
  if (ffmpeg !== child || stopping) {
154
176
  return;
155
177
  }
156
- emit({ type: 'error', message: error.message });
157
- stop(1);
178
+ fail('FFmpeg could not start for AirPlay playback. Run radiocli doctor.');
158
179
  });
159
- child.once('exit', code => {
180
+ child.once('close', (code, signal) => {
160
181
  if (ffmpeg !== child || stopping) {
161
182
  return;
162
183
  }
184
+ if (!plainHttp && !signal && !pcmStarted && canRetryPlainHttp(streamUrl, diagnostic)) {
185
+ startFfmpeg(streamUrl, true);
186
+ return;
187
+ }
163
188
  ffmpeg = null;
164
189
  if (code !== 0) {
165
- emit({ type: 'error', message: `ffmpeg exited with code ${code}` });
190
+ emit({ type: 'error', message: ffmpegInputFailure(diagnostic) ?? `ffmpeg exited with code ${code}` });
166
191
  }
167
192
  stop(code ?? 0);
168
193
  });
@@ -172,6 +197,24 @@ function setAirPlayVolume(nextVolume) {
172
197
  airtunes.setVolume(deviceKey, nextVolume);
173
198
  }
174
199
  }
200
+ function publishStation() {
201
+ if (deviceKey)
202
+ airtunes.setTrackInfo(deviceKey, stationName, 'RadioCLI', 'Live radio');
203
+ }
204
+ function fail(message) {
205
+ if (stopping)
206
+ return;
207
+ emit({ type: 'error', message });
208
+ stop(1);
209
+ }
210
+ function stopDecoder(child) {
211
+ if (child.exitCode !== null || child.signalCode !== null)
212
+ return;
213
+ child.kill('SIGTERM');
214
+ const force = setTimeout(() => child.kill('SIGKILL'), 500);
215
+ force.unref();
216
+ child.once('close', () => clearTimeout(force));
217
+ }
175
218
  function emit(event) {
176
219
  process.stdout.write(serializeWorkerMessage(event));
177
220
  }
@@ -183,7 +226,7 @@ function loadAirTunesSender() {
183
226
  }
184
227
  installAirPlaySenderPatch();
185
228
  try {
186
- return require('node-airtunes2');
229
+ return require(airPlaySenderRoot);
187
230
  }
188
231
  catch {
189
232
  emit({ type: 'error', message: 'AirPlay sender bridge could not be loaded.' });
@@ -204,17 +247,30 @@ function stop(code) {
204
247
  return;
205
248
  }
206
249
  stopping = true;
207
- if (ffmpeg && !ffmpeg.killed) {
208
- ffmpeg.kill('SIGTERM');
209
- }
250
+ process.exitCode = code;
251
+ for (const child of decoders)
252
+ stopDecoder(child);
210
253
  ffmpeg = null;
211
- const watchdog = setTimeout(() => process.exit(code), 1500);
212
- watchdog.unref();
213
- airtunes.stopAll(() => {
254
+ let senderStopped = false;
255
+ const watchdog = setTimeout(() => {
256
+ for (const child of decoders)
257
+ child.kill('SIGKILL');
258
+ process.exit(code);
259
+ }, 1500);
260
+ finishStop = () => {
261
+ if (!senderStopped || decoders.size)
262
+ return;
214
263
  clearTimeout(watchdog);
215
264
  emit({ type: 'stopped' });
216
265
  process.exit(code);
217
- });
266
+ };
267
+ try {
268
+ airtunes.stopAll(() => { senderStopped = true; finishStop?.(); });
269
+ }
270
+ catch {
271
+ senderStopped = true;
272
+ finishStop();
273
+ }
218
274
  }
219
275
  function clampVolume(nextVolume) {
220
276
  return Math.min(100, Math.max(0, Math.round(nextVolume)));
@@ -1,19 +1,22 @@
1
1
  import { ffplayInstallCommand, mpvInstallCommand } from '../platform/packages.js';
2
- import { identifyPlatform, nativeAdapters, readLinuxOsRelease } from '../platform/runtime.js';
2
+ import { readLinuxOsRelease } from '../platform/runtime.js';
3
3
  import { commandExists } from '../platform/executables.js';
4
+ import { airPlayPolicy } from './airplay-policy.js';
5
+ export { isAirPlayPlatformSupported } from './airplay-policy.js';
4
6
  import { airPlaySenderHealth } from './airplay-sender-health.js';
5
7
  export const ffplayLimitedControlsMessage = 'ffplay fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
6
8
  export const vlcLimitedControlsMessage = 'VLC fallback has limited controls. Install mpv for pause, mute, volume, and media keys.';
7
- export const airPlayMacOSOnlyMessage = 'AirPlay output is available only on macOS and is not supported on this operating system.';
8
- export function isAirPlayPlatformSupported(platform = process.platform) {
9
- return nativeAdapters(identifyPlatform({ platform })).airPlay;
9
+ export function airPlayLocalDeviceLabel(platform = process.platform) {
10
+ return platform === 'darwin' ? 'this Mac' : 'this device';
10
11
  }
11
- export function detectPlaybackBackends({ platform = process.platform, hasCommand = commandExists, hasAirPlaySender = hasAirPlaySenderPackage } = {}) {
12
+ export function detectPlaybackBackends({ platform = process.platform, settings, env, release, hasCommand = commandExists, hasAirPlaySender = hasAirPlaySenderPackage } = {}) {
12
13
  const backends = ['mpv', 'ffplay'].filter(hasCommand);
13
14
  if (hasCommand('cvlc') || hasCommand('vlc')) {
14
15
  backends.push('vlc');
15
16
  }
16
- if (isAirPlayPlatformSupported(platform) && 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()) {
17
20
  backends.push('airplay');
18
21
  }
19
22
  return backends;
@@ -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
+ }