@ciphore/radiocli 0.1.1 → 0.1.3

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 CHANGED
@@ -5,6 +5,38 @@ 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
+ ## [0.1.3] - 2026-05-30
9
+
10
+ ### Added
11
+
12
+ - Experimental macOS AirPlay playback backend with Bonjour receiver discovery,
13
+ Settings target selection, `:airplay-code` passcode entry, worker-based stream
14
+ forwarding, and active receiver labels in the playback footer.
15
+
16
+ ### Changed
17
+
18
+ - `ffplay` is now treated as a playback-only fallback: the UI labels it as
19
+ `ffplay fallback`, `radiocli doctor` reports limited controls, and unsupported
20
+ pause, mute, volume, and media-key actions explain that `mpv` is required.
21
+
22
+ ### Security
23
+
24
+ - AirPlay sender support is disabled unless a compatible sender package passes
25
+ RadioCLI's dependency safety gate; the current public `node-airtunes2` package
26
+ line remains blocked because of vulnerable transitive dependencies.
27
+ - Hardened AirPlay discovery and worker messaging with bounded payloads,
28
+ sanitized receiver data, passcode validation, and worker shutdown on startup
29
+ timeout.
30
+
31
+ ## [0.1.2] - 2026-05-30
32
+
33
+ ### Fixed
34
+
35
+ - Ignored Kitty keyboard release events so Ghostty users do not skip multiple
36
+ tabs from one left/right arrow key press.
37
+ - Ignored Kitty release events for raw media-key transport sequences so learned
38
+ or native media keys do not double-fire on key release.
39
+
8
40
  ## [0.1.1] - 2026-05-30
9
41
 
10
42
  Initial public release.
@@ -17,9 +49,10 @@ Initial public release.
17
49
  countries, world map, nearby, stats, and settings screens.
18
50
  - Radio Browser provider with mirror fallback, durable cache, and tune
19
51
  resolution, plus an experimental, opt-in Radio Garden provider.
20
- - `mpv` playback with `ffplay` fallback, readiness checks, ICY metadata polling,
21
- volume, pause, mute, sleep timer, tune timeouts, and skip-broken-stream
22
- behavior, using Unix sockets on macOS/Linux and named pipes on native Windows.
52
+ - `mpv` playback controls for readiness checks, ICY metadata polling, volume,
53
+ pause, and mute, with `ffplay` playback fallback, sleep timer, tune timeouts,
54
+ and skip-broken-stream behavior, using Unix sockets on macOS/Linux and named
55
+ pipes on native Windows.
23
56
  - Receiver-style Now Playing screen with 50 selectable visualizers and
24
57
  zero-signal frames whenever playback is idle, paused, stopped, or not
25
58
  backend-ready.
@@ -47,4 +80,6 @@ Initial public release.
47
80
  backend/volume that already appear in the header and footer. The reclaimed
48
81
  rows go to the visualizer.
49
82
 
83
+ [0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
84
+ [0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
50
85
  [0.1.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.1
package/CONTRIBUTING.md CHANGED
@@ -12,7 +12,8 @@ npm run build
12
12
  npm run test
13
13
  ```
14
14
 
15
- Install `mpv` for the best playback experience:
15
+ Install `mpv` for playback controls, metadata polling, and the best playback
16
+ experience:
16
17
 
17
18
  ```bash
18
19
  brew install mpv
@@ -57,4 +58,6 @@ checks.
57
58
  ## Issue Triage
58
59
 
59
60
  Playback issues should include station name, country, backend, and
60
- `radiocli check` output when possible.
61
+ `radiocli check` or `radiocli doctor` output when possible. If the backend is
62
+ `ffplay`, pause, mute, volume, and play/pause media-key behavior are expected to
63
+ be limited until `mpv` is installed.
package/README.md CHANGED
@@ -13,8 +13,8 @@ It is built with [Ink](https://github.com/vadimdemedes/ink), [React](https://rea
13
13
  ## Features
14
14
 
15
15
  - Explore public radio from around the world through a cosmo-style braille world map beside the station list, with click-to-place mouse support and WASD keyboard movement backed by a cached geotagged station atlas. Country lists, global station search, a country-density map, and opt-in nearby discovery round out the discovery surface.
16
- - Tune stations with `mpv` first and `ffplay` fallback when available.
17
- - Use a receiver-style Now Playing screen with 50 selectable receiver visualizers, backend status, cleaned ICY track metadata, stream diagnostics, sleep timer, favorite state, volume, pause, mute, station skipping, and zero-signal graphics whenever playback is idle, paused, stopped, or not backend-ready.
16
+ - Tune stations with `mpv` first for full playback controls, with `ffplay` as a playback-only fallback when available.
17
+ - Use a receiver-style Now Playing screen with 50 selectable receiver visualizers, backend status, cleaned ICY track metadata, stream diagnostics, sleep timer, favorite state, `mpv`-backed volume, pause, mute, station skipping, and zero-signal graphics whenever playback is idle, paused, stopped, or not backend-ready.
18
18
  - Keep shortcuts in a fixed adaptive footer: a compact live station and track row appears above page-specific and global controls while playback is active.
19
19
  - Move previous/next through the exact station list you tuned from, even after navigating to another screen.
20
20
  - Browse dense station lists with inline location/codec metadata and yellow favorite stars next to station names.
@@ -139,8 +139,9 @@ Requirements:
139
139
 
140
140
  - Homebrew on macOS: installs RadioCLI, Node.js, and `mpv`
141
141
  - npm on macOS, Linux, and Windows: Node.js 22 or newer
142
- - `mpv` for best playback; RadioCLI expects one local playback backend at runtime
143
- - `ffplay` from FFmpeg as an optional fallback
142
+ - `mpv` for playback, pause, mute, volume, media keys, metadata, and readiness checks
143
+ - `ffmpeg` plus an audited, compatible sender package on macOS for experimental AirPlay
144
+ - `ffplay` from FFmpeg as an optional playback-only fallback
144
145
 
145
146
  Recommended macOS install:
146
147
 
@@ -152,6 +153,22 @@ radiocli
152
153
  The Homebrew formula depends on `node` and `mpv`, so the native playback backend
153
154
  comes from the native package manager.
154
155
 
156
+ AirPlay playback on macOS is experimental. RadioCLI discovers AirPlay/RAOP
157
+ receivers with Bonjour and decodes streams with `ffmpeg`, but it only advertises
158
+ the `airplay` backend when a compatible sender package passes its dependency
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`.
171
+
155
172
  Universal npm install:
156
173
 
157
174
  ```bash
@@ -203,6 +220,11 @@ Windows:
203
220
  winget install --id Gyan.FFmpeg -e
204
221
  ```
205
222
 
223
+ `ffplay` can keep streams playable when `mpv` is not installed, but it does not
224
+ provide reliable pause, mute, volume, media-key, or metadata control. In that
225
+ mode RadioCLI labels the backend as `ffplay fallback`, shows limited-control
226
+ footer text, and `radiocli doctor` reports `controls=limited`.
227
+
206
228
  CI covers command-mode typecheck, tests, builds, package checks, and fresh
207
229
  install smoke checks on Ubuntu, macOS, and Windows.
208
230
 
@@ -257,6 +279,11 @@ RadioCLI keeps shortcuts at the bottom of the terminal. When playback is active,
257
279
  - `+` / `-`: volume.
258
280
  - `q` or `Ctrl+C`: quit cleanly.
259
281
 
282
+ Pause, mute, volume, and play/pause media-key control require `mpv`. When only
283
+ `ffplay` is active, RadioCLI keeps playback and station skipping available but
284
+ labels interactive playback controls as limited and shows an `Install mpv`
285
+ warning instead of pretending the control worked.
286
+
260
287
  Page-specific footer controls:
261
288
 
262
289
  | Screen | Controls |
@@ -269,7 +296,7 @@ Page-specific footer controls:
269
296
  | World map | `/` filter, `↑` / `↓` move, `Enter` open country, `w` list, `b` home |
270
297
  | Station lists | `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
271
298
  | Now Playing | `space` / `F8` pause, `f` favorite, `m` mute, `s` sleep, `d` diagnostics, `b` home |
272
- | Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` backend, `r` health, `b` home |
299
+ | Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` backend, `a` AirPlay, `r` health, `b` home |
273
300
  | Stats | `b` home |
274
301
 
275
302
  Other active shortcuts:
@@ -292,7 +319,7 @@ Other active shortcuts:
292
319
 
293
320
  When you tune a station from Library, Explore, Search, Countries, or Nearby, that list becomes the playback queue. Previous/next keeps moving through that source list from any screen until you tune from another list.
294
321
 
295
- Hardware media keys depend on the OS and terminal. RadioCLI maximizes compatibility by enabling enhanced keyboard reporting where supported, recognizing common F7/F8/F9 sequences, Kitty consumer/media-key codes, modified-arrow sequences, comma/dot transport fallback, and learned custom bindings. Learn keys from Settings or with `:learn previous`, `:learn play`, and `:learn next`; clear them with `:keys reset`.
322
+ Hardware media keys depend on the OS and terminal. RadioCLI maximizes compatibility by enabling enhanced keyboard reporting where supported, recognizing common F7/F8/F9 sequences, Kitty consumer/media-key codes, modified-arrow sequences, comma/dot transport fallback, and learned custom bindings. Previous/next media actions stay app-level; play/pause needs the `mpv` backend. Learn keys from Settings or with `:learn previous`, `:learn play`, and `:learn next`; clear them with `:keys reset`.
296
323
 
297
324
  Explore mouse clicks use terminal mouse reporting while the Explore tab is active. If your terminal or tmux setup does not pass those events through, the WASD cursor controls stay fully available.
298
325
 
@@ -317,6 +344,7 @@ Useful command palette entries:
317
344
  :learn play
318
345
  :learn next
319
346
  :keys reset
347
+ :airplay-code 1234
320
348
  :map
321
349
  :library
322
350
  :stats
@@ -326,6 +354,11 @@ Useful command palette entries:
326
354
 
327
355
  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.
328
356
 
357
+ On macOS, Settings can cycle AirPlay targets discovered through Bonjour when
358
+ `ffmpeg` and a compatible sender package pass RadioCLI's dependency safety gate.
359
+ Select the `airplay` backend with `o`, choose a receiver with `a`, then tune a
360
+ station. If the receiver asks for a code, enter it with `:airplay-code 1234`.
361
+
329
362
  ## Architecture
330
363
 
331
364
  RadioCLI is split around four seams:
@@ -342,9 +375,9 @@ a station, `ProviderManager.resolve()` follows the provider-specific path, then
342
375
  `PlayerController` starts `mpv` with `--no-video`, `--force-window=no`, a local
343
376
  JSON IPC endpoint, and the configured volume. The endpoint is a Unix socket on
344
377
  macOS/Linux and a named pipe on native Windows. `ffplay` is available as a
345
- simpler fallback, but `mpv` is the intended backend because it handles
346
- redirects, HLS, codecs, metadata, pause, mute, volume, and readiness checks more
347
- reliably than a JavaScript stream client.
378
+ playback-only fallback, but `mpv` is the intended backend because it handles
379
+ redirects, HLS, codecs, metadata, pause, mute, volume, media keys, and readiness
380
+ checks more reliably than a JavaScript stream client.
348
381
 
349
382
  RadioCLI waits for the backend to become ready before marking playback as
350
383
  `playing`. With `mpv`, it polls playback state every 500ms and ICY metadata every
@@ -385,7 +418,7 @@ animation cost.
385
418
 
386
419
  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.
387
420
 
388
- 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.
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 only when Bonjour discovery, `ffmpeg`, and a compatible sender package pass RadioCLI's safety gate.
389
422
 
390
423
  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`.
391
424
 
@@ -0,0 +1,141 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { commandExists } from './command.js';
3
+ const defaultTimeoutMs = 2500;
4
+ const defaultMaxDevices = 12;
5
+ const defaultLookupConcurrency = 4;
6
+ const defaultMaxOutputBytes = 64 * 1024;
7
+ export async function discoverAirPlayDevices({ platform = process.platform, timeoutMs = defaultTimeoutMs, maxDevices = defaultMaxDevices, lookupConcurrency = defaultLookupConcurrency, maxOutputBytes = defaultMaxOutputBytes } = {}) {
8
+ if (platform !== 'darwin' || !commandExists('dns-sd')) {
9
+ return [];
10
+ }
11
+ const browseOutput = await runDnsSd(['-B', '_raop._tcp', 'local'], timeoutMs, maxOutputBytes).catch(() => '');
12
+ const instances = parseRaopBrowseOutput(browseOutput).slice(0, Math.max(0, maxDevices));
13
+ const devices = await mapWithConcurrency(instances, Math.max(1, lookupConcurrency), async (instance) => {
14
+ const lookupOutput = await runDnsSd(['-L', instance, '_raop._tcp', 'local'], timeoutMs, maxOutputBytes).catch(() => '');
15
+ return parseRaopLookupOutput(instance, lookupOutput);
16
+ });
17
+ return devices.filter((device) => Boolean(device));
18
+ }
19
+ export function parseRaopBrowseOutput(output) {
20
+ const instances = new Set();
21
+ for (const line of output.split('\n')) {
22
+ const match = /\s_raop\._tcp\.\s+(.+)$/.exec(line);
23
+ const instance = sanitizeDnsSdText(unescapeDnsSdName(match?.[1]?.trim() ?? ''), 256);
24
+ if (instance) {
25
+ instances.add(instance);
26
+ }
27
+ }
28
+ return [...instances];
29
+ }
30
+ export function parseRaopLookupOutput(instance, output) {
31
+ const reachable = output.match(/ can be reached at (.+?)\.?:(\d+) \(interface \d+\)/);
32
+ if (!reachable?.[1] || !reachable[2]) {
33
+ return null;
34
+ }
35
+ const id = sanitizeDnsSdText(instance, 256);
36
+ const host = normalizeHost(reachable[1]);
37
+ const port = Number(reachable[2]);
38
+ if (!id || !host || !Number.isInteger(port) || port <= 0 || port > 65535) {
39
+ return null;
40
+ }
41
+ const txtLine = output
42
+ .split('\n')
43
+ .map(line => line.trim())
44
+ .find(line => /\bcn=/.test(line) || /\bsf=/.test(line) || /\bpk=/.test(line));
45
+ const txt = txtLine
46
+ ? txtLine.split(/\s+/).map(entry => sanitizeDnsSdText(entry, 512)).filter(Boolean).slice(0, 64)
47
+ : [];
48
+ const txtMap = txtRecordMap(txt);
49
+ const sf = txtMap.get('sf');
50
+ const sfValue = sf ? Number.parseInt(sf.replace(/^0x/i, ''), 16) : 0;
51
+ return {
52
+ id,
53
+ name: displayNameForRaopInstance(id),
54
+ host,
55
+ port,
56
+ txt,
57
+ requiresPassword: sfValue !== 0 && sfValue !== 0x4,
58
+ airplay2: Boolean(txtMap.get('pk') || txtMap.get('vv') === '2' || txtMap.get('et')?.split(',').some(value => ['3', '4', '5'].includes(value)))
59
+ };
60
+ }
61
+ function displayNameForRaopInstance(instance) {
62
+ const atIndex = instance.indexOf('@');
63
+ return sanitizeDnsSdText(atIndex === -1 ? instance : instance.slice(atIndex + 1), 256) || 'AirPlay receiver';
64
+ }
65
+ function txtRecordMap(txt) {
66
+ const map = new Map();
67
+ for (const entry of txt) {
68
+ const equals = entry.indexOf('=');
69
+ if (equals === -1) {
70
+ continue;
71
+ }
72
+ map.set(entry.slice(0, equals), entry.slice(equals + 1));
73
+ }
74
+ return map;
75
+ }
76
+ function unescapeDnsSdName(value) {
77
+ return value.replace(/\\(\d{3})/g, (_match, code) => String.fromCharCode(Number(code)));
78
+ }
79
+ function normalizeHost(value) {
80
+ const host = sanitizeDnsSdText(unescapeDnsSdName(value), 253).replace(/\.$/, '');
81
+ if (!host || /[\s/\\]/.test(host) || !/^[A-Za-z0-9._:%-]+$/.test(host)) {
82
+ return null;
83
+ }
84
+ return host;
85
+ }
86
+ function sanitizeDnsSdText(value, maxBytes) {
87
+ const withoutControls = value.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ');
88
+ let cleaned = withoutControls.replace(/\s+/g, ' ').trim();
89
+ while (Buffer.byteLength(cleaned, 'utf8') > maxBytes) {
90
+ cleaned = cleaned.slice(0, -1);
91
+ }
92
+ return cleaned.trim();
93
+ }
94
+ function runDnsSd(args, timeoutMs, maxOutputBytes) {
95
+ return new Promise(resolve => {
96
+ const child = spawn('dns-sd', args, { stdio: ['ignore', 'pipe', 'pipe'] });
97
+ let output = '';
98
+ let killedForLimit = false;
99
+ const timer = setTimeout(() => {
100
+ child.kill('SIGTERM');
101
+ }, timeoutMs);
102
+ const appendOutput = (chunk) => {
103
+ if (Buffer.byteLength(output, 'utf8') >= maxOutputBytes) {
104
+ if (!killedForLimit) {
105
+ killedForLimit = true;
106
+ child.kill('SIGTERM');
107
+ }
108
+ return;
109
+ }
110
+ const remaining = maxOutputBytes - Buffer.byteLength(output, 'utf8');
111
+ output += chunk.toString('utf8').slice(0, remaining);
112
+ };
113
+ child.stdout.on('data', chunk => {
114
+ appendOutput(chunk);
115
+ });
116
+ child.stderr.on('data', chunk => {
117
+ appendOutput(chunk);
118
+ });
119
+ child.once('error', () => {
120
+ clearTimeout(timer);
121
+ resolve(output);
122
+ });
123
+ child.once('exit', () => {
124
+ clearTimeout(timer);
125
+ resolve(output);
126
+ });
127
+ });
128
+ }
129
+ async function mapWithConcurrency(items, concurrency, map) {
130
+ const results = [];
131
+ let nextIndex = 0;
132
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
133
+ while (nextIndex < items.length) {
134
+ const index = nextIndex;
135
+ nextIndex += 1;
136
+ results[index] = await map(items[index]);
137
+ }
138
+ });
139
+ await Promise.all(workers);
140
+ return results;
141
+ }
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname } from 'node:path';
4
+ const require = createRequire(import.meta.url);
5
+ const airPlaySenderPackageName = 'node-airtunes2';
6
+ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
7
+ const sender = lookupPackage(airPlaySenderPackageName);
8
+ if (!sender) {
9
+ return {
10
+ available: false,
11
+ safe: false,
12
+ packageName: airPlaySenderPackageName,
13
+ message: 'AirPlay sender package not installed. Install an audited node-airtunes2-compatible sender next to RadioCLI.',
14
+ vulnerablePackages: []
15
+ };
16
+ }
17
+ const vulnerablePackages = vulnerableAirPlaySenderPackages(sender, lookupPackage);
18
+ if (vulnerablePackages.length > 0) {
19
+ return {
20
+ available: true,
21
+ safe: false,
22
+ packageName: sender.name,
23
+ version: sender.version,
24
+ message: `Installed AirPlay sender ${sender.name}@${sender.version} is blocked because vulnerable transitive dependencies were found: ${vulnerablePackages.join(', ')}.`,
25
+ vulnerablePackages
26
+ };
27
+ }
28
+ return {
29
+ available: true,
30
+ safe: true,
31
+ packageName: sender.name,
32
+ version: sender.version,
33
+ message: `AirPlay sender ${sender.name}@${sender.version} passed the dependency safety gate.`,
34
+ vulnerablePackages: []
35
+ };
36
+ }
37
+ function vulnerableAirPlaySenderPackages(sender, lookupPackage) {
38
+ const packagePaths = [sender.root];
39
+ const vulnerable = [];
40
+ const protobuf = lookupPackage('protobufjs', packagePaths);
41
+ const elliptic = lookupPackage('elliptic', packagePaths);
42
+ if (protobuf && compareSemver(protobuf.version, '7.5.8') < 0) {
43
+ vulnerable.push(`protobufjs@${protobuf.version}`);
44
+ }
45
+ if (elliptic && compareSemver(elliptic.version, '6.6.2') < 0) {
46
+ vulnerable.push(`elliptic@${elliptic.version}`);
47
+ }
48
+ return vulnerable;
49
+ }
50
+ function lookupInstalledPackage(packageName, paths) {
51
+ try {
52
+ const packageJsonPath = paths
53
+ ? require.resolve(`${packageName}/package.json`, { paths })
54
+ : require.resolve(`${packageName}/package.json`);
55
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
56
+ if (!parsed.version) {
57
+ return null;
58
+ }
59
+ return {
60
+ name: parsed.name ?? packageName,
61
+ version: parsed.version,
62
+ root: dirname(packageJsonPath)
63
+ };
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ function compareSemver(left, right) {
70
+ const leftParts = semverParts(left);
71
+ const rightParts = semverParts(right);
72
+ for (let index = 0; index < 3; index += 1) {
73
+ const delta = leftParts[index] - rightParts[index];
74
+ if (delta !== 0) {
75
+ return delta;
76
+ }
77
+ }
78
+ return 0;
79
+ }
80
+ function semverParts(version) {
81
+ const [major = '0', minor = '0', patch = '0'] = version.split(/[.-]/);
82
+ return [
83
+ Number.parseInt(major, 10) || 0,
84
+ Number.parseInt(minor, 10) || 0,
85
+ Number.parseInt(patch, 10) || 0
86
+ ];
87
+ }
@@ -0,0 +1,129 @@
1
+ const maxWorkerStartBytes = 16_384;
2
+ export const maxWorkerMessageBytes = 8192;
3
+ const maxWorkerTextBytes = 512;
4
+ const maxPasscodeBytes = 64;
5
+ export function encodeWorkerStart(start) {
6
+ return Buffer.from(JSON.stringify(start), 'utf8').toString('base64url');
7
+ }
8
+ export function decodeWorkerStart(encoded) {
9
+ const decoded = Buffer.from(encoded, 'base64url').toString('utf8');
10
+ if (Buffer.byteLength(decoded, 'utf8') > maxWorkerStartBytes) {
11
+ throw new Error('AirPlay worker start payload is too large.');
12
+ }
13
+ return validateWorkerStart(JSON.parse(decoded));
14
+ }
15
+ export function serializeWorkerMessage(message) {
16
+ return `${JSON.stringify(message)}\n`;
17
+ }
18
+ export function parseWorkerMessage(line) {
19
+ if (!line.trim()) {
20
+ return null;
21
+ }
22
+ if (Buffer.byteLength(line, 'utf8') > maxWorkerMessageBytes) {
23
+ return null;
24
+ }
25
+ try {
26
+ return validateWorkerMessage(JSON.parse(line));
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ function validateWorkerStart(value) {
33
+ if (!isRecord(value) || !isRecord(value.device)) {
34
+ throw new Error('Invalid AirPlay worker start payload.');
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
+ const device = value.device;
42
+ const port = Number(device.port);
43
+ const txt = Array.isArray(device.txt)
44
+ ? device.txt.slice(0, 64).map(item => boundedString(item, 'txt', 512)).filter(Boolean)
45
+ : [];
46
+ return {
47
+ streamUrl,
48
+ stationName: boundedText(value.stationName, 'stationName', maxWorkerTextBytes),
49
+ volume: clampVolume(Number(value.volume)),
50
+ muted: typeof value.muted === 'boolean' ? value.muted : false,
51
+ device: {
52
+ id: boundedText(device.id, 'device.id', 256),
53
+ name: boundedText(device.name, 'device.name', 256),
54
+ host: safeHost(device.host),
55
+ port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : invalid('device.port'),
56
+ txt,
57
+ requiresPassword: Boolean(device.requiresPassword),
58
+ airplay2: Boolean(device.airplay2)
59
+ }
60
+ };
61
+ }
62
+ function validateWorkerMessage(value) {
63
+ if (!isRecord(value) || typeof value.type !== 'string') {
64
+ return null;
65
+ }
66
+ if (value.type === 'stop') {
67
+ return { type: 'stop' };
68
+ }
69
+ if (value.type === 'setVolume') {
70
+ return Number.isFinite(Number(value.volume)) ? { type: 'setVolume', volume: clampVolume(Number(value.volume)) } : null;
71
+ }
72
+ if (value.type === 'setMuted') {
73
+ return typeof value.muted === 'boolean' ? { type: 'setMuted', muted: value.muted } : null;
74
+ }
75
+ if (value.type === 'passcode') {
76
+ if (typeof value.code !== 'string' || Buffer.byteLength(value.code, 'utf8') > maxPasscodeBytes) {
77
+ return null;
78
+ }
79
+ const code = cleanText(value.code, maxPasscodeBytes);
80
+ return code ? { type: 'passcode', code } : null;
81
+ }
82
+ if (value.type === 'ready' || value.type === 'playing' || value.type === 'password-required' || value.type === 'stopped') {
83
+ return { type: value.type };
84
+ }
85
+ if (value.type === 'buffer') {
86
+ return { type: 'buffer', status: cleanText(value.status, 120) || 'unknown' };
87
+ }
88
+ if (value.type === 'error') {
89
+ return { type: 'error', message: cleanText(value.message, maxWorkerTextBytes) || 'AirPlay worker error.' };
90
+ }
91
+ return null;
92
+ }
93
+ function boundedString(value, field, maxBytes) {
94
+ if (typeof value !== 'string' || !value.trim()) {
95
+ return invalid(field);
96
+ }
97
+ const cleaned = cleanText(value, maxBytes);
98
+ return cleaned || invalid(field);
99
+ }
100
+ function boundedText(value, field, maxBytes) {
101
+ return boundedString(value, field, maxBytes);
102
+ }
103
+ function cleanText(value, maxBytes) {
104
+ if (typeof value !== 'string') {
105
+ return '';
106
+ }
107
+ const withoutControls = value.replace(/[\u0000-\u001F\u007F-\u009F]/g, ' ').replace(/\s+/g, ' ').trim();
108
+ let cleaned = withoutControls;
109
+ while (Buffer.byteLength(cleaned, 'utf8') > maxBytes) {
110
+ cleaned = cleaned.slice(0, -1);
111
+ }
112
+ return cleaned.trim();
113
+ }
114
+ function safeHost(value) {
115
+ const host = boundedString(value, 'device.host', 253).replace(/\.$/, '');
116
+ if (/[\s/\\]/.test(host) || !/^[A-Za-z0-9._:%-]+$/.test(host)) {
117
+ return invalid('device.host');
118
+ }
119
+ return host;
120
+ }
121
+ function clampVolume(volume) {
122
+ return Math.min(100, Math.max(0, Math.round(Number.isFinite(volume) ? volume : 70)));
123
+ }
124
+ function isRecord(value) {
125
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
126
+ }
127
+ function invalid(field) {
128
+ throw new Error(`Invalid AirPlay worker ${field}.`);
129
+ }