@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to RadioCLI are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-05-30
|
|
9
|
+
|
|
10
|
+
Initial public release.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- A square braille spinner now animates to the left of the station name in the
|
|
15
|
+
live playback footer while a station is buffering.
|
|
16
|
+
- Ink-based terminal UI with overview, now playing, library, explore, search,
|
|
17
|
+
countries, world map, nearby, stats, and settings screens.
|
|
18
|
+
- Radio Browser provider with mirror fallback, durable cache, and tune
|
|
19
|
+
resolution, plus an experimental, opt-in Radio Garden provider.
|
|
20
|
+
- `mpv` playback with `ffplay` fallback, readiness checks, ICY metadata polling,
|
|
21
|
+
volume, pause, mute, sleep timer, tune timeouts, and skip-broken-stream
|
|
22
|
+
behavior.
|
|
23
|
+
- Receiver-style Now Playing screen with 50 selectable visualizers and
|
|
24
|
+
zero-signal frames whenever playback is idle, paused, stopped, or not
|
|
25
|
+
backend-ready.
|
|
26
|
+
- Explore map: a braille world map beside the live station list with WASD
|
|
27
|
+
movement, click-to-place mouse support, and a cached geotagged station atlas
|
|
28
|
+
ranked by distance.
|
|
29
|
+
- Local-first library: recents, favorites, imports, listening activity,
|
|
30
|
+
settings, learned media keys, and provider cache stored as JSON on your
|
|
31
|
+
machine.
|
|
32
|
+
- Playlist import/export for `.m3u`, `.pls`, and `.xspf`.
|
|
33
|
+
- Listening stats with a 52-week local-calendar contribution graph, sessions,
|
|
34
|
+
streaks, active days, and total hours listened.
|
|
35
|
+
- `radiocli doctor` to report local playback readiness and print OS-specific
|
|
36
|
+
setup guidance.
|
|
37
|
+
- Homebrew formula template for `brew install` distribution.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- Tightened the Now Playing panel below the receiver: collapsed the stream
|
|
42
|
+
tech/tags, track metadata, and backend/volume rows into two compact lines
|
|
43
|
+
(track + favorite, then a single tech · tags · sleep line), dropping
|
|
44
|
+
backend/volume that already appear in the header and footer. The reclaimed
|
|
45
|
+
rows go to the visualizer.
|
|
46
|
+
|
|
47
|
+
[0.1.0]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
RadioCLI follows the Contributor Covenant standard in spirit: be respectful,
|
|
4
|
+
constructive, and precise. This project welcomes users and contributors who care
|
|
5
|
+
about terminal software, radio, resilient public APIs, and thoughtful product
|
|
6
|
+
craft.
|
|
7
|
+
|
|
8
|
+
Unacceptable behavior includes harassment, personal attacks, deliberate
|
|
9
|
+
disruption, doxxing, or dismissive conduct toward people reporting issues in
|
|
10
|
+
good faith.
|
|
11
|
+
|
|
12
|
+
Project maintainers may remove comments, close issues, or block participants
|
|
13
|
+
when behavior makes the project less useful or less welcoming.
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for considering a contribution. RadioCLI is intentionally small, but
|
|
4
|
+
the bar is high: changes should improve the product, preserve terminal
|
|
5
|
+
ergonomics, and respect the unreliability of public radio streams.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm ci
|
|
11
|
+
npm run build
|
|
12
|
+
npm run test
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Install `mpv` for the best playback experience:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
brew install mpv
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
On Linux, use your package manager's `mpv` package.
|
|
22
|
+
|
|
23
|
+
## Verification
|
|
24
|
+
|
|
25
|
+
Run these before opening a PR:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm run check
|
|
29
|
+
npm run lint
|
|
30
|
+
npm run test
|
|
31
|
+
npm run build
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Run `npm run smoke:playback` when changing playback code. It opens a public
|
|
35
|
+
stream briefly, so do not run it in environments where audio/network access is
|
|
36
|
+
not acceptable.
|
|
37
|
+
|
|
38
|
+
Live provider checks are useful before releases, but ordinary pull requests
|
|
39
|
+
should stay deterministic. Run `npm run verify:release` for a full maintainer
|
|
40
|
+
pre-release pass that includes live data, playback, packaging, and fresh-install
|
|
41
|
+
checks.
|
|
42
|
+
|
|
43
|
+
## Development Principles
|
|
44
|
+
|
|
45
|
+
- Keep public provider adapters isolated.
|
|
46
|
+
- Treat station streams as unreliable.
|
|
47
|
+
- Never make location lookup implicit.
|
|
48
|
+
- Prefer clear terminal text over decorative filler.
|
|
49
|
+
- Keep command-line and TUI behavior aligned.
|
|
50
|
+
- Add tests for parsing, storage, layout, or provider transformations when the
|
|
51
|
+
behavior can be checked without live audio.
|
|
52
|
+
|
|
53
|
+
## Issue Triage
|
|
54
|
+
|
|
55
|
+
Playback issues should include station name, country, backend, and
|
|
56
|
+
`radiocli check` output when possible.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ciphore
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# RadioCLI
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Ciphore/RadioCLI/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](package.json)
|
|
6
|
+
|
|
7
|
+
RadioCLI is a terminal-first world radio receiver for exploring live public stations, tuning real streams, and keeping your listening history close to the command line.
|
|
8
|
+
|
|
9
|
+
It is built with [Ink](https://github.com/vadimdemedes/ink), [React](https://react.dev/), [Radio Browser](https://api.radio-browser.info/), and `mpv`. The goal is not a thin wrapper around a station list. The goal is a polished TUI product: fast discovery, resilient stream handling, local-first library state, and the kind of engineering surface that can grow without turning into terminal spaghetti.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Explore public radio from around the world through a cosmo-style braille world map beside the station list, with click-to-place mouse support and WASD keyboard movement backed by a cached geotagged station atlas. Country lists, global station search, a country-density map, and opt-in nearby discovery round out the discovery surface.
|
|
14
|
+
- Tune stations with `mpv` first and `ffplay` fallback when available.
|
|
15
|
+
- Use a receiver-style Now Playing screen with 50 selectable receiver visualizers, backend status, cleaned ICY track metadata, stream diagnostics, sleep timer, favorite state, volume, pause, mute, station skipping, and zero-signal graphics whenever playback is idle, paused, stopped, or not backend-ready.
|
|
16
|
+
- Keep shortcuts in a fixed adaptive footer: a compact live station and track row appears above page-specific and global controls while playback is active.
|
|
17
|
+
- Move previous/next through the exact station list you tuned from, even after navigating to another screen.
|
|
18
|
+
- Browse dense station lists with inline location/codec metadata and yellow favorite stars next to station names.
|
|
19
|
+
- Search by station name, place, language, tag, codec, or minimum bitrate.
|
|
20
|
+
- Keep local recents, favorites, imported stations, listening activity, playback settings, learned media keys, and provider cache.
|
|
21
|
+
- Review listening stats with a GitHub-style local-calendar contribution graph, favorite station, thresholded stations listened, sessions, streaks, active days, and total hours listened from persisted sessions.
|
|
22
|
+
- Import `.m3u`, `.pls`, and `.xspf` playlists, including nested local playlists.
|
|
23
|
+
- Export favorites and imports as `.m3u`.
|
|
24
|
+
- Survive ordinary internet-radio failure modes with provider mirror fallback, stale cache fallback, corrupt-file backups, tune timeouts, and skip-broken-stream behavior.
|
|
25
|
+
- Resize with the terminal. The app listens for terminal resize events and recomputes list row counts, map density, receiver width, and compact-mode fallback from the current dimensions.
|
|
26
|
+
|
|
27
|
+
## Demo
|
|
28
|
+
|
|
29
|
+
The interactive TUI opens directly into the product, not a marketing screen:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
┌ RADIOCLI ──────────────────────────────────────────────────────────┐
|
|
33
|
+
│ Overview │ Playing │ Library │ Explore │ Search │ Countries │ … │ idle │
|
|
34
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
35
|
+
RADIOCLI ██████████████████
|
|
36
|
+
Live public radio from around the world
|
|
37
|
+
|
|
38
|
+
> 1 Playing · Receiver display and controls
|
|
39
|
+
2 Library · Favorites, recent stations, imported streams
|
|
40
|
+
3 Explore · Move a map cursor through geotagged stations
|
|
41
|
+
4 Search · Find stations by name, genre, language, place
|
|
42
|
+
5 Countries · Browse by country list with a world-map toggle
|
|
43
|
+
6 Nearby · Opt-in approximate location for local stations
|
|
44
|
+
7 Stats · Listening graph, sessions, streaks, hours
|
|
45
|
+
8 Settings · Playback backend, colors, providers
|
|
46
|
+
|
|
47
|
+
3 recent · 2 favorites · 1 imported
|
|
48
|
+
|
|
49
|
+
↑/↓ move · Enter open · number jump · : command
|
|
50
|
+
←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The Now Playing screen is a framed receiver panel with **50 selectable receiver styles**. The sample below shows the default pulse-grid display; press `v` to cycle through the catalog, which spans several families:
|
|
54
|
+
|
|
55
|
+
- **Classic receiver** — equalizer, LEDs, and goniometer.
|
|
56
|
+
- **High-resolution braille** — smooth waveform, radial EQ, spectrogram, nebula, silk, ripple tank, phyllotaxis, harmonograph, bloom bars, moiré, galaxy, caustics.
|
|
57
|
+
- **Generative & motion** — matrix, hologram, spinning ASCII cube, generated fire, fireworks, plasma, spinning donut, starfield, Lorenz attractor, Barnsley fern, Chladni plate, rotating tesseract, torus knot, fractal tree, Julia sets, and lava-lamp motion.
|
|
58
|
+
|
|
59
|
+
Visualizers animate only while playback is actually playing and backend-ready; paused, stopped, idle, loading, and error states render a flat zero-signal display instead of a frozen waveform:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
Now playing ──────────────────────────────────────────────── mpv · playing
|
|
63
|
+
╭────────────────────────────────────────────────────────────────────────╮
|
|
64
|
+
│ FM 128.M RADIOCLI PLAYING │
|
|
65
|
+
│ KEXP 90.3 FM │
|
|
66
|
+
│ UNITED STATES · WASHINGTON │
|
|
67
|
+
│ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
|
|
68
|
+
│ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
|
|
69
|
+
│ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
|
|
70
|
+
│ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
|
|
71
|
+
│ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
|
|
72
|
+
│ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ ▌ │
|
|
73
|
+
│ MP3 · 128 kbps · english │
|
|
74
|
+
│ alternative, indie, seattle │
|
|
75
|
+
│ Waiting for ICY track metadata │
|
|
76
|
+
│ Backend mpv · Vol 70 ☆ Add favorite (f) Sleep off │
|
|
77
|
+
╰────────────────────────────────────────────────────────────────────────╯
|
|
78
|
+
|
|
79
|
+
Playing: KEXP 90.3 FM · Now: Artist - Track · Seattle, Washington, United States · MP3 / 128 kbps / english · mpv · playing · vol 70 · Nearby 4/90
|
|
80
|
+
space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b home
|
|
81
|
+
←/→ tabs · F7/F9 or ,/. station · F8 pause · t/v display · +/- volume · q quit
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For the exact non-interactive demo transcript:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm run demo:script
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Recording instructions live in [the demo guide](apps/docs/content/docs/demo.mdx).
|
|
91
|
+
|
|
92
|
+
## Documentation Website
|
|
93
|
+
|
|
94
|
+
The public website and the manual now live together in `apps/docs`, a Fumadocs-powered Next app. It renders the homepage at `/`, the documentation tree at `/docs`, docs search at `/api/search`, generated page images, and LLM-readable text routes.
|
|
95
|
+
|
|
96
|
+
Docs content is written as MDX in `apps/docs/content/docs`, with the navigation tree defined by the local `meta.json` files.
|
|
97
|
+
|
|
98
|
+
Useful commands from the repo root:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm run docs:dev
|
|
102
|
+
npm run docs:check
|
|
103
|
+
npm run docs:build
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Set `NEXT_PUBLIC_SITE_URL` for the canonical public docs URL. Preview builds also read Vercel's `VERCEL_URL` and Cloudflare Pages' `CF_PAGES_URL`.
|
|
107
|
+
|
|
108
|
+
## Install
|
|
109
|
+
|
|
110
|
+
Requirements:
|
|
111
|
+
|
|
112
|
+
- Node.js 22 or newer
|
|
113
|
+
- `mpv` for best playback; RadioCLI expects one local playback backend at runtime
|
|
114
|
+
- `ffplay` from FFmpeg as an optional fallback
|
|
115
|
+
|
|
116
|
+
NPM installs RadioCLI and its JavaScript dependencies. It does not install native
|
|
117
|
+
system playback tools. Use `radiocli doctor` after installation to check local
|
|
118
|
+
playback readiness and get the right setup command for your OS:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npm install -g @ciphore/radiocli
|
|
122
|
+
radiocli doctor
|
|
123
|
+
radiocli
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
macOS with npm:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
brew install mpv
|
|
130
|
+
npm install -g @ciphore/radiocli
|
|
131
|
+
radiocli
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Linux with npm:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
sudo apt install mpv
|
|
138
|
+
npm install -g @ciphore/radiocli
|
|
139
|
+
radiocli
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`ffplay` is optional fallback support. Install FFmpeg separately if you want it:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
brew install ffmpeg # macOS
|
|
146
|
+
sudo apt install ffmpeg # Debian/Ubuntu
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The repo also includes a Homebrew formula template in `packaging/homebrew` for a
|
|
150
|
+
native one-command macOS tap. Once the tap formula is published, the intended
|
|
151
|
+
Homebrew install path is:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
brew install ciphore/tap/radiocli
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
That formula depends on `node` and `mpv`, keeping native dependencies in the
|
|
158
|
+
native package manager instead of running system installs from npm.
|
|
159
|
+
|
|
160
|
+
CI covers command-mode typecheck, tests, builds, and package checks on Ubuntu. Native Windows terminals are not release-tested yet; use WSL with Linux `mpv` / `ffplay` for the supported path.
|
|
161
|
+
|
|
162
|
+
Local checkout:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
git clone https://github.com/Ciphore/RadioCLI.git
|
|
166
|
+
cd RadioCLI
|
|
167
|
+
npm ci
|
|
168
|
+
npm run build
|
|
169
|
+
npm link
|
|
170
|
+
radiocli
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
If you do not want to link the package globally:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm run dev
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## CLI Usage
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
radiocli # Start the TUI
|
|
183
|
+
radiocli check # Show local store path, playback backends, provider health
|
|
184
|
+
radiocli doctor # Show playback setup status and install guidance
|
|
185
|
+
radiocli countries # Print top countries by station count
|
|
186
|
+
radiocli search "japan hits"
|
|
187
|
+
radiocli import stations.m3u
|
|
188
|
+
radiocli export favorites.m3u
|
|
189
|
+
radiocli add-url <stream-url> [station name]
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
`radiocli export` writes `radiocli-favorites.m3u` when no output path is provided.
|
|
193
|
+
|
|
194
|
+
After a local build, the same commands can be run with:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
node dist/cli.js check
|
|
198
|
+
node dist/cli.js search "lagos talk"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## TUI Controls
|
|
202
|
+
|
|
203
|
+
RadioCLI keeps shortcuts at the bottom of the terminal. When playback is active, a compact live row sits above the shortcuts with station, cleaned track metadata, volume or mute state, and an active sleep timer. The page shortcut row changes with the current screen, and the global transport row stays global:
|
|
204
|
+
|
|
205
|
+
- `←` / `→` or `Tab` / `Shift+Tab`: move across the top screen tabs.
|
|
206
|
+
- `F7` / `F9`, `,` / `.`, or `Shift+←` / `Shift+→`: tune previous or next station from the source list, wherever you are in the TUI.
|
|
207
|
+
- `space` / `F8`: pause or resume.
|
|
208
|
+
- `t`: cycle display color.
|
|
209
|
+
- `v`: cycle receiver style.
|
|
210
|
+
- `+` / `-`: volume.
|
|
211
|
+
- `q` or `Ctrl+C`: quit cleanly.
|
|
212
|
+
|
|
213
|
+
Page-specific footer controls:
|
|
214
|
+
|
|
215
|
+
| Screen | Controls |
|
|
216
|
+
| --- | --- |
|
|
217
|
+
| Home | `↑` / `↓` move, `Enter` open, number jump, `:` command |
|
|
218
|
+
| Search input | type query, `Backspace` edit, `Enter` search or tune, `Esc` finish |
|
|
219
|
+
| Search results | `/` edit query, `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `b` home |
|
|
220
|
+
| Explore | click map, `WASD` fine move, `Shift+WASD` jump, `↑` / `↓` station, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
|
|
221
|
+
| Countries | `/` filter, `↑` / `↓` move, `Enter` open stations, `w` map, `b` home |
|
|
222
|
+
| World map | `/` filter, `↑` / `↓` move, `Enter` open country, `w` list, `b` home |
|
|
223
|
+
| Station lists | `↑` / `↓` or `n` / `p` move, `Enter` tune, `f` favorite, `[` / `]` page, `b` home |
|
|
224
|
+
| Now Playing | `space` / `F8` pause, `f` favorite, `m` mute, `s` sleep, `d` diagnostics, `b` home |
|
|
225
|
+
| Settings | `Enter` change selected, `g` Radio Garden, `l` location, `x` skip broken streams, `o` backend, `r` health, `b` home |
|
|
226
|
+
| Stats | `b` home |
|
|
227
|
+
|
|
228
|
+
Other active shortcuts:
|
|
229
|
+
|
|
230
|
+
- `Enter`: open the selected item or tune the selected station without leaving the current list.
|
|
231
|
+
- `:`: command palette.
|
|
232
|
+
- `/`: edit search or country filter on screens that support it.
|
|
233
|
+
- `[` / `]`: page through long station and country lists.
|
|
234
|
+
- `m`: mute.
|
|
235
|
+
- `o`: cycle playback backend.
|
|
236
|
+
- `g`: toggle the experimental Radio Garden adapter.
|
|
237
|
+
- `l`: toggle nearby location lookup.
|
|
238
|
+
- `x`: toggle skip-broken-stream behavior.
|
|
239
|
+
- `r`: refresh provider health.
|
|
240
|
+
- `f`: favorite the current or selected station.
|
|
241
|
+
- `n` / `p`: move selection; on Now Playing, tune next or previous station from the source list.
|
|
242
|
+
- `s`: cycle the sleep timer on Now Playing through off, 15 minutes, 30 minutes, 60 minutes, then off again.
|
|
243
|
+
- `d`: stream diagnostics on Now Playing.
|
|
244
|
+
- `b`: back home.
|
|
245
|
+
|
|
246
|
+
When you tune a station from Library, Explore, Search, Countries, or Nearby, that list becomes the playback queue. Previous/next keeps moving through that source list from any screen until you tune from another list.
|
|
247
|
+
|
|
248
|
+
Hardware media keys depend on the OS and terminal. RadioCLI maximizes compatibility by enabling enhanced keyboard reporting where supported, recognizing common F7/F8/F9 sequences, Kitty consumer/media-key codes, modified-arrow sequences, comma/dot transport fallback, and learned custom bindings. Learn keys from Settings or with `:learn previous`, `:learn play`, and `:learn next`; clear them with `:keys reset`.
|
|
249
|
+
|
|
250
|
+
Explore mouse clicks use terminal mouse reporting while the Explore tab is active. If your terminal or tmux setup does not pass those events through, the WASD cursor controls stay fully available.
|
|
251
|
+
|
|
252
|
+
Useful command palette entries:
|
|
253
|
+
|
|
254
|
+
```text
|
|
255
|
+
:search lagos jazz
|
|
256
|
+
:country japan
|
|
257
|
+
:codec MP3
|
|
258
|
+
:language spanish
|
|
259
|
+
:bitrate 128
|
|
260
|
+
:clear
|
|
261
|
+
:volume 60
|
|
262
|
+
:mute
|
|
263
|
+
:favorite
|
|
264
|
+
:sleep 15
|
|
265
|
+
:sleep off
|
|
266
|
+
:timeout 15
|
|
267
|
+
:skip off
|
|
268
|
+
:location on
|
|
269
|
+
:learn previous
|
|
270
|
+
:learn play
|
|
271
|
+
:learn next
|
|
272
|
+
:keys reset
|
|
273
|
+
:map
|
|
274
|
+
:library
|
|
275
|
+
:stats
|
|
276
|
+
:settings
|
|
277
|
+
:stop
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Settings persist display colors and receiver styles without editing config files. The fourteen display colors are green, amber, blue, ruby, ice, teal, violet, copper, cyan, lime, coral, rose, slate, and mono, cycled with `t`. The 50 receiver styles span classic receiver displays, high-resolution braille visuals, and generative motion scenes; cycle them with `v` (see the [Demo](#demo) for the full family breakdown). The stats graph and legend follow the selected display color, and the selected Now Playing style is restored on the next launch.
|
|
281
|
+
|
|
282
|
+
## Architecture
|
|
283
|
+
|
|
284
|
+
RadioCLI is split around four seams:
|
|
285
|
+
|
|
286
|
+
- TUI state and screens in `src/ui`
|
|
287
|
+
- provider adapters in `src/providers`
|
|
288
|
+
- playback lifecycle and metadata in `src/player`
|
|
289
|
+
- local JSON persistence in `src/storage`
|
|
290
|
+
|
|
291
|
+
Radio Browser is the primary provider. Its own docs recommend using a speaking user agent, resolving station clicks through `/json/url`, and retrying with other servers when one fails; RadioCLI follows that shape with mirror fallback and durable cache. Explore and Nearby use a cached geotagged Radio Browser atlas, then compute local distance in the app so map movement is not biased toward the most-clicked stations worldwide. Radio Garden support is experimental because the useful endpoints are publicly discoverable but unofficial, and they can be blocked or changed independently of this project.
|
|
292
|
+
|
|
293
|
+
Playback prefers `mpv` because it handles real-world streams, redirects, HLS, codecs, and metadata better than a hand-rolled stream client. RadioCLI controls `mpv` through JSON IPC for readiness, pause, mute, volume, and metadata polling.
|
|
294
|
+
|
|
295
|
+
The npm package is `@ciphore/radiocli`, and the installed executable is `radiocli`. Current installs store data under RadioCLI paths such as `radiocli.json` and `radiocli-cache.json`. Existing Radio Atlas data is still discovered when a new RadioCLI store does not exist, and legacy `RADIO_ATLAS_HOME` / animation environment variables remain supported as migration fallbacks. New automation should use `RADIOCLI_HOME` and `RADIOCLI_DISABLE_ANIMATION`.
|
|
296
|
+
|
|
297
|
+
Read more:
|
|
298
|
+
|
|
299
|
+
- [Architecture](apps/docs/content/docs/architecture.mdx)
|
|
300
|
+
- [Design notes](apps/docs/content/docs/design.mdx)
|
|
301
|
+
- [Reliability](apps/docs/content/docs/reliability.mdx)
|
|
302
|
+
- [Roadmap](apps/docs/content/docs/roadmap.mdx)
|
|
303
|
+
- [Release packaging](apps/docs/content/docs/release-packaging.mdx)
|
|
304
|
+
|
|
305
|
+
These also render as a browsable site — run `npm run docs:dev` (see `apps/docs`).
|
|
306
|
+
|
|
307
|
+
## Engineering Highlights
|
|
308
|
+
|
|
309
|
+
This repo is intentionally small, but it is built like production software:
|
|
310
|
+
|
|
311
|
+
- provider boundary instead of UI-coupled fetch calls
|
|
312
|
+
- cached geotagged station atlas for true distance-first Explore and Nearby results
|
|
313
|
+
- Zod schemas at public API and persistence boundaries
|
|
314
|
+
- stale-cache fallback for directory outages
|
|
315
|
+
- corrupt store/cache backup instead of silent overwrite
|
|
316
|
+
- `mpv` readiness checks before reporting playback as active
|
|
317
|
+
- tune timeout and skip-broken-stream behavior
|
|
318
|
+
- cleaned ICY metadata, including key/value payloads such as `title="..." artist="..."` and station-specific `text="..."` fields
|
|
319
|
+
- source-list playback queues for previous/next transport
|
|
320
|
+
- enhanced terminal keyboard parsing with learned media-key bindings
|
|
321
|
+
- local-calendar activity bucketing for late-night listening sessions
|
|
322
|
+
- local-first privacy posture for history, favorites, imports, and settings
|
|
323
|
+
- responsive terminal layout utility with an adaptive footer and focused tests
|
|
324
|
+
- smoke tests that exercise live provider data and real playback
|
|
325
|
+
- package smoke test that packs the npm artifact, installs it into a fresh temp project, and runs the installed binary
|
|
326
|
+
|
|
327
|
+
## Privacy
|
|
328
|
+
|
|
329
|
+
Nearby station discovery is off by default. If you enable it, RadioCLI requests approximate IP-based location from `ipapi.co` and uses the returned city/region/country/coordinates to sort the local geotagged station atlas. Explore uses only the cursor coordinate you move in the terminal. The app does not require an account, does not store secrets, and does not proxy audio. It stores recents, favorites, imports, settings, and provider cache data locally on your machine.
|
|
330
|
+
|
|
331
|
+
## Development
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
npm ci
|
|
335
|
+
npm run check
|
|
336
|
+
npm run lint
|
|
337
|
+
npm run test
|
|
338
|
+
npm run build
|
|
339
|
+
npm run docs:check
|
|
340
|
+
npm run docs:build
|
|
341
|
+
npm run smoke:data
|
|
342
|
+
npm run verify
|
|
343
|
+
npm run smoke:playback
|
|
344
|
+
npm run pack:check
|
|
345
|
+
npm run fresh:check
|
|
346
|
+
npm run verify:release
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`npm run smoke:playback` briefly opens a public stream through your local playback backend.
|
|
350
|
+
|
|
351
|
+
## Contributing
|
|
352
|
+
|
|
353
|
+
Contributions are welcome when they keep the app practical, reliable, and honest about public radio streams. Start with [CONTRIBUTING.md](CONTRIBUTING.md), and include `radiocli check` output for playback issues.
|
|
354
|
+
|
|
355
|
+
## References
|
|
356
|
+
|
|
357
|
+
- [Ink README](https://github.com/vadimdemedes/ink)
|
|
358
|
+
- [Radio Browser API docs](https://api.radio-browser.info/)
|
|
359
|
+
- [mpv JSON IPC manual](https://mpv.io/manual/stable/#json-ipc)
|
|
360
|
+
- [Unofficial Radio Garden OpenAPI notes](https://github.com/jonasrmichel/radio-garden-openapi)
|
|
361
|
+
|
|
362
|
+
## License
|
|
363
|
+
|
|
364
|
+
MIT. See [LICENSE](LICENSE).
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
RadioCLI does not require secrets or accounts.
|
|
4
|
+
|
|
5
|
+
## Reporting
|
|
6
|
+
|
|
7
|
+
Open a private security advisory on GitHub:
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
https://github.com/Ciphore/RadioCLI/security/advisories
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
If private reporting is unavailable, open a minimal public issue asking for a
|
|
14
|
+
private disclosure path, but do not include exploit details. Please avoid filing
|
|
15
|
+
public issues for vulnerabilities that could expose local files, private network
|
|
16
|
+
information, or unexpected command execution.
|
|
17
|
+
|
|
18
|
+
## Privacy Notes
|
|
19
|
+
|
|
20
|
+
- Nearby station discovery is opt-in.
|
|
21
|
+
- Location lookup uses approximate IP-based location from `ipapi.co` when
|
|
22
|
+
enabled.
|
|
23
|
+
- The app stores recents, favorites, settings, imports, and provider cache data
|
|
24
|
+
locally under the user data directory.
|
|
25
|
+
- RadioCLI does not proxy audio. It resolves public stream URLs and hands
|
|
26
|
+
playback to `mpv` or `ffplay`.
|
|
27
|
+
|
|
28
|
+
## Supported Versions
|
|
29
|
+
|
|
30
|
+
Until a tagged release cadence is established, security fixes target the active
|
|
31
|
+
development branch and the latest public npm release.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { stationKey } from '../storage/store.js';
|
|
2
|
+
const trackedDays = 371;
|
|
3
|
+
const listenedStationThresholdSeconds = 120;
|
|
4
|
+
export function computeListeningStats(sessions, now = new Date()) {
|
|
5
|
+
const today = startOfLocalDay(now);
|
|
6
|
+
const firstDay = addLocalDays(today, -(trackedDays - 1));
|
|
7
|
+
const lastDayEnd = addLocalDays(today, 1);
|
|
8
|
+
const secondsByDay = new Map();
|
|
9
|
+
const secondsByStation = new Map();
|
|
10
|
+
let totalSeconds = 0;
|
|
11
|
+
for (const session of sessions) {
|
|
12
|
+
const seconds = sessionSeconds(session, now);
|
|
13
|
+
if (seconds <= 0) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
totalSeconds += seconds;
|
|
17
|
+
for (const allocation of splitSessionByDay(session, seconds, firstDay, lastDayEnd, now)) {
|
|
18
|
+
secondsByDay.set(allocation.date, (secondsByDay.get(allocation.date) ?? 0) + allocation.seconds);
|
|
19
|
+
}
|
|
20
|
+
const key = stationKey(session.station);
|
|
21
|
+
const current = secondsByStation.get(key) ?? { station: session.station, seconds: 0 };
|
|
22
|
+
secondsByStation.set(key, { station: current.station, seconds: current.seconds + seconds });
|
|
23
|
+
}
|
|
24
|
+
const days = Array.from({ length: trackedDays }, (_, index) => {
|
|
25
|
+
const date = addLocalDays(firstDay, index);
|
|
26
|
+
const key = localDay(date);
|
|
27
|
+
return { date: key, seconds: secondsByDay.get(key) ?? 0 };
|
|
28
|
+
});
|
|
29
|
+
const activeDays = days.filter(day => day.seconds > 0).length;
|
|
30
|
+
let favorite = null;
|
|
31
|
+
for (const entry of secondsByStation.values()) {
|
|
32
|
+
if (!favorite || entry.seconds > favorite.seconds) {
|
|
33
|
+
favorite = entry;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
favoriteStation: favorite?.station ?? null,
|
|
38
|
+
listenedStationCount: Array.from(secondsByStation.values()).filter(entry => entry.seconds >= listenedStationThresholdSeconds).length,
|
|
39
|
+
sessions: sessions.length,
|
|
40
|
+
currentStreak: currentStreak(days),
|
|
41
|
+
activeDays,
|
|
42
|
+
totalTrackedDays: trackedDays,
|
|
43
|
+
totalSeconds,
|
|
44
|
+
longestStreak: longestStreak(days),
|
|
45
|
+
days
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function sessionSeconds(session, now = new Date()) {
|
|
49
|
+
const started = Date.parse(session.startedAt);
|
|
50
|
+
const ended = session.endedAt ? Date.parse(session.endedAt) : now.getTime();
|
|
51
|
+
if (!Number.isFinite(started) || !Number.isFinite(ended) || ended <= started) {
|
|
52
|
+
return Math.max(0, Math.round(session.listenedSeconds));
|
|
53
|
+
}
|
|
54
|
+
return Math.max(Math.round(session.listenedSeconds), Math.round((ended - started) / 1000));
|
|
55
|
+
}
|
|
56
|
+
function localDay(date) {
|
|
57
|
+
const year = date.getFullYear();
|
|
58
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
59
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
60
|
+
return `${year}-${month}-${day}`;
|
|
61
|
+
}
|
|
62
|
+
function splitSessionByDay(session, seconds, firstDay, lastDayEnd, now) {
|
|
63
|
+
const started = Date.parse(session.startedAt);
|
|
64
|
+
if (!Number.isFinite(started)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const recordedEnd = session.endedAt ? Date.parse(session.endedAt) : now.getTime();
|
|
68
|
+
const rawEnd = Number.isFinite(recordedEnd) && recordedEnd > started
|
|
69
|
+
? recordedEnd
|
|
70
|
+
: started + seconds * 1000;
|
|
71
|
+
const end = Math.max(started, rawEnd);
|
|
72
|
+
const boundedStart = Math.max(started, firstDay.getTime());
|
|
73
|
+
const boundedEnd = Math.min(end, lastDayEnd.getTime());
|
|
74
|
+
if (boundedEnd <= boundedStart) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const allocations = [];
|
|
78
|
+
let cursor = boundedStart;
|
|
79
|
+
while (cursor < boundedEnd) {
|
|
80
|
+
const dayStart = startOfLocalDay(new Date(cursor)).getTime();
|
|
81
|
+
const nextDay = addLocalDays(new Date(dayStart), 1).getTime();
|
|
82
|
+
const segmentEnd = Math.min(nextDay, boundedEnd);
|
|
83
|
+
allocations.push({
|
|
84
|
+
date: localDay(new Date(cursor)),
|
|
85
|
+
seconds: Math.round((segmentEnd - cursor) / 1000)
|
|
86
|
+
});
|
|
87
|
+
cursor = segmentEnd;
|
|
88
|
+
}
|
|
89
|
+
return allocations;
|
|
90
|
+
}
|
|
91
|
+
function startOfLocalDay(date) {
|
|
92
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
93
|
+
}
|
|
94
|
+
function addLocalDays(date, days) {
|
|
95
|
+
const next = new Date(date.getTime());
|
|
96
|
+
next.setDate(next.getDate() + days);
|
|
97
|
+
return startOfLocalDay(next);
|
|
98
|
+
}
|
|
99
|
+
function currentStreak(days) {
|
|
100
|
+
let streak = 0;
|
|
101
|
+
for (let index = days.length - 1; index >= 0; index -= 1) {
|
|
102
|
+
if (days[index].seconds <= 0) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
streak += 1;
|
|
106
|
+
}
|
|
107
|
+
return streak;
|
|
108
|
+
}
|
|
109
|
+
function longestStreak(days) {
|
|
110
|
+
let longest = 0;
|
|
111
|
+
let current = 0;
|
|
112
|
+
for (const day of days) {
|
|
113
|
+
if (day.seconds > 0) {
|
|
114
|
+
current += 1;
|
|
115
|
+
longest = Math.max(longest, current);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
current = 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return longest;
|
|
122
|
+
}
|