@ciphore/radiocli 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- 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 +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- 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 +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- 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 +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- 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 +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +28 -21
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- 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
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { resolveCommandDetails } from '../platform/executables.js';
|
|
2
|
+
import { sessionBusSocket } from '../platform/mpris.js';
|
|
3
|
+
import { isAirPlayPlatformSupported } from './backend-install.js';
|
|
4
|
+
import { runAirPlayCommand } from './airplay-process.js';
|
|
5
|
+
import { probeAirPlaySender } from './airplay-sender-health.js';
|
|
6
|
+
/** Does not browse the network, activate services, or open a receiver. */
|
|
7
|
+
export async function probeAirPlayReadiness({ platform = process.platform, env = process.env, release, resolve = command => resolveCommandDetails(command, { platform, env }).path ?? undefined, hasCommand = command => Boolean(resolve(command)), run = runAirPlayCommand, sender = probeAirPlaySender } = {}) {
|
|
8
|
+
const eligible = isAirPlayPlatformSupported(platform, env, release);
|
|
9
|
+
const discoveryExecutable = eligible && hasCommand(platform === 'linux' ? 'avahi-browse' : 'dns-sd');
|
|
10
|
+
const ffmpeg = eligible && hasCommand('ffmpeg');
|
|
11
|
+
const busctl = eligible && platform === 'linux' && hasCommand('busctl') ? resolve('busctl') : undefined;
|
|
12
|
+
let discoveryService = 'unverified';
|
|
13
|
+
const senderResult = eligible ? sender() : Promise.resolve({ loaded: false, message: 'AirPlay is not supported in this environment.' });
|
|
14
|
+
if (!discoveryExecutable)
|
|
15
|
+
discoveryService = 'unavailable';
|
|
16
|
+
else if (platform === 'linux' && busctl) {
|
|
17
|
+
const socket = sessionBusSocket(env.DBUS_SYSTEM_BUS_ADDRESS ?? 'unix:path=/run/dbus/system_bus_socket');
|
|
18
|
+
if (!socket)
|
|
19
|
+
discoveryService = 'unavailable';
|
|
20
|
+
else {
|
|
21
|
+
// --system still honors executable/network transport overrides. Pass only
|
|
22
|
+
// one validated Unix address and remove all inherited D-Bus fallbacks.
|
|
23
|
+
const address = `unix:${socket.startsWith('\0') ? 'abstract' : 'path'}=${encodeURIComponent(socket.startsWith('\0') ? socket.slice(1) : socket)}`;
|
|
24
|
+
const childEnv = { ...env };
|
|
25
|
+
for (const key of Object.keys(childEnv))
|
|
26
|
+
if (key.startsWith('DBUS_'))
|
|
27
|
+
delete childEnv[key];
|
|
28
|
+
const result = await run(busctl, [`--address=${address}`, '--auto-start=no', '--timeout=0.5', 'call', 'org.freedesktop.Avahi', '/', 'org.freedesktop.Avahi.Server', 'GetState'], 600, 4096, { env: childEnv });
|
|
29
|
+
discoveryService = result.code === 0 && /^i\s+2\s*$/.test(result.output.trim()) && !result.truncated && !result.timedOut ? 'ready' : 'unavailable';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const senderLoaded = (await senderResult).loaded;
|
|
33
|
+
const ready = eligible && discoveryExecutable && ffmpeg && senderLoaded && discoveryService !== 'unavailable';
|
|
34
|
+
const message = !eligible ? 'AirPlay is experimental on macOS and native Linux; this environment is not supported.'
|
|
35
|
+
: !discoveryExecutable ? `AirPlay discovery requires ${platform === 'linux' ? 'avahi-browse' : 'dns-sd'}.`
|
|
36
|
+
: discoveryService === 'unavailable' ? 'The Avahi discovery service is not ready. Local playback remains available.'
|
|
37
|
+
: !ffmpeg ? 'AirPlay playback requires FFmpeg.'
|
|
38
|
+
: !senderLoaded ? 'The optional AirPlay sender could not load. Local playback remains available.'
|
|
39
|
+
: discoveryService === 'unverified' ? 'AirPlay prerequisites are present; the discovery service and receiver connection must still be verified.'
|
|
40
|
+
: 'Experimental AirPlay prerequisites are ready; select a receiver to verify playback.';
|
|
41
|
+
return { eligible, discoveryExecutable, discoveryService, senderLoaded, ffmpeg, ready, message };
|
|
42
|
+
}
|
|
@@ -1,8 +1,38 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
|
+
import { runAirPlayCommand } from './airplay-process.js';
|
|
5
|
+
import { airPlaySenderPackageJson } from './airplay-sender-package.js';
|
|
4
6
|
const require = createRequire(import.meta.url);
|
|
5
7
|
const airPlaySenderPackageName = 'node-airtunes2';
|
|
8
|
+
let senderProbe;
|
|
9
|
+
/** Explicit diagnostics/discovery only: loading native code stays outside the TUI. */
|
|
10
|
+
export async function probeAirPlaySender(options = {}) {
|
|
11
|
+
const health = options.health ?? airPlaySenderHealth();
|
|
12
|
+
if (!health.safe)
|
|
13
|
+
return { loaded: false, message: health.message };
|
|
14
|
+
const key = `${process.execPath}:${health.packageName}:${health.version}`;
|
|
15
|
+
if (!options.run && senderProbe?.key === key && senderProbe.until > Date.now())
|
|
16
|
+
return senderProbe.result;
|
|
17
|
+
const result = (async () => {
|
|
18
|
+
const code = [
|
|
19
|
+
"const r = require('node:module').createRequire(process.argv[1]);",
|
|
20
|
+
"try { const s = r('./');",
|
|
21
|
+
"if (typeof s !== 'function' || typeof s.prototype.add !== 'function' || typeof s.prototype.stopAll !== 'function') process.exit(1);",
|
|
22
|
+
// The Linux binding is otherwise loaded only when constructing a receiver.
|
|
23
|
+
// Match the sender's JS fallback on a normal require failure, but contain
|
|
24
|
+
// native crashes here before advertising a loadable sender. No sockets.
|
|
25
|
+
"if (process.platform === 'linux') { try { r('./build/Release/airtunes'); } catch {} }",
|
|
26
|
+
'process.exit(0); } catch { process.exit(1); }'
|
|
27
|
+
].join('\n');
|
|
28
|
+
const probe = await (options.run ?? runAirPlayCommand)(process.execPath, ['-e', code, airPlaySenderPackageJson], Math.min(1500, Math.max(1, options.timeoutMs ?? 1500)), 4096);
|
|
29
|
+
const loaded = probe.code === 0 && !probe.timedOut && !probe.truncated;
|
|
30
|
+
return { loaded, message: loaded ? 'The optional AirPlay sender loaded in an isolated process.' : 'The optional AirPlay sender could not load. Local playback remains available; run radiocli doctor.' };
|
|
31
|
+
})();
|
|
32
|
+
if (!options.run)
|
|
33
|
+
senderProbe = { key, until: Date.now() + 30_000, result };
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
6
36
|
export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
|
|
7
37
|
const sender = lookupPackage(airPlaySenderPackageName);
|
|
8
38
|
if (!sender) {
|
|
@@ -15,17 +45,21 @@ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
|
|
|
15
45
|
warningPackages: []
|
|
16
46
|
};
|
|
17
47
|
}
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
48
|
+
const missingPackages = (sender.dependencies ?? []).filter(name => !lookupPackage(name, [sender.root]));
|
|
49
|
+
if (missingPackages.length > 0) {
|
|
50
|
+
return { available: false, safe: false, packageName: sender.name, version: sender.version,
|
|
51
|
+
message: `Optional AirPlay sender dependencies are not installed: ${missingPackages.join(', ')}. Local playback remains available.`,
|
|
52
|
+
vulnerablePackages: [], warningPackages: [] };
|
|
53
|
+
}
|
|
54
|
+
if (!sender.nativeEd25519) {
|
|
21
55
|
return {
|
|
22
56
|
available: true,
|
|
23
57
|
safe: false,
|
|
24
58
|
packageName: sender.name,
|
|
25
59
|
version: sender.version,
|
|
26
|
-
message: `
|
|
27
|
-
vulnerablePackages,
|
|
28
|
-
warningPackages
|
|
60
|
+
message: `AirPlay sender ${sender.name}@${sender.version} is missing the native Ed25519 security fix.`,
|
|
61
|
+
vulnerablePackages: [`${sender.name}@${sender.version}`],
|
|
62
|
+
warningPackages: []
|
|
29
63
|
};
|
|
30
64
|
}
|
|
31
65
|
return {
|
|
@@ -33,34 +67,18 @@ export function airPlaySenderHealth(lookupPackage = lookupInstalledPackage) {
|
|
|
33
67
|
safe: true,
|
|
34
68
|
packageName: sender.name,
|
|
35
69
|
version: sender.version,
|
|
36
|
-
message:
|
|
37
|
-
? `Bundled AirPlay sender ${sender.name}@${sender.version} is available with warnings: ${warningPackages.join(', ')}.`
|
|
38
|
-
: `AirPlay sender ${sender.name}@${sender.version} passed the dependency safety gate.`,
|
|
70
|
+
message: `AirPlay sender ${sender.name}@${sender.version} uses native Ed25519 with its optional runtime dependencies installed.`,
|
|
39
71
|
vulnerablePackages: [],
|
|
40
|
-
warningPackages
|
|
72
|
+
warningPackages: []
|
|
41
73
|
};
|
|
42
74
|
}
|
|
43
|
-
function blockingAirPlaySenderPackages(sender, lookupPackage) {
|
|
44
|
-
const packagePaths = [sender.root];
|
|
45
|
-
const vulnerable = [];
|
|
46
|
-
const protobuf = lookupPackage('protobufjs', packagePaths);
|
|
47
|
-
if (protobuf && compareSemver(protobuf.version, '7.5.8') < 0) {
|
|
48
|
-
vulnerable.push(`protobufjs@${protobuf.version}`);
|
|
49
|
-
}
|
|
50
|
-
return vulnerable;
|
|
51
|
-
}
|
|
52
|
-
function warningAirPlaySenderPackages(sender, lookupPackage) {
|
|
53
|
-
const packagePaths = [sender.root];
|
|
54
|
-
const vulnerable = [];
|
|
55
|
-
const elliptic = lookupPackage('elliptic', packagePaths);
|
|
56
|
-
if (elliptic && compareSemver(elliptic.version, '6.6.2') < 0) {
|
|
57
|
-
vulnerable.push(`elliptic@${elliptic.version}`);
|
|
58
|
-
}
|
|
59
|
-
return vulnerable;
|
|
60
|
-
}
|
|
61
75
|
function lookupInstalledPackage(packageName, paths) {
|
|
62
76
|
try {
|
|
63
|
-
|
|
77
|
+
// Package exports can hide package.json while exposing the runtime entry.
|
|
78
|
+
if (paths && packageName !== airPlaySenderPackageName) {
|
|
79
|
+
return { name: packageName, version: 'installed', root: dirname(require.resolve(packageName, { paths })) };
|
|
80
|
+
}
|
|
81
|
+
const packageJsonPath = packageName === airPlaySenderPackageName ? airPlaySenderPackageJson : paths
|
|
64
82
|
? require.resolve(`${packageName}/package.json`, { paths })
|
|
65
83
|
: require.resolve(`${packageName}/package.json`);
|
|
66
84
|
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
@@ -70,29 +88,11 @@ function lookupInstalledPackage(packageName, paths) {
|
|
|
70
88
|
return {
|
|
71
89
|
name: parsed.name ?? packageName,
|
|
72
90
|
version: parsed.version,
|
|
73
|
-
root: dirname(packageJsonPath)
|
|
91
|
+
root: dirname(packageJsonPath),
|
|
92
|
+
...(packageName === airPlaySenderPackageName ? { nativeEd25519: parsed.radiocliLegacyPairing === 'node:crypto', dependencies: Object.keys(parsed.dependencies ?? {}) } : {})
|
|
74
93
|
};
|
|
75
94
|
}
|
|
76
95
|
catch {
|
|
77
96
|
return null;
|
|
78
97
|
}
|
|
79
98
|
}
|
|
80
|
-
function compareSemver(left, right) {
|
|
81
|
-
const leftParts = semverParts(left);
|
|
82
|
-
const rightParts = semverParts(right);
|
|
83
|
-
for (let index = 0; index < 3; index += 1) {
|
|
84
|
-
const delta = leftParts[index] - rightParts[index];
|
|
85
|
-
if (delta !== 0) {
|
|
86
|
-
return delta;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return 0;
|
|
90
|
-
}
|
|
91
|
-
function semverParts(version) {
|
|
92
|
-
const [major = '0', minor = '0', patch = '0'] = version.split(/[.-]/);
|
|
93
|
-
return [
|
|
94
|
-
Number.parseInt(major, 10) || 0,
|
|
95
|
-
Number.parseInt(minor, 10) || 0,
|
|
96
|
-
Number.parseInt(patch, 10) || 0
|
|
97
|
-
];
|
|
98
|
-
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { dirname } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
// The same relative location works from src/player and the packaged dist/player.
|
|
4
|
+
export const airPlaySenderPackageJson = fileURLToPath(new URL('../../vendor/node-airtunes2/package.json', import.meta.url));
|
|
5
|
+
export const airPlaySenderRoot = dirname(airPlaySenderPackageJson);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
+
import { airPlaySenderRoot } from './airplay-sender-package.js';
|
|
3
4
|
const require = createRequire(import.meta.url);
|
|
4
5
|
const setupAp2DigestGuard = 'this.status === SETUP_AP2_1 && response.code === 401';
|
|
5
6
|
const responseHeadersMarker = ' headers = response.headers';
|
|
@@ -58,6 +59,10 @@ const bufferedSocketDataHandler = ` var blob = Buffer.alloc(0)
|
|
|
58
59
|
self.clearTimeout()
|
|
59
60
|
|
|
60
61
|
blob = Buffer.concat([blob, Buffer.from(data)])
|
|
62
|
+
if (blob.length > 1024 * 1024) {
|
|
63
|
+
self.cleanup('invalid_response')
|
|
64
|
+
return
|
|
65
|
+
}
|
|
61
66
|
while (blob.length > 0) {
|
|
62
67
|
var endIndex = blob.indexOf('\\r\\n\\r\\n')
|
|
63
68
|
|
|
@@ -69,6 +74,10 @@ const bufferedSocketDataHandler = ` var blob = Buffer.alloc(0)
|
|
|
69
74
|
var headers = blob.slice(0, endIndex).toString()
|
|
70
75
|
var lengthMatch = /(?:^|\\r\\n)Content-Length:\\s*(\\d+)/i.exec(headers)
|
|
71
76
|
var contentLength = lengthMatch ? parseInt(lengthMatch[1], 10) : 0
|
|
77
|
+
if (!Number.isSafeInteger(contentLength) || contentLength > 1024 * 1024) {
|
|
78
|
+
self.cleanup('invalid_response')
|
|
79
|
+
return
|
|
80
|
+
}
|
|
72
81
|
var messageLength = endIndex + contentLength
|
|
73
82
|
|
|
74
83
|
if (blob.length < messageLength) {
|
|
@@ -82,13 +91,15 @@ const bufferedSocketDataHandler = ` var blob = Buffer.alloc(0)
|
|
|
82
91
|
}
|
|
83
92
|
})`;
|
|
84
93
|
let installed = false;
|
|
85
|
-
export function installAirPlaySenderPatch(packageName =
|
|
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)));
|