@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.
- package/CHANGELOG.md +65 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +53 -3
- package/dist/player/airplay-discovery.js +16 -4
- package/dist/player/airplay-worker-protocol.js +1 -0
- package/dist/player/airplay-worker.js +4 -1
- package/dist/player/command.js +37 -14
- package/dist/player/mpv-ipc-client.js +194 -0
- package/dist/player/player-controller.js +179 -98
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +28 -8
- package/dist/providers/radio-browser.js +141 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +264 -149
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +489 -54
- package/dist/ui/AppContent.js +11 -12
- package/dist/ui/app-state.js +26 -16
- package/dist/ui/ascii.js +42 -1
- package/dist/ui/components/Logo.js +6 -1
- package/dist/ui/components/ScreenHeader.js +2 -2
- package/dist/ui/components/StationList.js +8 -6
- package/dist/ui/components/TopTabs.js +8 -7
- package/dist/ui/cosmo-land-data.js +1 -1
- package/dist/ui/exit-confirmation.js +7 -0
- package/dist/ui/explore-map-layout.js +3 -1
- package/dist/ui/format.js +56 -2
- package/dist/ui/help-content.js +10 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +15 -2
- package/dist/ui/screen-items.js +60 -21
- package/dist/ui/screen-meta.js +35 -0
- package/dist/ui/screens/AirPlaySettingsScreen.js +4 -2
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +1 -1
- package/dist/ui/screens/HelpScreen.js +17 -3
- package/dist/ui/screens/HomeScreen.js +2 -3
- package/dist/ui/screens/MapScreen.js +2 -2
- package/dist/ui/screens/NowPlayingScreen.js +31 -51
- package/dist/ui/screens/SearchScreen.js +1 -1
- package/dist/ui/screens/SettingsScreen.js +109 -10
- package/dist/ui/screens/StationScreen.js +14 -1
- package/dist/ui/screens/StatsScreen.js +35 -44
- package/dist/ui/system-actions.js +10 -3
- package/dist/ui/terminal-mouse.js +44 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +37 -6
- package/dist/ui/use-command-executor.js +34 -1
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/dist/update-check.js +122 -0
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- package/dist/ui/screens/screen-render.test.js +0 -214
|
@@ -3,12 +3,13 @@ import { existsSync, unlinkSync } from 'node:fs';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { Socket } from 'node:net';
|
|
7
6
|
import { detectPlaybackBackends, ffplayLimitedControlsMessage, playbackBackendInstallHint, vlcLimitedControlsMessage } from './backend-install.js';
|
|
8
7
|
import { resolveCommand } from './command.js';
|
|
9
8
|
import { discoverAirPlayDevices } from './airplay-discovery.js';
|
|
10
9
|
import { airPlaySenderHealth } from './airplay-sender-health.js';
|
|
11
10
|
import { encodeWorkerStart, parseWorkerMessage, serializeWorkerMessage } from './airplay-worker-protocol.js';
|
|
11
|
+
import { safeMediaTarget, sanitizeTerminalText } from '../safety.js';
|
|
12
|
+
import { MpvIpcClient } from './mpv-ipc-client.js';
|
|
12
13
|
const minAirPlayTuneTimeoutSeconds = 30;
|
|
13
14
|
export class PlaybackOutputError extends Error {
|
|
14
15
|
constructor(message) {
|
|
@@ -24,6 +25,7 @@ export class PlayerController {
|
|
|
24
25
|
process = null;
|
|
25
26
|
backend = null;
|
|
26
27
|
ipcPath = null;
|
|
28
|
+
mpvIpcClient = null;
|
|
27
29
|
metadataTimer = null;
|
|
28
30
|
playbackStateTimer = null;
|
|
29
31
|
state = { backend: 'none', state: 'idle', volume: 70, muted: false, ready: false };
|
|
@@ -41,8 +43,13 @@ export class PlayerController {
|
|
|
41
43
|
currentAirPlayDevice = null;
|
|
42
44
|
currentAirPlayDeviceId = null;
|
|
43
45
|
pendingAirPlayPasscode = null;
|
|
44
|
-
nextMpvRequestId = 1;
|
|
45
46
|
currentMpvMediaTitle = null;
|
|
47
|
+
metadataPollInFlight = false;
|
|
48
|
+
playbackStatePollInFlight = false;
|
|
49
|
+
pendingMpvVolume = null;
|
|
50
|
+
mpvVolumeFlush = null;
|
|
51
|
+
confirmedMpvVolume = 70;
|
|
52
|
+
mpvSessionId = 0;
|
|
46
53
|
constructor(getSettings) {
|
|
47
54
|
this.getSettings = getSettings;
|
|
48
55
|
}
|
|
@@ -80,6 +87,11 @@ export class PlayerController {
|
|
|
80
87
|
return this.detectedBackends();
|
|
81
88
|
}
|
|
82
89
|
async play(station, url) {
|
|
90
|
+
const target = safeMediaTarget(url);
|
|
91
|
+
if (!target) {
|
|
92
|
+
throw new Error(`Station ${station.name} returned an unsupported stream URL.`);
|
|
93
|
+
}
|
|
94
|
+
url = target;
|
|
83
95
|
const backend = this.selectBackend();
|
|
84
96
|
if (!backend) {
|
|
85
97
|
await this.stop();
|
|
@@ -196,7 +208,12 @@ export class PlayerController {
|
|
|
196
208
|
return unsupported;
|
|
197
209
|
}
|
|
198
210
|
if (this.backend === 'mpv') {
|
|
199
|
-
|
|
211
|
+
try {
|
|
212
|
+
await this.sendMpv({ command: ['cycle', 'pause'] });
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return { ok: false, message: 'mpv did not acknowledge the pause command.' };
|
|
216
|
+
}
|
|
200
217
|
const synced = await this.syncMpvPlaybackState();
|
|
201
218
|
if (synced) {
|
|
202
219
|
return { ok: true };
|
|
@@ -212,22 +229,24 @@ export class PlayerController {
|
|
|
212
229
|
});
|
|
213
230
|
return { ok: true };
|
|
214
231
|
}
|
|
215
|
-
|
|
232
|
+
setVolume(volume) {
|
|
216
233
|
const clamped = clampVolume(volume);
|
|
217
234
|
const unsupported = this.unsupportedFfplayControl();
|
|
218
235
|
if (unsupported) {
|
|
219
|
-
return unsupported;
|
|
236
|
+
return Promise.resolve(unsupported);
|
|
220
237
|
}
|
|
221
238
|
if (this.backend === 'mpv') {
|
|
222
|
-
|
|
239
|
+
return this.queueMpvVolume(clamped);
|
|
223
240
|
}
|
|
224
241
|
else if (this.backend === 'airplay') {
|
|
225
|
-
this.sendAirPlayCommand({ type: 'setVolume', volume: clamped })
|
|
242
|
+
if (!this.sendAirPlayCommand({ type: 'setVolume', volume: clamped })) {
|
|
243
|
+
return Promise.resolve({ ok: false, message: 'The AirPlay worker is not available.' });
|
|
244
|
+
}
|
|
226
245
|
}
|
|
227
246
|
this.setState({ ...this.state, volume: clamped });
|
|
228
|
-
return { ok: true };
|
|
247
|
+
return Promise.resolve({ ok: true });
|
|
229
248
|
}
|
|
230
|
-
|
|
249
|
+
adjustVolume(delta) {
|
|
231
250
|
return this.setVolume(this.state.volume + delta);
|
|
232
251
|
}
|
|
233
252
|
async toggleMute() {
|
|
@@ -237,15 +256,23 @@ export class PlayerController {
|
|
|
237
256
|
return unsupported;
|
|
238
257
|
}
|
|
239
258
|
if (this.backend === 'mpv') {
|
|
240
|
-
|
|
259
|
+
try {
|
|
260
|
+
await this.sendMpv({ command: ['set_property', 'mute', muted] });
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return { ok: false, message: 'mpv did not acknowledge the mute change.' };
|
|
264
|
+
}
|
|
241
265
|
}
|
|
242
266
|
else if (this.backend === 'airplay') {
|
|
243
|
-
this.sendAirPlayCommand({ type: 'setMuted', muted })
|
|
267
|
+
if (!this.sendAirPlayCommand({ type: 'setMuted', muted })) {
|
|
268
|
+
return { ok: false, message: 'The AirPlay worker is not available.' };
|
|
269
|
+
}
|
|
244
270
|
}
|
|
245
271
|
this.setState({ ...this.state, muted });
|
|
246
272
|
return { ok: true };
|
|
247
273
|
}
|
|
248
274
|
async stop() {
|
|
275
|
+
const child = this.process;
|
|
249
276
|
this.stopMpvPolling();
|
|
250
277
|
this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
|
|
251
278
|
this.rejectPendingAirPlayRetune(new Error('AirPlay playback stopped.'));
|
|
@@ -259,10 +286,16 @@ export class PlayerController {
|
|
|
259
286
|
this.pendingAirPlayPasscode = null;
|
|
260
287
|
this.airPlaySessionEstablished = false;
|
|
261
288
|
}
|
|
262
|
-
if (
|
|
263
|
-
|
|
289
|
+
if (child && child.exitCode == null && !child.killed) {
|
|
290
|
+
child.kill('SIGTERM');
|
|
291
|
+
if (!(await waitForChildExit(child, 1200)) && child.exitCode === null) {
|
|
292
|
+
child.kill('SIGKILL');
|
|
293
|
+
await waitForChildExit(child, 500);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (this.process === child) {
|
|
297
|
+
this.process = null;
|
|
264
298
|
}
|
|
265
|
-
this.process = null;
|
|
266
299
|
this.cleanupIpc();
|
|
267
300
|
this.setState({
|
|
268
301
|
...this.state,
|
|
@@ -334,11 +367,16 @@ export class PlayerController {
|
|
|
334
367
|
}
|
|
335
368
|
playWithMpv(url, initialTitle) {
|
|
336
369
|
this.ipcPath = createMpvIpcPath();
|
|
370
|
+
this.mpvIpcClient = new MpvIpcClient(this.ipcPath);
|
|
371
|
+
this.mpvSessionId += 1;
|
|
372
|
+
this.confirmedMpvVolume = clampVolume(this.getSettings().volume);
|
|
373
|
+
this.pendingMpvVolume = null;
|
|
337
374
|
this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
|
|
338
375
|
this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
|
|
339
376
|
'--no-video',
|
|
340
377
|
'--really-quiet',
|
|
341
378
|
'--force-window=no',
|
|
379
|
+
...(process.env.RADIOCLI_MPV_AUDIO_OUTPUT ? [`--ao=${process.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] : []),
|
|
342
380
|
`--force-media-title=${this.currentMpvMediaTitle}`,
|
|
343
381
|
`--volume=${this.getSettings().volume}`,
|
|
344
382
|
`--input-ipc-server=${this.ipcPath}`,
|
|
@@ -450,6 +488,7 @@ export class PlayerController {
|
|
|
450
488
|
const workerPath = airPlayWorkerPath();
|
|
451
489
|
const workerArgs = airPlayWorkerArgs(workerPath, encodeWorkerStart({
|
|
452
490
|
streamUrl: url,
|
|
491
|
+
ffmpegPath: resolveCommand('ffmpeg') ?? 'ffmpeg',
|
|
453
492
|
stationName,
|
|
454
493
|
volume: this.getSettings().volume,
|
|
455
494
|
muted: false,
|
|
@@ -584,6 +623,10 @@ export class PlayerController {
|
|
|
584
623
|
if (!child) {
|
|
585
624
|
return;
|
|
586
625
|
}
|
|
626
|
+
// Local players can write diagnostics indefinitely. Drain both pipes so a
|
|
627
|
+
// full OS pipe buffer can never stall playback.
|
|
628
|
+
child.stdout.resume?.();
|
|
629
|
+
child.stderr.resume?.();
|
|
587
630
|
child.on('error', error => {
|
|
588
631
|
this.setState({
|
|
589
632
|
...this.state,
|
|
@@ -618,6 +661,53 @@ export class PlayerController {
|
|
|
618
661
|
sendMpv(payload) {
|
|
619
662
|
return this.queryMpv(payload).then(() => undefined);
|
|
620
663
|
}
|
|
664
|
+
queueMpvVolume(volume) {
|
|
665
|
+
this.pendingMpvVolume = volume;
|
|
666
|
+
this.setState({ ...this.state, volume });
|
|
667
|
+
if (this.mpvVolumeFlush) {
|
|
668
|
+
return this.mpvVolumeFlush;
|
|
669
|
+
}
|
|
670
|
+
const sessionId = this.mpvSessionId;
|
|
671
|
+
let trackedFlush;
|
|
672
|
+
trackedFlush = this.flushMpvVolume(sessionId).finally(() => {
|
|
673
|
+
if (this.mpvVolumeFlush === trackedFlush) {
|
|
674
|
+
this.mpvVolumeFlush = null;
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
this.mpvVolumeFlush = trackedFlush;
|
|
678
|
+
return trackedFlush;
|
|
679
|
+
}
|
|
680
|
+
async flushMpvVolume(sessionId) {
|
|
681
|
+
let lastError = null;
|
|
682
|
+
while (sessionId === this.mpvSessionId && this.backend === 'mpv' && this.pendingMpvVolume !== null) {
|
|
683
|
+
const target = this.pendingMpvVolume;
|
|
684
|
+
this.pendingMpvVolume = null;
|
|
685
|
+
try {
|
|
686
|
+
await this.sendMpv({ command: ['set_property', 'volume', target] });
|
|
687
|
+
if (sessionId !== this.mpvSessionId) {
|
|
688
|
+
lastError = new Error('mpv playback session changed.');
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
this.confirmedMpvVolume = target;
|
|
692
|
+
lastError = null;
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
lastError = error;
|
|
696
|
+
if (sessionId !== this.mpvSessionId) {
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
if (this.pendingMpvVolume === null) {
|
|
700
|
+
if (this.state.volume === target) {
|
|
701
|
+
this.setState({ ...this.state, volume: this.confirmedMpvVolume });
|
|
702
|
+
}
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return lastError
|
|
708
|
+
? { ok: false, message: 'mpv did not acknowledge the volume change.' }
|
|
709
|
+
: { ok: true };
|
|
710
|
+
}
|
|
621
711
|
sendAirPlayCommand(command) {
|
|
622
712
|
if (this.backend === 'airplay' && this.process && !this.process.killed) {
|
|
623
713
|
try {
|
|
@@ -661,67 +751,10 @@ export class PlayerController {
|
|
|
661
751
|
rejecter?.(error);
|
|
662
752
|
}
|
|
663
753
|
queryMpv(payload) {
|
|
664
|
-
if (!this.
|
|
754
|
+
if (!this.mpvIpcClient) {
|
|
665
755
|
return Promise.resolve(null);
|
|
666
756
|
}
|
|
667
|
-
return
|
|
668
|
-
const socket = new Socket();
|
|
669
|
-
let buffer = '';
|
|
670
|
-
let settled = false;
|
|
671
|
-
const requestId = this.nextMpvRequestId;
|
|
672
|
-
this.nextMpvRequestId = this.nextMpvRequestId >= Number.MAX_SAFE_INTEGER ? 1 : this.nextMpvRequestId + 1;
|
|
673
|
-
const requestPayload = attachMpvRequestId(payload, requestId);
|
|
674
|
-
const settle = (callback) => {
|
|
675
|
-
if (settled) {
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
settled = true;
|
|
679
|
-
clearTimeout(timeout);
|
|
680
|
-
socket.end();
|
|
681
|
-
callback();
|
|
682
|
-
};
|
|
683
|
-
const timeout = setTimeout(() => {
|
|
684
|
-
socket.destroy();
|
|
685
|
-
settle(() => reject(new Error('mpv IPC timed out.')));
|
|
686
|
-
}, 1000);
|
|
687
|
-
socket.once('error', error => {
|
|
688
|
-
settle(() => reject(error));
|
|
689
|
-
});
|
|
690
|
-
socket.on('data', chunk => {
|
|
691
|
-
buffer += chunk.toString('utf8');
|
|
692
|
-
let newlineIndex = buffer.indexOf('\n');
|
|
693
|
-
while (newlineIndex !== -1) {
|
|
694
|
-
const line = buffer.slice(0, newlineIndex);
|
|
695
|
-
buffer = buffer.slice(newlineIndex + 1);
|
|
696
|
-
newlineIndex = buffer.indexOf('\n');
|
|
697
|
-
if (!line.trim()) {
|
|
698
|
-
continue;
|
|
699
|
-
}
|
|
700
|
-
try {
|
|
701
|
-
const parsed = JSON.parse(line);
|
|
702
|
-
if (parsed.request_id !== requestId) {
|
|
703
|
-
continue;
|
|
704
|
-
}
|
|
705
|
-
if (parsed.error && parsed.error !== 'success') {
|
|
706
|
-
settle(() => reject(new Error(`mpv IPC failed: ${parsed.error}`)));
|
|
707
|
-
}
|
|
708
|
-
else {
|
|
709
|
-
settle(() => resolve(parsed.data ?? null));
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
catch {
|
|
713
|
-
settle(() => resolve(null));
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
});
|
|
717
|
-
socket.connect(this.ipcPath, () => {
|
|
718
|
-
socket.write(`${JSON.stringify(requestPayload)}\n`, error => {
|
|
719
|
-
if (error) {
|
|
720
|
-
settle(() => reject(error));
|
|
721
|
-
}
|
|
722
|
-
});
|
|
723
|
-
});
|
|
724
|
-
});
|
|
757
|
+
return this.mpvIpcClient.query(payload);
|
|
725
758
|
}
|
|
726
759
|
async waitForReady(backend) {
|
|
727
760
|
const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
|
|
@@ -737,7 +770,9 @@ export class PlayerController {
|
|
|
737
770
|
if (this.ipcPath) {
|
|
738
771
|
try {
|
|
739
772
|
await this.queryMpv({ command: ['get_property', 'path'] });
|
|
740
|
-
|
|
773
|
+
if (await this.hasMpvAudioStarted()) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
741
776
|
}
|
|
742
777
|
catch {
|
|
743
778
|
// The IPC socket can exist briefly before accepting commands.
|
|
@@ -748,13 +783,22 @@ export class PlayerController {
|
|
|
748
783
|
await this.stop();
|
|
749
784
|
throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
|
|
750
785
|
}
|
|
786
|
+
async hasMpvAudioStarted() {
|
|
787
|
+
const [timePos, audioPts] = await Promise.all([
|
|
788
|
+
this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null),
|
|
789
|
+
this.queryMpv({ command: ['get_property', 'audio-pts'] }).catch(() => null)
|
|
790
|
+
]);
|
|
791
|
+
return typeof timePos === 'number' || typeof audioPts === 'number';
|
|
792
|
+
}
|
|
751
793
|
startMpvMetadataPolling() {
|
|
752
794
|
this.stopMpvPolling();
|
|
753
795
|
this.metadataTimer = setInterval(() => {
|
|
754
|
-
|
|
796
|
+
if (!this.metadataPollInFlight)
|
|
797
|
+
void this.pollMpvMetadata();
|
|
755
798
|
}, 2500);
|
|
756
799
|
this.playbackStateTimer = setInterval(() => {
|
|
757
|
-
|
|
800
|
+
if (!this.playbackStatePollInFlight)
|
|
801
|
+
void this.syncMpvPlaybackState();
|
|
758
802
|
}, 500);
|
|
759
803
|
void this.pollMpvMetadata();
|
|
760
804
|
void this.syncMpvPlaybackState();
|
|
@@ -773,15 +817,21 @@ export class PlayerController {
|
|
|
773
817
|
if (this.backend !== 'mpv' || !this.process) {
|
|
774
818
|
return;
|
|
775
819
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
this.
|
|
820
|
+
this.metadataPollInFlight = true;
|
|
821
|
+
try {
|
|
822
|
+
const metadata = await this.queryMpv({ command: ['get_property', 'metadata'] }).catch(() => null);
|
|
823
|
+
const elapsed = await this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null);
|
|
824
|
+
if (typeof elapsed === 'number') {
|
|
825
|
+
this.setState({ ...this.state, elapsedSeconds: Math.floor(elapsed) });
|
|
826
|
+
}
|
|
827
|
+
const title = extractMpvTitle(metadata);
|
|
828
|
+
if (title) {
|
|
829
|
+
await this.setMpvMediaTitle(title);
|
|
830
|
+
this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
|
|
831
|
+
}
|
|
780
832
|
}
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
await this.setMpvMediaTitle(title);
|
|
784
|
-
this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
|
|
833
|
+
finally {
|
|
834
|
+
this.metadataPollInFlight = false;
|
|
785
835
|
}
|
|
786
836
|
}
|
|
787
837
|
async setMpvMediaTitle(title) {
|
|
@@ -796,22 +846,40 @@ export class PlayerController {
|
|
|
796
846
|
if (this.backend !== 'mpv' || !this.process || !this.state.ready) {
|
|
797
847
|
return false;
|
|
798
848
|
}
|
|
799
|
-
|
|
800
|
-
if (typeof paused !== 'boolean') {
|
|
849
|
+
if (this.playbackStatePollInFlight)
|
|
801
850
|
return false;
|
|
851
|
+
this.playbackStatePollInFlight = true;
|
|
852
|
+
try {
|
|
853
|
+
const paused = await this.queryMpv({ command: ['get_property', 'pause'] }).catch(() => null);
|
|
854
|
+
if (typeof paused !== 'boolean') {
|
|
855
|
+
return false;
|
|
856
|
+
}
|
|
857
|
+
const state = paused ? 'paused' : 'playing';
|
|
858
|
+
if (this.state.state !== state) {
|
|
859
|
+
this.setState({ ...this.state, state });
|
|
860
|
+
}
|
|
861
|
+
return true;
|
|
802
862
|
}
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
this.setState({ ...this.state, state });
|
|
863
|
+
finally {
|
|
864
|
+
this.playbackStatePollInFlight = false;
|
|
806
865
|
}
|
|
807
|
-
return true;
|
|
808
866
|
}
|
|
809
867
|
emitMetadata(metadata) {
|
|
810
868
|
for (const listener of this.metadataListeners) {
|
|
811
|
-
|
|
869
|
+
try {
|
|
870
|
+
listener(metadata);
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
// UI/storage listeners must not terminate the player polling loop.
|
|
874
|
+
}
|
|
812
875
|
}
|
|
813
876
|
}
|
|
814
877
|
cleanupIpc() {
|
|
878
|
+
this.mpvSessionId += 1;
|
|
879
|
+
this.mpvIpcClient?.close();
|
|
880
|
+
this.mpvIpcClient = null;
|
|
881
|
+
this.pendingMpvVolume = null;
|
|
882
|
+
this.mpvVolumeFlush = null;
|
|
815
883
|
if (this.ipcPath && !isWindowsNamedPipePath(this.ipcPath) && existsSync(this.ipcPath)) {
|
|
816
884
|
try {
|
|
817
885
|
unlinkSync(this.ipcPath);
|
|
@@ -853,6 +921,25 @@ function isWindowsNamedPipePath(path) {
|
|
|
853
921
|
function clampVolume(volume) {
|
|
854
922
|
return Math.min(100, Math.max(0, Math.round(volume)));
|
|
855
923
|
}
|
|
924
|
+
function waitForChildExit(child, timeoutMs) {
|
|
925
|
+
if (child.exitCode === undefined) {
|
|
926
|
+
return Promise.resolve(true);
|
|
927
|
+
}
|
|
928
|
+
if (child.exitCode !== null) {
|
|
929
|
+
return Promise.resolve(true);
|
|
930
|
+
}
|
|
931
|
+
return new Promise(resolve => {
|
|
932
|
+
const onExit = () => {
|
|
933
|
+
clearTimeout(timeout);
|
|
934
|
+
resolve(true);
|
|
935
|
+
};
|
|
936
|
+
const timeout = setTimeout(() => {
|
|
937
|
+
child.off('exit', onExit);
|
|
938
|
+
resolve(false);
|
|
939
|
+
}, timeoutMs);
|
|
940
|
+
child.once('exit', onExit);
|
|
941
|
+
});
|
|
942
|
+
}
|
|
856
943
|
function delay(ms) {
|
|
857
944
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
858
945
|
}
|
|
@@ -876,12 +963,6 @@ export function extractMpvTitle(metadata) {
|
|
|
876
963
|
}
|
|
877
964
|
return undefined;
|
|
878
965
|
}
|
|
879
|
-
function attachMpvRequestId(payload, requestId) {
|
|
880
|
-
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
881
|
-
return { ...payload, request_id: requestId };
|
|
882
|
-
}
|
|
883
|
-
return { command: payload, request_id: requestId };
|
|
884
|
-
}
|
|
885
966
|
async function waitForStartupWindow(getProcess, ms) {
|
|
886
967
|
const started = Date.now();
|
|
887
968
|
while (Date.now() - started < ms) {
|
|
@@ -942,7 +1023,7 @@ function leadingMetadataPrefix(value) {
|
|
|
942
1023
|
return cleanMediaTitle(value.slice(0, firstFieldIndex).replace(/[-–—:;,]\s*$/, ''));
|
|
943
1024
|
}
|
|
944
1025
|
function cleanMediaTitle(value) {
|
|
945
|
-
const cleaned = value?.replace(
|
|
1026
|
+
const cleaned = sanitizeTerminalText(value)?.replace(/^"+|"+$/g, '').trim();
|
|
946
1027
|
return cleaned || undefined;
|
|
947
1028
|
}
|
|
948
1029
|
function stripIcyStreamTitleWrapper(value) {
|
package/dist/providers/cache.js
CHANGED
|
@@ -1,30 +1,34 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
|
-
import {
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const maxCacheEntries = 256;
|
|
6
|
+
const maxCacheBytes = 128 * 1024 * 1024;
|
|
4
7
|
export class ProviderCache {
|
|
5
8
|
filePath;
|
|
6
9
|
cache;
|
|
10
|
+
writesSincePrune = 0;
|
|
7
11
|
constructor(filePath = defaultProviderCachePath()) {
|
|
8
12
|
this.filePath = filePath;
|
|
9
13
|
this.cache = this.read();
|
|
10
14
|
}
|
|
11
15
|
get(key, maxAgeMs) {
|
|
12
|
-
const entry = this.cache.entries[key];
|
|
16
|
+
const entry = this.cache.entries[key] ?? this.readShard(key);
|
|
13
17
|
if (!entry) {
|
|
14
18
|
return null;
|
|
15
19
|
}
|
|
16
20
|
if (Date.now() - entry.createdAt > maxAgeMs) {
|
|
17
21
|
return null;
|
|
18
22
|
}
|
|
19
|
-
return
|
|
23
|
+
return entry.value;
|
|
20
24
|
}
|
|
21
|
-
getStale(key) {
|
|
22
|
-
const entry = this.cache.entries[key];
|
|
23
|
-
return entry
|
|
25
|
+
getStale(key, maxAgeMs = 30 * 24 * 60 * 60 * 1000) {
|
|
26
|
+
const entry = this.cache.entries[key] ?? this.readShard(key);
|
|
27
|
+
return entry && Date.now() - entry.createdAt <= maxAgeMs ? entry.value : null;
|
|
24
28
|
}
|
|
25
29
|
set(key, value) {
|
|
26
30
|
this.cache.entries[key] = { createdAt: Date.now(), value };
|
|
27
|
-
this.
|
|
31
|
+
this.writeEntry(key, this.cache.entries[key]);
|
|
28
32
|
}
|
|
29
33
|
read() {
|
|
30
34
|
if (!existsSync(this.filePath)) {
|
|
@@ -39,9 +43,63 @@ export class ProviderCache {
|
|
|
39
43
|
return { version: 1, entries: {} };
|
|
40
44
|
}
|
|
41
45
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
readShard(key) {
|
|
47
|
+
const path = join(this.shardDirectory(), `${createHash('sha256').update(key).digest('hex')}.json`);
|
|
48
|
+
if (!existsSync(path))
|
|
49
|
+
return undefined;
|
|
50
|
+
try {
|
|
51
|
+
const entry = JSON.parse(readFileSync(path, 'utf8'));
|
|
52
|
+
if (entry.version !== 1 || entry.key !== key || !Number.isFinite(entry.createdAt))
|
|
53
|
+
return undefined;
|
|
54
|
+
const cached = { createdAt: entry.createdAt, value: entry.value };
|
|
55
|
+
this.cache.entries[key] = cached;
|
|
56
|
+
return cached;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
writeEntry(key, entry) {
|
|
63
|
+
const directory = this.shardDirectory();
|
|
64
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
65
|
+
const path = join(directory, `${createHash('sha256').update(key).digest('hex')}.json`);
|
|
66
|
+
writeJsonAtomically(path, { version: 1, key, ...entry });
|
|
67
|
+
this.writesSincePrune += 1;
|
|
68
|
+
if (this.writesSincePrune >= 16) {
|
|
69
|
+
this.writesSincePrune = 0;
|
|
70
|
+
this.pruneShards(directory);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
pruneShards(directory) {
|
|
74
|
+
const files = readdirSync(directory)
|
|
75
|
+
.filter(name => name.endsWith('.json'))
|
|
76
|
+
.flatMap(name => {
|
|
77
|
+
try {
|
|
78
|
+
const path = join(directory, name);
|
|
79
|
+
const stat = statSync(path);
|
|
80
|
+
return [{ path, bytes: stat.size, modified: stat.mtimeMs }];
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
.sort((left, right) => right.modified - left.modified);
|
|
87
|
+
let totalBytes = files.reduce((total, file) => total + file.bytes, 0);
|
|
88
|
+
while (files.length > maxCacheEntries || totalBytes > maxCacheBytes) {
|
|
89
|
+
const file = files.pop();
|
|
90
|
+
if (!file)
|
|
91
|
+
break;
|
|
92
|
+
try {
|
|
93
|
+
unlinkSync(file.path);
|
|
94
|
+
totalBytes -= file.bytes;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Cache eviction is best-effort.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
shardDirectory() {
|
|
102
|
+
return `${this.filePath}.d`;
|
|
45
103
|
}
|
|
46
104
|
}
|
|
47
105
|
function defaultProviderCachePath() {
|
|
@@ -57,7 +115,10 @@ function defaultProviderCachePath() {
|
|
|
57
115
|
}
|
|
58
116
|
function currentDefaultProviderCachePath() {
|
|
59
117
|
if (process.platform === 'darwin') {
|
|
60
|
-
return join(homedir(), 'Library', '
|
|
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');
|
|
61
122
|
}
|
|
62
123
|
return join(process.env.XDG_CACHE_HOME ?? join(homedir(), '.cache'), 'radiocli', 'radiocli-cache.json');
|
|
63
124
|
}
|
|
@@ -70,8 +131,11 @@ function legacyDefaultProviderCachePath() {
|
|
|
70
131
|
function writeJsonAtomically(filePath, value) {
|
|
71
132
|
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
72
133
|
try {
|
|
73
|
-
writeFileSync(tempPath, `${JSON.stringify(value
|
|
134
|
+
writeFileSync(tempPath, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
74
135
|
renameSync(tempPath, filePath);
|
|
136
|
+
if (process.platform !== 'win32') {
|
|
137
|
+
chmodSync(filePath, 0o600);
|
|
138
|
+
}
|
|
75
139
|
}
|
|
76
140
|
catch (error) {
|
|
77
141
|
rmSync(tempPath, { force: true });
|
|
@@ -13,8 +13,8 @@ export class ProviderManager {
|
|
|
13
13
|
popular(limit) {
|
|
14
14
|
return this.radioBrowser.popular(limit);
|
|
15
15
|
}
|
|
16
|
-
byCountry(countryCode, limit) {
|
|
17
|
-
return this.radioBrowser.byCountry(countryCode, limit);
|
|
16
|
+
byCountry(countryCode, limit, offset) {
|
|
17
|
+
return this.radioBrowser.byCountry(countryCode, limit, offset);
|
|
18
18
|
}
|
|
19
19
|
nearby(location, limit) {
|
|
20
20
|
return this.radioBrowser.nearby(location, limit);
|
|
@@ -23,13 +23,20 @@ export class ProviderManager {
|
|
|
23
23
|
return this.radioBrowser.detectLocation();
|
|
24
24
|
}
|
|
25
25
|
async search(query, settings, options = {}) {
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
28
|
-
return radioBrowser;
|
|
26
|
+
const includeGarden = (settings.enableRadioGarden || options.includeExperimental) && (options.offset ?? 0) === 0;
|
|
27
|
+
if (!includeGarden) {
|
|
28
|
+
return this.radioBrowser.search(query, options);
|
|
29
29
|
}
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const [browserResult, gardenResult] = await Promise.allSettled([
|
|
31
|
+
this.radioBrowser.search(query, options),
|
|
32
|
+
this.radioGarden.search(query, options)
|
|
33
|
+
]);
|
|
34
|
+
const radioBrowser = browserResult.status === 'fulfilled' ? browserResult.value : [];
|
|
35
|
+
const radioGarden = gardenResult.status === 'fulfilled' ? gardenResult.value : [];
|
|
36
|
+
if (radioBrowser.length === 0 && radioGarden.length === 0 && browserResult.status === 'rejected') {
|
|
37
|
+
throw browserResult.reason;
|
|
38
|
+
}
|
|
39
|
+
return dedupeStations(interleave(radioBrowser, radioGarden)).slice(0, options.limit ?? 80);
|
|
33
40
|
}
|
|
34
41
|
async resolve(station) {
|
|
35
42
|
if (station.provider === 'radio-garden') {
|
|
@@ -51,3 +58,16 @@ export class ProviderManager {
|
|
|
51
58
|
};
|
|
52
59
|
}
|
|
53
60
|
}
|
|
61
|
+
function interleave(primary, secondary) {
|
|
62
|
+
const merged = [];
|
|
63
|
+
const length = Math.max(primary.length, secondary.length);
|
|
64
|
+
for (let index = 0; index < length; index += 1) {
|
|
65
|
+
const primaryStation = primary[index];
|
|
66
|
+
const secondaryStation = secondary[index];
|
|
67
|
+
if (primaryStation)
|
|
68
|
+
merged.push(primaryStation);
|
|
69
|
+
if (secondaryStation)
|
|
70
|
+
merged.push(secondaryStation);
|
|
71
|
+
}
|
|
72
|
+
return merged;
|
|
73
|
+
}
|