@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
@@ -0,0 +1,42 @@
1
+ import { resolveCommandDetails } from '../platform/executables.js';
2
+ import { sessionBusSocket } from '../platform/mpris.js';
3
+ import { isAirPlayPlatformSupported } from './backend-install.js';
4
+ import { runAirPlayCommand } from './airplay-process.js';
5
+ import { probeAirPlaySender } from './airplay-sender-health.js';
6
+ /** Does not browse the network, activate services, or open a receiver. */
7
+ export async function probeAirPlayReadiness({ platform = process.platform, env = process.env, release, resolve = command => resolveCommandDetails(command, { platform, env }).path ?? undefined, hasCommand = command => Boolean(resolve(command)), run = runAirPlayCommand, sender = probeAirPlaySender } = {}) {
8
+ const eligible = isAirPlayPlatformSupported(platform, env, release);
9
+ const discoveryExecutable = eligible && hasCommand(platform === 'linux' ? 'avahi-browse' : 'dns-sd');
10
+ const ffmpeg = eligible && hasCommand('ffmpeg');
11
+ const busctl = eligible && platform === 'linux' && hasCommand('busctl') ? resolve('busctl') : undefined;
12
+ let discoveryService = 'unverified';
13
+ const senderResult = eligible ? sender() : Promise.resolve({ loaded: false, message: 'AirPlay is not supported in this environment.' });
14
+ if (!discoveryExecutable)
15
+ discoveryService = 'unavailable';
16
+ else if (platform === 'linux' && busctl) {
17
+ const socket = sessionBusSocket(env.DBUS_SYSTEM_BUS_ADDRESS ?? 'unix:path=/run/dbus/system_bus_socket');
18
+ if (!socket)
19
+ discoveryService = 'unavailable';
20
+ else {
21
+ // --system still honors executable/network transport overrides. Pass only
22
+ // one validated Unix address and remove all inherited D-Bus fallbacks.
23
+ const address = `unix:${socket.startsWith('\0') ? 'abstract' : 'path'}=${encodeURIComponent(socket.startsWith('\0') ? socket.slice(1) : socket)}`;
24
+ const childEnv = { ...env };
25
+ for (const key of Object.keys(childEnv))
26
+ if (key.startsWith('DBUS_'))
27
+ delete childEnv[key];
28
+ const result = await run(busctl, [`--address=${address}`, '--auto-start=no', '--timeout=0.5', 'call', 'org.freedesktop.Avahi', '/', 'org.freedesktop.Avahi.Server', 'GetState'], 600, 4096, { env: childEnv });
29
+ discoveryService = result.code === 0 && /^i\s+2\s*$/.test(result.output.trim()) && !result.truncated && !result.timedOut ? 'ready' : 'unavailable';
30
+ }
31
+ }
32
+ const senderLoaded = (await senderResult).loaded;
33
+ const ready = eligible && discoveryExecutable && ffmpeg && senderLoaded && discoveryService !== 'unavailable';
34
+ const message = !eligible ? 'AirPlay is experimental on macOS and native Linux; this environment is not supported.'
35
+ : !discoveryExecutable ? `AirPlay discovery requires ${platform === 'linux' ? 'avahi-browse' : 'dns-sd'}.`
36
+ : discoveryService === 'unavailable' ? 'The Avahi discovery service is not ready. Local playback remains available.'
37
+ : !ffmpeg ? 'AirPlay playback requires FFmpeg.'
38
+ : !senderLoaded ? 'The optional AirPlay sender could not load. Local playback remains available.'
39
+ : discoveryService === 'unverified' ? 'AirPlay prerequisites are present; the discovery service and receiver connection must still be verified.'
40
+ : 'Experimental AirPlay prerequisites are ready; select a receiver to verify playback.';
41
+ return { eligible, discoveryExecutable, discoveryService, senderLoaded, ffmpeg, ready, message };
42
+ }
@@ -1,8 +1,38 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
3
  import { dirname } from 'node:path';
4
+ import { runAirPlayCommand } from './airplay-process.js';
5
+ import { airPlaySenderPackageJson } from './airplay-sender-package.js';
4
6
  const require = createRequire(import.meta.url);
5
7
  const airPlaySenderPackageName = 'node-airtunes2';
8
+ let senderProbe;
9
+ /** Explicit diagnostics/discovery only: loading native code stays outside the TUI. */
10
+ export async function probeAirPlaySender(options = {}) {
11
+ const health = options.health ?? airPlaySenderHealth();
12
+ if (!health.safe)
13
+ return { loaded: false, message: health.message };
14
+ const key = `${process.execPath}:${health.packageName}:${health.version}`;
15
+ if (!options.run && senderProbe?.key === key && senderProbe.until > Date.now())
16
+ return senderProbe.result;
17
+ const result = (async () => {
18
+ const code = [
19
+ "const r = require('node:module').createRequire(process.argv[1]);",
20
+ "try { const s = r('./');",
21
+ "if (typeof s !== 'function' || typeof s.prototype.add !== 'function' || typeof s.prototype.stopAll !== 'function') process.exit(1);",
22
+ // The Linux binding is otherwise loaded only when constructing a receiver.
23
+ // Match the sender's JS fallback on a normal require failure, but contain
24
+ // native crashes here before advertising a loadable sender. No sockets.
25
+ "if (process.platform === 'linux') { try { r('./build/Release/airtunes'); } catch {} }",
26
+ 'process.exit(0); } catch { process.exit(1); }'
27
+ ].join('\n');
28
+ const probe = await (options.run ?? runAirPlayCommand)(process.execPath, ['-e', code, airPlaySenderPackageJson], Math.min(1500, Math.max(1, options.timeoutMs ?? 1500)), 4096);
29
+ const loaded = probe.code === 0 && !probe.timedOut && !probe.truncated;
30
+ return { loaded, message: loaded ? 'The optional AirPlay sender loaded in an isolated process.' : 'The optional AirPlay sender could not load. Local playback remains available; run radiocli doctor.' };
31
+ })();
32
+ if (!options.run)
33
+ senderProbe = { key, until: Date.now() + 30_000, result };
34
+ return result;
35
+ }
6
36
  export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
7
37
  const sender = lookupPackage(airPlaySenderPackageName);
8
38
  if (!sender) {
@@ -15,17 +45,21 @@ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
15
45
  warningPackages: []
16
46
  };
17
47
  }
18
- const vulnerablePackages = blockingAirPlaySenderPackages(sender, lookupPackage);
19
- const warningPackages = warningAirPlaySenderPackages(sender, lookupPackage);
20
- if (vulnerablePackages.length > 0) {
48
+ const missingPackages = (sender.dependencies ?? []).filter(name => !lookupPackage(name, [sender.root]));
49
+ if (missingPackages.length > 0) {
50
+ return { available: false, safe: false, packageName: sender.name, version: sender.version,
51
+ message: `Optional AirPlay sender dependencies are not installed: ${missingPackages.join(', ')}. Local playback remains available.`,
52
+ vulnerablePackages: [], warningPackages: [] };
53
+ }
54
+ if (!sender.nativeEd25519) {
21
55
  return {
22
56
  available: true,
23
57
  safe: false,
24
58
  packageName: sender.name,
25
59
  version: sender.version,
26
- message: `Installed AirPlay sender ${sender.name}@${sender.version} is blocked because vulnerable transitive dependencies were found: ${vulnerablePackages.join(', ')}.`,
27
- vulnerablePackages,
28
- warningPackages
60
+ message: `AirPlay sender ${sender.name}@${sender.version} is missing the native Ed25519 security fix.`,
61
+ vulnerablePackages: [`${sender.name}@${sender.version}`],
62
+ warningPackages: []
29
63
  };
30
64
  }
31
65
  return {
@@ -33,34 +67,18 @@ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
33
67
  safe: true,
34
68
  packageName: sender.name,
35
69
  version: sender.version,
36
- message: warningPackages.length > 0
37
- ? `Bundled AirPlay sender ${sender.name}@${sender.version} is available with warnings: ${warningPackages.join(', ')}.`
38
- : `AirPlay sender ${sender.name}@${sender.version} passed the dependency safety gate.`,
70
+ message: `AirPlay sender ${sender.name}@${sender.version} uses native Ed25519 with its optional runtime dependencies installed.`,
39
71
  vulnerablePackages: [],
40
- warningPackages
72
+ warningPackages: []
41
73
  };
42
74
  }
43
- function blockingAirPlaySenderPackages(sender, lookupPackage) {
44
- const packagePaths = [sender.root];
45
- const vulnerable = [];
46
- const protobuf = lookupPackage('protobufjs', packagePaths);
47
- if (protobuf && compareSemver(protobuf.version, '7.5.8') < 0) {
48
- vulnerable.push(`protobufjs@${protobuf.version}`);
49
- }
50
- return vulnerable;
51
- }
52
- function warningAirPlaySenderPackages(sender, lookupPackage) {
53
- const packagePaths = [sender.root];
54
- const vulnerable = [];
55
- const elliptic = lookupPackage('elliptic', packagePaths);
56
- if (elliptic && compareSemver(elliptic.version, '6.6.2') < 0) {
57
- vulnerable.push(`elliptic@${elliptic.version}`);
58
- }
59
- return vulnerable;
60
- }
61
75
  function lookupInstalledPackage(packageName, paths) {
62
76
  try {
63
- const packageJsonPath = paths
77
+ // Package exports can hide package.json while exposing the runtime entry.
78
+ if (paths && packageName !== airPlaySenderPackageName) {
79
+ return { name: packageName, version: 'installed', root: dirname(require.resolve(packageName, { paths })) };
80
+ }
81
+ const packageJsonPath = packageName === airPlaySenderPackageName ? airPlaySenderPackageJson : paths
64
82
  ? require.resolve(`${packageName}/package.json`, { paths })
65
83
  : require.resolve(`${packageName}/package.json`);
66
84
  const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
@@ -70,29 +88,11 @@ function lookupInstalledPackage(packageName, paths) {
70
88
  return {
71
89
  name: parsed.name ?? packageName,
72
90
  version: parsed.version,
73
- root: dirname(packageJsonPath)
91
+ root: dirname(packageJsonPath),
92
+ ...(packageName === airPlaySenderPackageName ? { nativeEd25519: parsed.radiocliLegacyPairing === 'node:crypto', dependencies: Object.keys(parsed.dependencies ?? {}) } : {})
74
93
  };
75
94
  }
76
95
  catch {
77
96
  return null;
78
97
  }
79
98
  }
80
- function compareSemver(left, right) {
81
- const leftParts = semverParts(left);
82
- const rightParts = semverParts(right);
83
- for (let index = 0; index < 3; index += 1) {
84
- const delta = leftParts[index] - rightParts[index];
85
- if (delta !== 0) {
86
- return delta;
87
- }
88
- }
89
- return 0;
90
- }
91
- function semverParts(version) {
92
- const [major = '0', minor = '0', patch = '0'] = version.split(/[.-]/);
93
- return [
94
- Number.parseInt(major, 10) || 0,
95
- Number.parseInt(minor, 10) || 0,
96
- Number.parseInt(patch, 10) || 0
97
- ];
98
- }
@@ -0,0 +1,5 @@
1
+ import { dirname } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ // The same relative location works from src/player and the packaged dist/player.
4
+ export const airPlaySenderPackageJson = fileURLToPath(new URL('../../vendor/node-airtunes2/package.json', import.meta.url));
5
+ export const airPlaySenderRoot = dirname(airPlaySenderPackageJson);
@@ -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)));