@ciphore/radiocli 0.2.0 → 0.2.1

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +107 -411
  3. package/SECURITY.md +6 -3
  4. package/dist/activity/stats.js +14 -2
  5. package/dist/cli.js +36 -2
  6. package/dist/player/airplay-discovery.js +16 -4
  7. package/dist/player/airplay-worker-protocol.js +1 -0
  8. package/dist/player/airplay-worker.js +4 -1
  9. package/dist/player/command.js +37 -14
  10. package/dist/player/mpv-ipc-client.js +194 -0
  11. package/dist/player/player-controller.js +169 -103
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +26 -6
  14. package/dist/providers/radio-browser.js +139 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +253 -152
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +251 -47
  20. package/dist/ui/AppContent.js +9 -10
  21. package/dist/ui/app-state.js +26 -16
  22. package/dist/ui/ascii.js +42 -1
  23. package/dist/ui/components/Logo.js +6 -1
  24. package/dist/ui/components/ScreenHeader.js +2 -2
  25. package/dist/ui/components/StationList.js +8 -6
  26. package/dist/ui/components/TopTabs.js +8 -7
  27. package/dist/ui/cosmo-land-data.js +1 -1
  28. package/dist/ui/exit-confirmation.js +7 -0
  29. package/dist/ui/explore-map-layout.js +3 -1
  30. package/dist/ui/format.js +56 -2
  31. package/dist/ui/help-content.js +9 -2
  32. package/dist/ui/layout.js +26 -9
  33. package/dist/ui/page-footer.js +36 -13
  34. package/dist/ui/playback-footer.js +12 -1
  35. package/dist/ui/screen-items.js +60 -22
  36. package/dist/ui/screen-meta.js +35 -0
  37. package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
  38. package/dist/ui/screens/CountriesScreen.js +8 -5
  39. package/dist/ui/screens/ExploreScreen.js +1 -1
  40. package/dist/ui/screens/HelpScreen.js +17 -3
  41. package/dist/ui/screens/HomeScreen.js +2 -3
  42. package/dist/ui/screens/MapScreen.js +2 -2
  43. package/dist/ui/screens/NowPlayingScreen.js +31 -51
  44. package/dist/ui/screens/SearchScreen.js +1 -1
  45. package/dist/ui/screens/SettingsScreen.js +93 -21
  46. package/dist/ui/screens/StationScreen.js +14 -1
  47. package/dist/ui/screens/StatsScreen.js +35 -44
  48. package/dist/ui/system-actions.js +10 -3
  49. package/dist/ui/terminal-mouse.js +29 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +24 -7
  52. package/dist/ui/use-command-executor.js +28 -0
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  56. package/package.json +2 -2
  57. package/dist/ui/screens/screen-render.test.js +0 -235
package/SECURITY.md CHANGED
@@ -17,13 +17,16 @@ information, or unexpected command execution.
17
17
 
18
18
  ## Privacy Notes
19
19
 
20
- - Nearby station discovery is opt-in.
21
- - Location lookup uses approximate IP-based location from `ipapi.co` when
22
- enabled.
20
+ - Nearby location is enabled by default for new libraries, but no location
21
+ request is made until the Nearby screen is opened.
22
+ - Location lookup uses approximate IP-based location from `ipapi.co`; disable it
23
+ with `l`, Settings, or `:location off`.
23
24
  - The app stores recents, favorites, settings, imports, and provider cache data
24
25
  locally under the user data directory.
25
26
  - RadioCLI does not proxy audio. It resolves public stream URLs and hands
26
27
  playback to `mpv` or `ffplay`.
28
+ - Favoriting a Radio Browser station sends a best-effort vote to that public
29
+ directory by default; the Settings screen can disable vote sharing.
27
30
 
28
31
  ## Supported Versions
29
32
 
@@ -48,7 +48,13 @@ export function computeListeningStats(sessions, now = new Date()) {
48
48
  }
49
49
  function sessionSeconds(session, now = new Date()) {
50
50
  const started = Date.parse(session.startedAt);
51
- const ended = session.endedAt ? Date.parse(session.endedAt) : now.getTime();
51
+ const ended = session.endedAt
52
+ ? Date.parse(session.endedAt)
53
+ : session.lastActiveAt
54
+ ? Date.parse(session.lastActiveAt)
55
+ : Number.isFinite(started)
56
+ ? started + Math.max(0, session.listenedSeconds) * 1000
57
+ : now.getTime();
52
58
  if (!Number.isFinite(started) || !Number.isFinite(ended) || ended <= started) {
53
59
  return Math.min(maxContinuousListeningSeconds, Math.max(0, Math.round(session.listenedSeconds)));
54
60
  }
@@ -65,7 +71,13 @@ function splitSessionByDay(session, seconds, firstDay, lastDayEnd, now) {
65
71
  if (!Number.isFinite(started)) {
66
72
  return [];
67
73
  }
68
- const recordedEnd = session.endedAt ? Date.parse(session.endedAt) : now.getTime();
74
+ const recordedEnd = session.endedAt
75
+ ? Date.parse(session.endedAt)
76
+ : session.lastActiveAt
77
+ ? Date.parse(session.lastActiveAt)
78
+ : Number.isFinite(started)
79
+ ? started + seconds * 1000
80
+ : now.getTime();
69
81
  const rawEnd = Number.isFinite(recordedEnd) && recordedEnd > started
70
82
  ? recordedEnd
71
83
  : started + seconds * 1000;
package/dist/cli.js CHANGED
@@ -8,6 +8,8 @@ import { PlayerController } from './player/player-controller.js';
8
8
  import { JsonLibraryStore } from './storage/store.js';
9
9
  import { parsePlaylistFile, stationFromUrl, writeM3u } from './playlists/playlist.js';
10
10
  import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
11
+ import { resolveCommand } from './player/command.js';
12
+ import { airPlaySenderHealth } from './player/airplay-sender-health.js';
11
13
  import { appVersion } from './version.js';
12
14
  import { checkForUpdate, updateCommandForInstall } from './update-check.js';
13
15
  if (isDirectRun(process.argv[1], import.meta.url)) {
@@ -21,7 +23,8 @@ if (isDirectRun(process.argv[1], import.meta.url)) {
21
23
  else {
22
24
  const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
23
25
  render(_jsx(App, {}), {
24
- exitOnCtrlC: true,
26
+ // App owns Ctrl+C so it can confirm before performing a clean shutdown.
27
+ exitOnCtrlC: false,
25
28
  kittyKeyboard: {
26
29
  mode: 'auto',
27
30
  flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']
@@ -44,6 +47,10 @@ export async function runCommand(args) {
44
47
  }
45
48
  if (command === 'doctor') {
46
49
  const backends = detectPlaybackBackends();
50
+ if (rest.includes('--json')) {
51
+ console.log(JSON.stringify(doctorReport(backends), null, 2));
52
+ return;
53
+ }
47
54
  console.log(`backends=${backends.join(',') || 'none'}`);
48
55
  printPlaybackBackendStatus(backends);
49
56
  return;
@@ -135,7 +142,7 @@ Usage:
135
142
  radiocli Start the TUI
136
143
  radiocli version Print the installed version
137
144
  radiocli check Show provider/backend health
138
- radiocli doctor Show local playback setup guidance
145
+ radiocli doctor [--json] Show local playback setup guidance
139
146
  radiocli update Show update availability and install command
140
147
  radiocli countries Print top countries
141
148
  radiocli search <query> Search public stations
@@ -163,3 +170,30 @@ function printPlaybackBackendStatus(backends) {
163
170
  console.log(line);
164
171
  }
165
172
  }
173
+ function doctorReport(backends) {
174
+ const commands = Object.fromEntries(['mpv', 'ffplay', 'vlc', 'cvlc', 'ffmpeg', 'dns-sd'].map(command => [command, redactHome(resolveCommand(command))]));
175
+ const airPlay = airPlaySenderHealth();
176
+ return {
177
+ radioCliVersion: appVersion(),
178
+ nodeVersion: process.version,
179
+ platform: process.platform,
180
+ architecture: process.arch,
181
+ backends,
182
+ commands,
183
+ airPlay: {
184
+ available: airPlay.available,
185
+ safe: airPlay.safe,
186
+ package: airPlay.packageName,
187
+ version: airPlay.version ?? null,
188
+ vulnerablePackages: airPlay.vulnerablePackages,
189
+ warningPackages: airPlay.warningPackages
190
+ },
191
+ guidance: playbackBackendStatusLines(backends)
192
+ };
193
+ }
194
+ function redactHome(path) {
195
+ if (!path)
196
+ return null;
197
+ const home = process.env.HOME ?? process.env.USERPROFILE;
198
+ return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
199
+ }
@@ -129,9 +129,23 @@ function runDnsSd(args, timeoutMs, maxOutputBytes) {
129
129
  const child = spawn('dns-sd', args, { stdio: ['ignore', 'pipe', 'pipe'] });
130
130
  let output = '';
131
131
  let killedForLimit = false;
132
+ let settled = false;
133
+ const finish = () => {
134
+ if (settled)
135
+ return;
136
+ settled = true;
137
+ clearTimeout(timer);
138
+ clearTimeout(forceTimer);
139
+ resolve(output);
140
+ };
132
141
  const timer = setTimeout(() => {
133
142
  child.kill('SIGTERM');
143
+ setTimeout(() => {
144
+ if (child.exitCode === null)
145
+ child.kill('SIGKILL');
146
+ }, 250).unref();
134
147
  }, timeoutMs);
148
+ const forceTimer = setTimeout(finish, timeoutMs + 750);
135
149
  const appendOutput = (chunk) => {
136
150
  if (Buffer.byteLength(output, 'utf8') >= maxOutputBytes) {
137
151
  if (!killedForLimit) {
@@ -150,12 +164,10 @@ function runDnsSd(args, timeoutMs, maxOutputBytes) {
150
164
  appendOutput(chunk);
151
165
  });
152
166
  child.once('error', () => {
153
- clearTimeout(timer);
154
- resolve(output);
167
+ finish();
155
168
  });
156
169
  child.once('exit', () => {
157
- clearTimeout(timer);
158
- resolve(output);
170
+ finish();
159
171
  });
160
172
  });
161
173
  }
@@ -40,6 +40,7 @@ function validateWorkerStart(value) {
40
40
  : [];
41
41
  return {
42
42
  streamUrl: boundedHttpUrl(value.streamUrl, 'streamUrl'),
43
+ ffmpegPath: boundedString(value.ffmpegPath, 'ffmpegPath', 4096),
43
44
  stationName: boundedText(value.stationName, 'stationName', maxWorkerTextBytes),
44
45
  volume: clampVolume(Number(value.volume)),
45
46
  muted: typeof value.muted === 'boolean' ? value.muted : false,
@@ -108,7 +108,7 @@ function handleCommand(command) {
108
108
  }
109
109
  function startFfmpeg(streamUrl) {
110
110
  const previous = ffmpeg;
111
- const child = spawn('ffmpeg', [
111
+ const child = spawn(start.ffmpegPath, [
112
112
  '-hide_banner',
113
113
  '-loglevel',
114
114
  'error',
@@ -208,7 +208,10 @@ function stop(code) {
208
208
  ffmpeg.kill('SIGTERM');
209
209
  }
210
210
  ffmpeg = null;
211
+ const watchdog = setTimeout(() => process.exit(code), 1500);
212
+ watchdog.unref();
211
213
  airtunes.stopAll(() => {
214
+ clearTimeout(watchdog);
212
215
  emit({ type: 'stopped' });
213
216
  process.exit(code);
214
217
  });
@@ -1,5 +1,4 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
1
+ import { accessSync, constants, existsSync } from 'node:fs';
3
2
  import { homedir } from 'node:os';
4
3
  import { join } from 'node:path';
5
4
  export function commandExists(command) {
@@ -11,23 +10,31 @@ export function commandExists(command) {
11
10
  // package-manager shim looks "missing" to a bare PATH lookup. We therefore fall
12
11
  // back to probing well-known install locations before giving up.
13
12
  export function resolveCommand(command) {
14
- return lookupOnPath(command) ?? probeKnownLocations(command);
13
+ const cached = commandCache.get(command);
14
+ if (cached && Date.now() - cached.checkedAt < commandCacheTtlMs) {
15
+ return cached.path;
16
+ }
17
+ const path = lookupOnPath(command) ?? probeKnownLocations(command);
18
+ commandCache.set(command, { path, checkedAt: Date.now() });
19
+ return path;
15
20
  }
16
21
  function lookupOnPath(command) {
17
- const lookup = process.platform === 'win32' ? 'where' : 'which';
18
- const result = spawnSync(lookup, [command], { encoding: 'utf8' });
19
- if (result.status !== 0 || typeof result.stdout !== 'string') {
20
- return null;
22
+ if (command.includes('/') || command.includes('\\')) {
23
+ return isRunnable(command) ? command : null;
24
+ }
25
+ const pathEntries = (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':').filter(Boolean);
26
+ for (const directory of pathEntries) {
27
+ for (const name of candidateNames(command)) {
28
+ const candidate = join(directory.replace(/^"|"$/g, ''), name);
29
+ if (isRunnable(candidate))
30
+ return candidate;
31
+ }
21
32
  }
22
- const first = result.stdout
23
- .split(/\r?\n/)
24
- .map(line => line.trim())
25
- .find(Boolean);
26
- return first ?? null;
33
+ return null;
27
34
  }
28
35
  function probeKnownLocations(command) {
29
36
  for (const candidate of candidatePaths(command)) {
30
- if (existsSync(candidate)) {
37
+ if (isRunnable(candidate)) {
31
38
  return candidate;
32
39
  }
33
40
  }
@@ -46,10 +53,26 @@ function candidatePaths(command) {
46
53
  }
47
54
  function candidateNames(command) {
48
55
  if (process.platform === 'win32' && !/\.[a-z0-9]+$/i.test(command)) {
49
- return [`${command}.exe`, `${command}.com`, `${command}.bat`, `${command}.cmd`, command];
56
+ const extensions = (process.env.PATHEXT ?? '.EXE;.COM;.BAT;.CMD').split(';').filter(Boolean);
57
+ return [...extensions.map(extension => `${command}${extension.toLowerCase()}`), command];
50
58
  }
51
59
  return [command];
52
60
  }
61
+ const commandCacheTtlMs = 5000;
62
+ const commandCache = new Map();
63
+ function isRunnable(path) {
64
+ if (!existsSync(path))
65
+ return false;
66
+ if (process.platform === 'win32')
67
+ return true;
68
+ try {
69
+ accessSync(path, constants.X_OK);
70
+ return true;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
53
76
  function knownBinaryDirs() {
54
77
  const home = homedir();
55
78
  if (process.platform === 'darwin') {
@@ -0,0 +1,194 @@
1
+ import { Socket } from 'node:net';
2
+ /**
3
+ * Multiplexes mpv JSON IPC requests over one reusable socket.
4
+ *
5
+ * mpv may also emit unsolicited event objects on this connection; responses
6
+ * are therefore matched strictly by request_id. A dropped connection rejects
7
+ * every in-flight request, while the next query reconnects lazily.
8
+ */
9
+ export class MpvIpcClient {
10
+ path;
11
+ timeoutMs;
12
+ socket = null;
13
+ connectingSocket = null;
14
+ connecting = null;
15
+ pending = new Map();
16
+ buffer = '';
17
+ nextRequestId = 1;
18
+ closed = false;
19
+ constructor(path, timeoutMs = 1000) {
20
+ this.path = path;
21
+ this.timeoutMs = timeoutMs;
22
+ }
23
+ async query(payload) {
24
+ if (this.closed) {
25
+ throw new Error('mpv IPC client is closed.');
26
+ }
27
+ const socket = await this.connect();
28
+ const requestId = this.allocateRequestId();
29
+ const requestPayload = attachMpvRequestId(payload, requestId);
30
+ const message = `${JSON.stringify(requestPayload)}\n`;
31
+ return new Promise((resolve, reject) => {
32
+ const timeout = setTimeout(() => {
33
+ this.settleRequest(requestId, pending => pending.reject(new Error('mpv IPC timed out.')));
34
+ }, this.timeoutMs);
35
+ this.pending.set(requestId, {
36
+ resolve: value => resolve((value ?? null)),
37
+ reject,
38
+ timeout
39
+ });
40
+ try {
41
+ socket.write(message, error => {
42
+ if (error) {
43
+ this.settleRequest(requestId, pending => pending.reject(error));
44
+ }
45
+ });
46
+ }
47
+ catch (error) {
48
+ this.settleRequest(requestId, pending => pending.reject(error instanceof Error ? error : new Error('mpv IPC write failed.')));
49
+ }
50
+ });
51
+ }
52
+ close(reason = new Error('mpv IPC connection closed.')) {
53
+ if (this.closed) {
54
+ return;
55
+ }
56
+ this.closed = true;
57
+ this.rejectPending(reason);
58
+ this.socket?.destroy();
59
+ this.connectingSocket?.destroy();
60
+ this.socket = null;
61
+ this.connectingSocket = null;
62
+ this.connecting = null;
63
+ this.buffer = '';
64
+ }
65
+ connect() {
66
+ if (this.socket && !this.socket.destroyed) {
67
+ return Promise.resolve(this.socket);
68
+ }
69
+ if (this.connecting) {
70
+ return this.connecting;
71
+ }
72
+ const socket = new Socket();
73
+ this.connectingSocket = socket;
74
+ const connecting = new Promise((resolve, reject) => {
75
+ let settled = false;
76
+ const connectionTimeout = setTimeout(() => failConnection(new Error('mpv IPC connection timed out.')), this.timeoutMs);
77
+ const closeBeforeConnection = () => failConnection(new Error('mpv IPC connection closed before it was ready.'));
78
+ const failConnection = (error) => {
79
+ if (settled) {
80
+ return;
81
+ }
82
+ settled = true;
83
+ clearTimeout(connectionTimeout);
84
+ socket.off('connect', finishConnection);
85
+ socket.off('close', closeBeforeConnection);
86
+ socket.destroy();
87
+ reject(error);
88
+ };
89
+ const finishConnection = () => {
90
+ if (settled) {
91
+ return;
92
+ }
93
+ settled = true;
94
+ clearTimeout(connectionTimeout);
95
+ socket.off('error', failConnection);
96
+ socket.off('close', closeBeforeConnection);
97
+ if (this.closed) {
98
+ socket.destroy();
99
+ reject(new Error('mpv IPC client is closed.'));
100
+ return;
101
+ }
102
+ this.socket = socket;
103
+ this.connectingSocket = null;
104
+ this.buffer = '';
105
+ socket.on('data', chunk => this.consume(chunk.toString('utf8')));
106
+ socket.on('error', error => this.dropSocket(socket, error));
107
+ socket.on('close', () => this.dropSocket(socket, new Error('mpv IPC connection closed.')));
108
+ resolve(socket);
109
+ };
110
+ socket.once('error', failConnection);
111
+ socket.once('close', closeBeforeConnection);
112
+ socket.once('connect', finishConnection);
113
+ socket.connect(this.path);
114
+ });
115
+ let trackedConnection;
116
+ trackedConnection = connecting.finally(() => {
117
+ if (this.connecting === trackedConnection) {
118
+ this.connecting = null;
119
+ }
120
+ if (this.connectingSocket === socket && !this.socket) {
121
+ this.connectingSocket = null;
122
+ }
123
+ });
124
+ this.connecting = trackedConnection;
125
+ return trackedConnection;
126
+ }
127
+ consume(chunk) {
128
+ this.buffer += chunk;
129
+ let newlineIndex = this.buffer.indexOf('\n');
130
+ while (newlineIndex !== -1) {
131
+ const line = this.buffer.slice(0, newlineIndex);
132
+ this.buffer = this.buffer.slice(newlineIndex + 1);
133
+ newlineIndex = this.buffer.indexOf('\n');
134
+ if (!line.trim()) {
135
+ continue;
136
+ }
137
+ let response;
138
+ try {
139
+ response = JSON.parse(line);
140
+ }
141
+ catch {
142
+ continue;
143
+ }
144
+ if (typeof response.request_id !== 'number' || !this.pending.has(response.request_id)) {
145
+ continue;
146
+ }
147
+ if (response.error && response.error !== 'success') {
148
+ this.settleRequest(response.request_id, pending => pending.reject(new Error(`mpv IPC failed: ${response.error}`)));
149
+ }
150
+ else {
151
+ this.settleRequest(response.request_id, pending => pending.resolve(response.data));
152
+ }
153
+ }
154
+ }
155
+ dropSocket(socket, error) {
156
+ if (this.socket !== socket) {
157
+ return;
158
+ }
159
+ this.socket = null;
160
+ this.buffer = '';
161
+ this.rejectPending(error);
162
+ }
163
+ rejectPending(error) {
164
+ for (const requestId of [...this.pending.keys()]) {
165
+ this.settleRequest(requestId, pending => pending.reject(error));
166
+ }
167
+ }
168
+ settleRequest(requestId, settle) {
169
+ const pending = this.pending.get(requestId);
170
+ if (!pending) {
171
+ return;
172
+ }
173
+ this.pending.delete(requestId);
174
+ clearTimeout(pending.timeout);
175
+ settle(pending);
176
+ }
177
+ allocateRequestId() {
178
+ while (this.pending.has(this.nextRequestId)) {
179
+ this.nextRequestId = incrementRequestId(this.nextRequestId);
180
+ }
181
+ const requestId = this.nextRequestId;
182
+ this.nextRequestId = incrementRequestId(this.nextRequestId);
183
+ return requestId;
184
+ }
185
+ }
186
+ function attachMpvRequestId(payload, requestId) {
187
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
188
+ return { ...payload, request_id: requestId };
189
+ }
190
+ return { command: payload, request_id: requestId };
191
+ }
192
+ function incrementRequestId(requestId) {
193
+ return requestId >= Number.MAX_SAFE_INTEGER ? 1 : requestId + 1;
194
+ }