@ciphore/radiocli 0.1.9 → 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 (58) hide show
  1. package/CHANGELOG.md +65 -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 +53 -3
  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 +179 -98
  12. package/dist/providers/cache.js +77 -13
  13. package/dist/providers/provider-manager.js +28 -8
  14. package/dist/providers/radio-browser.js +141 -57
  15. package/dist/safety.js +44 -0
  16. package/dist/storage/store.js +264 -149
  17. package/dist/types.js +2 -53
  18. package/dist/ui/AdaptiveContent.js +332 -0
  19. package/dist/ui/App.js +489 -54
  20. package/dist/ui/AppContent.js +11 -12
  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 +10 -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 +15 -2
  35. package/dist/ui/screen-items.js +60 -21
  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 +109 -10
  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 +44 -0
  50. package/dist/ui/theme.js +0 -1
  51. package/dist/ui/use-app-input.js +37 -6
  52. package/dist/ui/use-command-executor.js +34 -1
  53. package/dist/ui/visualizers/receiver-style-registry.js +101 -0
  54. package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
  55. package/dist/update-check.js +122 -0
  56. package/docs/THIRD_PARTY_NOTICES.md +7 -0
  57. package/package.json +2 -2
  58. package/dist/ui/screens/screen-render.test.js +0 -214
package/dist/cli.js CHANGED
@@ -8,7 +8,10 @@ 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';
14
+ import { checkForUpdate, updateCommandForInstall } from './update-check.js';
12
15
  if (isDirectRun(process.argv[1], import.meta.url)) {
13
16
  const args = process.argv.slice(2);
14
17
  if (args.length > 0) {
@@ -20,7 +23,8 @@ if (isDirectRun(process.argv[1], import.meta.url)) {
20
23
  else {
21
24
  const [{ render }, { App }] = await Promise.all([import('ink'), import('./ui/App.js')]);
22
25
  render(_jsx(App, {}), {
23
- exitOnCtrlC: true,
26
+ // App owns Ctrl+C so it can confirm before performing a clean shutdown.
27
+ exitOnCtrlC: false,
24
28
  kittyKeyboard: {
25
29
  mode: 'auto',
26
30
  flags: ['disambiguateEscapeCodes', 'reportEventTypes', 'reportAllKeysAsEscapeCodes']
@@ -43,10 +47,28 @@ export async function runCommand(args) {
43
47
  }
44
48
  if (command === 'doctor') {
45
49
  const backends = detectPlaybackBackends();
50
+ if (rest.includes('--json')) {
51
+ console.log(JSON.stringify(doctorReport(backends), null, 2));
52
+ return;
53
+ }
46
54
  console.log(`backends=${backends.join(',') || 'none'}`);
47
55
  printPlaybackBackendStatus(backends);
48
56
  return;
49
57
  }
58
+ if (command === 'update') {
59
+ const updateCheck = await checkForUpdate();
60
+ const updateCommand = updateCommandForInstall();
61
+ if (updateCheck.error) {
62
+ console.log(`update_check=failed ${updateCheck.error}`);
63
+ }
64
+ else {
65
+ console.log(`installed=${appVersion()}`);
66
+ console.log(`latest=${updateCheck.latestVersion ?? 'unknown'}`);
67
+ console.log(`available=${updateCheck.updateAvailable ? 'yes' : 'no'}`);
68
+ }
69
+ console.log(`command=${updateCommand.command}`);
70
+ return;
71
+ }
50
72
  if (command === 'check') {
51
73
  const store = new JsonLibraryStore();
52
74
  const providers = new ProviderManager();
@@ -120,7 +142,8 @@ Usage:
120
142
  radiocli Start the TUI
121
143
  radiocli version Print the installed version
122
144
  radiocli check Show provider/backend health
123
- radiocli doctor Show local playback setup guidance
145
+ radiocli doctor [--json] Show local playback setup guidance
146
+ radiocli update Show update availability and install command
124
147
  radiocli countries Print top countries
125
148
  radiocli search <query> Search public stations
126
149
  radiocli import <file> Import .m3u, .pls, or .xspf streams
@@ -140,10 +163,37 @@ export function isDirectRun(entryPath, moduleUrl) {
140
163
  }
141
164
  }
142
165
  function isKnownCommand(command) {
143
- return ['check', 'doctor', 'countries', 'search', 'import', 'export', 'add-url'].includes(command);
166
+ return ['check', 'doctor', 'update', 'countries', 'search', 'import', 'export', 'add-url'].includes(command);
144
167
  }
145
168
  function printPlaybackBackendStatus(backends) {
146
169
  for (const line of playbackBackendStatusLines(backends)) {
147
170
  console.log(line);
148
171
  }
149
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
+ }