@ciphore/radiocli 0.1.3 → 0.1.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 +43 -3
- package/README.md +23 -27
- package/dist/player/airplay-discovery.js +34 -1
- package/dist/player/airplay-sender-health.js +19 -8
- package/dist/player/airplay-sender-patch.js +138 -0
- package/dist/player/airplay-worker-protocol.js +22 -7
- package/dist/player/airplay-worker.js +79 -39
- package/dist/player/backend-install.js +1 -1
- package/dist/player/player-controller.js +253 -36
- package/dist/storage/store.js +14 -5
- package/dist/ui/App.js +198 -25
- package/dist/ui/AppContent.js +10 -1
- package/dist/ui/airplay-settings.js +56 -0
- package/dist/ui/app-state.js +11 -15
- package/dist/ui/audio-output.js +42 -0
- package/dist/ui/page-footer.js +10 -2
- package/dist/ui/screen-items.js +3 -3
- package/dist/ui/screens/AirPlayCodeScreen.js +18 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +24 -0
- package/dist/ui/screens/SettingsScreen.js +8 -8
- package/dist/ui/use-app-input.js +38 -7
- package/dist/ui/use-command-executor.js +11 -3
- package/package.json +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ All notable changes to RadioCLI are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.5] - 2026-05-31
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Moved the macOS AirPlay sender bridge to optional npm dependencies so Windows
|
|
15
|
+
and Linux installs do not fail while compiling an AirPlay-only native module.
|
|
16
|
+
|
|
17
|
+
## [0.1.4] - 2026-05-31
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Added a dedicated AirPlay receiver picker and AirPlay Code screen for
|
|
22
|
+
passcode-protected receivers.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Replaced silent AirPlay target cycling with a dedicated receiver picker in
|
|
27
|
+
Settings, clearer Audio output labels, immediate output switching while a
|
|
28
|
+
station is playing, and explicit AirPlay receiver selection before tuning.
|
|
29
|
+
- AirPlay is now an explicit current-session output instead of a persisted
|
|
30
|
+
default. Restarting RadioCLI returns to automatic local playback while keeping
|
|
31
|
+
the last receiver available for the next manual AirPlay switch.
|
|
32
|
+
- Bundled the AirPlay sender bridge and updated the Homebrew formula template to
|
|
33
|
+
install FFmpeg, so macOS users do not need manual AirPlay package setup.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Fixed same-session AirPlay station switching so the worker retunes the active
|
|
38
|
+
receiver, resets its sender buffer, and keeps the UI in buffering until the
|
|
39
|
+
new stream is actually ready.
|
|
40
|
+
- Fixed AirPlay output failures so `skip broken streams` no longer walks
|
|
41
|
+
through the library when the receiver is missing, off, local, or unavailable.
|
|
42
|
+
- Fixed library selection after playing a station that moves to the top of
|
|
43
|
+
recents.
|
|
44
|
+
- Patched the bundled AirPlay sender for modern macOS AirPlay 2 receivers that
|
|
45
|
+
return digest-protected setup responses or empty first setup bodies.
|
|
46
|
+
|
|
8
47
|
## [0.1.3] - 2026-05-30
|
|
9
48
|
|
|
10
49
|
### Added
|
|
@@ -21,9 +60,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
21
60
|
|
|
22
61
|
### Security
|
|
23
62
|
|
|
24
|
-
- AirPlay sender support is
|
|
25
|
-
|
|
26
|
-
line remains blocked because of vulnerable transitive dependencies.
|
|
63
|
+
- AirPlay sender support is worker-isolated and blocks high-risk sender
|
|
64
|
+
dependency advisories before the backend is advertised.
|
|
27
65
|
- Hardened AirPlay discovery and worker messaging with bounded payloads,
|
|
28
66
|
sanitized receiver data, passcode validation, and worker shutdown on startup
|
|
29
67
|
timeout.
|
|
@@ -80,6 +118,8 @@ Initial public release.
|
|
|
80
118
|
backend/volume that already appear in the header and footer. The reclaimed
|
|
81
119
|
rows go to the visualizer.
|
|
82
120
|
|
|
121
|
+
[0.1.5]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.5
|
|
122
|
+
[0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
|
|
83
123
|
[0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
|
|
84
124
|
[0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
|
|
85
125
|
[0.1.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.1
|
package/README.md
CHANGED
|
@@ -70,7 +70,7 @@ Live public radio from around the world
|
|
|
70
70
|
5 Countries · Browse by country list with a world-map toggle
|
|
71
71
|
6 Nearby · Opt-in approximate location for local stations
|
|
72
72
|
7 Stats · Listening graph, sessions, streaks, hours
|
|
73
|
-
8 Settings ·
|
|
73
|
+
8 Settings · Audio output, colors, providers
|
|
74
74
|
|
|
75
75
|
3 recent · 2 favorites · 1 imported
|
|
76
76
|
|
|
@@ -137,10 +137,10 @@ Set `NEXT_PUBLIC_SITE_URL` for the canonical public docs URL. Preview builds als
|
|
|
137
137
|
|
|
138
138
|
Requirements:
|
|
139
139
|
|
|
140
|
-
- Homebrew on macOS: installs RadioCLI, Node.js, and
|
|
140
|
+
- Homebrew on macOS: installs RadioCLI, Node.js, `mpv`, and FFmpeg
|
|
141
141
|
- npm on macOS, Linux, and Windows: Node.js 22 or newer
|
|
142
142
|
- `mpv` for playback, pause, mute, volume, media keys, metadata, and readiness checks
|
|
143
|
-
-
|
|
143
|
+
- FFmpeg on macOS for experimental AirPlay and optional `ffplay` fallback
|
|
144
144
|
- `ffplay` from FFmpeg as an optional playback-only fallback
|
|
145
145
|
|
|
146
146
|
Recommended macOS install:
|
|
@@ -150,24 +150,12 @@ brew install ciphore/tap/radiocli
|
|
|
150
150
|
radiocli
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
The Homebrew formula depends on `node
|
|
154
|
-
|
|
153
|
+
The Homebrew formula depends on `node`, `mpv`, and FFmpeg, so the native playback
|
|
154
|
+
and AirPlay prerequisites come from the native package manager.
|
|
155
155
|
|
|
156
|
-
AirPlay playback on macOS is experimental
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
safety gate.
|
|
160
|
-
|
|
161
|
-
```bash
|
|
162
|
-
brew install ffmpeg
|
|
163
|
-
npm audit --audit-level=low
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
Do not install `node-airtunes2` blindly just to enable this feature: the current
|
|
167
|
-
public package line is blocked by RadioCLI because its transitive dependency tree
|
|
168
|
-
contains known vulnerable versions. If you supply a patched compatible sender,
|
|
169
|
-
passcode-protected receivers prompt in the TUI; enter the code with
|
|
170
|
-
`:airplay-code 1234`.
|
|
156
|
+
AirPlay playback on macOS is experimental, but the sender bridge is bundled with
|
|
157
|
+
RadioCLI. The app discovers AirPlay/RAOP receivers with Bonjour and decodes
|
|
158
|
+
streams with FFmpeg; passcode-protected receivers prompt in the TUI.
|
|
171
159
|
|
|
172
160
|
Universal npm install:
|
|
173
161
|
|
|
@@ -296,7 +284,9 @@ Page-specific footer controls:
|
|
|
296
284
|
| World map | `/` filter, `↑` / `↓` move, `Enter` open country, `w` list, `b` home |
|
|
297
285
|
| Station lists | `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
|
|
298
286
|
| Now Playing | `space` / `F8` pause, `f` favorite, `m` mute, `s` sleep, `d` diagnostics, `b` home |
|
|
299
|
-
| Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o`
|
|
287
|
+
| Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` audio output, `a` AirPlay, `r` health, `b` home |
|
|
288
|
+
| AirPlay | `↑` / `↓` choose, `Enter` select receiver, `c` code entry, `r` refresh, `b` settings |
|
|
289
|
+
| AirPlay Code | type receiver code, `Backspace` edit, `Enter` submit, `Esc` AirPlay |
|
|
300
290
|
| Stats | `b` home |
|
|
301
291
|
|
|
302
292
|
Other active shortcuts:
|
|
@@ -306,7 +296,7 @@ Other active shortcuts:
|
|
|
306
296
|
- `/`: edit search or country filter on screens that support it.
|
|
307
297
|
- `[` / `]`: page through long station and country lists.
|
|
308
298
|
- `m`: mute.
|
|
309
|
-
- `o`: cycle
|
|
299
|
+
- `o`: cycle audio output. If a station is playing, RadioCLI retunes it on the new output immediately.
|
|
310
300
|
- `g`: toggle the experimental Radio Garden adapter.
|
|
311
301
|
- `l`: toggle nearby location lookup.
|
|
312
302
|
- `x`: toggle skip-broken-stream behavior.
|
|
@@ -344,6 +334,7 @@ Useful command palette entries:
|
|
|
344
334
|
:learn play
|
|
345
335
|
:learn next
|
|
346
336
|
:keys reset
|
|
337
|
+
:airplay
|
|
347
338
|
:airplay-code 1234
|
|
348
339
|
:map
|
|
349
340
|
:library
|
|
@@ -354,10 +345,15 @@ Useful command palette entries:
|
|
|
354
345
|
|
|
355
346
|
Settings persist display colors and receiver styles without editing config files. The fourteen display colors are green, amber, blue, ruby, ice, teal, violet, copper, cyan, lime, coral, rose, slate, and mono, cycled with `t`. The 50 receiver styles span classic receiver displays, high-resolution braille visuals, and generative motion scenes; cycle them with `v` (see the [Demo](#demo) for the full family breakdown). The stats graph and legend follow the selected display color, and the selected Now Playing style is restored on the next launch.
|
|
356
347
|
|
|
357
|
-
On macOS, Settings
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
348
|
+
On macOS, Settings opens a dedicated AirPlay receiver picker for Bonjour
|
|
349
|
+
receivers. Change Audio output to AirPlay with `o`, open AirPlay with `a` or
|
|
350
|
+
`:airplay`, and choose a visible receiver. If a station is already playing,
|
|
351
|
+
RadioCLI moves it to the selected receiver immediately; switch Audio output back
|
|
352
|
+
to `This device (mpv)` to leave AirPlay. RadioCLI does not auto-pick a receiver.
|
|
353
|
+
AirPlay is a current-session output: restarting RadioCLI returns to automatic
|
|
354
|
+
local playback while remembering the last receiver for the next manual switch.
|
|
355
|
+
If the receiver asks for a code, RadioCLI opens the AirPlay Code screen; you can
|
|
356
|
+
also press `c` from AirPlay settings or use `:airplay-code 1234`.
|
|
361
357
|
|
|
362
358
|
## Architecture
|
|
363
359
|
|
|
@@ -418,7 +414,7 @@ animation cost.
|
|
|
418
414
|
|
|
419
415
|
Radio Browser is the primary provider. Its own docs recommend using a speaking user agent, resolving station clicks through `/json/url`, and retrying with other servers when one fails; RadioCLI follows that shape with mirror fallback and durable cache. Explore and Nearby use a cached geotagged Radio Browser atlas, then compute local distance in the app so map movement is not biased toward the most-clicked stations worldwide. Radio Garden support is experimental because the useful endpoints are publicly discoverable but unofficial, and they can be blocked or changed independently of this project.
|
|
420
416
|
|
|
421
|
-
Playback prefers `mpv` because it handles real-world streams, redirects, HLS, codecs, and metadata better than a hand-rolled stream client. RadioCLI controls `mpv` through JSON IPC for readiness, pause, mute, volume, and metadata polling, using Unix sockets on macOS/Linux and named pipes on Windows. `ffplay` remains a playback-only fallback and is intentionally labeled with limited controls in the UI and doctor output. Experimental AirPlay output is available on macOS
|
|
417
|
+
Playback prefers `mpv` because it handles real-world streams, redirects, HLS, codecs, and metadata better than a hand-rolled stream client. RadioCLI controls `mpv` through JSON IPC for readiness, pause, mute, volume, and metadata polling, using Unix sockets on macOS/Linux and named pipes on Windows. `ffplay` remains a playback-only fallback and is intentionally labeled with limited controls in the UI and doctor output. Experimental AirPlay output is available on macOS when Bonjour discovery and FFmpeg are available.
|
|
422
418
|
|
|
423
419
|
The npm package is `@ciphore/radiocli`, and the installed executable is `radiocli`. Current installs store data under RadioCLI paths such as `radiocli.json` and `radiocli-cache.json`. Existing Radio Atlas data is still discovered when a new RadioCLI store does not exist, and legacy `RADIO_ATLAS_HOME` / animation environment variables remain supported as migration fallbacks. New automation should use `RADIOCLI_HOME` and `RADIOCLI_DISABLE_ANIMATION`.
|
|
424
420
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import { networkInterfaces } from 'node:os';
|
|
2
4
|
import { commandExists } from './command.js';
|
|
3
5
|
const defaultTimeoutMs = 2500;
|
|
4
6
|
const defaultMaxDevices = 12;
|
|
@@ -12,10 +14,41 @@ export async function discoverAirPlayDevices({ platform = process.platform, time
|
|
|
12
14
|
const instances = parseRaopBrowseOutput(browseOutput).slice(0, Math.max(0, maxDevices));
|
|
13
15
|
const devices = await mapWithConcurrency(instances, Math.max(1, lookupConcurrency), async (instance) => {
|
|
14
16
|
const lookupOutput = await runDnsSd(['-L', instance, '_raop._tcp', 'local'], timeoutMs, maxOutputBytes).catch(() => '');
|
|
15
|
-
|
|
17
|
+
const device = parseRaopLookupOutput(instance, lookupOutput);
|
|
18
|
+
return device ? enrichAirPlayDeviceHost(device) : null;
|
|
16
19
|
});
|
|
17
20
|
return devices.filter((device) => Boolean(device));
|
|
18
21
|
}
|
|
22
|
+
async function enrichAirPlayDeviceHost(device) {
|
|
23
|
+
const addresses = await lookup(device.host, { all: true, family: 4 }).catch(() => []);
|
|
24
|
+
if (addresses.length === 0) {
|
|
25
|
+
return device;
|
|
26
|
+
}
|
|
27
|
+
const localAddresses = localIpv4Addresses();
|
|
28
|
+
const preferred = addresses.find(address => !isLoopbackAddress(address.address)) ?? addresses[0];
|
|
29
|
+
if (!preferred) {
|
|
30
|
+
return device;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
...device,
|
|
34
|
+
host: preferred.address,
|
|
35
|
+
local: addresses.some(address => localAddresses.has(address.address))
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function localIpv4Addresses() {
|
|
39
|
+
const addresses = new Set(['127.0.0.1']);
|
|
40
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
41
|
+
for (const entry of entries ?? []) {
|
|
42
|
+
if (entry.family === 'IPv4') {
|
|
43
|
+
addresses.add(entry.address);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return addresses;
|
|
48
|
+
}
|
|
49
|
+
function isLoopbackAddress(address) {
|
|
50
|
+
return address === '127.0.0.1' || address.startsWith('127.');
|
|
51
|
+
}
|
|
19
52
|
export function parseRaopBrowseOutput(output) {
|
|
20
53
|
const instances = new Set();
|
|
21
54
|
for (const line of output.split('\n')) {
|
|
@@ -10,11 +10,13 @@ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
|
|
|
10
10
|
available: false,
|
|
11
11
|
safe: false,
|
|
12
12
|
packageName: airPlaySenderPackageName,
|
|
13
|
-
message: 'AirPlay sender package not installed.
|
|
14
|
-
vulnerablePackages: []
|
|
13
|
+
message: 'AirPlay sender package is not installed.',
|
|
14
|
+
vulnerablePackages: [],
|
|
15
|
+
warningPackages: []
|
|
15
16
|
};
|
|
16
17
|
}
|
|
17
|
-
const vulnerablePackages =
|
|
18
|
+
const vulnerablePackages = blockingAirPlaySenderPackages(sender, lookupPackage);
|
|
19
|
+
const warningPackages = warningAirPlaySenderPackages(sender, lookupPackage);
|
|
18
20
|
if (vulnerablePackages.length > 0) {
|
|
19
21
|
return {
|
|
20
22
|
available: true,
|
|
@@ -22,7 +24,8 @@ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
|
|
|
22
24
|
packageName: sender.name,
|
|
23
25
|
version: sender.version,
|
|
24
26
|
message: `Installed AirPlay sender ${sender.name}@${sender.version} is blocked because vulnerable transitive dependencies were found: ${vulnerablePackages.join(', ')}.`,
|
|
25
|
-
vulnerablePackages
|
|
27
|
+
vulnerablePackages,
|
|
28
|
+
warningPackages
|
|
26
29
|
};
|
|
27
30
|
}
|
|
28
31
|
return {
|
|
@@ -30,18 +33,26 @@ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
|
|
|
30
33
|
safe: true,
|
|
31
34
|
packageName: sender.name,
|
|
32
35
|
version: sender.version,
|
|
33
|
-
message:
|
|
34
|
-
|
|
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.`,
|
|
39
|
+
vulnerablePackages: [],
|
|
40
|
+
warningPackages
|
|
35
41
|
};
|
|
36
42
|
}
|
|
37
|
-
function
|
|
43
|
+
function blockingAirPlaySenderPackages(sender, lookupPackage) {
|
|
38
44
|
const packagePaths = [sender.root];
|
|
39
45
|
const vulnerable = [];
|
|
40
46
|
const protobuf = lookupPackage('protobufjs', packagePaths);
|
|
41
|
-
const elliptic = lookupPackage('elliptic', packagePaths);
|
|
42
47
|
if (protobuf && compareSemver(protobuf.version, '7.5.8') < 0) {
|
|
43
48
|
vulnerable.push(`protobufjs@${protobuf.version}`);
|
|
44
49
|
}
|
|
50
|
+
return vulnerable;
|
|
51
|
+
}
|
|
52
|
+
function warningAirPlaySenderPackages(sender, lookupPackage) {
|
|
53
|
+
const packagePaths = [sender.root];
|
|
54
|
+
const vulnerable = [];
|
|
55
|
+
const elliptic = lookupPackage('elliptic', packagePaths);
|
|
45
56
|
if (elliptic && compareSemver(elliptic.version, '6.6.2') < 0) {
|
|
46
57
|
vulnerable.push(`elliptic@${elliptic.version}`);
|
|
47
58
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
const setupAp2DigestGuard = 'this.status === SETUP_AP2_1 && response.code === 401';
|
|
5
|
+
const responseHeadersMarker = ' headers = response.headers';
|
|
6
|
+
const processDataResponseMarker = ' if (this.status != OPTIONS && this.status != OPTIONS2 && this.mode == 0) {';
|
|
7
|
+
const setupAp2TimingPortMarker = ` let sa1_bplist = bplistParser.parseBuffer(buf7)
|
|
8
|
+
this.eventPort = sa1_bplist[0]['eventPort']
|
|
9
|
+
if (sa1_bplist[0]['timingPort'])
|
|
10
|
+
this.timingDestPort = sa1_bplist[0]['timingPort']
|
|
11
|
+
console.log('timing port ok', sa1_bplist[0]['timingPort'])`;
|
|
12
|
+
const setupAp2TimingPortFallback = ` let sa1_bplist
|
|
13
|
+
try {
|
|
14
|
+
sa1_bplist = buf7.length > 0 ? bplistParser.parseBuffer(buf7) : [{}]
|
|
15
|
+
}
|
|
16
|
+
catch (_) {
|
|
17
|
+
sa1_bplist = [{}]
|
|
18
|
+
}
|
|
19
|
+
this.eventPort = sa1_bplist[0]['eventPort'] || this.eventPort || this.socket.remotePort || 7000
|
|
20
|
+
if (sa1_bplist[0]['timingPort'])
|
|
21
|
+
this.timingDestPort = sa1_bplist[0]['timingPort']
|
|
22
|
+
console.log('timing port ok', sa1_bplist[0]['timingPort'])`;
|
|
23
|
+
const socketDataHandlerMarker = ` var blob = ''
|
|
24
|
+
this.socket.on('data', function (data) {
|
|
25
|
+
if (self.encryptedChannel && self.credentials) {
|
|
26
|
+
// if (self.debug != false) console.log("incoming", data)
|
|
27
|
+
data = self.credentials.decrypt(data)
|
|
28
|
+
}
|
|
29
|
+
self.clearTimeout()
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* I wish I could use node's HTTP parser for this...
|
|
33
|
+
* I assume that all responses have empty bodies.
|
|
34
|
+
*/
|
|
35
|
+
var rawData = data
|
|
36
|
+
data = data.toString()
|
|
37
|
+
|
|
38
|
+
blob += data
|
|
39
|
+
var endIndex = blob.indexOf('\\r\\n\\r\\n')
|
|
40
|
+
|
|
41
|
+
if (endIndex < 0) {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
endIndex += 4
|
|
46
|
+
|
|
47
|
+
blob = blob.substring(0, endIndex)
|
|
48
|
+
self.processData(blob, rawData)
|
|
49
|
+
|
|
50
|
+
blob = data.substring(endIndex)
|
|
51
|
+
})`;
|
|
52
|
+
const bufferedSocketDataHandler = ` var blob = Buffer.alloc(0)
|
|
53
|
+
this.socket.on('data', function (data) {
|
|
54
|
+
if (self.encryptedChannel && self.credentials) {
|
|
55
|
+
// if (self.debug != false) console.log("incoming", data)
|
|
56
|
+
data = self.credentials.decrypt(data)
|
|
57
|
+
}
|
|
58
|
+
self.clearTimeout()
|
|
59
|
+
|
|
60
|
+
blob = Buffer.concat([blob, Buffer.from(data)])
|
|
61
|
+
while (blob.length > 0) {
|
|
62
|
+
var endIndex = blob.indexOf('\\r\\n\\r\\n')
|
|
63
|
+
|
|
64
|
+
if (endIndex < 0) {
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
endIndex += 4
|
|
69
|
+
var headers = blob.slice(0, endIndex).toString()
|
|
70
|
+
var lengthMatch = /(?:^|\\r\\n)Content-Length:\\s*(\\d+)/i.exec(headers)
|
|
71
|
+
var contentLength = lengthMatch ? parseInt(lengthMatch[1], 10) : 0
|
|
72
|
+
var messageLength = endIndex + contentLength
|
|
73
|
+
|
|
74
|
+
if (blob.length < messageLength) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
var rawData = blob.slice(0, messageLength)
|
|
79
|
+
self.processData(headers, rawData)
|
|
80
|
+
|
|
81
|
+
blob = blob.slice(messageLength)
|
|
82
|
+
}
|
|
83
|
+
})`;
|
|
84
|
+
let installed = false;
|
|
85
|
+
export function installAirPlaySenderPatch(packageName = 'node-airtunes2') {
|
|
86
|
+
if (installed) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
let rtspPath;
|
|
90
|
+
try {
|
|
91
|
+
rtspPath = require.resolve(`${packageName}/lib/rtsp.js`);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const moduleLoader = require('node:module');
|
|
97
|
+
const originalLoader = moduleLoader._extensions['.js'];
|
|
98
|
+
if (!originalLoader) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
moduleLoader._extensions['.js'] = (module, filename) => {
|
|
102
|
+
if (filename !== rtspPath) {
|
|
103
|
+
originalLoader(module, filename);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
module._compile(patchAirTunesRtspSource(readFileSync(filename, 'utf8')), filename);
|
|
107
|
+
};
|
|
108
|
+
installed = true;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
export function patchAirTunesRtspSource(source) {
|
|
112
|
+
let patched = source;
|
|
113
|
+
if (patched.includes(socketDataHandlerMarker) && !patched.includes('Buffer.concat([blob, Buffer.from(data)])')) {
|
|
114
|
+
patched = patched.replace(socketDataHandlerMarker, bufferedSocketDataHandler);
|
|
115
|
+
}
|
|
116
|
+
if (patched.includes(setupAp2TimingPortMarker) && !patched.includes("this.socket.remotePort || 7000")) {
|
|
117
|
+
patched = patched.replace(setupAp2TimingPortMarker, setupAp2TimingPortFallback);
|
|
118
|
+
}
|
|
119
|
+
if (patched.includes(responseHeadersMarker) && !patched.includes('headers = response.headers || {}')) {
|
|
120
|
+
patched = patched.replace(responseHeadersMarker, ' headers = response.headers || {}');
|
|
121
|
+
}
|
|
122
|
+
if (patched.includes(setupAp2DigestGuard) || !patched.includes(processDataResponseMarker)) {
|
|
123
|
+
return patched;
|
|
124
|
+
}
|
|
125
|
+
const setupAp2DigestRetry = ` if (${setupAp2DigestGuard} && headers['WWW-Authenticate'] && this.password) {
|
|
126
|
+
const auth = headers['WWW-Authenticate']
|
|
127
|
+
const di = {
|
|
128
|
+
realm: parseAuthenticate(auth, 'realm'),
|
|
129
|
+
nonce: parseAuthenticate(auth, 'nonce'),
|
|
130
|
+
username: 'iTunes',
|
|
131
|
+
password: this.password,
|
|
132
|
+
}
|
|
133
|
+
this.sendNextRequest(di)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
`;
|
|
137
|
+
return patched.replace(processDataResponseMarker, `${setupAp2DigestRetry}${processDataResponseMarker}`);
|
|
138
|
+
}
|
|
@@ -33,18 +33,13 @@ function validateWorkerStart(value) {
|
|
|
33
33
|
if (!isRecord(value) || !isRecord(value.device)) {
|
|
34
34
|
throw new Error('Invalid AirPlay worker start payload.');
|
|
35
35
|
}
|
|
36
|
-
const streamUrl = boundedString(value.streamUrl, 'streamUrl', 4096);
|
|
37
|
-
const parsedUrl = new URL(streamUrl);
|
|
38
|
-
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
39
|
-
throw new Error('AirPlay streams must use http or https URLs.');
|
|
40
|
-
}
|
|
41
36
|
const device = value.device;
|
|
42
37
|
const port = Number(device.port);
|
|
43
38
|
const txt = Array.isArray(device.txt)
|
|
44
39
|
? device.txt.slice(0, 64).map(item => boundedString(item, 'txt', 512)).filter(Boolean)
|
|
45
40
|
: [];
|
|
46
41
|
return {
|
|
47
|
-
streamUrl,
|
|
42
|
+
streamUrl: boundedHttpUrl(value.streamUrl, 'streamUrl'),
|
|
48
43
|
stationName: boundedText(value.stationName, 'stationName', maxWorkerTextBytes),
|
|
49
44
|
volume: clampVolume(Number(value.volume)),
|
|
50
45
|
muted: typeof value.muted === 'boolean' ? value.muted : false,
|
|
@@ -72,6 +67,18 @@ function validateWorkerMessage(value) {
|
|
|
72
67
|
if (value.type === 'setMuted') {
|
|
73
68
|
return typeof value.muted === 'boolean' ? { type: 'setMuted', muted: value.muted } : null;
|
|
74
69
|
}
|
|
70
|
+
if (value.type === 'retune') {
|
|
71
|
+
try {
|
|
72
|
+
return {
|
|
73
|
+
type: 'retune',
|
|
74
|
+
streamUrl: boundedHttpUrl(value.streamUrl, 'streamUrl'),
|
|
75
|
+
stationName: boundedText(value.stationName, 'stationName', maxWorkerTextBytes)
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
75
82
|
if (value.type === 'passcode') {
|
|
76
83
|
if (typeof value.code !== 'string' || Buffer.byteLength(value.code, 'utf8') > maxPasscodeBytes) {
|
|
77
84
|
return null;
|
|
@@ -79,7 +86,7 @@ function validateWorkerMessage(value) {
|
|
|
79
86
|
const code = cleanText(value.code, maxPasscodeBytes);
|
|
80
87
|
return code ? { type: 'passcode', code } : null;
|
|
81
88
|
}
|
|
82
|
-
if (value.type === 'ready' || value.type === 'playing' || value.type === 'password-required' || value.type === 'stopped') {
|
|
89
|
+
if (value.type === 'ready' || value.type === 'playing' || value.type === 'retuned' || value.type === 'password-required' || value.type === 'stopped') {
|
|
83
90
|
return { type: value.type };
|
|
84
91
|
}
|
|
85
92
|
if (value.type === 'buffer') {
|
|
@@ -100,6 +107,14 @@ function boundedString(value, field, maxBytes) {
|
|
|
100
107
|
function boundedText(value, field, maxBytes) {
|
|
101
108
|
return boundedString(value, field, maxBytes);
|
|
102
109
|
}
|
|
110
|
+
function boundedHttpUrl(value, field) {
|
|
111
|
+
const streamUrl = boundedString(value, field, 4096);
|
|
112
|
+
const parsedUrl = new URL(streamUrl);
|
|
113
|
+
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
|
114
|
+
throw new Error('AirPlay streams must use http or https URLs.');
|
|
115
|
+
}
|
|
116
|
+
return streamUrl;
|
|
117
|
+
}
|
|
103
118
|
function cleanText(value, maxBytes) {
|
|
104
119
|
if (typeof value !== 'string') {
|
|
105
120
|
return '';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { airPlaySenderHealth } from './airplay-sender-health.js';
|
|
4
|
+
import { installAirPlaySenderPatch } from './airplay-sender-patch.js';
|
|
4
5
|
import { parseWorkerMessage, decodeWorkerStart, maxWorkerMessageBytes, serializeWorkerMessage } from './airplay-worker-protocol.js';
|
|
5
6
|
const require = createRequire(import.meta.url);
|
|
6
7
|
// node-airtunes2 writes diagnostics with console.log; keep stdout reserved for JSON events.
|
|
@@ -16,8 +17,10 @@ const AirTunes = loadAirTunesSender();
|
|
|
16
17
|
const start = decodeStartPayload(encodedStart);
|
|
17
18
|
const airtunes = new AirTunes();
|
|
18
19
|
let deviceKey = '';
|
|
20
|
+
let ffmpeg = null;
|
|
19
21
|
let muted = start.muted;
|
|
20
22
|
let volume = start.volume;
|
|
23
|
+
let retuning = false;
|
|
21
24
|
let stopping = false;
|
|
22
25
|
airtunes.on('device', (key, status) => {
|
|
23
26
|
if (typeof key === 'string') {
|
|
@@ -40,9 +43,11 @@ airtunes.on('device', (key, status) => {
|
|
|
40
43
|
}
|
|
41
44
|
});
|
|
42
45
|
airtunes.on('buffer', status => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const statusText = String(status);
|
|
47
|
+
emit({ type: 'buffer', status: statusText });
|
|
48
|
+
if (retuning && statusText === 'playing') {
|
|
49
|
+
retuning = false;
|
|
50
|
+
emit({ type: 'retuned' });
|
|
46
51
|
}
|
|
47
52
|
});
|
|
48
53
|
airtunes.on('error', error => {
|
|
@@ -58,40 +63,7 @@ const device = airtunes.add(start.device.host, {
|
|
|
58
63
|
mode: start.device.airplay2 ? 2 : 0
|
|
59
64
|
});
|
|
60
65
|
deviceKey = device.key;
|
|
61
|
-
|
|
62
|
-
'-hide_banner',
|
|
63
|
-
'-loglevel',
|
|
64
|
-
'error',
|
|
65
|
-
'-i',
|
|
66
|
-
start.streamUrl,
|
|
67
|
-
'-f',
|
|
68
|
-
's16le',
|
|
69
|
-
'-ac',
|
|
70
|
-
'2',
|
|
71
|
-
'-ar',
|
|
72
|
-
'44100',
|
|
73
|
-
'pipe:1'
|
|
74
|
-
], {
|
|
75
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
76
|
-
});
|
|
77
|
-
ffmpeg.stdout.on('data', chunk => {
|
|
78
|
-
airtunes.write(chunk);
|
|
79
|
-
});
|
|
80
|
-
ffmpeg.stderr.on('data', chunk => {
|
|
81
|
-
process.stderr.write(chunk);
|
|
82
|
-
});
|
|
83
|
-
ffmpeg.once('error', error => {
|
|
84
|
-
emit({ type: 'error', message: error.message });
|
|
85
|
-
stop(1);
|
|
86
|
-
});
|
|
87
|
-
ffmpeg.once('exit', code => {
|
|
88
|
-
if (!stopping) {
|
|
89
|
-
if (code !== 0) {
|
|
90
|
-
emit({ type: 'error', message: `ffmpeg exited with code ${code}` });
|
|
91
|
-
}
|
|
92
|
-
stop(code ?? 0);
|
|
93
|
-
}
|
|
94
|
-
});
|
|
66
|
+
startFfmpeg(start.streamUrl);
|
|
95
67
|
let stdinBuffer = '';
|
|
96
68
|
process.stdin.on('data', chunk => {
|
|
97
69
|
stdinBuffer += chunk.toString('utf8');
|
|
@@ -125,10 +97,76 @@ function handleCommand(command) {
|
|
|
125
97
|
muted = command.muted;
|
|
126
98
|
setAirPlayVolume(muted ? 0 : volume);
|
|
127
99
|
}
|
|
100
|
+
else if (command.type === 'retune') {
|
|
101
|
+
retuning = true;
|
|
102
|
+
airtunes.reset();
|
|
103
|
+
startFfmpeg(command.streamUrl);
|
|
104
|
+
}
|
|
128
105
|
else if (command.type === 'passcode') {
|
|
129
106
|
device.setPasscode?.(command.code);
|
|
130
107
|
}
|
|
131
108
|
}
|
|
109
|
+
function startFfmpeg(streamUrl) {
|
|
110
|
+
const previous = ffmpeg;
|
|
111
|
+
const child = spawn('ffmpeg', [
|
|
112
|
+
'-hide_banner',
|
|
113
|
+
'-loglevel',
|
|
114
|
+
'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',
|
|
126
|
+
'-i',
|
|
127
|
+
streamUrl,
|
|
128
|
+
'-f',
|
|
129
|
+
's16le',
|
|
130
|
+
'-ac',
|
|
131
|
+
'2',
|
|
132
|
+
'-ar',
|
|
133
|
+
'44100',
|
|
134
|
+
'pipe:1'
|
|
135
|
+
], {
|
|
136
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
137
|
+
});
|
|
138
|
+
ffmpeg = child;
|
|
139
|
+
if (previous && !previous.killed) {
|
|
140
|
+
previous.kill('SIGTERM');
|
|
141
|
+
}
|
|
142
|
+
child.stdout.on('data', chunk => {
|
|
143
|
+
if (ffmpeg === child && !stopping) {
|
|
144
|
+
airtunes.write(chunk);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
child.stderr.on('data', chunk => {
|
|
148
|
+
if (ffmpeg === child) {
|
|
149
|
+
process.stderr.write(chunk);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
child.once('error', error => {
|
|
153
|
+
if (ffmpeg !== child || stopping) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
emit({ type: 'error', message: error.message });
|
|
157
|
+
stop(1);
|
|
158
|
+
});
|
|
159
|
+
child.once('exit', code => {
|
|
160
|
+
if (ffmpeg !== child || stopping) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
ffmpeg = null;
|
|
164
|
+
if (code !== 0) {
|
|
165
|
+
emit({ type: 'error', message: `ffmpeg exited with code ${code}` });
|
|
166
|
+
}
|
|
167
|
+
stop(code ?? 0);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
132
170
|
function setAirPlayVolume(nextVolume) {
|
|
133
171
|
if (deviceKey) {
|
|
134
172
|
airtunes.setVolume(deviceKey, nextVolume);
|
|
@@ -143,11 +181,12 @@ function loadAirTunesSender() {
|
|
|
143
181
|
emit({ type: 'error', message: health.message });
|
|
144
182
|
process.exit(1);
|
|
145
183
|
}
|
|
184
|
+
installAirPlaySenderPatch();
|
|
146
185
|
try {
|
|
147
186
|
return require('node-airtunes2');
|
|
148
187
|
}
|
|
149
188
|
catch {
|
|
150
|
-
emit({ type: 'error', message: 'AirPlay sender
|
|
189
|
+
emit({ type: 'error', message: 'AirPlay sender bridge could not be loaded.' });
|
|
151
190
|
process.exit(1);
|
|
152
191
|
}
|
|
153
192
|
}
|
|
@@ -165,9 +204,10 @@ function stop(code) {
|
|
|
165
204
|
return;
|
|
166
205
|
}
|
|
167
206
|
stopping = true;
|
|
168
|
-
if (!ffmpeg.killed) {
|
|
207
|
+
if (ffmpeg && !ffmpeg.killed) {
|
|
169
208
|
ffmpeg.kill('SIGTERM');
|
|
170
209
|
}
|
|
210
|
+
ffmpeg = null;
|
|
171
211
|
airtunes.stopAll(() => {
|
|
172
212
|
emit({ type: 'stopped' });
|
|
173
213
|
process.exit(code);
|
|
@@ -61,7 +61,7 @@ export function playbackBackendInstallHint(platform = process.platform, osReleas
|
|
|
61
61
|
export function playbackBackendStatusLines(backends, platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
62
62
|
const backendSet = new Set(backends);
|
|
63
63
|
const lines = [
|
|
64
|
-
'npm_install=RadioCLI
|
|
64
|
+
'npm_install=RadioCLI installs the optional AirPlay sender when native dependencies are available; playback tools come from mpv and FFmpeg',
|
|
65
65
|
`install_mpv=${mpvInstallCommand(platform, osRelease)}`,
|
|
66
66
|
`optional_ffplay=${ffplayInstallCommand(platform, osRelease)}`
|
|
67
67
|
];
|