@ciphore/radiocli 0.2.3 → 0.2.4
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 +77 -0
- package/CONTRIBUTING.md +36 -6
- package/README.md +54 -10
- package/dist/agent/headless-host.js +39 -17
- package/dist/agent/launcher.js +6 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/service.js +3 -3
- package/dist/agent/session.js +13 -29
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +56 -25
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +103 -38
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +203 -33
- package/dist/providers/cache.js +4 -26
- package/dist/providers/radio-browser.js +152 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +79 -151
- package/dist/storage/store.js +105 -52
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +33 -24
- package/dist/ui/App.js +173 -86
- package/dist/ui/AppContent.js +3 -3
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +6 -4
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/screen-items.js +2 -2
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +5 -1
- package/dist/ui/screens/MapScreen.js +1 -1
- package/dist/ui/screens/NowPlayingScreen.js +4 -4
- package/dist/ui/screens/SearchScreen.js +3 -1
- package/dist/ui/screens/SettingsScreen.js +14 -7
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/use-alarm-tui.js +36 -21
- package/dist/ui/use-app-input.js +42 -22
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/update-check.js +8 -17
- package/package.json +1 -1
package/dist/setup.js
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
|
+
import { componentLabel, detectPackageManager, packageCommandInvocation, packageInstallCommand, packageInstallPrerequisites, packageManagerElevation, packageManagerNeedsRoot, packageManagerNotes, packageManagerProgram, packageManagers, platformLabel } from './platform/packages.js';
|
|
1
2
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
3
4
|
import { createInterface } from 'node:readline/promises';
|
|
4
|
-
import { clearCommandCache, commandExists } from './
|
|
5
|
+
import { clearCommandCache, commandExists, resolveCommand } from './platform/executables.js';
|
|
5
6
|
import { detectPlaybackBackends, playbackBackendStatusLines } from './player/backend-install.js';
|
|
6
7
|
import { configureMcpIntegrations } from './agent/mcp-install.js';
|
|
7
8
|
import { JsonLibraryStore } from './storage/store.js';
|
|
8
9
|
import { defaultAgentControlSettings } from './types.js';
|
|
10
|
+
import { resolveTerminalCapabilities } from './platform/terminal.js';
|
|
11
|
+
import { identifyPlatform, readLinuxOsRelease } from './platform/runtime.js';
|
|
9
12
|
const components = ['mpv', 'ffmpeg', 'vlc'];
|
|
10
|
-
const packageManagers = ['brew', 'winget', 'scoop', 'choco', 'apt', 'dnf', 'pacman', 'apk', 'zypper'];
|
|
11
13
|
export async function runSetup(options = {}) {
|
|
12
14
|
const platform = options.platform ?? process.platform;
|
|
13
15
|
const osRelease = options.osRelease ?? readLinuxOsRelease(platform);
|
|
16
|
+
const env = options.env ?? process.env;
|
|
17
|
+
const host = identifyPlatform({ platform, osRelease, env });
|
|
14
18
|
const input = options.input ?? process.stdin;
|
|
15
19
|
const output = options.output ?? process.stdout;
|
|
16
20
|
const hasCommand = options.hasCommand ?? commandExists;
|
|
17
21
|
const parsed = parseSetupArgs(options.args ?? []);
|
|
22
|
+
if (parsed.packageManager)
|
|
23
|
+
validateNativePackageManager(parsed.packageManager, host);
|
|
18
24
|
const installed = detectInstalledComponents(hasCommand);
|
|
19
|
-
const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand);
|
|
25
|
+
const packageManager = parsed.packageManager ?? detectPackageManager(platform, osRelease, hasCommand, env);
|
|
20
26
|
writeHeader(output);
|
|
21
|
-
output.write(`System ${platformLabel(platform, osRelease)}
|
|
27
|
+
output.write(`System ${platformLabel(platform, osRelease, env)} ${separator(output)} Node ${process.version}\n`);
|
|
22
28
|
output.write(`Manager ${packageManager ?? 'not detected'}\n\n`);
|
|
23
29
|
let selected = parsed.only ?? defaultComponents(platform, parsed.all);
|
|
24
30
|
if (!parsed.yes && !parsed.only && isInteractive(input, output)) {
|
|
@@ -35,8 +41,15 @@ export async function runSetup(options = {}) {
|
|
|
35
41
|
: ' (opens a separate terminal window)';
|
|
36
42
|
agentUi = await promptYesNo(input, output, ` Open the RadioCLI TUI for agent playback${note}`, true);
|
|
37
43
|
}
|
|
38
|
-
const
|
|
44
|
+
const isRoot = (options.getUid ?? process.getuid)?.() === 0;
|
|
45
|
+
const elevation = packageManagerElevation(hasCommand, isRoot);
|
|
46
|
+
const plan = createSetupPlan({ platform, osRelease, env, packageManager, installed, selected, elevation });
|
|
39
47
|
printPlan(plan, output);
|
|
48
|
+
if (packageManager && packageManagerNeedsRoot(packageManager) && !isRoot && !elevation) {
|
|
49
|
+
output.write(' Note: Run package commands as root; sudo/doas unavailable.\n');
|
|
50
|
+
}
|
|
51
|
+
for (const note of packageManagerNotes(packageManager, host))
|
|
52
|
+
output.write(` Note: ${note}\n`);
|
|
40
53
|
const missing = plan.selected.filter(component => !plan.installed[component]);
|
|
41
54
|
if (missing.length === 0) {
|
|
42
55
|
output.write(plan.selected.length === 0 ? '\nNo components selected.\n' : '\nEverything selected is already installed.\n');
|
|
@@ -57,8 +70,17 @@ export async function runSetup(options = {}) {
|
|
|
57
70
|
await finishMcpSetup(mcp, agentUi, true, output);
|
|
58
71
|
return;
|
|
59
72
|
}
|
|
60
|
-
|
|
61
|
-
|
|
73
|
+
const manual = missing.filter(component => !plan.commands.some(command => command.component === component));
|
|
74
|
+
if (manual.length)
|
|
75
|
+
throw new Error(`No verified automatic installation command for ${manual.join(', ')} with ${plan.packageManager}. Install these components manually or select --only=mpv.`);
|
|
76
|
+
if (plan.packageManager === 'termux-pkg' && isRoot)
|
|
77
|
+
throw new Error('Run setup as the normal Termux app user. Termux pkg refuses root execution.');
|
|
78
|
+
if (packageManagerNeedsRoot(plan.packageManager) && !isRoot && !elevation) {
|
|
79
|
+
throw new Error('System package installation requires root, sudo, or doas. Review the dry-run plan and install prerequisites with your administrator.');
|
|
80
|
+
}
|
|
81
|
+
if (parsed.packageManager && !hasCommand(packageManagerProgram(parsed.packageManager))) {
|
|
82
|
+
const program = packageManagerProgram(parsed.packageManager);
|
|
83
|
+
throw new Error(`Requested package manager is not available: ${parsed.packageManager}${program !== parsed.packageManager ? ` (${program})` : ''}.`);
|
|
62
84
|
}
|
|
63
85
|
if (!parsed.yes && isInteractive(input, output)) {
|
|
64
86
|
const confirmed = await promptYesNo(input, output, '\nInstall these components?', true);
|
|
@@ -72,9 +94,9 @@ export async function runSetup(options = {}) {
|
|
|
72
94
|
}
|
|
73
95
|
if (plan.commands.some(command => command.program === 'sudo')) {
|
|
74
96
|
output.write('\nRadioCLI needs administrator approval for the system package manager.\n');
|
|
75
|
-
await runVisibleCommand('sudo', ['-v']);
|
|
97
|
+
await runVisibleCommand(resolveCommand('sudo') ?? 'sudo', ['-v']);
|
|
76
98
|
}
|
|
77
|
-
const execute = options.runCommand ?? runInstallCommand;
|
|
99
|
+
const execute = options.runCommand ?? ((command, destination) => runInstallCommand(command, destination, platform));
|
|
78
100
|
output.write('\nInstalling\n');
|
|
79
101
|
for (const command of plan.commands) {
|
|
80
102
|
await execute(command, output);
|
|
@@ -84,48 +106,32 @@ export async function runSetup(options = {}) {
|
|
|
84
106
|
await finishMcpSetup(mcp, agentUi, false, output);
|
|
85
107
|
printVerification(output);
|
|
86
108
|
}
|
|
87
|
-
export function createSetupPlan({ platform, osRelease = '', packageManager, installed, selected }) {
|
|
109
|
+
export function createSetupPlan({ platform, osRelease = '', env = process.env, packageManager, installed, selected, elevation = 'sudo' }) {
|
|
110
|
+
if (packageManager)
|
|
111
|
+
validateNativePackageManager(packageManager, identifyPlatform({ platform, osRelease, env }));
|
|
88
112
|
const uniqueSelected = components.filter(component => selected.includes(component));
|
|
89
113
|
const missing = uniqueSelected.filter(component => !installed[component]);
|
|
90
|
-
const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component)) : [];
|
|
91
|
-
if (packageManager
|
|
92
|
-
commands.unshift(
|
|
93
|
-
component: null,
|
|
94
|
-
label: 'Scoop extras bucket',
|
|
95
|
-
program: 'scoop',
|
|
96
|
-
args: ['bucket', 'add', 'extras'],
|
|
97
|
-
display: 'scoop bucket add extras'
|
|
98
|
-
});
|
|
99
|
-
}
|
|
114
|
+
const commands = packageManager ? missing.map(component => packageInstallCommand(packageManager, component, { elevation })).filter((command) => command !== null) : [];
|
|
115
|
+
if (packageManager)
|
|
116
|
+
commands.unshift(...packageInstallPrerequisites(packageManager, missing));
|
|
100
117
|
return {
|
|
101
118
|
platform,
|
|
102
|
-
platformLabel: platformLabel(platform, osRelease),
|
|
119
|
+
platformLabel: platformLabel(platform, osRelease, env),
|
|
103
120
|
packageManager,
|
|
104
121
|
installed,
|
|
105
122
|
selected: uniqueSelected,
|
|
106
123
|
commands
|
|
107
124
|
};
|
|
108
125
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
? ['apt']
|
|
119
|
-
: hasAny(ids, ['fedora', 'rhel', 'centos'])
|
|
120
|
-
? ['dnf']
|
|
121
|
-
: hasAny(ids, ['arch', 'manjaro'])
|
|
122
|
-
? ['pacman']
|
|
123
|
-
: hasAny(ids, ['alpine'])
|
|
124
|
-
? ['apk']
|
|
125
|
-
: hasAny(ids, ['opensuse', 'suse'])
|
|
126
|
-
? ['zypper']
|
|
127
|
-
: ['apt', 'dnf', 'pacman', 'apk', 'zypper'];
|
|
128
|
-
return firstAvailable(preferred, hasCommand);
|
|
126
|
+
function validateNativePackageManager(manager, host) {
|
|
127
|
+
const required = host.id === 'termux' ? 'termux-pkg' : host.id === 'haiku' ? 'pkgman' : host.id === 'sunos' ? 'pkgin' : null;
|
|
128
|
+
if ((required && manager !== required)
|
|
129
|
+
|| (manager === 'termux-pkg' && host.id !== 'termux')
|
|
130
|
+
|| (manager === 'pkgman' && host.id !== 'haiku')
|
|
131
|
+
|| (manager === 'pkg' && host.id !== 'freebsd')
|
|
132
|
+
|| ['aix', 'android', 'unknown'].includes(host.id)) {
|
|
133
|
+
throw new Error(`No verified ${manager} playback package recipe for ${host.id}.${required ? ` Use --package-manager=${required}.` : ' Install a native player manually and run radiocli doctor.'}`);
|
|
134
|
+
}
|
|
129
135
|
}
|
|
130
136
|
export function parseSetupArgs(args) {
|
|
131
137
|
let all = false;
|
|
@@ -220,51 +226,6 @@ function parsePackageManager(value) {
|
|
|
220
226
|
}
|
|
221
227
|
return value;
|
|
222
228
|
}
|
|
223
|
-
function packageInstallCommand(manager, component) {
|
|
224
|
-
const packages = {
|
|
225
|
-
brew: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
226
|
-
winget: { mpv: 'shinchiro.mpv', ffmpeg: 'Gyan.FFmpeg', vlc: 'VideoLAN.VLC' },
|
|
227
|
-
scoop: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
228
|
-
choco: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
229
|
-
apt: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
230
|
-
dnf: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
231
|
-
pacman: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
232
|
-
apk: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' },
|
|
233
|
-
zypper: { mpv: 'mpv', ffmpeg: 'ffmpeg', vlc: 'vlc' }
|
|
234
|
-
};
|
|
235
|
-
const packageName = packages[manager][component];
|
|
236
|
-
let program = manager;
|
|
237
|
-
let args;
|
|
238
|
-
if (manager === 'brew')
|
|
239
|
-
args = ['install', component === 'vlc' ? '--cask' : packageName, ...(component === 'vlc' ? [packageName] : [])];
|
|
240
|
-
else if (manager === 'winget')
|
|
241
|
-
args = ['install', '--id', packageName, '-e', '--accept-package-agreements', '--accept-source-agreements'];
|
|
242
|
-
else if (manager === 'scoop')
|
|
243
|
-
args = ['install', packageName];
|
|
244
|
-
else if (manager === 'choco')
|
|
245
|
-
args = ['install', packageName, '-y'];
|
|
246
|
-
else if (manager === 'apt') {
|
|
247
|
-
program = 'sudo';
|
|
248
|
-
args = ['apt-get', 'install', '-y', packageName];
|
|
249
|
-
}
|
|
250
|
-
else if (manager === 'dnf') {
|
|
251
|
-
program = 'sudo';
|
|
252
|
-
args = ['dnf', 'install', '-y', packageName];
|
|
253
|
-
}
|
|
254
|
-
else if (manager === 'pacman') {
|
|
255
|
-
program = 'sudo';
|
|
256
|
-
args = ['pacman', '-S', '--needed', '--noconfirm', packageName];
|
|
257
|
-
}
|
|
258
|
-
else if (manager === 'apk') {
|
|
259
|
-
program = 'sudo';
|
|
260
|
-
args = ['apk', 'add', packageName];
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
program = 'sudo';
|
|
264
|
-
args = ['zypper', '--non-interactive', 'install', packageName];
|
|
265
|
-
}
|
|
266
|
-
return { component, label: componentLabel(component), program, args, display: [program, ...args].join(' ') };
|
|
267
|
-
}
|
|
268
229
|
function defaultComponents(platform, all) {
|
|
269
230
|
if (all)
|
|
270
231
|
return [...components];
|
|
@@ -280,11 +241,11 @@ function detectInstalledComponents(hasCommand) {
|
|
|
280
241
|
async function promptForComponents({ platform, installed, input, output }) {
|
|
281
242
|
output.write('Choose components (installed items will be skipped):\n');
|
|
282
243
|
const selected = [];
|
|
283
|
-
if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ?
|
|
244
|
+
if (await promptYesNo(input, output, ` mpv Full playback controls${installed.mpv ? ` ${separator(output)} installed` : ''}`, true))
|
|
284
245
|
selected.push('mpv');
|
|
285
|
-
if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ?
|
|
246
|
+
if (await promptYesNo(input, output, ` FFmpeg ${platform === 'darwin' ? 'AirPlay + ' : ''}ffplay fallback${installed.ffmpeg ? ` ${separator(output)} installed` : ''}`, platform === 'darwin'))
|
|
286
247
|
selected.push('ffmpeg');
|
|
287
|
-
if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ?
|
|
248
|
+
if (await promptYesNo(input, output, ` VLC Additional playback fallback${installed.vlc ? ` ${separator(output)} installed` : ''}`, false))
|
|
288
249
|
selected.push('vlc');
|
|
289
250
|
return selected;
|
|
290
251
|
}
|
|
@@ -302,19 +263,23 @@ async function promptYesNo(input, output, question, defaultValue) {
|
|
|
302
263
|
}
|
|
303
264
|
function printPlan(plan, output) {
|
|
304
265
|
output.write('\nInstallation plan\n');
|
|
266
|
+
for (const command of plan.commands.filter(command => command.component === null)) {
|
|
267
|
+
output.write(` ${pendingMark(output)} ${command.label} ${separator(output)} ${command.display}\n`);
|
|
268
|
+
}
|
|
305
269
|
for (const component of plan.selected) {
|
|
306
270
|
if (plan.installed[component])
|
|
307
271
|
output.write(` ${successMark(output)} ${componentLabel(component)} already installed\n`);
|
|
308
272
|
else {
|
|
309
273
|
const command = plan.commands.find(candidate => candidate.component === component);
|
|
310
|
-
output.write(` ${pendingMark(output)} ${componentLabel(component)}${command ?
|
|
274
|
+
output.write(` ${pendingMark(output)} ${componentLabel(component)} ${separator(output)} ${command ? command.display : 'manual installation required'}\n`);
|
|
311
275
|
}
|
|
312
276
|
}
|
|
313
277
|
if (plan.selected.length === 0)
|
|
314
278
|
output.write(' No components selected\n');
|
|
315
279
|
}
|
|
316
|
-
async function runInstallCommand(command, output) {
|
|
317
|
-
const
|
|
280
|
+
async function runInstallCommand(command, output, platform) {
|
|
281
|
+
const terminal = setupTerminal(output);
|
|
282
|
+
const interactive = terminal.interactive && !terminal.reduceMotion;
|
|
318
283
|
const startedAt = Date.now();
|
|
319
284
|
let timer;
|
|
320
285
|
let frame = 0;
|
|
@@ -330,7 +295,8 @@ async function runInstallCommand(command, output) {
|
|
|
330
295
|
}
|
|
331
296
|
try {
|
|
332
297
|
await new Promise((resolve, reject) => {
|
|
333
|
-
const
|
|
298
|
+
const invocation = packageCommandInvocation(command, platform, resolveCommand);
|
|
299
|
+
const child = spawn(invocation.program, invocation.args, { shell: false, stdio: ['inherit', 'pipe', 'pipe'] });
|
|
334
300
|
child.stdout?.on('data', chunk => { stdout = tail(`${stdout}${String(chunk)}`); });
|
|
335
301
|
child.stderr?.on('data', chunk => { stderr = tail(`${stderr}${String(chunk)}`); });
|
|
336
302
|
child.once('error', reject);
|
|
@@ -358,8 +324,9 @@ function progressFrame(label, frame, elapsedMs, output) {
|
|
|
358
324
|
const cycle = travel * 2;
|
|
359
325
|
const offset = frame % cycle;
|
|
360
326
|
const start = offset <= travel ? offset : cycle - offset;
|
|
361
|
-
const
|
|
362
|
-
|
|
327
|
+
const unicode = setupTerminal(output).unicode;
|
|
328
|
+
const bar = Array.from({ length: width }, (_, index) => index >= start && index < start + segment ? (unicode ? '█' : '#') : (unicode ? '░' : '.')).join('');
|
|
329
|
+
return ` ${accent(output, unicode ? '◒' : '*')} ${accent(output, `[${bar}]`)} Installing ${label} ${formatElapsed(elapsedMs)}`;
|
|
363
330
|
}
|
|
364
331
|
function printVerification(output) {
|
|
365
332
|
clearCommandCache();
|
|
@@ -372,41 +339,34 @@ function printVerification(output) {
|
|
|
372
339
|
}
|
|
373
340
|
function writeHeader(output) {
|
|
374
341
|
output.write(`${accent(output, 'RADIOCLI')} SETUP RECEIVER\n`);
|
|
375
|
-
output.write(`${accent(output, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')}\n`);
|
|
376
|
-
}
|
|
377
|
-
function platformLabel(platform, osRelease) {
|
|
378
|
-
if (platform === 'darwin')
|
|
379
|
-
return `${process.arch === 'arm64' ? 'macOS Apple Silicon' : 'macOS'}`;
|
|
380
|
-
if (platform === 'win32')
|
|
381
|
-
return 'Windows';
|
|
382
|
-
if (platform === 'linux')
|
|
383
|
-
return osReleaseValue(osRelease, 'PRETTY_NAME') || 'Linux';
|
|
384
|
-
return platform;
|
|
385
|
-
}
|
|
386
|
-
function componentLabel(component) {
|
|
387
|
-
if (component === 'ffmpeg')
|
|
388
|
-
return 'FFmpeg / ffplay';
|
|
389
|
-
if (component === 'vlc')
|
|
390
|
-
return 'VLC fallback';
|
|
391
|
-
return 'mpv';
|
|
342
|
+
output.write(`${accent(output, setupTerminal(output).unicode ? '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' : '------------------------------------')}\n`);
|
|
392
343
|
}
|
|
393
344
|
function isInteractive(input, output) {
|
|
394
345
|
return Boolean(input.isTTY && output.isTTY);
|
|
395
346
|
}
|
|
396
347
|
function accent(output, value) {
|
|
397
|
-
|
|
348
|
+
const level = setupTerminal(output).colorLevel;
|
|
349
|
+
const color = level === 3 ? '38;2;116;242;138' : level === 2 ? '38;5;120' : '32';
|
|
350
|
+
return level > 0 ? `\u001b[${color}m${value}\u001b[0m` : value;
|
|
398
351
|
}
|
|
399
352
|
function successMark(output) {
|
|
400
|
-
return accent(output, '✓');
|
|
353
|
+
return accent(output, setupTerminal(output).unicode ? '✓' : '+');
|
|
401
354
|
}
|
|
402
355
|
function pendingMark(output) {
|
|
403
|
-
return accent(output, '◆');
|
|
356
|
+
return accent(output, setupTerminal(output).unicode ? '◆' : '*');
|
|
404
357
|
}
|
|
405
358
|
function failureMark(output) {
|
|
406
|
-
|
|
359
|
+
const terminal = setupTerminal(output);
|
|
360
|
+
const value = terminal.unicode ? '✗' : 'x';
|
|
361
|
+
const color = terminal.colorLevel === 3 ? '38;2;255;95;135' : terminal.colorLevel === 2 ? '38;5;204' : '31';
|
|
362
|
+
return terminal.colorLevel > 0 ? `\u001b[${color}m${value}\u001b[0m` : value;
|
|
363
|
+
}
|
|
364
|
+
function setupTerminal(output) {
|
|
365
|
+
const stream = output;
|
|
366
|
+
return resolveTerminalCapabilities(process.env, { isTTY: Boolean(stream.isTTY), colorDepth: stream.getColorDepth?.() });
|
|
407
367
|
}
|
|
408
|
-
function
|
|
409
|
-
return
|
|
368
|
+
function separator(output) {
|
|
369
|
+
return setupTerminal(output).unicode ? '·' : '-';
|
|
410
370
|
}
|
|
411
371
|
function clearLine() {
|
|
412
372
|
return '\u001b[2K';
|
|
@@ -428,35 +388,3 @@ async function runVisibleCommand(program, args) {
|
|
|
428
388
|
child.once('close', code => code === 0 ? resolve() : reject(new Error(`${program} ${args.join(' ')} exited with code ${code}.`)));
|
|
429
389
|
});
|
|
430
390
|
}
|
|
431
|
-
function firstAvailable(values, hasCommand) {
|
|
432
|
-
return values.find(hasCommand) ?? null;
|
|
433
|
-
}
|
|
434
|
-
function readLinuxOsRelease(platform) {
|
|
435
|
-
if (platform !== 'linux' || !existsSync('/etc/os-release'))
|
|
436
|
-
return '';
|
|
437
|
-
try {
|
|
438
|
-
return readFileSync('/etc/os-release', 'utf8');
|
|
439
|
-
}
|
|
440
|
-
catch {
|
|
441
|
-
return '';
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
function linuxReleaseIds(osRelease) {
|
|
445
|
-
const ids = new Set();
|
|
446
|
-
for (const line of osRelease.split('\n')) {
|
|
447
|
-
const match = /^(ID|ID_LIKE)=(.*)$/.exec(line);
|
|
448
|
-
if (!match)
|
|
449
|
-
continue;
|
|
450
|
-
for (const value of match[2].replaceAll('"', '').split(/\s+/))
|
|
451
|
-
if (value.trim())
|
|
452
|
-
ids.add(value.trim().toLowerCase());
|
|
453
|
-
}
|
|
454
|
-
return ids;
|
|
455
|
-
}
|
|
456
|
-
function osReleaseValue(osRelease, key) {
|
|
457
|
-
const line = osRelease.split('\n').find(candidate => candidate.startsWith(`${key}=`));
|
|
458
|
-
return line?.slice(key.length + 1).replace(/^"|"$/g, '') ?? '';
|
|
459
|
-
}
|
|
460
|
-
function hasAny(values, candidates) {
|
|
461
|
-
return candidates.some(candidate => values.has(candidate));
|
|
462
|
-
}
|
package/dist/storage/store.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { platformPaths } from '../platform/paths.js';
|
|
2
|
+
import { assertStorageWritable } from '../platform/storage.js';
|
|
1
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
4
|
import { homedir } from 'node:os';
|
|
3
5
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
@@ -5,7 +7,6 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
7
|
import { z } from 'zod';
|
|
6
8
|
import { defaultReceiverStyle, receiverStyleNames, themeNames } from '../types.js';
|
|
7
9
|
import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone } from '../alarms/schedule.js';
|
|
8
|
-
import { backupBadFile } from '../providers/cache.js';
|
|
9
10
|
import { migrateReceiverStyle } from '../ui/visualizers/receiver-style-registry.js';
|
|
10
11
|
const stationSchema = z
|
|
11
12
|
.object({
|
|
@@ -234,7 +235,7 @@ export class JsonLibraryStore {
|
|
|
234
235
|
state;
|
|
235
236
|
idGenerator;
|
|
236
237
|
now;
|
|
237
|
-
constructor(filePath =
|
|
238
|
+
constructor(filePath = defaultLibraryPath(), options = {}) {
|
|
238
239
|
this.filePath = filePath;
|
|
239
240
|
this.idGenerator = options.idGenerator ?? randomUUID;
|
|
240
241
|
this.now = options.now ?? (() => new Date());
|
|
@@ -496,13 +497,21 @@ export class JsonLibraryStore {
|
|
|
496
497
|
return this.commit({ ...this.state, favorites });
|
|
497
498
|
}
|
|
498
499
|
addImported(stations) {
|
|
499
|
-
const
|
|
500
|
+
const incoming = [];
|
|
501
|
+
const incomingKeys = new Set();
|
|
500
502
|
for (const station of stations) {
|
|
501
|
-
|
|
503
|
+
const key = stationKey(station);
|
|
504
|
+
if (incomingKeys.has(key))
|
|
505
|
+
continue;
|
|
506
|
+
incomingKeys.add(key);
|
|
507
|
+
incoming.push(station);
|
|
502
508
|
}
|
|
503
509
|
return this.commit({
|
|
504
510
|
...this.state,
|
|
505
|
-
imported: [
|
|
511
|
+
imported: [
|
|
512
|
+
...incoming,
|
|
513
|
+
...this.state.imported.filter(station => !incomingKeys.has(stationKey(station)))
|
|
514
|
+
].slice(0, 1000)
|
|
506
515
|
});
|
|
507
516
|
}
|
|
508
517
|
isFavorite(station) {
|
|
@@ -535,14 +544,27 @@ export class JsonLibraryStore {
|
|
|
535
544
|
}
|
|
536
545
|
}
|
|
537
546
|
read() {
|
|
538
|
-
|
|
539
|
-
|
|
547
|
+
let contents;
|
|
548
|
+
try {
|
|
549
|
+
contents = readFileSync(this.filePath, 'utf8');
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
if (error.code === 'ENOENT')
|
|
553
|
+
return defaultState();
|
|
554
|
+
throw storageError('read the RadioCLI library', error);
|
|
540
555
|
}
|
|
541
556
|
try {
|
|
542
|
-
return migrateLibraryState(librarySchema.parse(JSON.parse(
|
|
557
|
+
return migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
|
|
543
558
|
}
|
|
544
559
|
catch {
|
|
545
|
-
|
|
560
|
+
try {
|
|
561
|
+
assertStorageWritable(this.filePath);
|
|
562
|
+
renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`);
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
// Reset only after preserving the original bytes. A failed read never reaches here.
|
|
566
|
+
throw storageError('preserve the corrupt RadioCLI library', error);
|
|
567
|
+
}
|
|
546
568
|
return defaultState();
|
|
547
569
|
}
|
|
548
570
|
}
|
|
@@ -560,8 +582,14 @@ export class JsonLibraryStore {
|
|
|
560
582
|
}
|
|
561
583
|
}
|
|
562
584
|
write(state) {
|
|
563
|
-
|
|
564
|
-
|
|
585
|
+
try {
|
|
586
|
+
assertStorageWritable(this.filePath);
|
|
587
|
+
mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
588
|
+
writeJsonAtomically(this.filePath, libraryStateForDisk(state));
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
throw storageError('write the RadioCLI library', error);
|
|
592
|
+
}
|
|
565
593
|
}
|
|
566
594
|
}
|
|
567
595
|
export function stationKey(station) {
|
|
@@ -602,31 +630,21 @@ function recoverActiveListeningSessionInState(state) {
|
|
|
602
630
|
: Date.now();
|
|
603
631
|
return finishActiveListeningSessionInState(state, new Date(recoveredEnd));
|
|
604
632
|
}
|
|
605
|
-
function
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json');
|
|
619
|
-
}
|
|
620
|
-
if (process.platform === 'win32') {
|
|
621
|
-
return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'RadioCLI', 'radiocli.json');
|
|
622
|
-
}
|
|
623
|
-
return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json');
|
|
624
|
-
}
|
|
625
|
-
function legacyDefaultStorePath() {
|
|
626
|
-
if (process.platform === 'darwin') {
|
|
627
|
-
return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas.json');
|
|
633
|
+
export function defaultLibraryPath() {
|
|
634
|
+
const { library, legacyLibrary } = platformPaths();
|
|
635
|
+
for (const filePath of [library, legacyLibrary]) {
|
|
636
|
+
try {
|
|
637
|
+
statSync(filePath);
|
|
638
|
+
return filePath;
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
// A permission failure must not select an older library or empty defaults.
|
|
642
|
+
if (error.code !== 'ENOENT') {
|
|
643
|
+
throw storageError('read the RadioCLI library', error);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
628
646
|
}
|
|
629
|
-
return
|
|
647
|
+
return library;
|
|
630
648
|
}
|
|
631
649
|
function resolveUserPath(requestedPath) {
|
|
632
650
|
const unquoted = requestedPath.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, (_match, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted ?? '');
|
|
@@ -700,53 +718,87 @@ function libraryStateForDisk(state) {
|
|
|
700
718
|
: state;
|
|
701
719
|
}
|
|
702
720
|
function writeJsonAtomically(filePath, value) {
|
|
703
|
-
const tempPath = `${filePath}.tmp-${process.pid}-${
|
|
721
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
|
|
704
722
|
try {
|
|
705
|
-
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
706
|
-
renameSync(tempPath, filePath);
|
|
723
|
+
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
707
724
|
if (process.platform !== 'win32') {
|
|
708
|
-
chmodSync(
|
|
725
|
+
chmodSync(tempPath, 0o600);
|
|
709
726
|
}
|
|
727
|
+
// No fallible persistence steps follow the atomic replacement.
|
|
728
|
+
renameSync(tempPath, filePath);
|
|
710
729
|
}
|
|
711
730
|
catch (error) {
|
|
712
|
-
|
|
713
|
-
|
|
731
|
+
try {
|
|
732
|
+
rmSync(tempPath, { force: true });
|
|
733
|
+
}
|
|
734
|
+
catch { /* Preserve the original write failure. */ }
|
|
735
|
+
throw storageError('write RadioCLI data', error);
|
|
714
736
|
}
|
|
715
737
|
}
|
|
716
738
|
function acquireStoreLock(filePath) {
|
|
717
739
|
const lockPath = `${filePath}.lock`;
|
|
718
|
-
|
|
740
|
+
try {
|
|
741
|
+
assertStorageWritable(filePath);
|
|
742
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
throw storageError('write the RadioCLI library', error);
|
|
746
|
+
}
|
|
719
747
|
const deadline = Date.now() + 1000;
|
|
720
748
|
while (true) {
|
|
721
749
|
try {
|
|
722
750
|
mkdirSync(lockPath, { mode: 0o700 });
|
|
723
|
-
return () =>
|
|
751
|
+
return () => {
|
|
752
|
+
// The write has already succeeded or failed. Lock cleanup cannot reverse that result.
|
|
753
|
+
try {
|
|
754
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
755
|
+
}
|
|
756
|
+
catch { /* Stale locks expire below. */ }
|
|
757
|
+
};
|
|
724
758
|
}
|
|
725
759
|
catch (error) {
|
|
726
760
|
const code = error.code;
|
|
727
761
|
if (code !== 'EEXIST')
|
|
728
|
-
throw error;
|
|
762
|
+
throw storageError('lock the RadioCLI library', error);
|
|
729
763
|
try {
|
|
730
764
|
if (Date.now() - statSync(lockPath).mtimeMs > 10_000) {
|
|
731
765
|
rmSync(lockPath, { recursive: true, force: true });
|
|
732
|
-
continue;
|
|
733
766
|
}
|
|
734
767
|
}
|
|
735
|
-
catch {
|
|
736
|
-
|
|
768
|
+
catch (lockError) {
|
|
769
|
+
if (lockError.code !== 'ENOENT') {
|
|
770
|
+
throw storageError('lock the RadioCLI library', lockError);
|
|
771
|
+
}
|
|
737
772
|
}
|
|
738
773
|
if (Date.now() >= deadline) {
|
|
739
|
-
throw new Error(
|
|
774
|
+
throw storageError('lock the RadioCLI library', Object.assign(new Error('Another writer holds the library lock.'), { code: 'EBUSY', cause: error }));
|
|
740
775
|
}
|
|
741
776
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
742
777
|
}
|
|
743
778
|
}
|
|
744
779
|
}
|
|
780
|
+
class LibraryStorageError extends Error {
|
|
781
|
+
code;
|
|
782
|
+
errno;
|
|
783
|
+
constructor(operation, cause) {
|
|
784
|
+
const code = cause?.code;
|
|
785
|
+
const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : '';
|
|
786
|
+
super(code === 'EBUSY'
|
|
787
|
+
? 'The RadioCLI library is busy. Retry after the other writer finishes, or set RADIOCLI_HOME to a private writable directory.'
|
|
788
|
+
: `Unable to ${operation}${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`, { cause });
|
|
789
|
+
this.name = 'LibraryStorageError';
|
|
790
|
+
this.code = code;
|
|
791
|
+
this.errno = cause?.errno;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function storageError(operation, cause) {
|
|
795
|
+
return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause);
|
|
796
|
+
}
|
|
745
797
|
function mergeLibraryChanges(base, disk, next) {
|
|
746
798
|
return {
|
|
747
799
|
recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50),
|
|
748
800
|
favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true),
|
|
749
|
-
imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true),
|
|
801
|
+
imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true, true),
|
|
750
802
|
trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
|
|
751
803
|
searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
|
|
752
804
|
alarms: mergeKeyedChanges(base.alarms, disk.alarms, next.alarms, alarm => alarm.id, MAX_ALARMS, true),
|
|
@@ -768,15 +820,16 @@ function normalizeAlarm(alarm) {
|
|
|
768
820
|
};
|
|
769
821
|
return { ...alarm, schedule };
|
|
770
822
|
}
|
|
771
|
-
function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false) {
|
|
823
|
+
function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false, applyOrderChanges = false) {
|
|
772
824
|
const baseByKey = new Map(base.map(item => [keyFor(item), item]));
|
|
825
|
+
const basePositions = new Map(base.map((item, index) => [keyFor(item), index]));
|
|
773
826
|
const nextKeys = new Set(next.map(keyFor));
|
|
774
827
|
const removedKeys = applyRemovals
|
|
775
828
|
? new Set(base.map(keyFor).filter(key => !nextKeys.has(key)))
|
|
776
829
|
: new Set();
|
|
777
|
-
const localChanges = next.filter(item => {
|
|
830
|
+
const localChanges = next.filter((item, index) => {
|
|
778
831
|
const prior = baseByKey.get(keyFor(item));
|
|
779
|
-
return prior === undefined || !sameValue(prior, item);
|
|
832
|
+
return prior === undefined || !sameValue(prior, item) || applyOrderChanges && basePositions.get(keyFor(item)) !== index;
|
|
780
833
|
});
|
|
781
834
|
const changedKeys = new Set(localChanges.map(keyFor));
|
|
782
835
|
return [
|