@ciphore/radiocli 0.1.0
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 +47 -0
- package/CODE_OF_CONDUCT.md +13 -0
- package/CONTRIBUTING.md +56 -0
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/SECURITY.md +31 -0
- package/dist/activity/stats.js +122 -0
- package/dist/cli.js +143 -0
- package/dist/player/backend-install.js +122 -0
- package/dist/player/command.js +8 -0
- package/dist/player/player-controller.js +494 -0
- package/dist/playlists/playlist.js +169 -0
- package/dist/providers/cache.js +92 -0
- package/dist/providers/provider-manager.js +50 -0
- package/dist/providers/radio-browser.js +412 -0
- package/dist/providers/radio-garden.js +87 -0
- package/dist/storage/store.js +369 -0
- package/dist/types.js +55 -0
- package/dist/ui/App.js +770 -0
- package/dist/ui/AppContent.js +45 -0
- package/dist/ui/app-state.js +250 -0
- package/dist/ui/components/Logo.js +9 -0
- package/dist/ui/components/Menu.js +23 -0
- package/dist/ui/components/ScreenHeader.js +15 -0
- package/dist/ui/components/StationList.js +23 -0
- package/dist/ui/components/TopTabs.js +82 -0
- package/dist/ui/cosmo-land-data.js +4 -0
- package/dist/ui/cosmo-world-map.js +156 -0
- package/dist/ui/explore-map-layout.js +24 -0
- package/dist/ui/format.js +22 -0
- package/dist/ui/layout.js +27 -0
- package/dist/ui/list-window.js +8 -0
- package/dist/ui/page-footer.js +45 -0
- package/dist/ui/playback-footer.js +48 -0
- package/dist/ui/screen-items.js +26 -0
- package/dist/ui/screens/CountriesScreen.js +10 -0
- package/dist/ui/screens/ExploreScreen.js +44 -0
- package/dist/ui/screens/HomeScreen.js +9 -0
- package/dist/ui/screens/MapScreen.js +59 -0
- package/dist/ui/screens/NowPlayingScreen.js +79 -0
- package/dist/ui/screens/SearchScreen.js +12 -0
- package/dist/ui/screens/SettingsScreen.js +38 -0
- package/dist/ui/screens/StationScreen.js +7 -0
- package/dist/ui/screens/StatsScreen.js +90 -0
- package/dist/ui/terminal-mouse.js +38 -0
- package/dist/ui/theme.js +105 -0
- package/dist/ui/use-app-input.js +387 -0
- package/dist/ui/use-command-executor.js +156 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2188 -0
- package/dist/ui/world-map.js +274 -0
- package/docs/THIRD_PARTY_NOTICES.md +33 -0
- package/package.json +83 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { ProviderManager } from './providers/provider-manager.js';
|
|
7
|
+
import { PlayerController } from './player/player-controller.js';
|
|
8
|
+
import { JsonLibraryStore } from './storage/store.js';
|
|
9
|
+
import { parsePlaylistFile, stationFromUrl, writeM3u } from './playlists/playlist.js';
|
|
10
|
+
import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
|
|
11
|
+
if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
if (args.length > 0) {
|
|
14
|
+
await runCommand(args).catch(error => {
|
|
15
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
|
|
21
|
+
render(_jsx(App, {}), {
|
|
22
|
+
exitOnCtrlC: true,
|
|
23
|
+
kittyKeyboard: {
|
|
24
|
+
mode: 'auto',
|
|
25
|
+
flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function runCommand(args) {
|
|
31
|
+
const [command, ...rest] = args;
|
|
32
|
+
if (!command || command === 'help' || command === '--help' || command === '-h') {
|
|
33
|
+
printHelp();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (!isKnownCommand(command)) {
|
|
37
|
+
throw new Error(`Unknown command: ${command}\nRun radiocli help.`);
|
|
38
|
+
}
|
|
39
|
+
if (command === 'doctor') {
|
|
40
|
+
const backends = detectPlaybackBackends();
|
|
41
|
+
console.log(`backends=${backends.join(',') || 'none'}`);
|
|
42
|
+
printPlaybackBackendStatus(backends);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (command === 'check') {
|
|
46
|
+
const store = new JsonLibraryStore();
|
|
47
|
+
const providers = new ProviderManager();
|
|
48
|
+
const player = new PlayerController(() => store.snapshot().settings);
|
|
49
|
+
const backends = player.refreshDetectedBackends();
|
|
50
|
+
const health = await providers.health(store.snapshot().settings);
|
|
51
|
+
console.log(`store=${store.filePath}`);
|
|
52
|
+
console.log(`backends=${backends.join(',') || 'none'}`);
|
|
53
|
+
printPlaybackBackendStatus(backends);
|
|
54
|
+
for (const [provider, status] of Object.entries(health)) {
|
|
55
|
+
console.log(`${provider}=${status}`);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (command === 'countries') {
|
|
60
|
+
const providers = new ProviderManager();
|
|
61
|
+
const countries = await providers.countries(30);
|
|
62
|
+
for (const country of countries) {
|
|
63
|
+
console.log(`${country.code}\t${country.stationCount}\t${country.name}`);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (command === 'search') {
|
|
68
|
+
const query = rest.join(' ').trim();
|
|
69
|
+
if (!query) {
|
|
70
|
+
throw new Error('Usage: radiocli search <query>');
|
|
71
|
+
}
|
|
72
|
+
const store = new JsonLibraryStore();
|
|
73
|
+
const providers = new ProviderManager();
|
|
74
|
+
const stations = await providers.search(query, store.snapshot().settings, { limit: 20 });
|
|
75
|
+
for (const station of stations) {
|
|
76
|
+
console.log(`${station.provider}:${station.id}\t${station.name}\t${station.country ?? ''}\t${station.codec ?? ''}\t${station.bitrate ?? ''}`);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (command === 'import') {
|
|
81
|
+
const file = rest[0];
|
|
82
|
+
if (!file) {
|
|
83
|
+
throw new Error('Usage: radiocli import <playlist.m3u|playlist.pls|playlist.xspf>');
|
|
84
|
+
}
|
|
85
|
+
const stations = parsePlaylistFile(file);
|
|
86
|
+
const store = new JsonLibraryStore();
|
|
87
|
+
store.addImported(stations);
|
|
88
|
+
console.log(`imported=${stations.length}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (command === 'export') {
|
|
92
|
+
const file = rest[0] ?? 'radiocli-favorites.m3u';
|
|
93
|
+
const store = new JsonLibraryStore();
|
|
94
|
+
const state = store.snapshot();
|
|
95
|
+
writeM3u(file, [...state.favorites, ...state.imported]);
|
|
96
|
+
console.log(`exported=${file}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (command === 'add-url') {
|
|
100
|
+
const url = rest[0];
|
|
101
|
+
if (!url || !/^https?:\/\//i.test(url)) {
|
|
102
|
+
throw new Error('Usage: radiocli add-url <stream-url> [station name]');
|
|
103
|
+
}
|
|
104
|
+
const station = stationFromUrl(url, rest.slice(1).join(' ') || url);
|
|
105
|
+
const store = new JsonLibraryStore();
|
|
106
|
+
store.addImported([station]);
|
|
107
|
+
console.log(`added=${station.name}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function printHelp() {
|
|
112
|
+
console.log(`RadioCLI
|
|
113
|
+
|
|
114
|
+
Usage:
|
|
115
|
+
radiocli Start the TUI
|
|
116
|
+
radiocli check Show provider/backend health
|
|
117
|
+
radiocli doctor Show local playback setup guidance
|
|
118
|
+
radiocli countries Print top countries
|
|
119
|
+
radiocli search <query> Search public stations
|
|
120
|
+
radiocli import <file> Import .m3u, .pls, or .xspf streams
|
|
121
|
+
radiocli export [file] Export favorites/imports as .m3u
|
|
122
|
+
radiocli add-url <url> [name]
|
|
123
|
+
`);
|
|
124
|
+
}
|
|
125
|
+
export function isDirectRun(entryPath, moduleUrl) {
|
|
126
|
+
if (!entryPath) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return realpathSync(resolve(entryPath)) === realpathSync(fileURLToPath(moduleUrl));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function isKnownCommand(command) {
|
|
137
|
+
return ['check', 'doctor', 'countries', 'search', 'import', 'export', 'add-url'].includes(command);
|
|
138
|
+
}
|
|
139
|
+
function printPlaybackBackendStatus(backends) {
|
|
140
|
+
for (const line of playbackBackendStatusLines(backends)) {
|
|
141
|
+
console.log(line);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { commandExists } from './command.js';
|
|
3
|
+
const playbackBackends = ['mpv', 'ffplay'];
|
|
4
|
+
export function detectPlaybackBackends() {
|
|
5
|
+
return playbackBackends.filter(commandExists);
|
|
6
|
+
}
|
|
7
|
+
export function playbackBackendInstallHint(platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
8
|
+
return `Install mpv for playback (${mpvInstallCommand(platform, osRelease)}), then run radiocli doctor.`;
|
|
9
|
+
}
|
|
10
|
+
export function playbackBackendStatusLines(backends, platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
11
|
+
const backendSet = new Set(backends);
|
|
12
|
+
const lines = [
|
|
13
|
+
'npm_install=RadioCLI only; native playback comes from mpv or ffplay',
|
|
14
|
+
`install_mpv=${mpvInstallCommand(platform, osRelease)}`,
|
|
15
|
+
`optional_ffplay=${ffplayInstallCommand(platform, osRelease)}`
|
|
16
|
+
];
|
|
17
|
+
if (backendSet.has('mpv')) {
|
|
18
|
+
return [
|
|
19
|
+
'playback=ready',
|
|
20
|
+
'playback_backend=mpv',
|
|
21
|
+
...lines
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
if (backendSet.has('ffplay')) {
|
|
25
|
+
return [
|
|
26
|
+
'playback=fallback-only',
|
|
27
|
+
'playback_backend=ffplay',
|
|
28
|
+
...lines
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
return [
|
|
32
|
+
'playback=missing',
|
|
33
|
+
'playback_backend=none',
|
|
34
|
+
...lines
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
export function mpvInstallCommand(platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
38
|
+
if (platform === 'darwin') {
|
|
39
|
+
return 'brew install mpv';
|
|
40
|
+
}
|
|
41
|
+
if (platform === 'win32') {
|
|
42
|
+
return 'use WSL with sudo apt install mpv, or install mpv from https://mpv.io/installation/';
|
|
43
|
+
}
|
|
44
|
+
if (platform !== 'linux') {
|
|
45
|
+
return 'install mpv with your system package manager';
|
|
46
|
+
}
|
|
47
|
+
const ids = linuxReleaseIds(osRelease);
|
|
48
|
+
if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) {
|
|
49
|
+
return 'sudo apt install mpv';
|
|
50
|
+
}
|
|
51
|
+
if (hasAny(ids, ['fedora', 'rhel', 'centos'])) {
|
|
52
|
+
return 'sudo dnf install mpv';
|
|
53
|
+
}
|
|
54
|
+
if (hasAny(ids, ['arch', 'manjaro'])) {
|
|
55
|
+
return 'sudo pacman -S mpv';
|
|
56
|
+
}
|
|
57
|
+
if (hasAny(ids, ['alpine'])) {
|
|
58
|
+
return 'sudo apk add mpv';
|
|
59
|
+
}
|
|
60
|
+
if (hasAny(ids, ['opensuse', 'suse'])) {
|
|
61
|
+
return 'sudo zypper install mpv';
|
|
62
|
+
}
|
|
63
|
+
return 'install mpv with your system package manager';
|
|
64
|
+
}
|
|
65
|
+
function ffplayInstallCommand(platform = process.platform, osRelease = readLinuxOsRelease()) {
|
|
66
|
+
if (platform === 'darwin') {
|
|
67
|
+
return 'brew install ffmpeg';
|
|
68
|
+
}
|
|
69
|
+
if (platform === 'win32') {
|
|
70
|
+
return 'use WSL with sudo apt install ffmpeg, or install FFmpeg from https://ffmpeg.org/download.html';
|
|
71
|
+
}
|
|
72
|
+
if (platform !== 'linux') {
|
|
73
|
+
return 'install FFmpeg with your system package manager';
|
|
74
|
+
}
|
|
75
|
+
const ids = linuxReleaseIds(osRelease);
|
|
76
|
+
if (hasAny(ids, ['debian', 'ubuntu', 'linuxmint', 'pop'])) {
|
|
77
|
+
return 'sudo apt install ffmpeg';
|
|
78
|
+
}
|
|
79
|
+
if (hasAny(ids, ['fedora', 'rhel', 'centos'])) {
|
|
80
|
+
return 'sudo dnf install ffmpeg';
|
|
81
|
+
}
|
|
82
|
+
if (hasAny(ids, ['arch', 'manjaro'])) {
|
|
83
|
+
return 'sudo pacman -S ffmpeg';
|
|
84
|
+
}
|
|
85
|
+
if (hasAny(ids, ['alpine'])) {
|
|
86
|
+
return 'sudo apk add ffmpeg';
|
|
87
|
+
}
|
|
88
|
+
if (hasAny(ids, ['opensuse', 'suse'])) {
|
|
89
|
+
return 'sudo zypper install ffmpeg';
|
|
90
|
+
}
|
|
91
|
+
return 'install FFmpeg with your system package manager';
|
|
92
|
+
}
|
|
93
|
+
function readLinuxOsRelease() {
|
|
94
|
+
if (process.platform !== 'linux' || !existsSync('/etc/os-release')) {
|
|
95
|
+
return '';
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return readFileSync('/etc/os-release', 'utf8');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function linuxReleaseIds(osRelease) {
|
|
105
|
+
const ids = new Set();
|
|
106
|
+
for (const line of osRelease.split('\n')) {
|
|
107
|
+
const match = /^(ID|ID_LIKE)=(.*)$/.exec(line);
|
|
108
|
+
if (!match) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
for (const value of match[2].replaceAll('"', '').split(/\s+/)) {
|
|
112
|
+
const normalized = value.trim().toLowerCase();
|
|
113
|
+
if (normalized) {
|
|
114
|
+
ids.add(normalized);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return ids;
|
|
119
|
+
}
|
|
120
|
+
function hasAny(values, candidates) {
|
|
121
|
+
return candidates.some(candidate => values.has(candidate));
|
|
122
|
+
}
|