@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.
- package/CHANGELOG.md +70 -2
- package/CONTRIBUTING.md +10 -0
- package/README.md +108 -253
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +178 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +249 -76
- package/dist/ui/AdaptiveContent.js +18 -10
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -1,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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
54
|
-
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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.
|
|
173
|
+
child.stderr.resume();
|
|
174
|
+
child.once('error', () => {
|
|
153
175
|
if (ffmpeg !== child || stopping) {
|
|
154
176
|
return;
|
|
155
177
|
}
|
|
156
|
-
|
|
157
|
-
stop(1);
|
|
178
|
+
fail('FFmpeg could not start for AirPlay playback. Run radiocli doctor.');
|
|
158
179
|
});
|
|
159
|
-
child.once('
|
|
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(
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
250
|
+
process.exitCode = code;
|
|
251
|
+
for (const child of decoders)
|
|
252
|
+
stopDecoder(child);
|
|
210
253
|
ffmpeg = null;
|
|
211
|
-
|
|
212
|
-
watchdog
|
|
213
|
-
|
|
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 {
|
|
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
|
|
8
|
-
|
|
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
|
-
|
|
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
|
+
}
|