@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
|
@@ -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
|
}
|
|
@@ -244,6 +273,31 @@ export class PlayerController {
|
|
|
244
273
|
return { ok: false, message: 'RadioCLI has no paused station to resume.' };
|
|
245
274
|
return this.togglePause();
|
|
246
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
|
+
}
|
|
247
301
|
setVolume(volume) {
|
|
248
302
|
const clamped = clampVolume(volume);
|
|
249
303
|
const unsupported = this.unsupportedFfplayControl();
|
|
@@ -287,9 +341,30 @@ export class PlayerController {
|
|
|
287
341
|
return { ok: true };
|
|
288
342
|
}
|
|
289
343
|
async setMuted(muted) {
|
|
290
|
-
|
|
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) {
|
|
291
364
|
return { ok: true };
|
|
292
|
-
|
|
365
|
+
}
|
|
366
|
+
this.setState({ ...this.state, muted });
|
|
367
|
+
return { ok: true };
|
|
293
368
|
}
|
|
294
369
|
stop() {
|
|
295
370
|
if (this.stopPromise)
|
|
@@ -353,7 +428,7 @@ export class PlayerController {
|
|
|
353
428
|
return { ok: true, message };
|
|
354
429
|
}
|
|
355
430
|
async refreshAirPlayDevices() {
|
|
356
|
-
this.availableAirPlayDevices = await discoverAirPlayDevices();
|
|
431
|
+
this.availableAirPlayDevices = await discoverAirPlayDevices({ platform: this.runtime.platform });
|
|
357
432
|
return [...this.availableAirPlayDevices];
|
|
358
433
|
}
|
|
359
434
|
detectedAirPlayDevices() {
|
|
@@ -388,6 +463,9 @@ export class PlayerController {
|
|
|
388
463
|
playbackUnavailableMessage() {
|
|
389
464
|
const preferred = this.getSettings().preferredBackend;
|
|
390
465
|
if (preferred === 'airplay') {
|
|
466
|
+
if (!isAirPlayPlatformSupported(this.runtime.platform)) {
|
|
467
|
+
return airPlayMacOSOnlyMessage;
|
|
468
|
+
}
|
|
391
469
|
return `AirPlay is not ready on this install. Run radiocli doctor. ${airPlaySenderHealth().message}`;
|
|
392
470
|
}
|
|
393
471
|
if (preferred === 'mpv' || preferred === 'ffplay') {
|
|
@@ -395,8 +473,9 @@ export class PlayerController {
|
|
|
395
473
|
}
|
|
396
474
|
return `No playback backend found. ${playbackBackendInstallHint()}`;
|
|
397
475
|
}
|
|
398
|
-
playWithMpv(url, initialTitle) {
|
|
399
|
-
this.
|
|
476
|
+
playWithMpv(url, initialTitle, recoveredAudioOutput) {
|
|
477
|
+
this.playbackSessionId += 1;
|
|
478
|
+
this.ipcPath = mpvIpcPath();
|
|
400
479
|
this.mpvIpcClient = new MpvIpcClient(this.ipcPath);
|
|
401
480
|
this.mpvSessionId += 1;
|
|
402
481
|
this.confirmedMpvVolume = clampVolume(this.getSettings().volume);
|
|
@@ -404,9 +483,11 @@ export class PlayerController {
|
|
|
404
483
|
this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
|
|
405
484
|
this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
|
|
406
485
|
'--no-video',
|
|
407
|
-
'--
|
|
486
|
+
'--msg-level=all=warn',
|
|
408
487
|
'--force-window=no',
|
|
409
|
-
...(
|
|
488
|
+
...(recoveredAudioOutput || this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT
|
|
489
|
+
? [`--ao=${recoveredAudioOutput ?? this.runtime.env.RADIOCLI_MPV_AUDIO_OUTPUT}`]
|
|
490
|
+
: []),
|
|
410
491
|
`--force-media-title=${this.currentMpvMediaTitle}`,
|
|
411
492
|
`--volume=${this.getSettings().volume}`,
|
|
412
493
|
`--input-ipc-server=${this.ipcPath}`,
|
|
@@ -417,12 +498,14 @@ export class PlayerController {
|
|
|
417
498
|
this.wireProcess();
|
|
418
499
|
}
|
|
419
500
|
playWithFfplay(url) {
|
|
501
|
+
this.playbackSessionId += 1;
|
|
420
502
|
this.process = spawn(resolveCommand('ffplay') ?? 'ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
|
|
421
503
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
422
504
|
});
|
|
423
505
|
this.wireProcess();
|
|
424
506
|
}
|
|
425
507
|
playWithVlc(url) {
|
|
508
|
+
this.playbackSessionId += 1;
|
|
426
509
|
// VLC ships a `cvlc` shim for headless playback; fall back to the main `vlc`
|
|
427
510
|
// binary (resolved from app bundles when off PATH) with a dummy interface.
|
|
428
511
|
const binary = resolveCommand('cvlc') ?? resolveCommand('vlc') ?? 'cvlc';
|
|
@@ -650,24 +733,49 @@ export class PlayerController {
|
|
|
650
733
|
}
|
|
651
734
|
wireProcess() {
|
|
652
735
|
const child = this.process;
|
|
653
|
-
|
|
736
|
+
const backend = this.backend;
|
|
737
|
+
if (!child || !backend) {
|
|
654
738
|
return;
|
|
655
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
|
+
});
|
|
656
748
|
// Local players can write diagnostics indefinitely. Drain both pipes so a
|
|
657
749
|
// full OS pipe buffer can never stall playback.
|
|
658
750
|
child.stdout.resume?.();
|
|
659
751
|
child.stderr.resume?.();
|
|
660
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();
|
|
661
768
|
this.setState({
|
|
662
769
|
...this.state,
|
|
663
|
-
backend
|
|
770
|
+
backend,
|
|
664
771
|
state: 'error',
|
|
665
772
|
message: error.message,
|
|
666
773
|
ready: false
|
|
667
774
|
});
|
|
668
775
|
});
|
|
669
|
-
child.on('exit', code => {
|
|
776
|
+
child.on('exit', (code, signal) => {
|
|
670
777
|
if (this.process === child) {
|
|
778
|
+
this.lastPlayerExit = { backend, code, signal, diagnostic };
|
|
671
779
|
this.rejectPendingAirPlayRetune(new Error('AirPlay worker exited.'));
|
|
672
780
|
if (this.backend === 'airplay') {
|
|
673
781
|
this.currentAirPlayDevice = null;
|
|
@@ -789,18 +897,30 @@ export class PlayerController {
|
|
|
789
897
|
async waitForReady(backend) {
|
|
790
898
|
const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
|
|
791
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;
|
|
792
905
|
while (Date.now() - started < timeoutMs) {
|
|
793
|
-
if (
|
|
794
|
-
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);
|
|
795
911
|
}
|
|
796
912
|
if (backend === 'ffplay' || backend === 'vlc') {
|
|
797
|
-
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);
|
|
798
918
|
return;
|
|
799
919
|
}
|
|
800
|
-
if (
|
|
920
|
+
if (mpvClient) {
|
|
801
921
|
try {
|
|
802
|
-
await
|
|
803
|
-
if (await this.hasMpvAudioStarted()) {
|
|
922
|
+
await mpvClient.query({ command: ['get_property', 'path'] });
|
|
923
|
+
if (await this.hasMpvAudioStarted(mpvClient) && isCurrent()) {
|
|
804
924
|
return;
|
|
805
925
|
}
|
|
806
926
|
}
|
|
@@ -810,13 +930,36 @@ export class PlayerController {
|
|
|
810
930
|
}
|
|
811
931
|
await delay(150);
|
|
812
932
|
}
|
|
933
|
+
if (wasSuperseded()) {
|
|
934
|
+
throw new Error(`${backend} playback request was superseded.`);
|
|
935
|
+
}
|
|
936
|
+
if (this.process !== child)
|
|
937
|
+
throw this.playerExitedBeforeReady(backend);
|
|
813
938
|
await this.stop();
|
|
814
939
|
throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
|
|
815
940
|
}
|
|
816
|
-
|
|
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) {
|
|
817
960
|
const [timePos, audioPts] = await Promise.all([
|
|
818
|
-
|
|
819
|
-
|
|
961
|
+
client.query({ command: ['get_property', 'time-pos'] }).catch(() => null),
|
|
962
|
+
client.query({ command: ['get_property', 'audio-pts'] }).catch(() => null)
|
|
820
963
|
]);
|
|
821
964
|
return typeof timePos === 'number' || typeof audioPts === 'number';
|
|
822
965
|
}
|
|
@@ -884,6 +1027,9 @@ export class PlayerController {
|
|
|
884
1027
|
if (typeof paused !== 'boolean') {
|
|
885
1028
|
return false;
|
|
886
1029
|
}
|
|
1030
|
+
if (!paused && this.state.state === 'paused' && !this.mpvLiveRetuneInFlight) {
|
|
1031
|
+
return (await this.resumeMpvAtLiveEdge()).ok;
|
|
1032
|
+
}
|
|
887
1033
|
const state = paused ? 'paused' : 'playing';
|
|
888
1034
|
if (this.state.state !== state) {
|
|
889
1035
|
this.setState({ ...this.state, state });
|
|
@@ -928,12 +1074,6 @@ export class PlayerController {
|
|
|
928
1074
|
}
|
|
929
1075
|
}
|
|
930
1076
|
}
|
|
931
|
-
export function createMpvIpcPath(platform = process.platform, pid = process.pid, timestamp = Date.now()) {
|
|
932
|
-
if (platform === 'win32') {
|
|
933
|
-
return `\\\\.\\pipe\\radiocli-${pid}-${timestamp}`;
|
|
934
|
-
}
|
|
935
|
-
return join(tmpdir(), `radiocli-${pid}-${timestamp}.sock`);
|
|
936
|
-
}
|
|
937
1077
|
function airPlayWorkerPath() {
|
|
938
1078
|
const currentPath = fileURLToPath(import.meta.url);
|
|
939
1079
|
const extension = currentPath.endsWith('.ts') || currentPath.endsWith('.tsx') ? '.ts' : '.js';
|
|
@@ -993,15 +1133,45 @@ export function extractMpvTitle(metadata) {
|
|
|
993
1133
|
}
|
|
994
1134
|
return undefined;
|
|
995
1135
|
}
|
|
996
|
-
async function waitForStartupWindow(getProcess, ms) {
|
|
1136
|
+
async function waitForStartupWindow(getProcess, ms, exitedError = () => new Error('Player exited before the stream became ready.')) {
|
|
997
1137
|
const started = Date.now();
|
|
998
1138
|
while (Date.now() - started < ms) {
|
|
999
1139
|
if (!getProcess()) {
|
|
1000
|
-
throw
|
|
1140
|
+
throw exitedError();
|
|
1001
1141
|
}
|
|
1002
1142
|
await delay(Math.min(100, ms - (Date.now() - started)));
|
|
1003
1143
|
}
|
|
1004
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
|
+
}
|
|
1005
1175
|
function cleanMetadataTitle(value) {
|
|
1006
1176
|
const normalized = cleanMediaTitle(value);
|
|
1007
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
|
}
|