@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { withExternalResponse } from '../platform/network.js';
|
|
2
3
|
const searchSchema = z
|
|
3
4
|
.object({
|
|
4
5
|
hits: z
|
|
@@ -27,28 +28,31 @@ export class RadioGardenProvider {
|
|
|
27
28
|
}
|
|
28
29
|
async health() {
|
|
29
30
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
return await withExternalResponse(`${this.baseUrl}/geo`, {
|
|
32
|
+
timeoutMs: 5000,
|
|
33
|
+
init: { headers: { 'User-Agent': 'radiocli/0.1' } }
|
|
34
|
+
}, response => {
|
|
35
|
+
if (response.status === 403)
|
|
36
|
+
return 'blocked by edge protection';
|
|
37
|
+
return response.ok ? 'online' : `${response.status} ${response.statusText}`;
|
|
32
38
|
});
|
|
33
|
-
if (response.status === 403) {
|
|
34
|
-
return 'blocked by edge protection';
|
|
35
|
-
}
|
|
36
|
-
return response.ok ? 'online' : `${response.status} ${response.statusText}`;
|
|
37
39
|
}
|
|
38
40
|
catch (error) {
|
|
39
41
|
return error instanceof Error ? error.message : 'unavailable';
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
async search(query, options = {}) {
|
|
45
|
+
options.signal?.throwIfAborted();
|
|
43
46
|
const url = new URL(`${this.baseUrl}/search`);
|
|
44
47
|
url.searchParams.set('q', query);
|
|
45
|
-
const
|
|
46
|
-
|
|
48
|
+
const parsed = await withExternalResponse(url, {
|
|
49
|
+
timeoutMs: 9000,
|
|
50
|
+
init: { headers: { 'User-Agent': 'radiocli/0.1', Accept: 'application/json' }, signal: options.signal }
|
|
51
|
+
}, async (response) => {
|
|
52
|
+
if (!response.ok)
|
|
53
|
+
throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`);
|
|
54
|
+
return searchSchema.parse(await response.json());
|
|
47
55
|
});
|
|
48
|
-
if (!response.ok) {
|
|
49
|
-
throw new Error(`${this.label} search failed: ${response.status} ${response.statusText}`);
|
|
50
|
-
}
|
|
51
|
-
const parsed = searchSchema.parse(await response.json());
|
|
52
56
|
return (parsed.hits ?? [])
|
|
53
57
|
.map(hit => hit._source)
|
|
54
58
|
.filter((source) => source?.type === 'channel' && Boolean(source.url))
|
|
@@ -65,7 +69,8 @@ export class RadioGardenProvider {
|
|
|
65
69
|
})
|
|
66
70
|
.slice(0, options.limit ?? 30);
|
|
67
71
|
}
|
|
68
|
-
async resolve(station) {
|
|
72
|
+
async resolve(station, options = {}) {
|
|
73
|
+
options.signal?.throwIfAborted();
|
|
69
74
|
if (station.streamUrl) {
|
|
70
75
|
return { url: station.streamUrl, name: station.name };
|
|
71
76
|
}
|
|
@@ -75,13 +80,3 @@ export class RadioGardenProvider {
|
|
|
75
80
|
};
|
|
76
81
|
}
|
|
77
82
|
}
|
|
78
|
-
async function fetchWithTimeout(url, timeoutMs, init = {}) {
|
|
79
|
-
const controller = new AbortController();
|
|
80
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
81
|
-
try {
|
|
82
|
-
return await fetch(url, { ...init, signal: controller.signal });
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
clearTimeout(timeout);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { resolveCommand } from './platform/executables.js';
|
|
6
|
+
/** Exercise local decoders without sending audio to an output device or receiver. */
|
|
7
|
+
export async function verifyLocalPlayback(backends, options = {}) {
|
|
8
|
+
options.signal?.throwIfAborted();
|
|
9
|
+
const local = [...new Set(backends)].filter((value) => ['mpv', 'ffplay', 'vlc'].includes(value));
|
|
10
|
+
if (!local.length)
|
|
11
|
+
return [];
|
|
12
|
+
const result = (backend, status, message) => ({
|
|
13
|
+
backend, status, source: 'local-wav', output: backend === 'mpv' ? 'null' : 'dummy', physicalOutput: 'unverified', message
|
|
14
|
+
});
|
|
15
|
+
if (options.platform && options.platform !== process.platform && !options.execute) {
|
|
16
|
+
return local.map(backend => result(backend, 'unverified', 'The requested platform differs from the current runtime. Run the rehearsal on that device.'));
|
|
17
|
+
}
|
|
18
|
+
const directory = await mkdtemp(join(tmpdir(), 'radiocli-setup-'));
|
|
19
|
+
try {
|
|
20
|
+
const fixture = join(directory, 'local silence.wav');
|
|
21
|
+
await writeFile(fixture, silentWav(), { mode: 0o600 });
|
|
22
|
+
const resolve = options.resolve ?? resolveCommand;
|
|
23
|
+
const execute = options.execute ?? runDecoderProcess;
|
|
24
|
+
const results = [];
|
|
25
|
+
for (const backend of local) {
|
|
26
|
+
options.signal?.throwIfAborted();
|
|
27
|
+
const command = backend === 'vlc' ? resolve('cvlc') ?? resolve('vlc') : resolve(backend);
|
|
28
|
+
if (!command) {
|
|
29
|
+
results.push(result(backend, 'failed', 'The executable was detected earlier but could not be resolved for the rehearsal.'));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const args = backend === 'mpv'
|
|
33
|
+
? ['--no-config', '--no-video', '--ao=null', '--ao-null-untimed', '--idle=no', '--term-status-msg=', '--term-playing-msg=RADIOCLI_DECODER_READY', fixture]
|
|
34
|
+
: backend === 'ffplay'
|
|
35
|
+
? ['-nodisp', '-autoexit', '-hide_banner', '-loglevel', 'info', fixture]
|
|
36
|
+
: ['--ignore-config', '--intf', 'dummy', '--no-video', '--aout=dummy', '--no-one-instance', '--no-media-library', '--no-repeat', '--no-loop', '--play-and-exit', '--verbose=2', fixture];
|
|
37
|
+
let completed;
|
|
38
|
+
try {
|
|
39
|
+
completed = await execute(command, args, {
|
|
40
|
+
env: { ...options.env ?? process.env, LC_ALL: 'C', ...(backend === 'ffplay' ? { SDL_AUDIODRIVER: 'dummy', SDL_AUDIO_DRIVER: 'dummy', SDL_VIDEODRIVER: 'dummy', SDL_VIDEO_DRIVER: 'dummy' } : {}) },
|
|
41
|
+
timeoutMs: Math.max(250, Math.min(options.timeoutMs ?? 8000, 30_000)), signal: options.signal
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
options.signal?.throwIfAborted();
|
|
46
|
+
completed = { code: null, output: '', failure: 'The playback process could not be started.' };
|
|
47
|
+
}
|
|
48
|
+
options.signal?.throwIfAborted();
|
|
49
|
+
const fatal = hasPlaybackFailure(backend, completed.output);
|
|
50
|
+
const observed = backend === 'mpv' ? completed.output.includes('RADIOCLI_DECODER_READY')
|
|
51
|
+
: backend === 'ffplay' ? /Audio:\s*pcm_s16le/i.test(completed.output)
|
|
52
|
+
: /using audio output module "(?:adummy|dummy)"/i.test(completed.output) && /(?:end of file|EOF)/i.test(completed.output);
|
|
53
|
+
results.push(completed.failure || completed.code !== 0 || fatal
|
|
54
|
+
? result(backend, 'failed', completed.failure ?? `Local WAV playback failed${completed.code !== null ? ` (exit ${completed.code})` : ''}. Run radiocli doctor and check this backend's native dependencies.`)
|
|
55
|
+
: observed ? result(backend, 'passed', 'Local WAV playback completed with a null or dummy audio output.')
|
|
56
|
+
: result(backend, 'unverified', 'The process exited successfully, but it did not expose the expected playback evidence.'));
|
|
57
|
+
}
|
|
58
|
+
return results;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await rm(directory, { force: true, recursive: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function hasPlaybackFailure(backend, output) {
|
|
65
|
+
return output.split(/\r?\n/).some(line => {
|
|
66
|
+
if (!/\b(?:error|failed|could not|cannot|invalid data)\b/i.test(line))
|
|
67
|
+
return false;
|
|
68
|
+
// The null-output rehearsal does not render OSD text. Minimal Linux installs
|
|
69
|
+
// can lack fonts while the audio decoder and output complete successfully.
|
|
70
|
+
if (backend === 'mpv' && /^\[osd(?:\/[^\]]+)?\]/i.test(line))
|
|
71
|
+
return false;
|
|
72
|
+
if (backend === 'vlc') {
|
|
73
|
+
// VLC logs rejected optional modules and build flags at debug level, and
|
|
74
|
+
// starts desktop interfaces even with a dummy playback interface. Neither
|
|
75
|
+
// proves failure of the isolated audio pipeline; readiness is checked below.
|
|
76
|
+
if (/\bdebug:/i.test(line))
|
|
77
|
+
return false;
|
|
78
|
+
if (/\b(?:dbus|interface)\b/i.test(line) && !/\b(?:audio output|decoder|demux|input|access|stream)\b/i.test(line))
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function runDecoderProcess(command, args, options) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
options.signal?.throwIfAborted();
|
|
87
|
+
const child = spawn(command, args, { env: options.env, stdio: ['ignore', 'pipe', 'pipe'], shell: false, windowsHide: true });
|
|
88
|
+
let output = '';
|
|
89
|
+
let failure;
|
|
90
|
+
let force;
|
|
91
|
+
let deadline;
|
|
92
|
+
let settled = false;
|
|
93
|
+
const finish = (code) => {
|
|
94
|
+
if (settled)
|
|
95
|
+
return;
|
|
96
|
+
settled = true;
|
|
97
|
+
clearTimeout(timeout);
|
|
98
|
+
clearTimeout(force);
|
|
99
|
+
clearTimeout(deadline);
|
|
100
|
+
options.signal?.removeEventListener('abort', abort);
|
|
101
|
+
options.signal?.aborted ? reject(options.signal.reason) : resolve({ code, output, failure });
|
|
102
|
+
};
|
|
103
|
+
const stop = (message) => {
|
|
104
|
+
if (settled || failure)
|
|
105
|
+
return;
|
|
106
|
+
failure = message;
|
|
107
|
+
try {
|
|
108
|
+
child.kill();
|
|
109
|
+
}
|
|
110
|
+
catch { /* The force deadline also covers failed termination. */ }
|
|
111
|
+
force = setTimeout(() => { try {
|
|
112
|
+
child.kill('SIGKILL');
|
|
113
|
+
}
|
|
114
|
+
catch { /* Report through the cleanup deadline. */ } }, 250);
|
|
115
|
+
deadline = setTimeout(() => {
|
|
116
|
+
// A failed native termination must not hang setup or retain stdio handles.
|
|
117
|
+
child.stdout?.destroy();
|
|
118
|
+
child.stderr?.destroy();
|
|
119
|
+
child.unref();
|
|
120
|
+
failure = 'The playback process did not acknowledge termination. Check for a remaining native player process.';
|
|
121
|
+
finish(null);
|
|
122
|
+
}, 1500);
|
|
123
|
+
};
|
|
124
|
+
const abort = () => stop('The rehearsal was cancelled.');
|
|
125
|
+
const timeout = setTimeout(() => stop('Local WAV playback exceeded its deadline.'), options.timeoutMs);
|
|
126
|
+
const collect = (chunk) => { output = `${output}${chunk.toString()}`.slice(-64 * 1024); };
|
|
127
|
+
child.stdout?.on('data', collect);
|
|
128
|
+
child.stderr?.on('data', collect);
|
|
129
|
+
child.once('error', () => { failure = 'The playback process could not be started.'; finish(null); });
|
|
130
|
+
child.once('close', code => finish(code));
|
|
131
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
132
|
+
if (options.signal?.aborted)
|
|
133
|
+
abort();
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
function silentWav() {
|
|
137
|
+
const dataBytes = 44_100; // 250 ms, stereo, signed 16-bit PCM.
|
|
138
|
+
const buffer = Buffer.alloc(44 + dataBytes);
|
|
139
|
+
buffer.write('RIFF');
|
|
140
|
+
buffer.writeUInt32LE(36 + dataBytes, 4);
|
|
141
|
+
buffer.write('WAVEfmt ', 8);
|
|
142
|
+
buffer.writeUInt32LE(16, 16);
|
|
143
|
+
buffer.writeUInt16LE(1, 20);
|
|
144
|
+
buffer.writeUInt16LE(2, 22);
|
|
145
|
+
buffer.writeUInt32LE(44_100, 24);
|
|
146
|
+
buffer.writeUInt32LE(44_100 * 4, 28);
|
|
147
|
+
buffer.writeUInt16LE(4, 32);
|
|
148
|
+
buffer.writeUInt16LE(16, 34);
|
|
149
|
+
buffer.write('data', 36);
|
|
150
|
+
buffer.writeUInt32LE(dataBytes, 40);
|
|
151
|
+
return buffer;
|
|
152
|
+
}
|