@ciphore/radiocli 0.2.0 → 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 +44 -0
- package/README.md +107 -411
- package/SECURITY.md +6 -3
- package/dist/activity/stats.js +14 -2
- package/dist/cli.js +36 -2
- 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 +169 -103
- package/dist/providers/cache.js +77 -13
- package/dist/providers/provider-manager.js +26 -6
- package/dist/providers/radio-browser.js +139 -57
- package/dist/safety.js +44 -0
- package/dist/storage/store.js +253 -152
- package/dist/types.js +2 -53
- package/dist/ui/AdaptiveContent.js +332 -0
- package/dist/ui/App.js +251 -47
- package/dist/ui/AppContent.js +9 -10
- 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 +9 -2
- package/dist/ui/layout.js +26 -9
- package/dist/ui/page-footer.js +36 -13
- package/dist/ui/playback-footer.js +12 -1
- package/dist/ui/screen-items.js +60 -22
- 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 +93 -21
- 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 +29 -0
- package/dist/ui/theme.js +0 -1
- package/dist/ui/use-app-input.js +24 -7
- package/dist/ui/use-command-executor.js +28 -0
- package/dist/ui/visualizers/receiver-style-registry.js +101 -0
- package/dist/ui/visualizers/receiver-visualizers.js +2856 -745
- package/docs/THIRD_PARTY_NOTICES.md +7 -0
- package/package.json +2 -2
- package/dist/ui/screens/screen-render.test.js +0 -235
|
@@ -3,14 +3,14 @@ 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
|
-
const mpvAudioStartFallbackMs = 1500;
|
|
14
14
|
export class PlaybackOutputError extends Error {
|
|
15
15
|
constructor(message) {
|
|
16
16
|
super(message);
|
|
@@ -25,6 +25,7 @@ export class PlayerController {
|
|
|
25
25
|
process = null;
|
|
26
26
|
backend = null;
|
|
27
27
|
ipcPath = null;
|
|
28
|
+
mpvIpcClient = null;
|
|
28
29
|
metadataTimer = null;
|
|
29
30
|
playbackStateTimer = null;
|
|
30
31
|
state = { backend: 'none', state: 'idle', volume: 70, muted: false, ready: false };
|
|
@@ -42,8 +43,13 @@ export class PlayerController {
|
|
|
42
43
|
currentAirPlayDevice = null;
|
|
43
44
|
currentAirPlayDeviceId = null;
|
|
44
45
|
pendingAirPlayPasscode = null;
|
|
45
|
-
nextMpvRequestId = 1;
|
|
46
46
|
currentMpvMediaTitle = null;
|
|
47
|
+
metadataPollInFlight = false;
|
|
48
|
+
playbackStatePollInFlight = false;
|
|
49
|
+
pendingMpvVolume = null;
|
|
50
|
+
mpvVolumeFlush = null;
|
|
51
|
+
confirmedMpvVolume = 70;
|
|
52
|
+
mpvSessionId = 0;
|
|
47
53
|
constructor(getSettings) {
|
|
48
54
|
this.getSettings = getSettings;
|
|
49
55
|
}
|
|
@@ -81,6 +87,11 @@ export class PlayerController {
|
|
|
81
87
|
return this.detectedBackends();
|
|
82
88
|
}
|
|
83
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;
|
|
84
95
|
const backend = this.selectBackend();
|
|
85
96
|
if (!backend) {
|
|
86
97
|
await this.stop();
|
|
@@ -197,7 +208,12 @@ export class PlayerController {
|
|
|
197
208
|
return unsupported;
|
|
198
209
|
}
|
|
199
210
|
if (this.backend === 'mpv') {
|
|
200
|
-
|
|
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
|
+
}
|
|
201
217
|
const synced = await this.syncMpvPlaybackState();
|
|
202
218
|
if (synced) {
|
|
203
219
|
return { ok: true };
|
|
@@ -213,22 +229,24 @@ export class PlayerController {
|
|
|
213
229
|
});
|
|
214
230
|
return { ok: true };
|
|
215
231
|
}
|
|
216
|
-
|
|
232
|
+
setVolume(volume) {
|
|
217
233
|
const clamped = clampVolume(volume);
|
|
218
234
|
const unsupported = this.unsupportedFfplayControl();
|
|
219
235
|
if (unsupported) {
|
|
220
|
-
return unsupported;
|
|
236
|
+
return Promise.resolve(unsupported);
|
|
221
237
|
}
|
|
222
238
|
if (this.backend === 'mpv') {
|
|
223
|
-
|
|
239
|
+
return this.queueMpvVolume(clamped);
|
|
224
240
|
}
|
|
225
241
|
else if (this.backend === 'airplay') {
|
|
226
|
-
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
|
+
}
|
|
227
245
|
}
|
|
228
246
|
this.setState({ ...this.state, volume: clamped });
|
|
229
|
-
return { ok: true };
|
|
247
|
+
return Promise.resolve({ ok: true });
|
|
230
248
|
}
|
|
231
|
-
|
|
249
|
+
adjustVolume(delta) {
|
|
232
250
|
return this.setVolume(this.state.volume + delta);
|
|
233
251
|
}
|
|
234
252
|
async toggleMute() {
|
|
@@ -238,15 +256,23 @@ export class PlayerController {
|
|
|
238
256
|
return unsupported;
|
|
239
257
|
}
|
|
240
258
|
if (this.backend === 'mpv') {
|
|
241
|
-
|
|
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
|
+
}
|
|
242
265
|
}
|
|
243
266
|
else if (this.backend === 'airplay') {
|
|
244
|
-
this.sendAirPlayCommand({ type: 'setMuted', muted })
|
|
267
|
+
if (!this.sendAirPlayCommand({ type: 'setMuted', muted })) {
|
|
268
|
+
return { ok: false, message: 'The AirPlay worker is not available.' };
|
|
269
|
+
}
|
|
245
270
|
}
|
|
246
271
|
this.setState({ ...this.state, muted });
|
|
247
272
|
return { ok: true };
|
|
248
273
|
}
|
|
249
274
|
async stop() {
|
|
275
|
+
const child = this.process;
|
|
250
276
|
this.stopMpvPolling();
|
|
251
277
|
this.rejectPendingAirPlayReady(new Error('AirPlay playback stopped.'));
|
|
252
278
|
this.rejectPendingAirPlayRetune(new Error('AirPlay playback stopped.'));
|
|
@@ -260,10 +286,16 @@ export class PlayerController {
|
|
|
260
286
|
this.pendingAirPlayPasscode = null;
|
|
261
287
|
this.airPlaySessionEstablished = false;
|
|
262
288
|
}
|
|
263
|
-
if (
|
|
264
|
-
|
|
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;
|
|
265
298
|
}
|
|
266
|
-
this.process = null;
|
|
267
299
|
this.cleanupIpc();
|
|
268
300
|
this.setState({
|
|
269
301
|
...this.state,
|
|
@@ -335,11 +367,16 @@ export class PlayerController {
|
|
|
335
367
|
}
|
|
336
368
|
playWithMpv(url, initialTitle) {
|
|
337
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;
|
|
338
374
|
this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
|
|
339
375
|
this.process = spawn(resolveCommand('mpv') ?? 'mpv', [
|
|
340
376
|
'--no-video',
|
|
341
377
|
'--really-quiet',
|
|
342
378
|
'--force-window=no',
|
|
379
|
+
...(process.env.RADIOCLI_MPV_AUDIO_OUTPUT ? [`--ao=${process.env.RADIOCLI_MPV_AUDIO_OUTPUT}`] : []),
|
|
343
380
|
`--force-media-title=${this.currentMpvMediaTitle}`,
|
|
344
381
|
`--volume=${this.getSettings().volume}`,
|
|
345
382
|
`--input-ipc-server=${this.ipcPath}`,
|
|
@@ -451,6 +488,7 @@ export class PlayerController {
|
|
|
451
488
|
const workerPath = airPlayWorkerPath();
|
|
452
489
|
const workerArgs = airPlayWorkerArgs(workerPath, encodeWorkerStart({
|
|
453
490
|
streamUrl: url,
|
|
491
|
+
ffmpegPath: resolveCommand('ffmpeg') ?? 'ffmpeg',
|
|
454
492
|
stationName,
|
|
455
493
|
volume: this.getSettings().volume,
|
|
456
494
|
muted: false,
|
|
@@ -585,6 +623,10 @@ export class PlayerController {
|
|
|
585
623
|
if (!child) {
|
|
586
624
|
return;
|
|
587
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?.();
|
|
588
630
|
child.on('error', error => {
|
|
589
631
|
this.setState({
|
|
590
632
|
...this.state,
|
|
@@ -619,6 +661,53 @@ export class PlayerController {
|
|
|
619
661
|
sendMpv(payload) {
|
|
620
662
|
return this.queryMpv(payload).then(() => undefined);
|
|
621
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
|
+
}
|
|
622
711
|
sendAirPlayCommand(command) {
|
|
623
712
|
if (this.backend === 'airplay' && this.process && !this.process.killed) {
|
|
624
713
|
try {
|
|
@@ -662,72 +751,14 @@ export class PlayerController {
|
|
|
662
751
|
rejecter?.(error);
|
|
663
752
|
}
|
|
664
753
|
queryMpv(payload) {
|
|
665
|
-
if (!this.
|
|
754
|
+
if (!this.mpvIpcClient) {
|
|
666
755
|
return Promise.resolve(null);
|
|
667
756
|
}
|
|
668
|
-
return
|
|
669
|
-
const socket = new Socket();
|
|
670
|
-
let buffer = '';
|
|
671
|
-
let settled = false;
|
|
672
|
-
const requestId = this.nextMpvRequestId;
|
|
673
|
-
this.nextMpvRequestId = this.nextMpvRequestId >= Number.MAX_SAFE_INTEGER ? 1 : this.nextMpvRequestId + 1;
|
|
674
|
-
const requestPayload = attachMpvRequestId(payload, requestId);
|
|
675
|
-
const settle = (callback) => {
|
|
676
|
-
if (settled) {
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
settled = true;
|
|
680
|
-
clearTimeout(timeout);
|
|
681
|
-
socket.end();
|
|
682
|
-
callback();
|
|
683
|
-
};
|
|
684
|
-
const timeout = setTimeout(() => {
|
|
685
|
-
socket.destroy();
|
|
686
|
-
settle(() => reject(new Error('mpv IPC timed out.')));
|
|
687
|
-
}, 1000);
|
|
688
|
-
socket.once('error', error => {
|
|
689
|
-
settle(() => reject(error));
|
|
690
|
-
});
|
|
691
|
-
socket.on('data', chunk => {
|
|
692
|
-
buffer += chunk.toString('utf8');
|
|
693
|
-
let newlineIndex = buffer.indexOf('\n');
|
|
694
|
-
while (newlineIndex !== -1) {
|
|
695
|
-
const line = buffer.slice(0, newlineIndex);
|
|
696
|
-
buffer = buffer.slice(newlineIndex + 1);
|
|
697
|
-
newlineIndex = buffer.indexOf('\n');
|
|
698
|
-
if (!line.trim()) {
|
|
699
|
-
continue;
|
|
700
|
-
}
|
|
701
|
-
try {
|
|
702
|
-
const parsed = JSON.parse(line);
|
|
703
|
-
if (parsed.request_id !== requestId) {
|
|
704
|
-
continue;
|
|
705
|
-
}
|
|
706
|
-
if (parsed.error && parsed.error !== 'success') {
|
|
707
|
-
settle(() => reject(new Error(`mpv IPC failed: ${parsed.error}`)));
|
|
708
|
-
}
|
|
709
|
-
else {
|
|
710
|
-
settle(() => resolve(parsed.data ?? null));
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
catch {
|
|
714
|
-
settle(() => resolve(null));
|
|
715
|
-
}
|
|
716
|
-
}
|
|
717
|
-
});
|
|
718
|
-
socket.connect(this.ipcPath, () => {
|
|
719
|
-
socket.write(`${JSON.stringify(requestPayload)}\n`, error => {
|
|
720
|
-
if (error) {
|
|
721
|
-
settle(() => reject(error));
|
|
722
|
-
}
|
|
723
|
-
});
|
|
724
|
-
});
|
|
725
|
-
});
|
|
757
|
+
return this.mpvIpcClient.query(payload);
|
|
726
758
|
}
|
|
727
759
|
async waitForReady(backend) {
|
|
728
760
|
const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
|
|
729
761
|
const started = Date.now();
|
|
730
|
-
let mpvPathReadyAt = 0;
|
|
731
762
|
while (Date.now() - started < timeoutMs) {
|
|
732
763
|
if (!this.process) {
|
|
733
764
|
throw new Error('Player exited before the stream became ready.');
|
|
@@ -739,13 +770,9 @@ export class PlayerController {
|
|
|
739
770
|
if (this.ipcPath) {
|
|
740
771
|
try {
|
|
741
772
|
await this.queryMpv({ command: ['get_property', 'path'] });
|
|
742
|
-
mpvPathReadyAt = mpvPathReadyAt || Date.now();
|
|
743
773
|
if (await this.hasMpvAudioStarted()) {
|
|
744
774
|
return;
|
|
745
775
|
}
|
|
746
|
-
if (Date.now() - mpvPathReadyAt >= mpvAudioStartFallbackMs) {
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
776
|
}
|
|
750
777
|
catch {
|
|
751
778
|
// The IPC socket can exist briefly before accepting commands.
|
|
@@ -766,10 +793,12 @@ export class PlayerController {
|
|
|
766
793
|
startMpvMetadataPolling() {
|
|
767
794
|
this.stopMpvPolling();
|
|
768
795
|
this.metadataTimer = setInterval(() => {
|
|
769
|
-
|
|
796
|
+
if (!this.metadataPollInFlight)
|
|
797
|
+
void this.pollMpvMetadata();
|
|
770
798
|
}, 2500);
|
|
771
799
|
this.playbackStateTimer = setInterval(() => {
|
|
772
|
-
|
|
800
|
+
if (!this.playbackStatePollInFlight)
|
|
801
|
+
void this.syncMpvPlaybackState();
|
|
773
802
|
}, 500);
|
|
774
803
|
void this.pollMpvMetadata();
|
|
775
804
|
void this.syncMpvPlaybackState();
|
|
@@ -788,15 +817,21 @@ export class PlayerController {
|
|
|
788
817
|
if (this.backend !== 'mpv' || !this.process) {
|
|
789
818
|
return;
|
|
790
819
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
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
|
+
}
|
|
795
832
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
await this.setMpvMediaTitle(title);
|
|
799
|
-
this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
|
|
833
|
+
finally {
|
|
834
|
+
this.metadataPollInFlight = false;
|
|
800
835
|
}
|
|
801
836
|
}
|
|
802
837
|
async setMpvMediaTitle(title) {
|
|
@@ -811,22 +846,40 @@ export class PlayerController {
|
|
|
811
846
|
if (this.backend !== 'mpv' || !this.process || !this.state.ready) {
|
|
812
847
|
return false;
|
|
813
848
|
}
|
|
814
|
-
|
|
815
|
-
if (typeof paused !== 'boolean') {
|
|
849
|
+
if (this.playbackStatePollInFlight)
|
|
816
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;
|
|
817
862
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
this.setState({ ...this.state, state });
|
|
863
|
+
finally {
|
|
864
|
+
this.playbackStatePollInFlight = false;
|
|
821
865
|
}
|
|
822
|
-
return true;
|
|
823
866
|
}
|
|
824
867
|
emitMetadata(metadata) {
|
|
825
868
|
for (const listener of this.metadataListeners) {
|
|
826
|
-
|
|
869
|
+
try {
|
|
870
|
+
listener(metadata);
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
// UI/storage listeners must not terminate the player polling loop.
|
|
874
|
+
}
|
|
827
875
|
}
|
|
828
876
|
}
|
|
829
877
|
cleanupIpc() {
|
|
878
|
+
this.mpvSessionId += 1;
|
|
879
|
+
this.mpvIpcClient?.close();
|
|
880
|
+
this.mpvIpcClient = null;
|
|
881
|
+
this.pendingMpvVolume = null;
|
|
882
|
+
this.mpvVolumeFlush = null;
|
|
830
883
|
if (this.ipcPath && !isWindowsNamedPipePath(this.ipcPath) && existsSync(this.ipcPath)) {
|
|
831
884
|
try {
|
|
832
885
|
unlinkSync(this.ipcPath);
|
|
@@ -868,6 +921,25 @@ function isWindowsNamedPipePath(path) {
|
|
|
868
921
|
function clampVolume(volume) {
|
|
869
922
|
return Math.min(100, Math.max(0, Math.round(volume)));
|
|
870
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
|
+
}
|
|
871
943
|
function delay(ms) {
|
|
872
944
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
873
945
|
}
|
|
@@ -891,12 +963,6 @@ export function extractMpvTitle(metadata) {
|
|
|
891
963
|
}
|
|
892
964
|
return undefined;
|
|
893
965
|
}
|
|
894
|
-
function attachMpvRequestId(payload, requestId) {
|
|
895
|
-
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
896
|
-
return { ...payload, request_id: requestId };
|
|
897
|
-
}
|
|
898
|
-
return { command: payload, request_id: requestId };
|
|
899
|
-
}
|
|
900
966
|
async function waitForStartupWindow(getProcess, ms) {
|
|
901
967
|
const started = Date.now();
|
|
902
968
|
while (Date.now() - started < ms) {
|
|
@@ -957,7 +1023,7 @@ function leadingMetadataPrefix(value) {
|
|
|
957
1023
|
return cleanMediaTitle(value.slice(0, firstFieldIndex).replace(/[-–—:;,]\s*$/, ''));
|
|
958
1024
|
}
|
|
959
1025
|
function cleanMediaTitle(value) {
|
|
960
|
-
const cleaned = value?.replace(
|
|
1026
|
+
const cleaned = sanitizeTerminalText(value)?.replace(/^"+|"+$/g, '').trim();
|
|
961
1027
|
return cleaned || undefined;
|
|
962
1028
|
}
|
|
963
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 });
|
|
@@ -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
|
+
}
|