@ciphore/radiocli 0.2.2 → 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 +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- 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 +96 -48
- 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 +157 -35
- 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 +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- 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 +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- 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 +5 -3
- package/dist/ui/screen-items.js +42 -11
- 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/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -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 +80 -56
- 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 +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { existsSync, unlinkSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
3
|
import { dirname, join } from 'node:path';
|
|
5
4
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
|
|
7
|
-
import { resolveCommand } from '
|
|
5
|
+
import { airPlayMacOSOnlyMessage, detectPlaybackBackends, ffplayLimitedControlsMessage, isAirPlayPlatformSupported, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
|
|
6
|
+
import { resolveCommand } from '../platform/executables.js';
|
|
8
7
|
import { discoverAirPlayDevices } from './airplay-discovery.js';
|
|
9
8
|
import { airPlaySenderHealth } from './airplay-sender-health.js';
|
|
10
9
|
import { encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage } from './airplay-worker-protocol.js';
|
|
11
10
|
import { safeMediaTarget, sanitizeTerminalText } from '../safety.js';
|
|
12
11
|
import { MpvIpcClient } from './mpv-ipc-client.js';
|
|
12
|
+
import { mpvIpcPath } from '../platform/ipc.js';
|
|
13
13
|
const minAirPlayTuneTimeoutSeconds = 30;
|
|
14
|
+
const maxPlayerDiagnosticCharacters = 4096;
|
|
14
15
|
export class PlaybackOutputError extends Error {
|
|
15
16
|
constructor(message) {
|
|
16
17
|
super(message);
|
|
@@ -22,6 +23,7 @@ export function isPlaybackOutputError(error) {
|
|
|
22
23
|
}
|
|
23
24
|
export class PlayerController {
|
|
24
25
|
getSettings;
|
|
26
|
+
runtime;
|
|
25
27
|
process = null;
|
|
26
28
|
backend = null;
|
|
27
29
|
ipcPath = null;
|
|
@@ -46,13 +48,17 @@ export class PlayerController {
|
|
|
46
48
|
currentMpvMediaTitle = null;
|
|
47
49
|
metadataPollInFlight = false;
|
|
48
50
|
playbackStatePollInFlight = false;
|
|
51
|
+
mpvLiveRetuneInFlight = false;
|
|
49
52
|
pendingMpvVolume = null;
|
|
50
53
|
mpvVolumeFlush = null;
|
|
51
54
|
confirmedMpvVolume = 70;
|
|
52
55
|
mpvSessionId = 0;
|
|
56
|
+
playbackSessionId = 0;
|
|
53
57
|
stopPromise = null;
|
|
54
|
-
|
|
58
|
+
lastPlayerExit = null;
|
|
59
|
+
constructor(getSettings, runtime = { platform: process.platform, arch: process.arch, env: process.env }) {
|
|
55
60
|
this.getSettings = getSettings;
|
|
61
|
+
this.runtime = runtime;
|
|
56
62
|
}
|
|
57
63
|
onChange(listener) {
|
|
58
64
|
this.listeners.add(listener);
|
|
@@ -84,7 +90,7 @@ export class PlayerController {
|
|
|
84
90
|
return [...(this.availableBackends ?? [])];
|
|
85
91
|
}
|
|
86
92
|
refreshDetectedBackends() {
|
|
87
|
-
this.availableBackends = detectPlaybackBackends();
|
|
93
|
+
this.availableBackends = detectPlaybackBackends({ platform: this.runtime.platform });
|
|
88
94
|
return this.detectedBackends();
|
|
89
95
|
}
|
|
90
96
|
async play(station, url) {
|
|
@@ -175,7 +181,27 @@ export class PlayerController {
|
|
|
175
181
|
});
|
|
176
182
|
if (backend === 'mpv') {
|
|
177
183
|
this.playWithMpv(url, station.name);
|
|
178
|
-
|
|
184
|
+
try {
|
|
185
|
+
await this.waitForReady(backend);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (!this.shouldRetryMpvWithAlsa(error))
|
|
189
|
+
throw error;
|
|
190
|
+
await this.stop();
|
|
191
|
+
this.backend = backend;
|
|
192
|
+
this.setState({
|
|
193
|
+
backend,
|
|
194
|
+
state: 'loading',
|
|
195
|
+
message: `Opening ${station.name}`,
|
|
196
|
+
volume: this.getSettings().volume,
|
|
197
|
+
muted: false,
|
|
198
|
+
stationName: station.name,
|
|
199
|
+
streamUrl: url,
|
|
200
|
+
ready: false
|
|
201
|
+
});
|
|
202
|
+
this.playWithMpv(url, station.name, 'alsa,');
|
|
203
|
+
await this.waitForReady(backend);
|
|
204
|
+
}
|
|
179
205
|
}
|
|
180
206
|
else if (backend === 'ffplay') {
|
|
181
207
|
this.playWithFfplay(url);
|
|
@@ -209,6 +235,9 @@ export class PlayerController {
|
|
|
209
235
|
return unsupported;
|
|
210
236
|
}
|
|
211
237
|
if (this.backend === 'mpv') {
|
|
238
|
+
if (this.state.state === 'paused') {
|
|
239
|
+
return this.resumeMpvAtLiveEdge();
|
|
240
|
+
}
|
|
212
241
|
try {
|
|
213
242
|
await this.sendMpv({ command: ['cycle', 'pause'] });
|
|
214
243
|
}
|
|
@@ -230,6 +259,45 @@ export class PlayerController {
|
|
|
230
259
|
});
|
|
231
260
|
return { ok: true };
|
|
232
261
|
}
|
|
262
|
+
async pause() {
|
|
263
|
+
if (this.state.state === 'paused')
|
|
264
|
+
return { ok: true };
|
|
265
|
+
if (this.state.state !== 'playing')
|
|
266
|
+
return { ok: false, message: 'RadioCLI is not currently playing.' };
|
|
267
|
+
return this.togglePause();
|
|
268
|
+
}
|
|
269
|
+
async resume() {
|
|
270
|
+
if (this.state.state === 'playing')
|
|
271
|
+
return { ok: true };
|
|
272
|
+
if (this.state.state !== 'paused')
|
|
273
|
+
return { ok: false, message: 'RadioCLI has no paused station to resume.' };
|
|
274
|
+
return this.togglePause();
|
|
275
|
+
}
|
|
276
|
+
async resumeMpvAtLiveEdge() {
|
|
277
|
+
if (this.mpvLiveRetuneInFlight)
|
|
278
|
+
return { ok: true, message: 'Reconnecting at the live broadcast.' };
|
|
279
|
+
const url = this.state.streamUrl ? safeMediaTarget(this.state.streamUrl) : null;
|
|
280
|
+
if (!url)
|
|
281
|
+
return { ok: false, message: 'The paused station no longer has a usable stream URL.' };
|
|
282
|
+
this.mpvLiveRetuneInFlight = true;
|
|
283
|
+
try {
|
|
284
|
+
// Reloading the same URL discards mpv's delayed live buffer and opens a
|
|
285
|
+
// fresh stream connection. This keeps radio pause/play semantics at the
|
|
286
|
+
// live edge instead of behaving like time-shifted on-demand audio.
|
|
287
|
+
await this.sendMpv({ command: ['loadfile', url, 'replace'] });
|
|
288
|
+
await this.sendMpv({ command: ['set_property', 'pause', false] });
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return { ok: false, message: 'mpv could not reconnect the paused station at the live edge.' };
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
this.mpvLiveRetuneInFlight = false;
|
|
295
|
+
}
|
|
296
|
+
this.currentMpvMediaTitle = cleanMediaTitle(this.state.stationName ?? '') ?? 'RadioCLI';
|
|
297
|
+
this.emitMetadata({ updatedAt: new Date().toISOString() });
|
|
298
|
+
this.setState({ ...this.state, state: 'playing', ready: true, startedAt: new Date().toISOString() });
|
|
299
|
+
return { ok: true, message: 'Reconnected at the live broadcast.' };
|
|
300
|
+
}
|
|
233
301
|
setVolume(volume) {
|
|
234
302
|
const clamped = clampVolume(volume);
|
|
235
303
|
const unsupported = this.unsupportedFfplayControl();
|
|
@@ -272,6 +340,32 @@ export class PlayerController {
|
|
|
272
340
|
this.setState({ ...this.state, muted });
|
|
273
341
|
return { ok: true };
|
|
274
342
|
}
|
|
343
|
+
async setMuted(muted) {
|
|
344
|
+
const unsupported = this.unsupportedFfplayControl();
|
|
345
|
+
if (unsupported)
|
|
346
|
+
return this.state.muted === muted ? { ok: true } : unsupported;
|
|
347
|
+
// mpv can inherit mute=yes from its configuration, so the controller's
|
|
348
|
+
// startup default is not authoritative. Explicit setters must always reach
|
|
349
|
+
// the backend even when the requested value matches our local state.
|
|
350
|
+
if (this.backend === 'mpv') {
|
|
351
|
+
try {
|
|
352
|
+
await this.sendMpv({ command: ['set_property', 'mute', muted] });
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return { ok: false, message: 'mpv did not acknowledge the mute change.' };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
else if (this.backend === 'airplay') {
|
|
359
|
+
if (!this.sendAirPlayCommand({ type: 'setMuted', muted })) {
|
|
360
|
+
return { ok: false, message: 'The AirPlay worker is not available.' };
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else if (this.state.muted === muted) {
|
|
364
|
+
return { ok: true };
|
|
365
|
+
}
|
|
366
|
+
this.setState({ ...this.state, muted });
|
|
367
|
+
return { ok: true };
|
|
368
|
+
}
|
|
275
369
|
stop() {
|
|
276
370
|
if (this.stopPromise)
|
|
277
371
|
return this.stopPromise;
|
|
@@ -334,7 +428,7 @@ export class PlayerController {
|
|
|
334
428
|
return { ok: true, message };
|
|
335
429
|
}
|
|
336
430
|
async refreshAirPlayDevices() {
|
|
337
|
-
this.availableAirPlayDevices = await discoverAirPlayDevices();
|
|
431
|
+
this.availableAirPlayDevices = await discoverAirPlayDevices({ platform: this.runtime.platform });
|
|
338
432
|
return [...this.availableAirPlayDevices];
|
|
339
433
|
}
|
|
340
434
|
detectedAirPlayDevices() {
|
|
@@ -369,6 +463,9 @@ export class PlayerController {
|
|
|
369
463
|
playbackUnavailableMessage() {
|
|
370
464
|
const preferred = this.getSettings().preferredBackend;
|
|
371
465
|
if (preferred === 'airplay') {
|
|
466
|
+
if (!isAirPlayPlatformSupported(this.runtime.platform)) {
|
|
467
|
+
return airPlayMacOSOnlyMessage;
|
|
468
|
+
}
|
|
372
469
|
return `AirPlay is not ready on this install. Run radiocli doctor. ${airPlaySenderHealth().message}`;
|
|
373
470
|
}
|
|
374
471
|
if (preferred === 'mpv' || preferred === 'ffplay') {
|
|
@@ -376,8 +473,9 @@ export class PlayerController {
|
|
|
376
473
|
}
|
|
377
474
|
return `No playback backend found. ${playbackBackendInstallHint()}`;
|
|
378
475
|
}
|
|
379
|
-
playWithMpv(url, initialTitle) {
|
|
380
|
-
this.
|
|
476
|
+
playWithMpv(url, initialTitle, recoveredAudioOutput) {
|
|
477
|
+
this.playbackSessionId += 1;
|
|
478
|
+
this.ipcPath = mpvIpcPath();
|
|
381
479
|
this.mpvIpcClient = new MpvIpcClient(this.ipcPath);
|
|
382
480
|
this.mpvSessionId += 1;
|
|
383
481
|
this.confirmedMpvVolume = clampVolume(this.getSettings().volume);
|
|
@@ -385,9 +483,11 @@ export class PlayerController {
|
|
|
385
483
|
this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
|
|
386
484
|
this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
|
|
387
485
|
'--no-video',
|
|
388
|
-
'--
|
|
486
|
+
'--msg-level=all=warn',
|
|
389
487
|
'--force-window=no',
|
|
390
|
-
...(
|
|
488
|
+
...(recoveredAudioOutput || this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT
|
|
489
|
+
? [`--ao=${recoveredAudioOutput ?? this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT}`]
|
|
490
|
+
: []),
|
|
391
491
|
`--force-media-title=${this.currentMpvMediaTitle}`,
|
|
392
492
|
`--volume=${this.getSettings().volume}`,
|
|
393
493
|
`--input-ipc-server=${this.ipcPath}`,
|
|
@@ -398,12 +498,14 @@ export class PlayerController {
|
|
|
398
498
|
this.wireProcess();
|
|
399
499
|
}
|
|
400
500
|
playWithFfplay(url) {
|
|
501
|
+
this.playbackSessionId += 1;
|
|
401
502
|
this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
|
|
402
503
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
403
504
|
});
|
|
404
505
|
this.wireProcess();
|
|
405
506
|
}
|
|
406
507
|
playWithVlc(url) {
|
|
508
|
+
this.playbackSessionId += 1;
|
|
407
509
|
// VLC ships a `cvlc` shim for headless playback; fall back to the main `vlc`
|
|
408
510
|
// binary (resolved from app bundles when off PATH) with a dummy interface.
|
|
409
511
|
const binary = resolveCommand('cvlc') ?? resolveCommand('vlc') ?? 'cvlc';
|
|
@@ -631,24 +733,49 @@ export class PlayerController {
|
|
|
631
733
|
}
|
|
632
734
|
wireProcess() {
|
|
633
735
|
const child = this.process;
|
|
634
|
-
|
|
736
|
+
const backend = this.backend;
|
|
737
|
+
if (!child || !backend) {
|
|
635
738
|
return;
|
|
636
739
|
}
|
|
740
|
+
this.lastPlayerExit = null;
|
|
741
|
+
let diagnostic = '';
|
|
742
|
+
child.stdout.on('data', chunk => {
|
|
743
|
+
diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk));
|
|
744
|
+
});
|
|
745
|
+
child.stderr.on('data', chunk => {
|
|
746
|
+
diagnostic = appendPlayerDiagnostic(diagnostic, String(chunk));
|
|
747
|
+
});
|
|
637
748
|
// Local players can write diagnostics indefinitely. Drain both pipes so a
|
|
638
749
|
// full OS pipe buffer can never stall playback.
|
|
639
750
|
child.stdout.resume?.();
|
|
640
751
|
child.stderr.resume?.();
|
|
641
752
|
child.on('error', error => {
|
|
753
|
+
diagnostic = appendPlayerDiagnostic(diagnostic, error.message);
|
|
754
|
+
if (this.process !== child)
|
|
755
|
+
return;
|
|
756
|
+
this.lastPlayerExit = { backend, code: null, signal: null, diagnostic, spawnError: true };
|
|
757
|
+
this.rejectPendingAirPlayReady(error);
|
|
758
|
+
this.rejectPendingAirPlayRetune(error);
|
|
759
|
+
if (backend === 'airplay') {
|
|
760
|
+
this.currentAirPlayDevice = null;
|
|
761
|
+
this.currentAirPlayDeviceId = null;
|
|
762
|
+
this.pendingAirPlayPasscode = null;
|
|
763
|
+
this.airPlaySessionEstablished = false;
|
|
764
|
+
}
|
|
765
|
+
this.process = null;
|
|
766
|
+
this.stopMpvPolling();
|
|
767
|
+
this.cleanupIpc();
|
|
642
768
|
this.setState({
|
|
643
769
|
...this.state,
|
|
644
|
-
backend
|
|
770
|
+
backend,
|
|
645
771
|
state: 'error',
|
|
646
772
|
message: error.message,
|
|
647
773
|
ready: false
|
|
648
774
|
});
|
|
649
775
|
});
|
|
650
|
-
child.on('exit', code => {
|
|
776
|
+
child.on('exit', (code, signal) => {
|
|
651
777
|
if (this.process === child) {
|
|
778
|
+
this.lastPlayerExit = { backend, code, signal, diagnostic };
|
|
652
779
|
this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.'));
|
|
653
780
|
if (this.backend === 'airplay') {
|
|
654
781
|
this.currentAirPlayDevice = null;
|
|
@@ -770,18 +897,30 @@ export class PlayerController {
|
|
|
770
897
|
async waitForReady(backend) {
|
|
771
898
|
const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
|
|
772
899
|
const started = Date.now();
|
|
900
|
+
const child = this.process;
|
|
901
|
+
const playbackSessionId = this.playbackSessionId;
|
|
902
|
+
const mpvClient = this.mpvIpcClient;
|
|
903
|
+
const wasSuperseded = () => this.playbackSessionId !== playbackSessionId;
|
|
904
|
+
const isCurrent = () => !wasSuperseded() && this.process === child;
|
|
773
905
|
while (Date.now() - started < timeoutMs) {
|
|
774
|
-
if (
|
|
775
|
-
throw new Error(
|
|
906
|
+
if (wasSuperseded()) {
|
|
907
|
+
throw new Error(`${backend} playback request was superseded.`);
|
|
908
|
+
}
|
|
909
|
+
if (!child || this.process !== child) {
|
|
910
|
+
throw this.playerExitedBeforeReady(backend);
|
|
776
911
|
}
|
|
777
912
|
if (backend === 'ffplay' || backend === 'vlc') {
|
|
778
|
-
await waitForStartupWindow(() => this.process, Math.min(500, timeoutMs));
|
|
913
|
+
await waitForStartupWindow(() => this.process === child ? child : null, Math.min(500, timeoutMs), () => this.playerExitedBeforeReady(backend));
|
|
914
|
+
if (wasSuperseded())
|
|
915
|
+
throw new Error(`${backend} playback request was superseded.`);
|
|
916
|
+
if (!isCurrent())
|
|
917
|
+
throw this.playerExitedBeforeReady(backend);
|
|
779
918
|
return;
|
|
780
919
|
}
|
|
781
|
-
if (
|
|
920
|
+
if (mpvClient) {
|
|
782
921
|
try {
|
|
783
|
-
await
|
|
784
|
-
if (await this.hasMpvAudioStarted()) {
|
|
922
|
+
await mpvClient.query({ command: ['get_property', 'path'] });
|
|
923
|
+
if (await this.hasMpvAudioStarted(mpvClient) && isCurrent()) {
|
|
785
924
|
return;
|
|
786
925
|
}
|
|
787
926
|
}
|
|
@@ -791,13 +930,36 @@ export class PlayerController {
|
|
|
791
930
|
}
|
|
792
931
|
await delay(150);
|
|
793
932
|
}
|
|
933
|
+
if (wasSuperseded()) {
|
|
934
|
+
throw new Error(`${backend} playback request was superseded.`);
|
|
935
|
+
}
|
|
936
|
+
if (this.process !== child)
|
|
937
|
+
throw this.playerExitedBeforeReady(backend);
|
|
794
938
|
await this.stop();
|
|
795
939
|
throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
|
|
796
940
|
}
|
|
797
|
-
|
|
941
|
+
shouldRetryMpvWithAlsa(error) {
|
|
942
|
+
return this.runtime.platform === 'linux' &&
|
|
943
|
+
(this.runtime.arch === 'arm' || this.runtime.arch === 'arm64') &&
|
|
944
|
+
!this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT &&
|
|
945
|
+
isAudioOutputInitializationFailure(this.lastPlayerExit?.diagnostic ?? '') &&
|
|
946
|
+
isPlaybackOutputError(error);
|
|
947
|
+
}
|
|
948
|
+
playerExitedBeforeReady(backend) {
|
|
949
|
+
const exit = this.lastPlayerExit?.backend === backend ? this.lastPlayerExit : null;
|
|
950
|
+
const reason = exit
|
|
951
|
+
? exit.signal ? `signal ${exit.signal}` : exit.code === null ? 'without an exit code' : `code ${exit.code}`
|
|
952
|
+
: 'before its exit status was available';
|
|
953
|
+
const diagnostic = concisePlayerDiagnostic(exit?.diagnostic ?? '');
|
|
954
|
+
const message = `${backend} exited before the stream became ready (${reason})${diagnostic ? `: ${diagnostic}` : '.'}`;
|
|
955
|
+
return isBackendInitializationFailure(backend, exit, diagnostic)
|
|
956
|
+
? new PlaybackOutputError(message)
|
|
957
|
+
: new Error(message);
|
|
958
|
+
}
|
|
959
|
+
async hasMpvAudioStarted(client) {
|
|
798
960
|
const [timePos, audioPts] = await Promise.all([
|
|
799
|
-
|
|
800
|
-
|
|
961
|
+
client.query({ command: ['get_property', 'time-pos'] }).catch(() => null),
|
|
962
|
+
client.query({ command: ['get_property', 'audio-pts'] }).catch(() => null)
|
|
801
963
|
]);
|
|
802
964
|
return typeof timePos === 'number' || typeof audioPts === 'number';
|
|
803
965
|
}
|
|
@@ -865,6 +1027,9 @@ export class PlayerController {
|
|
|
865
1027
|
if (typeof paused !== 'boolean') {
|
|
866
1028
|
return false;
|
|
867
1029
|
}
|
|
1030
|
+
if (!paused && this.state.state === 'paused' && !this.mpvLiveRetuneInFlight) {
|
|
1031
|
+
return (await this.resumeMpvAtLiveEdge()).ok;
|
|
1032
|
+
}
|
|
868
1033
|
const state = paused ? 'paused' : 'playing';
|
|
869
1034
|
if (this.state.state !== state) {
|
|
870
1035
|
this.setState({ ...this.state, state });
|
|
@@ -909,12 +1074,6 @@ export class PlayerController {
|
|
|
909
1074
|
}
|
|
910
1075
|
}
|
|
911
1076
|
}
|
|
912
|
-
export function createMpvIpcPath(platform = process.platform, pid = process.pid, timestamp = Date.now()) {
|
|
913
|
-
if (platform === 'win32') {
|
|
914
|
-
return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`;
|
|
915
|
-
}
|
|
916
|
-
return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`);
|
|
917
|
-
}
|
|
918
1077
|
function airPlayWorkerPath() {
|
|
919
1078
|
const currentPath = fileURLToPath(import.meta.url);
|
|
920
1079
|
const extension = currentPath.endsWith('.ts') || currentPath.endsWith('.tsx') ? '.ts' : '.js';
|
|
@@ -974,15 +1133,45 @@ export function extractMpvTitle(metadata) {
|
|
|
974
1133
|
}
|
|
975
1134
|
return undefined;
|
|
976
1135
|
}
|
|
977
|
-
async function waitForStartupWindow(getProcess, ms) {
|
|
1136
|
+
async function waitForStartupWindow(getProcess, ms, exitedError = () => new Error('Player exited before the stream became ready.')) {
|
|
978
1137
|
const started = Date.now();
|
|
979
1138
|
while (Date.now() - started < ms) {
|
|
980
1139
|
if (!getProcess()) {
|
|
981
|
-
throw
|
|
1140
|
+
throw exitedError();
|
|
982
1141
|
}
|
|
983
1142
|
await delay(Math.min(100, ms - (Date.now() - started)));
|
|
984
1143
|
}
|
|
985
1144
|
}
|
|
1145
|
+
function appendPlayerDiagnostic(current, chunk) {
|
|
1146
|
+
const combined = current + chunk;
|
|
1147
|
+
return combined.length <= maxPlayerDiagnosticCharacters
|
|
1148
|
+
? combined
|
|
1149
|
+
: combined.slice(-maxPlayerDiagnosticCharacters);
|
|
1150
|
+
}
|
|
1151
|
+
function concisePlayerDiagnostic(value) {
|
|
1152
|
+
const lines = value
|
|
1153
|
+
.split(/\r?\n/)
|
|
1154
|
+
.map(line => sanitizeTerminalText(line.replace(/https?:\/\/\S+/gi, '[stream URL]')))
|
|
1155
|
+
.filter((line) => Boolean(line))
|
|
1156
|
+
.slice(-3)
|
|
1157
|
+
.join(' · ')
|
|
1158
|
+
.slice(0, 700);
|
|
1159
|
+
return lines;
|
|
1160
|
+
}
|
|
1161
|
+
function isAudioOutputInitializationFailure(value) {
|
|
1162
|
+
return /audio output initialization failed|could not open\/initialize audio device|failed to initialize audio driver/i.test(value);
|
|
1163
|
+
}
|
|
1164
|
+
function isBackendInitializationFailure(backend, exit, diagnostic) {
|
|
1165
|
+
if (isAudioOutputInitializationFailure(diagnostic))
|
|
1166
|
+
return true;
|
|
1167
|
+
if (exit?.spawnError)
|
|
1168
|
+
return true;
|
|
1169
|
+
if (/unknown (?:command line )?option|error initializing|failed to (?:create|connect to) .*(?:socket|pipe)/i.test(diagnostic))
|
|
1170
|
+
return true;
|
|
1171
|
+
// mpv reserves status 1 for player initialization and invalid options. A
|
|
1172
|
+
// stream that could not be played uses status 2 and remains skippable.
|
|
1173
|
+
return backend === 'mpv' && exit?.code === 1;
|
|
1174
|
+
}
|
|
986
1175
|
function cleanMetadataTitle(value) {
|
|
987
1176
|
const normalized = cleanMediaTitle(value);
|
|
988
1177
|
if (!normalized) {
|
package/dist/providers/cache.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { platformPaths } from '../platform/paths.js';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
const maxCacheEntries = 256;
|
|
6
6
|
const maxCacheBytes = 128 * 1024 * 1024;
|
|
@@ -103,30 +103,8 @@ export class ProviderCache {
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
function defaultProviderCachePath() {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
109
|
-
if (process.env.RADIO_ATLAS_HOME) {
|
|
110
|
-
return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas-cache.json');
|
|
111
|
-
}
|
|
112
|
-
const currentPath = currentDefaultProviderCachePath();
|
|
113
|
-
const legacyPath = legacyDefaultProviderCachePath();
|
|
114
|
-
return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath;
|
|
115
|
-
}
|
|
116
|
-
function currentDefaultProviderCachePath() {
|
|
117
|
-
if (process.platform === 'darwin') {
|
|
118
|
-
return join(homedir(), 'Library', 'Caches', 'radiocli', 'radiocli-cache.json');
|
|
119
|
-
}
|
|
120
|
-
if (process.platform === 'win32') {
|
|
121
|
-
return join(process.env.LOCALAPPDATA ?? join(homedir(), 'AppData', 'Local'), 'RadioCLI', 'radiocli-cache.json');
|
|
122
|
-
}
|
|
123
|
-
return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radiocli', 'radiocli-cache.json');
|
|
124
|
-
}
|
|
125
|
-
function legacyDefaultProviderCachePath() {
|
|
126
|
-
if (process.platform === 'darwin') {
|
|
127
|
-
return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas-cache.json');
|
|
128
|
-
}
|
|
129
|
-
return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radio-atlas', 'radio-atlas-cache.json');
|
|
106
|
+
const { cache, legacyCache } = platformPaths();
|
|
107
|
+
return existsSync(cache) || !existsSync(legacyCache) ? cache : legacyCache;
|
|
130
108
|
}
|
|
131
109
|
function writeJsonAtomically(filePath, value) {
|
|
132
110
|
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
@@ -142,7 +120,7 @@ function writeJsonAtomically(filePath, value) {
|
|
|
142
120
|
throw error;
|
|
143
121
|
}
|
|
144
122
|
}
|
|
145
|
-
|
|
123
|
+
function backupBadFile(filePath) {
|
|
146
124
|
if (!existsSync(filePath)) {
|
|
147
125
|
return;
|
|
148
126
|
}
|
|
@@ -16,6 +16,11 @@ export class ProviderManager {
|
|
|
16
16
|
byCountry(countryCode, limit, offset) {
|
|
17
17
|
return this.radioBrowser.byCountry(countryCode, limit, offset);
|
|
18
18
|
}
|
|
19
|
+
byId(provider, id) {
|
|
20
|
+
if (provider !== 'radio-browser')
|
|
21
|
+
return Promise.resolve(null);
|
|
22
|
+
return this.radioBrowser.byId(id);
|
|
23
|
+
}
|
|
19
24
|
nearby(location, limit) {
|
|
20
25
|
return this.radioBrowser.nearby(location, limit);
|
|
21
26
|
}
|