@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,44 @@
|
|
|
1
|
+
export function isLoopbackHost(value) {
|
|
2
|
+
return value === '127.0.0.1' || value === '::1';
|
|
3
|
+
}
|
|
4
|
+
/** Bind only literal loopback addresses, including hosts without an IPv4 stack. */
|
|
5
|
+
export async function listenLoopback(server) {
|
|
6
|
+
try {
|
|
7
|
+
return await listen(server, '127.0.0.1');
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
const code = error.code;
|
|
11
|
+
if (code !== 'EAFNOSUPPORT' && code !== 'EPROTONOSUPPORT' && code !== 'EADDRNOTAVAIL')
|
|
12
|
+
throw error;
|
|
13
|
+
return listen(server, '::1');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function listen(server, host) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const cleanup = () => {
|
|
19
|
+
server.removeListener('error', failed);
|
|
20
|
+
server.removeListener('listening', ready);
|
|
21
|
+
};
|
|
22
|
+
const failed = (error) => {
|
|
23
|
+
cleanup();
|
|
24
|
+
reject(error);
|
|
25
|
+
};
|
|
26
|
+
const ready = () => {
|
|
27
|
+
cleanup();
|
|
28
|
+
const address = server.address();
|
|
29
|
+
if (!address || typeof address === 'string') {
|
|
30
|
+
reject(new Error('Unable to create a local control endpoint.'));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolve({ host, port: address.port });
|
|
34
|
+
};
|
|
35
|
+
server.once('error', failed);
|
|
36
|
+
server.once('listening', ready);
|
|
37
|
+
try {
|
|
38
|
+
server.listen(0, host);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
failed(error);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createConnection as connectSocket } from 'node:net';
|
|
3
|
+
const objectPath = '/org/mpris/MediaPlayer2';
|
|
4
|
+
const rootInterface = 'org.mpris.MediaPlayer2';
|
|
5
|
+
const playerInterface = `${rootInterface}.Player`;
|
|
6
|
+
const propertiesInterface = 'org.freedesktop.DBus.Properties';
|
|
7
|
+
/** Only local Unix transports are accepted. Never hand a unixexec or TCP
|
|
8
|
+
* address to the dependency's address parser. Node handles abstract sockets. */
|
|
9
|
+
export function sessionBusSocket(address) {
|
|
10
|
+
if (address.length > 4096 || /[\0\r\n]/.test(address))
|
|
11
|
+
return null;
|
|
12
|
+
for (const entry of address.split(';')) {
|
|
13
|
+
if (!entry.startsWith('unix:'))
|
|
14
|
+
continue;
|
|
15
|
+
const values = new Map();
|
|
16
|
+
try {
|
|
17
|
+
for (const part of entry.slice(5).split(',')) {
|
|
18
|
+
const at = part.indexOf('=');
|
|
19
|
+
if (at <= 0)
|
|
20
|
+
throw new Error('Invalid bus address');
|
|
21
|
+
const key = part.slice(0, at);
|
|
22
|
+
if (values.has(key))
|
|
23
|
+
throw new Error('Duplicate bus address key');
|
|
24
|
+
values.set(key, decodeURIComponent(part.slice(at + 1)));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const path = values.get('path');
|
|
31
|
+
const abstract = values.get('abstract');
|
|
32
|
+
if (path && !abstract && path.startsWith('/') && !/[\0\r\n]/.test(path) && Buffer.byteLength(path) < 108)
|
|
33
|
+
return path;
|
|
34
|
+
if (abstract && !path && !/[\0\r\n]/.test(abstract) && Buffer.byteLength(abstract) < 107)
|
|
35
|
+
return `\0${abstract}`;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
export function mprisServiceName(identity) {
|
|
40
|
+
return `${rootInterface}.radiocli.h${createHash('sha256').update(identity).digest('hex').slice(0, 24)}`;
|
|
41
|
+
}
|
|
42
|
+
export function mprisProperties(snapshot) {
|
|
43
|
+
const { playback, station, queue } = snapshot;
|
|
44
|
+
const active = snapshot.available && playback.ready && ['playing', 'paused'].includes(playback.state);
|
|
45
|
+
const selected = snapshot.available && Boolean(station) && (active || ['idle', 'stopped'].includes(playback.state));
|
|
46
|
+
const pause = selected && playback.backend === 'mpv';
|
|
47
|
+
const metadata = [];
|
|
48
|
+
if (selected && station) {
|
|
49
|
+
metadata.push(['mpris:trackid', ['o', `/org/radiocli/track/t${createHash('sha256').update(`${station.provider}\0${station.id}`).digest('hex').slice(0, 24)}`]]);
|
|
50
|
+
metadata.push(['xesam:title', ['s', publicText(snapshot.title) || publicText(station.name) || 'RadioCLI']]);
|
|
51
|
+
const stationName = publicText(station.name);
|
|
52
|
+
if (stationName)
|
|
53
|
+
metadata.push(['xesam:album', ['s', stationName]]);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
root: {
|
|
57
|
+
CanQuit: ['b', false], CanRaise: ['b', false], HasTrackList: ['b', false],
|
|
58
|
+
Identity: ['s', 'RadioCLI'], SupportedUriSchemes: ['as', []], SupportedMimeTypes: ['as', []]
|
|
59
|
+
},
|
|
60
|
+
player: {
|
|
61
|
+
PlaybackStatus: ['s', active ? playback.state === 'paused' ? 'Paused' : 'Playing' : 'Stopped'],
|
|
62
|
+
Rate: ['d', 1], Metadata: ['a{sv}', metadata], Volume: ['d', playback.muted ? 0 : Math.max(0, Math.min(1, playback.volume / 100))],
|
|
63
|
+
Position: ['x', 0], MinimumRate: ['d', 1], MaximumRate: ['d', 1],
|
|
64
|
+
CanGoNext: ['b', selected && queue.length > 1], CanGoPrevious: ['b', selected && queue.length > 1],
|
|
65
|
+
CanPlay: ['b', selected], CanPause: ['b', pause], CanSeek: ['b', false], CanControl: ['b', snapshot.available]
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function publicText(value) {
|
|
70
|
+
// Broadcast text is untrusted; URI/path-shaped metadata can contain tokens.
|
|
71
|
+
return (value ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '').replace(/(?:[a-z][a-z0-9+.-]*:\/\/|\bfile:|\b[a-z]:[\\/]|(?:^|\s)[/~\\])\S*/gi, '').trim().slice(0, 512);
|
|
72
|
+
}
|
|
73
|
+
/** A small, typed MPRIS endpoint. Closing the private connection releases its
|
|
74
|
+
* name, so no ReleaseName race can release a replacement owner's service. */
|
|
75
|
+
export function startMpris(options) {
|
|
76
|
+
const path = sessionBusSocket(options.address);
|
|
77
|
+
let disposed = false;
|
|
78
|
+
let generation = 0;
|
|
79
|
+
let connection;
|
|
80
|
+
let connected = false;
|
|
81
|
+
let registered = false;
|
|
82
|
+
let starting = false;
|
|
83
|
+
let serial = 1;
|
|
84
|
+
let retries = 0;
|
|
85
|
+
let timer;
|
|
86
|
+
let lastProperties = '';
|
|
87
|
+
const pending = new Map();
|
|
88
|
+
const available = () => !disposed && options.snapshot().available;
|
|
89
|
+
const send = (message) => {
|
|
90
|
+
if (connected && connection && !connection.stream.destroyed)
|
|
91
|
+
connection.message({ ...message, serial: serial++ });
|
|
92
|
+
};
|
|
93
|
+
const disconnect = () => {
|
|
94
|
+
generation += 1;
|
|
95
|
+
registered = false;
|
|
96
|
+
starting = false;
|
|
97
|
+
connected = false;
|
|
98
|
+
if (timer)
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
timer = undefined;
|
|
101
|
+
for (const request of pending.values()) {
|
|
102
|
+
clearTimeout(request.timer);
|
|
103
|
+
request.reject();
|
|
104
|
+
}
|
|
105
|
+
pending.clear();
|
|
106
|
+
connection?.stream.destroy();
|
|
107
|
+
connection = undefined;
|
|
108
|
+
};
|
|
109
|
+
const retry = () => {
|
|
110
|
+
disconnect();
|
|
111
|
+
if (!available())
|
|
112
|
+
return;
|
|
113
|
+
timer = setTimeout(() => { timer = undefined; void start(); }, Math.min(30_000, 500 * 2 ** Math.min(6, retries++)));
|
|
114
|
+
timer.unref();
|
|
115
|
+
};
|
|
116
|
+
const call = (member, signature = '', body = []) => new Promise((resolve, reject) => {
|
|
117
|
+
if (!connected || !connection || connection.stream.destroyed) {
|
|
118
|
+
reject(new Error('Session bus disconnected.'));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const id = serial++;
|
|
122
|
+
const timeout = setTimeout(() => { pending.delete(id); reject(new Error('Session bus did not respond.')); }, 2000);
|
|
123
|
+
timeout.unref();
|
|
124
|
+
pending.set(id, { resolve, reject: () => reject(new Error('Session bus disconnected.')), timer: timeout });
|
|
125
|
+
connection.message({ type: 1, serial: id, path: '/org/freedesktop/DBus', destination: 'org.freedesktop.DBus', interface: 'org.freedesktop.DBus', member, signature, body });
|
|
126
|
+
});
|
|
127
|
+
const error = (request, name, detail) => send({ type: 3, destination: request.sender, replySerial: request.serial, errorName: `org.freedesktop.DBus.Error.${name}`, signature: 's', body: [detail] });
|
|
128
|
+
const reply = (request, signature = '', body = []) => send({ type: 2, destination: request.sender, replySerial: request.serial, signature, body });
|
|
129
|
+
const receive = async (request, current) => {
|
|
130
|
+
if (current !== generation || disposed)
|
|
131
|
+
return;
|
|
132
|
+
if (request.type === 2 || request.type === 3) {
|
|
133
|
+
const handler = request.replySerial === undefined ? undefined : pending.get(request.replySerial);
|
|
134
|
+
if (handler) {
|
|
135
|
+
pending.delete(request.replySerial);
|
|
136
|
+
clearTimeout(handler.timer);
|
|
137
|
+
if (request.type === 2)
|
|
138
|
+
handler.resolve(request.body ?? []);
|
|
139
|
+
else
|
|
140
|
+
handler.reject();
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (request.type !== 1 || !registered)
|
|
145
|
+
return;
|
|
146
|
+
if (request.path !== objectPath) {
|
|
147
|
+
error(request, 'UnknownObject', 'Unknown object.');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
if (request.interface === 'org.freedesktop.DBus.Introspectable' && request.member === 'Introspect' && !request.signature) {
|
|
152
|
+
reply(request, 's', [introspection]);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (request.interface === 'org.freedesktop.DBus.Peer' && request.member === 'Ping' && !request.signature) {
|
|
156
|
+
reply(request);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const properties = mprisProperties(options.snapshot());
|
|
160
|
+
if (request.interface === propertiesInterface) {
|
|
161
|
+
const [iface, property, value] = request.body ?? [];
|
|
162
|
+
const values = iface === rootInterface ? properties.root : iface === playerInterface ? properties.player : null;
|
|
163
|
+
if (!values) {
|
|
164
|
+
error(request, 'UnknownInterface', 'Unknown interface.');
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (request.member === 'GetAll' && request.signature === 's') {
|
|
168
|
+
reply(request, 'a{sv}', [Object.entries(values)]);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (typeof property !== 'string' || !Object.hasOwn(values, property)) {
|
|
172
|
+
error(request, 'UnknownProperty', 'Unknown property.');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (request.member === 'Get' && request.signature === 'ss') {
|
|
176
|
+
reply(request, 'v', [values[property]]);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (request.member === 'Set' && request.signature === 'ssv') {
|
|
180
|
+
if (iface === playerInterface && property === 'Rate') {
|
|
181
|
+
const rate = doubleVariant(value);
|
|
182
|
+
if (rate === 1)
|
|
183
|
+
reply(request);
|
|
184
|
+
else if (rate === 0) {
|
|
185
|
+
const result = await options.command({ type: 'desktop-transport', action: 'pause' });
|
|
186
|
+
if (!result.ok)
|
|
187
|
+
throw new Error('Control unavailable.');
|
|
188
|
+
if (current === generation) {
|
|
189
|
+
reply(request);
|
|
190
|
+
refresh();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else
|
|
194
|
+
error(request, 'NotSupported', 'Live radio plays at its broadcast rate.');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (iface !== playerInterface || property !== 'Volume') {
|
|
198
|
+
error(request, 'PropertyReadOnly', 'Property cannot be changed.');
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const volume = doubleVariant(value);
|
|
202
|
+
if (volume === null) {
|
|
203
|
+
error(request, 'InvalidArgs', 'Volume must be a finite double.');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const result = await options.command({ type: 'set-volume', volume: Math.min(1, Math.max(0, volume)) * 100, unmute: true });
|
|
207
|
+
if (!result.ok)
|
|
208
|
+
throw new Error('Control unavailable.');
|
|
209
|
+
if (current === generation) {
|
|
210
|
+
reply(request);
|
|
211
|
+
refresh();
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
error(request, 'InvalidArgs', 'Invalid properties request.');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (request.interface === playerInterface && (request.member === 'Seek' && request.signature === 'x' || request.member === 'SetPosition' && request.signature === 'ox')) {
|
|
219
|
+
reply(request);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (request.interface !== playerInterface || request.signature) {
|
|
223
|
+
error(request, 'NotSupported', 'This action is not supported for live radio.');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
let action;
|
|
227
|
+
switch (request.member) {
|
|
228
|
+
case 'Next':
|
|
229
|
+
action = 'next';
|
|
230
|
+
break;
|
|
231
|
+
case 'Previous':
|
|
232
|
+
action = 'previous';
|
|
233
|
+
break;
|
|
234
|
+
case 'Pause':
|
|
235
|
+
action = 'pause';
|
|
236
|
+
break;
|
|
237
|
+
case 'Play':
|
|
238
|
+
action = 'play';
|
|
239
|
+
break;
|
|
240
|
+
case 'PlayPause':
|
|
241
|
+
action = 'toggle';
|
|
242
|
+
break;
|
|
243
|
+
case 'Stop':
|
|
244
|
+
action = 'stop';
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
if (!action) {
|
|
248
|
+
error(request, 'NotSupported', 'This action is currently unavailable.');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const result = await options.command({ type: 'desktop-transport', action });
|
|
252
|
+
if (!result.ok)
|
|
253
|
+
throw new Error('Control unavailable.');
|
|
254
|
+
if (current === generation) {
|
|
255
|
+
reply(request);
|
|
256
|
+
refresh();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
if (current === generation)
|
|
261
|
+
error(request, 'Failed', 'RadioCLI could not complete the control request.');
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
const start = async () => {
|
|
265
|
+
if (!path || !available() || connection || timer || starting)
|
|
266
|
+
return;
|
|
267
|
+
starting = true;
|
|
268
|
+
const current = ++generation;
|
|
269
|
+
try {
|
|
270
|
+
const created = await (options.connect ?? nativeConnection)(path);
|
|
271
|
+
if (disposed || current !== generation || !available()) {
|
|
272
|
+
created.stream.destroy();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
connection = created;
|
|
276
|
+
created.on('error', () => { if (current === generation)
|
|
277
|
+
retry(); });
|
|
278
|
+
created.on('end', () => { if (current === generation)
|
|
279
|
+
retry(); });
|
|
280
|
+
created.stream.on('close', () => { if (current === generation)
|
|
281
|
+
retry(); });
|
|
282
|
+
created.on('message', message => { void receive(message, current); });
|
|
283
|
+
timer = setTimeout(() => { if (current === generation)
|
|
284
|
+
retry(); }, 2000);
|
|
285
|
+
timer.unref();
|
|
286
|
+
created.once('connect', () => {
|
|
287
|
+
if (current !== generation || connection !== created || !available()) {
|
|
288
|
+
created.stream.destroy();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
connected = true;
|
|
292
|
+
if (timer)
|
|
293
|
+
clearTimeout(timer);
|
|
294
|
+
timer = undefined;
|
|
295
|
+
void (async () => {
|
|
296
|
+
await call('Hello');
|
|
297
|
+
if (current !== generation)
|
|
298
|
+
return;
|
|
299
|
+
const [result] = await call('RequestName', 'su', [mprisServiceName(options.identity), 4]);
|
|
300
|
+
if (current !== generation)
|
|
301
|
+
return;
|
|
302
|
+
// DO_NOT_QUEUE: never replace or wait in the ownership queue.
|
|
303
|
+
if (result !== 1) {
|
|
304
|
+
retry();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
registered = true;
|
|
308
|
+
starting = false;
|
|
309
|
+
retries = 0;
|
|
310
|
+
lastProperties = '';
|
|
311
|
+
refresh();
|
|
312
|
+
})().catch(() => { if (current === generation)
|
|
313
|
+
retry(); });
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
if (current === generation)
|
|
318
|
+
retry();
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const refresh = () => {
|
|
322
|
+
if (!available()) {
|
|
323
|
+
disconnect();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!registered) {
|
|
327
|
+
void start();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const properties = mprisProperties(options.snapshot()).player;
|
|
331
|
+
// Position is not a clock for a live broadcast and does not emit changes.
|
|
332
|
+
const changed = Object.entries(properties).filter(([name]) => name !== 'Position' && name !== 'CanControl');
|
|
333
|
+
const encoded = JSON.stringify(changed);
|
|
334
|
+
if (encoded === lastProperties)
|
|
335
|
+
return;
|
|
336
|
+
lastProperties = encoded;
|
|
337
|
+
send({ type: 4, path: objectPath, interface: propertiesInterface, member: 'PropertiesChanged', signature: 'sa{sv}as', body: [playerInterface, changed, []] });
|
|
338
|
+
};
|
|
339
|
+
void start();
|
|
340
|
+
return { refresh, close() { disposed = true; disconnect(); } };
|
|
341
|
+
}
|
|
342
|
+
async function nativeConnection(path) {
|
|
343
|
+
const module = await import('@homebridge/dbus-native');
|
|
344
|
+
const factory = module.default;
|
|
345
|
+
const stream = connectSocket(path);
|
|
346
|
+
return factory.createConnection({ stream, authMethods: ['EXTERNAL'] });
|
|
347
|
+
}
|
|
348
|
+
function doubleVariant(value) {
|
|
349
|
+
if (!Array.isArray(value) || !Array.isArray(value[0]) || !Array.isArray(value[1]))
|
|
350
|
+
return null;
|
|
351
|
+
const [type] = value[0];
|
|
352
|
+
const [number] = value[1];
|
|
353
|
+
return value[0].length === 1 && type?.type === 'd' && typeof number === 'number' && Number.isFinite(number) ? number : null;
|
|
354
|
+
}
|
|
355
|
+
const propertyXml = (values, writable = []) => Object.entries(values).map(([name, type]) => `<property name="${name}" type="${type}" access="${writable.includes(name) ? 'readwrite' : 'read'}"/>`).join('');
|
|
356
|
+
const introspection = `<node><interface name="org.freedesktop.DBus.Introspectable"><method name="Introspect"><arg direction="out" type="s"/></method></interface><interface name="org.freedesktop.DBus.Peer"><method name="Ping"/></interface><interface name="${propertiesInterface}"><method name="Get"><arg direction="in" type="s"/><arg direction="in" type="s"/><arg direction="out" type="v"/></method><method name="GetAll"><arg direction="in" type="s"/><arg direction="out" type="a{sv}"/></method><method name="Set"><arg direction="in" type="s"/><arg direction="in" type="s"/><arg direction="in" type="v"/></method><signal name="PropertiesChanged"><arg type="s"/><arg type="a{sv}"/><arg type="as"/></signal></interface><interface name="${rootInterface}"><method name="Raise"/><method name="Quit"/>${propertyXml({ CanQuit: 'b', CanRaise: 'b', HasTrackList: 'b', Identity: 's', SupportedUriSchemes: 'as', SupportedMimeTypes: 'as' })}</interface><interface name="${playerInterface}">${['Next', 'Previous', 'Pause', 'PlayPause', 'Stop', 'Play'].map(name => `<method name="${name}"/>`).join('')}<method name="Seek"><arg direction="in" type="x"/></method><method name="SetPosition"><arg direction="in" type="o"/><arg direction="in" type="x"/></method><method name="OpenUri"><arg direction="in" type="s"/></method>${propertyXml({ PlaybackStatus: 's', Rate: 'd', Metadata: 'a{sv}', Volume: 'd', Position: 'x', MinimumRate: 'd', MaximumRate: 'd', CanGoNext: 'b', CanGoPrevious: 'b', CanPlay: 'b', CanPause: 'b', CanSeek: 'b', CanControl: 'b' }, ['Volume', 'Rate'])}</interface></node>`;
|