@ciphore/radiocli 0.2.4 → 0.2.6
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 +103 -2
- package/CONTRIBUTING.md +28 -0
- package/README.md +108 -253
- 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 +226 -81
- package/dist/agent/launcher.js +2 -1
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +75 -40
- package/dist/agent/session.js +214 -24
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/runner.js +87 -23
- package/dist/alarms/schedule.js +29 -1
- package/dist/alarms/scheduler.js +11 -3
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +76 -8
- package/dist/alarms/system-volume.js +44 -23
- 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 +182 -199
- package/dist/platform/capabilities.js +8 -1
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +9 -6
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +69 -1
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +7 -1
- package/dist/platform/runtime.js +1 -1
- package/dist/platform/terminals.js +24 -4
- package/dist/player/airplay-discovery.js +144 -61
- 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 +9 -6
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +51 -2
- package/dist/player/player-controller.js +390 -73
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +291 -75
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +272 -41
- package/dist/providers/radio-garden.js +4 -2
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +88 -21
- package/dist/storage/file-lock.js +130 -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 +314 -76
- package/dist/types.js +22 -1
- package/dist/ui/AdaptiveContent.js +21 -24
- package/dist/ui/App.js +708 -454
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +16 -9
- package/dist/ui/components/ReceiverVisualRow.js +39 -0
- package/dist/ui/format.js +37 -4
- package/dist/ui/help-content.js +5 -2
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +10 -4
- package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/HelpScreen.js +3 -3
- package/dist/ui/screens/NowPlayingScreen.js +2 -21
- package/dist/ui/screens/SettingsScreen.js +16 -16
- package/dist/ui/screens/StatsScreen.js +1 -1
- package/dist/ui/text-direction.js +9 -0
- package/dist/ui/theme.js +46 -0
- package/dist/ui/use-alarm-tui.js +24 -24
- package/dist/ui/use-app-input.js +42 -8
- package/dist/ui/use-command-executor.js +21 -14
- package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
- package/dist/ui/visualizers/boat-lake.js +161 -0
- package/dist/ui/visualizers/gallop.js +2 -2
- package/dist/ui/visualizers/receiver-style-registry.js +3 -1
- package/dist/ui/visualizers/receiver-visualizers.js +6 -108
- package/dist/ui/visualizers/sumi-ocean.js +183 -0
- package/dist/ui/visualizers/terminal-cell.js +45 -0
- package/dist/update-check.js +2 -1
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/docs/boat-lake.md +88 -0
- package/docs/sumi-ocean.md +80 -0
- package/package.json +36 -12
- 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 completionScript(shell) {
|
|
2
|
+
if (shell === 'bash')
|
|
3
|
+
return `# RadioCLI Bash completion
|
|
4
|
+
_radiocli_complete() {
|
|
5
|
+
local candidate
|
|
6
|
+
COMPREPLY=()
|
|
7
|
+
while IFS= read -r candidate; do
|
|
8
|
+
COMPREPLY+=("$candidate")
|
|
9
|
+
done < <(radiocli _complete "\${COMP_WORDS[@]:1:COMP_CWORD}" 2>/dev/null)
|
|
10
|
+
}
|
|
11
|
+
complete -F _radiocli_complete radiocli
|
|
12
|
+
`;
|
|
13
|
+
if (shell === 'zsh')
|
|
14
|
+
return `# RadioCLI Zsh completion (load after compinit)
|
|
15
|
+
_radiocli_complete() {
|
|
16
|
+
local -a candidates
|
|
17
|
+
candidates=("\${(@f)$(radiocli _complete "\${words[@]:1:$((CURRENT - 1))}" 2>/dev/null)}")
|
|
18
|
+
compadd -- "\${candidates[@]}"
|
|
19
|
+
}
|
|
20
|
+
compdef _radiocli_complete radiocli
|
|
21
|
+
`;
|
|
22
|
+
if (shell === 'fish')
|
|
23
|
+
return `# RadioCLI Fish completion
|
|
24
|
+
function __radiocli_candidates
|
|
25
|
+
set -l words (commandline -opc)
|
|
26
|
+
set -e words[1]
|
|
27
|
+
set -l current (commandline -ct)
|
|
28
|
+
radiocli _complete $words "$current" 2>/dev/null
|
|
29
|
+
end
|
|
30
|
+
complete -c radiocli -f -a '(__radiocli_candidates)'
|
|
31
|
+
`;
|
|
32
|
+
if (shell === 'powershell')
|
|
33
|
+
return `# RadioCLI PowerShell completion
|
|
34
|
+
Register-ArgumentCompleter -Native -CommandName radiocli -ScriptBlock {
|
|
35
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
36
|
+
$words = @($commandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_.Extent.StartOffset -lt $cursorPosition } | ForEach-Object { $_.Extent.Text })
|
|
37
|
+
if (!$wordToComplete) { $words += '' }
|
|
38
|
+
& radiocli _complete @words 2>$null | ForEach-Object {
|
|
39
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
throw new Error('Choose a completion shell: bash, zsh, fish, or powershell. Run radiocli completion --help.');
|
|
44
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { defaultLibraryPath } from '../storage/store.js';
|
|
2
|
+
import { detectPlaybackBackends, playbackBackendStatusLines } from '../player/backend-install.js';
|
|
3
|
+
import { resolveCommand } from '../platform/executables.js';
|
|
4
|
+
import { diagnoseCommand } from '../player/command-diagnostics.js';
|
|
5
|
+
import { readSettingsWithoutMigration } from '../storage/read-settings.js';
|
|
6
|
+
import { airPlaySenderHealth } from '../player/airplay-sender-health.js';
|
|
7
|
+
import { appVersion } from '../version.js';
|
|
8
|
+
import { identifyPlatform } from '../platform/runtime.js';
|
|
9
|
+
import { platformCapabilities } from '../platform/capabilities.js';
|
|
10
|
+
import { hasGraphicalSession } from '../platform/desktop.js';
|
|
11
|
+
import { detectPackageManager } from '../platform/packages.js';
|
|
12
|
+
import { storageReadiness } from '../platform/storage.js';
|
|
13
|
+
import { resolveTerminalCapabilities } from '../platform/terminal.js';
|
|
14
|
+
import { networkDiagnostic } from '../platform/network.js';
|
|
15
|
+
import { assessPlatformSupport } from '../platform/support.js';
|
|
16
|
+
export async function runDoctor(rest) {
|
|
17
|
+
const saved = (() => { try {
|
|
18
|
+
return readSettingsWithoutMigration(defaultLibraryPath());
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
} })();
|
|
23
|
+
const backends = detectPlaybackBackends({ settings: { linuxAirPlayEnabled: saved?.linuxAirPlayEnabled === true } });
|
|
24
|
+
const mpvDiagnostic = diagnoseCommand('mpv');
|
|
25
|
+
const base = await doctorReport(backends, mpvDiagnostic);
|
|
26
|
+
const playbackRehearsal = rest.includes('--playback') ? await (await import('../setup-playback.js')).verifyLocalPlayback(backends) : undefined;
|
|
27
|
+
const report = { ...base, ...(playbackRehearsal ? { playbackRehearsal } : {}) };
|
|
28
|
+
if (rest.includes('--json')) {
|
|
29
|
+
console.log(JSON.stringify(report, null, 2));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
console.log(`backends=${backends.join(',') || 'none'}`);
|
|
33
|
+
printMpvDiagnostic(report.mpv);
|
|
34
|
+
for (const line of playbackBackendStatusLines(backends))
|
|
35
|
+
console.log(line);
|
|
36
|
+
for (const [name, capability] of Object.entries(report.capabilities)) {
|
|
37
|
+
console.log(`capability_${name}=${capability.status} ${capability.message}`);
|
|
38
|
+
}
|
|
39
|
+
console.log(`omarchy=${report.omarchy.kind} status=${report.omarchy.status} version=${report.omarchy.version ?? 'unknown'} source=${report.omarchy.source ?? 'none'}`);
|
|
40
|
+
if (report.omarchy.session)
|
|
41
|
+
console.log(`desktop_session=${report.omarchy.session.type} remote=${report.omarchy.session.remote ? 'yes' : 'no'}`);
|
|
42
|
+
console.log(`omarchy_launcher=${report.omarchy.terminal.prerequisitesReady ? 'ready' : 'unavailable'} missing=${report.omarchy.terminal.missing.join(',') || 'none'}`);
|
|
43
|
+
console.log(`desktop_media_preference=${report.desktopMedia.preference.value} source=${report.desktopMedia.preference.source} status=${report.desktopMedia.preference.status}`);
|
|
44
|
+
console.log(`desktop_media_policy=${report.desktopMedia.policy.status} eligible=${report.desktopMedia.policy.eligible ? 'yes' : 'no'} ${report.desktopMedia.policy.message}`);
|
|
45
|
+
console.log(`desktop_media_bus=${report.desktopMedia.sessionBus.status} ${report.desktopMedia.sessionBus.message}`);
|
|
46
|
+
console.log(`desktop_media_owner=${report.desktopMedia.owner.status} ${report.desktopMedia.owner.message}`);
|
|
47
|
+
console.log(`terminal_launcher=${report.terminalLauncher.selected ?? 'none'} prerequisites=${report.terminalLauncher.prerequisites} launch=${report.terminalLauncher.launch} ${report.terminalLauncher.message}`);
|
|
48
|
+
console.log(`audio_session=${report.audio.status} backend=${report.audio.backend ?? 'none'} ${report.audio.message}`);
|
|
49
|
+
if (playbackRehearsal) {
|
|
50
|
+
for (const rehearsal of playbackRehearsal)
|
|
51
|
+
console.log(`decoder_${rehearsal.backend}=${rehearsal.status} ${rehearsal.message}`);
|
|
52
|
+
if (!playbackRehearsal.length)
|
|
53
|
+
console.log('decoder=unverified No local playback backend is available for a rehearsal.');
|
|
54
|
+
console.log('physical_output=unverified Null/dummy playback does not verify speakers, headphones, receivers, or a live network stream.');
|
|
55
|
+
}
|
|
56
|
+
console.log(`airplay_readiness=${report.airPlay.state !== 'enabled' ? report.airPlay.state : report.airPlay.ready ? 'ready' : 'unavailable'} discovery=${report.airPlay.discoveryService} sender=${report.airPlay.senderLoaded ? 'loaded' : 'unavailable'} ${report.airPlay.message}`);
|
|
57
|
+
console.log(`network=${report.network.status} ${report.network.message}`);
|
|
58
|
+
console.log(`support_tier=${report.support.tier} runtime=${report.support.runtime.status}`);
|
|
59
|
+
console.log(`support_scope=${report.support.scope}`);
|
|
60
|
+
console.log('support_note=Doctor checks this installation without running packed-install or audio acceptance tests; reference CI results are documented in the platform matrix.');
|
|
61
|
+
for (const reason of report.support.reasons)
|
|
62
|
+
console.log(`support_note=${reason}`);
|
|
63
|
+
}
|
|
64
|
+
async function doctorReport(backends, mpvDiagnostic) {
|
|
65
|
+
const commands = Object.fromEntries(['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]));
|
|
66
|
+
const airPlay = airPlaySenderHealth();
|
|
67
|
+
const host = identifyPlatform();
|
|
68
|
+
const { probeNativeIntegrations, probeDoctorScheduler } = await import('../platform/diagnostics.js');
|
|
69
|
+
const native = await probeNativeIntegrations({ host, libraryPath: defaultLibraryPath });
|
|
70
|
+
const terminal = resolveTerminalCapabilities(process.env, { isTTY: Boolean(process.stdout.isTTY), colorDepth: process.stdout.getColorDepth?.() });
|
|
71
|
+
const scheduler = await probeDoctorScheduler(host);
|
|
72
|
+
const integrationCommands = [
|
|
73
|
+
'launchctl', 'schtasks.exe', 'systemctl', 'caffeinate', 'systemd-inhibit',
|
|
74
|
+
'powershell.exe', 'pwsh.exe', 'osascript', 'wpctl', 'pactl', 'amixer',
|
|
75
|
+
'open', 'explorer', 'xdg-open', 'pbcopy', 'clip', 'wl-copy', 'xclip', 'xsel',
|
|
76
|
+
'termux-open-url', 'termux-clipboard-set'
|
|
77
|
+
].filter(command => resolveCommand(command) !== null);
|
|
78
|
+
const capabilities = platformCapabilities(host, {
|
|
79
|
+
backends,
|
|
80
|
+
commands: integrationCommands,
|
|
81
|
+
graphicalSession: hasGraphicalSession(host),
|
|
82
|
+
packageManager: detectPackageManager(process.platform, host.osRelease),
|
|
83
|
+
storageWritable: libraryWritesAvailable(),
|
|
84
|
+
scheduler,
|
|
85
|
+
unicode: terminal.unicode,
|
|
86
|
+
color: terminal.colorLevel > 0,
|
|
87
|
+
screenReader: terminal.screenReader,
|
|
88
|
+
airPlay: native.airPlay
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
radioCliVersion: appVersion(),
|
|
92
|
+
nodeVersion: process.version,
|
|
93
|
+
platform: process.platform,
|
|
94
|
+
architecture: process.arch,
|
|
95
|
+
host: { id: host.id, arch: host.arch, armVersion: host.armVersion, endianness: host.endianness, release: host.release, libc: host.libc, isWsl: host.isWsl },
|
|
96
|
+
support: { scope: 'current-installation', ...assessPlatformSupport(host) },
|
|
97
|
+
capabilities,
|
|
98
|
+
terminal,
|
|
99
|
+
omarchy: native.omarchy,
|
|
100
|
+
desktopMedia: native.desktopMedia,
|
|
101
|
+
terminalLauncher: native.terminalLauncher,
|
|
102
|
+
audio: native.audio,
|
|
103
|
+
network: networkDiagnostic(),
|
|
104
|
+
backends,
|
|
105
|
+
commands,
|
|
106
|
+
mpv: redactDiagnostic(mpvDiagnostic),
|
|
107
|
+
airPlay: {
|
|
108
|
+
available: airPlay.available,
|
|
109
|
+
safe: airPlay.safe,
|
|
110
|
+
package: airPlay.packageName,
|
|
111
|
+
version: airPlay.version ?? null,
|
|
112
|
+
vulnerablePackages: airPlay.vulnerablePackages,
|
|
113
|
+
warningPackages: airPlay.warningPackages,
|
|
114
|
+
...native.airPlay
|
|
115
|
+
},
|
|
116
|
+
guidance: playbackBackendStatusLines(backends)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function libraryWritesAvailable() {
|
|
120
|
+
try {
|
|
121
|
+
return storageReadiness(defaultLibraryPath()).status === 'available';
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function printMpvDiagnostic(diagnostic) {
|
|
128
|
+
console.log(`mpv_path=${redactHome(diagnostic.path) ?? 'not-found'}`);
|
|
129
|
+
console.log(`mpv_discovery=${diagnostic.discovery}`);
|
|
130
|
+
console.log(`mpv_launch=${diagnostic.launchable ? 'ready' : 'failed'}`);
|
|
131
|
+
if (diagnostic.version)
|
|
132
|
+
console.log(`mpv_version=${diagnostic.version}`);
|
|
133
|
+
if (diagnostic.error)
|
|
134
|
+
console.log(`mpv_error=${diagnostic.error}`);
|
|
135
|
+
if (diagnostic.launchable && diagnostic.discovery !== 'path') {
|
|
136
|
+
console.log('mpv_hint=RadioCLI found mpv outside PATH and can use it directly; no PATH changes are required.');
|
|
137
|
+
}
|
|
138
|
+
else if (!diagnostic.path && process.platform === 'win32') {
|
|
139
|
+
console.log('mpv_hint=Install with winget, then rerun Doctor; RadioCLI also checks the standard MPV Player install directory.');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function redactDiagnostic(diagnostic) {
|
|
143
|
+
// Executable banners and spawn errors can include private build paths, URLs,
|
|
144
|
+
// or environment values. Keep a bounded version token and actionable status.
|
|
145
|
+
const version = diagnostic.version?.slice(0, 256).match(/^mpv\s+(v?\d+(?:\.\d+){1,3}(?:[a-z\d.+-]{0,80})?)(?:\s|$)/i)?.[1];
|
|
146
|
+
const error = diagnostic.error === null ? null : diagnostic.discovery === 'missing' ? 'not found'
|
|
147
|
+
: diagnostic.discovery === 'configured-path' ? 'The configured mpv executable could not be run. Check RADIOCLI_MPV_PATH.'
|
|
148
|
+
: 'mpv could not be launched. Check the executable and its native runtime dependencies.';
|
|
149
|
+
return { ...diagnostic, path: redactHome(diagnostic.path), version: version ? `mpv ${version}` : null, error };
|
|
150
|
+
}
|
|
151
|
+
function redactHome(path) {
|
|
152
|
+
if (!path)
|
|
153
|
+
return null;
|
|
154
|
+
const home = process.env.HOME ?? process.env.USERPROFILE;
|
|
155
|
+
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
156
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
export function requireInteractive(action) {
|
|
4
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
5
|
+
throw new Error(`${action} needs an interactive terminal. Use --json or --plain for scripts, and radiocli play <provider:id> to select a station directly.`);
|
|
6
|
+
}
|
|
7
|
+
/** A small line-mode chooser preserves scrollback and ordinary terminal editing. */
|
|
8
|
+
export async function pickStation(stations, output, favorites) {
|
|
9
|
+
requireInteractive('--pick');
|
|
10
|
+
if (!stations.length)
|
|
11
|
+
throw new Error('No stations found. Try a broader search or remove a filter.');
|
|
12
|
+
output.stations(stations, favorites, undefined, false);
|
|
13
|
+
const input = createInterface({ input: process.stdin, output: process.stderr });
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let settled = false;
|
|
16
|
+
const cancel = () => {
|
|
17
|
+
if (settled)
|
|
18
|
+
return;
|
|
19
|
+
settled = true;
|
|
20
|
+
reject(Object.assign(new Error('Selection cancelled.'), { name: 'AbortError' }));
|
|
21
|
+
input.close();
|
|
22
|
+
};
|
|
23
|
+
input.once('SIGINT', cancel);
|
|
24
|
+
input.once('close', cancel);
|
|
25
|
+
const ask = () => input.question(`Play station [1-${stations.length}], or q to cancel: `, answer => {
|
|
26
|
+
if (answer.trim().toLowerCase() === 'q' || !answer.trim()) {
|
|
27
|
+
cancel();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const index = /^\d+$/.test(answer.trim()) ? Number(answer.trim()) - 1 : -1;
|
|
31
|
+
const station = stations[index];
|
|
32
|
+
if (!station) {
|
|
33
|
+
process.stderr.write(`Choose a number from 1 through ${stations.length}.\n`);
|
|
34
|
+
ask();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
settled = true;
|
|
38
|
+
input.close();
|
|
39
|
+
resolve(station);
|
|
40
|
+
});
|
|
41
|
+
ask();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export async function watchNow(read, output) {
|
|
45
|
+
requireInteractive('--watch');
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const interrupt = () => { process.exitCode = 130; controller.abort(); };
|
|
48
|
+
const terminate = () => { process.exitCode = 143; controller.abort(); };
|
|
49
|
+
process.once('SIGINT', interrupt);
|
|
50
|
+
process.once('SIGTERM', terminate);
|
|
51
|
+
const rewrite = output.terminal.interactive && !output.terminal.screenReader;
|
|
52
|
+
let rendered = false;
|
|
53
|
+
let previous = '';
|
|
54
|
+
try {
|
|
55
|
+
process.stderr.write('Watching playback. Ctrl+C exits this view; audio continues.\n');
|
|
56
|
+
while (!controller.signal.aborted) {
|
|
57
|
+
const status = await read(controller.signal);
|
|
58
|
+
if (controller.signal.aborted)
|
|
59
|
+
break;
|
|
60
|
+
const line = output.fit(output.nowLine(status), Math.max(1, (process.stdout.columns || 100) - 1));
|
|
61
|
+
if (!rendered || line !== previous) {
|
|
62
|
+
process.stdout.write(rewrite ? `\r\u001b[2K${line}` : `${line}\n`);
|
|
63
|
+
rendered = true;
|
|
64
|
+
previous = line;
|
|
65
|
+
}
|
|
66
|
+
await delay(1000, undefined, { signal: controller.signal });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!controller.signal.aborted)
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
process.removeListener('SIGINT', interrupt);
|
|
75
|
+
process.removeListener('SIGTERM', terminate);
|
|
76
|
+
if (rendered && rewrite)
|
|
77
|
+
process.stdout.write('\n');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { AgentRadioService } from '../agent/service.js';
|
|
3
|
+
import { connectRadioSession } from '../agent/session.js';
|
|
4
|
+
import { connectActiveAlarms } from '../alarms/active-session.js';
|
|
5
|
+
import { JsonLibraryStore, stationKey } from '../storage/store.js';
|
|
6
|
+
import { matchesStationId } from '../storage/station-identity.js';
|
|
7
|
+
import { ProviderManager } from '../providers/provider-manager.js';
|
|
8
|
+
import { computeListeningStats } from '../activity/stats.js';
|
|
9
|
+
import { integerOption, parseListenerArgs } from './arguments.js';
|
|
10
|
+
import { CliOutput, clean } from './output.js';
|
|
11
|
+
import { pickStation, requireInteractive, watchNow } from './interactive.js';
|
|
12
|
+
import { completionScript } from './completion.js';
|
|
13
|
+
export async function runListenerCommand(command, args, runtime) {
|
|
14
|
+
const parsed = parseListenerArgs(command, args);
|
|
15
|
+
const { words, flags, options } = parsed;
|
|
16
|
+
if (command === 'completion') {
|
|
17
|
+
if (words.length !== 1)
|
|
18
|
+
throw new Error('Usage: radiocli completion <bash|zsh|fish|powershell>');
|
|
19
|
+
const script = completionScript(words[0]);
|
|
20
|
+
if (!flags.quiet)
|
|
21
|
+
console.log(flags.json ? JSON.stringify(script) : script);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (options.has('--pick'))
|
|
25
|
+
requireInteractive('--pick');
|
|
26
|
+
if (options.has('--watch'))
|
|
27
|
+
requireInteractive('--watch');
|
|
28
|
+
const store = new JsonLibraryStore();
|
|
29
|
+
const state = store.snapshot();
|
|
30
|
+
const providers = new ProviderManager();
|
|
31
|
+
const service = new AgentRadioService(runtime, store, providers, 'cli');
|
|
32
|
+
const output = new CliOutput(flags, state.settings);
|
|
33
|
+
const query = words.join(' ').trim();
|
|
34
|
+
const filters = searchOptions(parsed);
|
|
35
|
+
const noWords = () => { if (words.length)
|
|
36
|
+
throw new Error(`Unexpected argument: ${clean(words[0])}\nRun radiocli ${command} --help.`); };
|
|
37
|
+
const finish = (result, background = false) => {
|
|
38
|
+
if (!result.ok && (output.mode !== 'json' || flags.quiet))
|
|
39
|
+
throw new Error(result.message);
|
|
40
|
+
output.result(result, background);
|
|
41
|
+
if (!result.ok)
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
};
|
|
44
|
+
const select = async (stations, forcePick = options.has('--pick')) => {
|
|
45
|
+
if (!stations.length)
|
|
46
|
+
throw new Error('No stations found. Try radiocli search with a broader query, or radiocli favorites.');
|
|
47
|
+
if (stations.length === 1 && !forcePick)
|
|
48
|
+
return stations[0];
|
|
49
|
+
if (process.stdin.isTTY && process.stdout.isTTY && output.mode === 'human' && !flags.noInput && !flags.quiet)
|
|
50
|
+
return pickStation(stations, output, state.favorites);
|
|
51
|
+
const target = command === 'preset' ? 'preset save <name>' : command === 'search' ? 'play' : command;
|
|
52
|
+
throw new Error(`Several stations match. Use an exact provider:id with radiocli ${target}:\n${stations.slice(0, 20).map(station => ` ${clean(stationKey(station))} ${clean(station.name)}`).join('\n')}`);
|
|
53
|
+
};
|
|
54
|
+
const saved = uniqueStations([...state.favorites, ...state.imported, ...state.recent.map(item => item.station), ...(state.presets ?? []).map(preset => preset.station)]);
|
|
55
|
+
const resolve = async (text) => {
|
|
56
|
+
if (/^https?:\/\//i.test(text))
|
|
57
|
+
throw new Error('Import a stream first with radiocli import <stream-url>, then play its saved name or ID.');
|
|
58
|
+
const preset = (state.presets ?? []).find(item => item.name === normalize(text));
|
|
59
|
+
const exactIds = saved.filter(station => matchesStationId(station, text));
|
|
60
|
+
if (preset || exactIds.length) {
|
|
61
|
+
const station = preset?.station ?? await select(exactIds);
|
|
62
|
+
if (!matchesFilters(station, filters))
|
|
63
|
+
throw new Error('The selected saved station does not match those filters. Remove the filters or search for another station.');
|
|
64
|
+
return { station, queue: [station] };
|
|
65
|
+
}
|
|
66
|
+
if (/^(radio-browser|radio-garden|playlist):/.test(text)) {
|
|
67
|
+
const split = text.indexOf(':');
|
|
68
|
+
const station = await providers.byId(text.slice(0, split), text.slice(split + 1));
|
|
69
|
+
if (!station)
|
|
70
|
+
throw new Error(`Station not found: ${clean(text)}. Use radiocli search to find a current ID, or import this station into your library.`);
|
|
71
|
+
if (!matchesFilters(station, filters))
|
|
72
|
+
throw new Error('The station does not match those filters.');
|
|
73
|
+
return { station, queue: [station] };
|
|
74
|
+
}
|
|
75
|
+
const matching = saved.filter(station => matchesFilters(station, filters));
|
|
76
|
+
const exact = matching.filter(station => normalize(station.name) === normalize(text));
|
|
77
|
+
const local = exact.length ? exact : matching.filter(station => normalize(text).split(/\s+/).every(word => normalize(station.name).includes(word)));
|
|
78
|
+
const stations = local.length ? local : await providers.search(text, state.settings, filters);
|
|
79
|
+
const remoteExact = stations.filter(station => normalize(station.name) === normalize(text));
|
|
80
|
+
const candidates = remoteExact.length && !options.has('--pick') ? remoteExact : stations;
|
|
81
|
+
const station = options.has('--random') && stations.length ? stations[randomInt(stations.length)] : await select(candidates);
|
|
82
|
+
return { station, queue: stations };
|
|
83
|
+
};
|
|
84
|
+
const currentStation = async () => {
|
|
85
|
+
const station = (await readStatus())?.station;
|
|
86
|
+
if (!station)
|
|
87
|
+
throw new Error('No station is playing. Supply a station name, or start playback with radiocli play "station name".');
|
|
88
|
+
return station;
|
|
89
|
+
};
|
|
90
|
+
if (command === 'now' || command === 'status') {
|
|
91
|
+
noWords();
|
|
92
|
+
if (options.has('--watch'))
|
|
93
|
+
await watchNow(readStatus, output);
|
|
94
|
+
else
|
|
95
|
+
output.now(await readStatus());
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (command === 'play') {
|
|
99
|
+
if (options.has('--random') && options.has('--pick'))
|
|
100
|
+
throw new Error('Choose either --random or --pick.');
|
|
101
|
+
if (query && options.has('--source'))
|
|
102
|
+
throw new Error('Choose a station name or a station queue (--favorites, --recent, --popular, or --source).');
|
|
103
|
+
const selectingStation = query || ['--source', '--random', '--pick', '--country', '--language', '--codec', '--min-bitrate', '--limit'].some(option => options.has(option));
|
|
104
|
+
const active = selectingStation ? null : await readStatus();
|
|
105
|
+
if (active?.station && ['paused', 'playing', 'loading'].includes(active.playback.state)) {
|
|
106
|
+
if ((await connectActiveAlarms()).length)
|
|
107
|
+
throw new Error('An alarm is currently active. Dismiss or keep the alarm from its ringing controls before starting interactive playback.');
|
|
108
|
+
if (active.playback.state === 'paused' && !options.has('--keep-playing'))
|
|
109
|
+
finish(await service.control({ type: 'resume' }, false), true);
|
|
110
|
+
else
|
|
111
|
+
finish({ ok: true, message: `Kept current station ${active.station.name} (${active.playback.state}).`, status: active }, true);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
let selection;
|
|
115
|
+
if (active?.station)
|
|
116
|
+
selection = { station: active.station, queue: active.queue };
|
|
117
|
+
else if (query)
|
|
118
|
+
selection = await resolve(query);
|
|
119
|
+
else {
|
|
120
|
+
const source = options.get('--source') ?? (filters.countryCode ? 'country' : 'recent');
|
|
121
|
+
if (!['recent', 'favorite', 'popular', 'country'].includes(String(source)))
|
|
122
|
+
throw new Error('--source must be recent, favorites, popular, or country (favorite is also accepted).');
|
|
123
|
+
if (source === 'country' && !filters.countryCode)
|
|
124
|
+
throw new Error('Use --country with a two-letter code, for example radiocli play --country JP.');
|
|
125
|
+
let queue = source === 'favorite' ? state.favorites : source === 'recent' ? state.recent.map(item => item.station) : source === 'country' ? await providers.byCountry(filters.countryCode, 100) : await providers.popular(100);
|
|
126
|
+
queue = queue.filter(station => matchesFilters(station, filters)).slice(0, filters.limit);
|
|
127
|
+
if (!queue.length)
|
|
128
|
+
throw new Error(`No ${source === 'favorite' ? 'favorite' : source} stations match. Start with radiocli play "station name".`);
|
|
129
|
+
const station = options.has('--pick') ? await select(queue, true) : queue[options.has('--random') ? randomInt(queue.length) : 0];
|
|
130
|
+
selection = { station, queue };
|
|
131
|
+
}
|
|
132
|
+
finish(await service.playStation(selection.station, selection.queue, { openUi: false, ifPlaying: options.has('--keep-playing') ? 'keep' : 'replace' }), true);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (['pause', 'resume', 'stop', 'next', 'previous', 'mute', 'unmute', 'volume'].includes(command)) {
|
|
136
|
+
if (command === 'volume') {
|
|
137
|
+
if (words.length !== 1)
|
|
138
|
+
throw new Error('Usage: radiocli volume <0-100>');
|
|
139
|
+
finish(await service.control({ type: 'set-volume', volume: integerOption(words[0], 70, 'Volume', 0, 100) }, false));
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
noWords();
|
|
143
|
+
const control = command === 'mute' || command === 'unmute' ? { type: 'set-muted', muted: command === 'mute' } : { type: command };
|
|
144
|
+
finish(await service.control(control, false));
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (command === 'search' || command === 'favorites' || command === 'recent') {
|
|
149
|
+
if (command === 'search' && !query)
|
|
150
|
+
throw new Error('Usage: radiocli search <query> [--country CODE] [--pick]\nExample: radiocli search jazz --pick');
|
|
151
|
+
if (command !== 'search')
|
|
152
|
+
noWords();
|
|
153
|
+
const stations = command === 'search' ? await providers.search(query, state.settings, filters) : (command === 'favorites' ? state.favorites : state.recent.map(item => item.station)).slice(0, filters.limit);
|
|
154
|
+
if (options.has('--pick'))
|
|
155
|
+
finish(await service.playStation(await select(stations, true), stations, { openUi: false }), true);
|
|
156
|
+
else
|
|
157
|
+
output.stations(stations, state.favorites, command === 'favorites' ? 'No favorites yet. Tune a station, then run radiocli favorite.' : command === 'recent' ? 'No recent stations yet. Start with radiocli play jazz.' : undefined);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (command === 'countries') {
|
|
161
|
+
noWords();
|
|
162
|
+
output.countries(await providers.countries(integerOption(options.get('--limit'), 30, '--limit', 1, 100)));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (command === 'favorite' || command === 'unfavorite') {
|
|
166
|
+
finish(await service.favoriteStation(query ? (await resolve(query)).station : await currentStation(), command === 'favorite'));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (command === 'preset' || command === 'presets') {
|
|
170
|
+
if (command === 'presets')
|
|
171
|
+
noWords();
|
|
172
|
+
const action = command === 'presets' ? 'list' : words[0] ?? 'list';
|
|
173
|
+
if (action === 'list') {
|
|
174
|
+
if (words.length > 1)
|
|
175
|
+
throw new Error('Usage: radiocli preset list');
|
|
176
|
+
output.presets(state.presets ?? []);
|
|
177
|
+
}
|
|
178
|
+
else if (action === 'save') {
|
|
179
|
+
const name = words[1];
|
|
180
|
+
if (!name)
|
|
181
|
+
throw new Error('Usage: radiocli preset save <name> [station name|provider:id]');
|
|
182
|
+
// Validate the shortcut before any directory request or selection prompt.
|
|
183
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,39}$/i.test(name))
|
|
184
|
+
throw new Error('Preset names must be 1-40 letters, numbers, hyphens, or underscores, starting with a letter or number.');
|
|
185
|
+
const station = words.length > 2 ? (await resolve(words.slice(2).join(' '))).station : await currentStation();
|
|
186
|
+
store.savePreset(name, station);
|
|
187
|
+
finish({ ok: true, message: `Saved preset ${name.toLowerCase()}: ${station.name}. Play it with radiocli play ${name.toLowerCase()}.` });
|
|
188
|
+
}
|
|
189
|
+
else if (action === 'remove') {
|
|
190
|
+
if (words.length !== 2)
|
|
191
|
+
throw new Error('Usage: radiocli preset remove <name>');
|
|
192
|
+
store.removePreset(words[1]);
|
|
193
|
+
finish({ ok: true, message: `Removed preset ${words[1].toLowerCase()}.` });
|
|
194
|
+
}
|
|
195
|
+
else
|
|
196
|
+
throw new Error('Usage: radiocli preset <save|list|remove>. Run radiocli preset --help for examples.');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (command === 'stats') {
|
|
200
|
+
noWords();
|
|
201
|
+
output.stats(computeListeningStats(state.activity.sessions));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function readStatus(signal) {
|
|
205
|
+
const client = await connectRadioSession(undefined, { source: 'cli', signal });
|
|
206
|
+
return client ? client.status() : null;
|
|
207
|
+
}
|
|
208
|
+
function searchOptions({ options }) {
|
|
209
|
+
const countryCode = options.get('--country');
|
|
210
|
+
if (countryCode && (typeof countryCode !== 'string' || !/^[a-z]{2}$/i.test(countryCode)))
|
|
211
|
+
throw new Error('--country requires a two-letter code, for example US, GB, or JP.');
|
|
212
|
+
return {
|
|
213
|
+
limit: integerOption(options.get('--limit'), 20, '--limit', 1, 100),
|
|
214
|
+
countryCode: typeof countryCode === 'string' ? countryCode.toUpperCase() : undefined,
|
|
215
|
+
language: options.get('--language'),
|
|
216
|
+
codec: options.get('--codec'),
|
|
217
|
+
minBitrate: options.has('--min-bitrate') ? integerOption(options.get('--min-bitrate'), 0, '--min-bitrate', 0, 10000) : undefined
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function matchesFilters(station, options) {
|
|
221
|
+
return (!options.countryCode || station.countryCode?.toUpperCase() === options.countryCode)
|
|
222
|
+
&& (!options.language || normalize(station.language ?? '').includes(normalize(options.language)))
|
|
223
|
+
&& (!options.codec || normalize(station.codec ?? '') === normalize(options.codec))
|
|
224
|
+
&& (options.minBitrate === undefined || (station.bitrate ?? 0) >= options.minBitrate);
|
|
225
|
+
}
|
|
226
|
+
function uniqueStations(stations) { return [...new Map(stations.map(station => [stationKey(station), station])).values()]; }
|
|
227
|
+
function normalize(value) { return value.normalize('NFKC').trim().toLowerCase(); }
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { stationKey } from '../storage/store.js';
|
|
2
|
+
import { sanitizeTerminalText } from '../safety.js';
|
|
3
|
+
import { resolveTerminalCapabilities } from '../platform/terminal.js';
|
|
4
|
+
import { displayWidth, padDisplayEnd, truncate } from '../ui/format.js';
|
|
5
|
+
import { toAsciiSafe } from '../ui/ascii.js';
|
|
6
|
+
import { themeAccent } from '../ui/theme.js';
|
|
7
|
+
export const clean = (value) => sanitizeTerminalText(value == null ? '' : String(value)) ?? '';
|
|
8
|
+
export class CliOutput {
|
|
9
|
+
flags;
|
|
10
|
+
settings;
|
|
11
|
+
mode;
|
|
12
|
+
terminal;
|
|
13
|
+
width;
|
|
14
|
+
color;
|
|
15
|
+
constructor(flags, settings, evidence = { isTTY: Boolean(process.stdout.isTTY), columns: process.stdout.columns || 100 }, env = process.env) {
|
|
16
|
+
this.flags = flags;
|
|
17
|
+
this.settings = settings;
|
|
18
|
+
this.mode = flags.json ? 'json' : flags.plain || !evidence.isTTY ? 'plain' : 'human';
|
|
19
|
+
this.terminal = resolveTerminalCapabilities(env, { isTTY: evidence.isTTY, asciiMode: settings.asciiMode, reduceMotion: settings.reduceMotion });
|
|
20
|
+
this.width = Math.max(12, evidence.columns - 1);
|
|
21
|
+
this.color = this.mode === 'human' && evidence.isTTY && !flags.noColor && this.terminal.colorLevel > 0;
|
|
22
|
+
}
|
|
23
|
+
text(value) { return this.terminal.unicode ? clean(value) : toAsciiSafe(clean(value)); }
|
|
24
|
+
heading(value) {
|
|
25
|
+
return this.accent(this.text(value));
|
|
26
|
+
}
|
|
27
|
+
accent(text) {
|
|
28
|
+
if (!this.color)
|
|
29
|
+
return text;
|
|
30
|
+
if (this.terminal.colorLevel < 3)
|
|
31
|
+
return `\u001b[1m${text}\u001b[0m`;
|
|
32
|
+
const hex = themeAccent(this.settings.theme).slice(1);
|
|
33
|
+
const rgb = [0, 2, 4].map(offset => parseInt(hex.slice(offset, offset + 2), 16)).join(';');
|
|
34
|
+
return `\u001b[1;38;2;${rgb}m${text}\u001b[0m`;
|
|
35
|
+
}
|
|
36
|
+
print(value, human, plain = human) {
|
|
37
|
+
if (this.flags.quiet)
|
|
38
|
+
return;
|
|
39
|
+
console.log(this.mode === 'json' ? JSON.stringify(value, null, 2) : this.mode === 'plain' ? plain : human);
|
|
40
|
+
}
|
|
41
|
+
stations(stations, favorites, empty = 'No stations found. Try a broader search or remove a filter.', showHint = true) {
|
|
42
|
+
const favoriteKeys = new Set(favorites.map(stationKey));
|
|
43
|
+
const rows = stations.map(station => ({ id: stationKey(station), name: station.name, country: station.country ?? '', countryCode: station.countryCode ?? '', codec: station.codec ?? '', bitrate: station.bitrate ?? null, language: station.language ?? '', tags: station.tags, favorite: favoriteKeys.has(stationKey(station)) }));
|
|
44
|
+
// Preserve the original five-column search TSV contract for existing pipes.
|
|
45
|
+
const plain = rows.map(row => [row.id, row.name, row.country, row.codec, row.bitrate ?? ''].map(clean).join('\t')).join('\n');
|
|
46
|
+
const hint = showHint ? '\n\n' + this.text('* Favorite · Tune by name with radiocli play "station name", or add --pick.') : '';
|
|
47
|
+
const human = stations.length ? this.table(['#', 'STATION', 'COUNTRY', 'QUALITY'], rows.map((row, index) => [String(index + 1), `${row.favorite ? '* ' : ''}${row.name}`, row.country || row.countryCode || '—', [row.codec, row.bitrate ? `${row.bitrate} kbps` : ''].filter(Boolean).join(' ') || '—'])) + hint : this.text(empty);
|
|
48
|
+
if (!stations.length && this.mode === 'plain') {
|
|
49
|
+
if (!this.flags.quiet)
|
|
50
|
+
process.stderr.write(`${this.text(empty)}\n`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.print(rows, human, plain);
|
|
54
|
+
}
|
|
55
|
+
countries(countries) {
|
|
56
|
+
this.print(countries, countries.length ? this.table(['CODE', 'COUNTRY', 'STATIONS'], countries.map(country => [country.code, country.name, String(country.stationCount)])) : 'No countries found. Try again when the directory is available.', countries.map(country => [country.code, country.stationCount, country.name].map(clean).join('\t')).join('\n'));
|
|
57
|
+
}
|
|
58
|
+
now(status) {
|
|
59
|
+
const fields = this.nowFields(status);
|
|
60
|
+
this.print(status, this.nowLines(status).map((line, index) => index === 0 ? this.accent(this.fit(line)) : this.fit(line)).join('\n'), Object.entries(fields).map(([key, value]) => `${key}\t${clean(value)}`).join('\n'));
|
|
61
|
+
}
|
|
62
|
+
nowLine(status) {
|
|
63
|
+
return this.text(this.nowLines(status).join(' · '));
|
|
64
|
+
}
|
|
65
|
+
result(result, background = false) {
|
|
66
|
+
const lines = [this.text(result.message)];
|
|
67
|
+
if (result.status?.persistenceWarning)
|
|
68
|
+
lines.push(this.text(result.status.persistenceWarning));
|
|
69
|
+
if (background && result.ok && result.status?.owner === 'headless')
|
|
70
|
+
lines.push('Playback continues in the background. Use radiocli now or radiocli stop.');
|
|
71
|
+
this.print(result, lines.join('\n'), `${result.ok ? 'ok' : 'error'}\t${clean(result.message)}`);
|
|
72
|
+
}
|
|
73
|
+
presets(presets) {
|
|
74
|
+
const rows = presets.map(preset => ({ name: preset.name, stationId: stationKey(preset.station), stationName: preset.station.name }));
|
|
75
|
+
this.print(rows, rows.length ? this.table(['PRESET', 'STATION'], rows.map(row => [row.name, row.stationName])) + '\n\nPlay a preset: radiocli play <name>' : 'No presets yet. Tune a station, then run radiocli preset save work.', rows.map(row => [row.name, row.stationId, row.stationName].map(clean).join('\t')).join('\n'));
|
|
76
|
+
}
|
|
77
|
+
stats(stats) {
|
|
78
|
+
const hours = Math.floor(stats.totalSeconds / 3600);
|
|
79
|
+
const minutes = Math.floor(stats.totalSeconds % 3600 / 60);
|
|
80
|
+
const lines = ['Listening stats', `${hours}h ${minutes}m listened · ${stats.sessions} sessions · ${stats.listenedStationCount} stations`, `${stats.currentStreak} day streak · ${stats.longestStreak} day best · ${stats.activeDays} active days`, `Most listened: ${this.text(stats.favoriteStation?.name ?? 'Start listening with radiocli play "station name".')}`];
|
|
81
|
+
this.print(stats, lines.map((line, index) => index === 0 ? this.heading(this.fit(line)) : this.fit(this.text(line))).join('\n'), Object.entries({ total_seconds: stats.totalSeconds, sessions: stats.sessions, stations: stats.listenedStationCount, current_streak: stats.currentStreak, longest_streak: stats.longestStreak, active_days: stats.activeDays, favorite_station: stats.favoriteStation?.name ?? '' }).map(([key, value]) => `${key}\t${clean(value)}`).join('\n'));
|
|
82
|
+
}
|
|
83
|
+
table(headers, rows) {
|
|
84
|
+
const sanitized = [headers, ...rows].map(row => row.map(value => this.text(value)));
|
|
85
|
+
const widths = headers.map((_, index) => Math.min(index === 1 ? 44 : 24, Math.max(...sanitized.map(row => displayWidth(row[index] ?? '')))));
|
|
86
|
+
while (widths.reduce((sum, width) => sum + width, 0) + (widths.length - 1) * 2 > this.width) {
|
|
87
|
+
const biggest = widths.indexOf(Math.max(...widths));
|
|
88
|
+
if (widths[biggest] <= 3)
|
|
89
|
+
break;
|
|
90
|
+
widths[biggest] = widths[biggest] - 1;
|
|
91
|
+
}
|
|
92
|
+
return sanitized.map((row, rowIndex) => {
|
|
93
|
+
const line = row.map((value, index) => padDisplayEnd(this.fit(value, widths[index]), widths[index])).join(' ').trimEnd();
|
|
94
|
+
return rowIndex === 0 ? this.accent(this.fit(line)) : this.fit(line);
|
|
95
|
+
}).join('\n');
|
|
96
|
+
}
|
|
97
|
+
fit(value, width = this.width) {
|
|
98
|
+
const result = truncate(value, width);
|
|
99
|
+
return this.terminal.unicode ? result : toAsciiSafe(result);
|
|
100
|
+
}
|
|
101
|
+
nowFields(status) {
|
|
102
|
+
return {
|
|
103
|
+
state: status?.playback.state ?? 'idle', station_id: status?.station ? stationKey(status.station) : '',
|
|
104
|
+
station: status?.station?.name ?? '', track: status?.nowPlaying?.title ?? '',
|
|
105
|
+
output: status?.playback.airPlayDeviceName ?? (status?.playback.backend === 'airplay' ? 'AirPlay' : 'Local speakers'),
|
|
106
|
+
volume: status?.playback.volume ?? this.settings.volume, muted: status?.playback.muted ?? false,
|
|
107
|
+
backend: status?.playback.backend ?? 'none', warning: status?.persistenceWarning ?? ''
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
nowLines(status) {
|
|
111
|
+
if (!status?.station)
|
|
112
|
+
return ['No station is playing. Start with radiocli play "station name".'];
|
|
113
|
+
const fields = this.nowFields(status);
|
|
114
|
+
const lines = [`${fields.station} · ${fields.state[0].toUpperCase()}${fields.state.slice(1)}`, fields.track || 'Track metadata unavailable', `${fields.output} · ${fields.backend} · Volume ${fields.volume}%${fields.muted ? ' (muted)' : ''}`];
|
|
115
|
+
if (status.playback.message && clean(status.playback.message) !== clean(status.station.name))
|
|
116
|
+
lines.push(status.playback.message);
|
|
117
|
+
if (status.persistenceWarning)
|
|
118
|
+
lines.push(status.persistenceWarning);
|
|
119
|
+
return lines.map(line => this.text(line));
|
|
120
|
+
}
|
|
121
|
+
}
|