@ciphore/radiocli 0.2.3 → 0.2.5
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 +147 -2
- package/CONTRIBUTING.md +46 -6
- package/README.md +108 -209
- package/SECURITY.md +23 -1
- package/dist/agent/alarm-service.js +10 -7
- package/dist/agent/cli.js +16 -1
- package/dist/agent/command-schema.js +47 -0
- package/dist/agent/headless-host.js +234 -67
- package/dist/agent/launcher.js +7 -67
- package/dist/agent/mcp-install.js +15 -15
- package/dist/agent/mcp-server.js +10 -5
- package/dist/agent/service.js +74 -39
- package/dist/agent/session.js +227 -53
- package/dist/agent/tui-session.js +213 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +19 -3
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/occurrence-cache.js +25 -0
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +140 -45
- package/dist/alarms/schedule.js +37 -2
- package/dist/alarms/scheduler.js +202 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-linux.js +160 -0
- package/dist/alarms/system-volume-ownership.js +335 -0
- package/dist/alarms/system-volume.js +188 -26
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli/arguments.js +212 -0
- package/dist/cli/completion.js +44 -0
- package/dist/cli/doctor.js +156 -0
- package/dist/cli/interactive.js +79 -0
- package/dist/cli/listener.js +227 -0
- package/dist/cli/output.js +121 -0
- package/dist/cli/render-tui.js +34 -0
- package/dist/cli.js +198 -154
- package/dist/platform/capabilities.js +60 -0
- package/dist/platform/desktop-media.js +61 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/platform/diagnostics.js +273 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/json-worker.js +111 -0
- package/dist/platform/launch-command.js +138 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/mpris.js +356 -0
- package/dist/platform/network.js +380 -0
- package/dist/platform/omarchy.js +147 -0
- package/dist/platform/packages.js +222 -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 +223 -0
- package/dist/player/airplay-discovery.js +145 -60
- package/dist/player/airplay-policy.js +15 -0
- package/dist/player/airplay-process.js +63 -0
- package/dist/player/airplay-readiness.js +42 -0
- package/dist/player/airplay-sender-health.js +49 -49
- package/dist/player/airplay-sender-package.js +5 -0
- package/dist/player/airplay-sender-patch.js +102 -4
- package/dist/player/airplay-setup.js +51 -0
- package/dist/player/airplay-worker.js +97 -41
- package/dist/player/backend-install.js +17 -95
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/ffmpeg-input.js +37 -0
- package/dist/player/mpv-desktop-media.js +194 -0
- package/dist/player/mpv-ipc-client.js +52 -2
- package/dist/player/player-controller.js +569 -82
- package/dist/player/radio-engine.js +282 -0
- package/dist/playlists/playlist.js +18 -16
- package/dist/providers/cache.js +293 -99
- package/dist/providers/discovery-session.js +111 -0
- package/dist/providers/provider-manager.js +106 -28
- package/dist/providers/radio-browser.js +418 -89
- package/dist/providers/radio-garden.js +18 -23
- package/dist/setup-playback.js +152 -0
- package/dist/setup.js +163 -168
- package/dist/storage/file-lock.js +113 -0
- package/dist/storage/read-settings.js +33 -0
- package/dist/storage/replace-file.js +20 -0
- package/dist/storage/repository.js +247 -0
- package/dist/storage/station-identity.js +21 -0
- package/dist/storage/station-schema.js +27 -0
- package/dist/storage/store.js +333 -107
- package/dist/streams/import-stream.js +163 -0
- package/dist/ui/AdaptiveContent.js +49 -32
- package/dist/ui/App.js +822 -481
- package/dist/ui/AppContent.js +5 -5
- package/dist/ui/airplay-settings.js +12 -5
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/bidi-ranges.js +45 -0
- package/dist/ui/components/AdaptiveMarquee.js +20 -10
- 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/format.js +37 -4
- package/dist/ui/help-content.js +9 -6
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +3 -3
- package/dist/ui/rtl-output.js +137 -0
- package/dist/ui/screen-items.js +12 -6
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
- package/dist/ui/screens/AlarmsScreen.js +5 -2
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +8 -4
- 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 +28 -21
- 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/text-direction.js +9 -0
- package/dist/ui/use-alarm-tui.js +57 -42
- package/dist/ui/use-app-input.js +84 -30
- package/dist/ui/use-command-executor.js +50 -19
- package/dist/ui/visualizers/gallop.js +1 -1
- package/dist/ui/visualizers/receiver-style-registry.js +2 -1
- package/dist/update-check.js +10 -18
- package/docs/THIRD_PARTY_NOTICES.md +6 -4
- package/package.json +34 -11
- package/scripts/build-airplay-native.mjs +22 -0
- package/vendor/node-airtunes2/LICENSE +208 -0
- package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
- package/vendor/node-airtunes2/README.md +225 -0
- package/vendor/node-airtunes2/UPSTREAM.json +50 -0
- package/vendor/node-airtunes2/binding.gyp +41 -0
- package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
- package/vendor/node-airtunes2/lib/atv.js +307 -0
- package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
- package/vendor/node-airtunes2/lib/audio_out.js +69 -0
- package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
- package/vendor/node-airtunes2/lib/config.js +26 -0
- package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
- package/vendor/node-airtunes2/lib/devices.js +266 -0
- package/vendor/node-airtunes2/lib/ed25519.js +26 -0
- package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
- package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
- package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
- package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
- package/vendor/node-airtunes2/lib/http.js +189 -0
- package/vendor/node-airtunes2/lib/index.d.ts +141 -0
- package/vendor/node-airtunes2/lib/index.js +127 -0
- package/vendor/node-airtunes2/lib/ntp.js +37 -0
- package/vendor/node-airtunes2/lib/num_util.js +21 -0
- package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
- package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
- package/vendor/node-airtunes2/lib/srp.js +158 -0
- package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
- package/vendor/node-airtunes2/lib/util.js +12 -0
- package/vendor/node-airtunes2/package.json +31 -0
- package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
- package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
- package/vendor/node-airtunes2/src/aes_utils.c +864 -0
- package/vendor/node-airtunes2/src/aes_utils.h +24 -0
- package/vendor/node-airtunes2/src/base64.c +136 -0
- package/vendor/node-airtunes2/src/base64.h +42 -0
- package/vendor/node-airtunes2/src/bindings.cc +29 -0
- package/vendor/node-airtunes2/src/codec.cc +170 -0
- package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { stationKey } from '../storage/store.js';
|
|
2
|
+
import { PlaybackOutputError } from './player-controller.js';
|
|
3
|
+
/** Product playback rules shared by interactive and headless owners. Screen
|
|
4
|
+
* navigation and native alarm ownership deliberately remain in their adapters. */
|
|
5
|
+
export class RadioEngine {
|
|
6
|
+
player;
|
|
7
|
+
providers;
|
|
8
|
+
store;
|
|
9
|
+
settings;
|
|
10
|
+
onSaved;
|
|
11
|
+
state;
|
|
12
|
+
listeners = new Set();
|
|
13
|
+
unsubscribe;
|
|
14
|
+
generation = 0;
|
|
15
|
+
tuneAbort;
|
|
16
|
+
candidate;
|
|
17
|
+
listeningKey;
|
|
18
|
+
lastTrack;
|
|
19
|
+
history = Promise.resolve();
|
|
20
|
+
checkpoint;
|
|
21
|
+
closed = false;
|
|
22
|
+
recoveryTimer;
|
|
23
|
+
recoveryAttempts = 0;
|
|
24
|
+
healthyPlaybackTimer;
|
|
25
|
+
closing;
|
|
26
|
+
constructor(player, providers, store, settings, onSaved) {
|
|
27
|
+
this.player = player;
|
|
28
|
+
this.providers = providers;
|
|
29
|
+
this.store = store;
|
|
30
|
+
this.settings = settings;
|
|
31
|
+
this.onSaved = onSaved;
|
|
32
|
+
this.state = { station: null, tuningStation: null, queue: [], playback: player.getState(), nowPlaying: null };
|
|
33
|
+
this.unsubscribe = [
|
|
34
|
+
player.onChange(playback => this.onPlayback(playback)),
|
|
35
|
+
player.onMetadata(metadata => this.onMetadata(metadata))
|
|
36
|
+
];
|
|
37
|
+
this.checkpoint = setInterval(() => {
|
|
38
|
+
if (this.listeningKey && this.state.playback.state === 'playing' && this.state.playback.ready && !this.state.playback.buffering) {
|
|
39
|
+
const at = new Date();
|
|
40
|
+
this.persist(() => store.checkpointActiveListeningSession(at));
|
|
41
|
+
}
|
|
42
|
+
}, 30_000);
|
|
43
|
+
this.checkpoint.unref();
|
|
44
|
+
}
|
|
45
|
+
snapshot() { return { ...this.state, playback: { ...this.state.playback }, queue: [...this.state.queue] }; }
|
|
46
|
+
onChange(listener) {
|
|
47
|
+
this.listeners.add(listener);
|
|
48
|
+
listener(this.snapshot());
|
|
49
|
+
return () => this.listeners.delete(listener);
|
|
50
|
+
}
|
|
51
|
+
clearWarning() { if (this.state.warning) {
|
|
52
|
+
this.state.warning = undefined;
|
|
53
|
+
this.emit();
|
|
54
|
+
} }
|
|
55
|
+
cancel() {
|
|
56
|
+
this.generation++;
|
|
57
|
+
clearTimeout(this.recoveryTimer);
|
|
58
|
+
this.recoveryTimer = undefined;
|
|
59
|
+
clearTimeout(this.healthyPlaybackTimer);
|
|
60
|
+
this.healthyPlaybackTimer = undefined;
|
|
61
|
+
this.tuneAbort?.abort(new DOMException('Tuning canceled.', 'AbortError'));
|
|
62
|
+
this.tuneAbort = undefined;
|
|
63
|
+
if (this.candidate && this.state.playback.ready)
|
|
64
|
+
this.candidate.generation = this.generation;
|
|
65
|
+
this.state.tuningStation = null;
|
|
66
|
+
this.state.message = undefined;
|
|
67
|
+
this.emit();
|
|
68
|
+
}
|
|
69
|
+
async tune(station, options = {}) {
|
|
70
|
+
if (this.closed)
|
|
71
|
+
throw new Error('RadioCLI playback engine is closed.');
|
|
72
|
+
options.signal?.throwIfAborted();
|
|
73
|
+
this.cancel();
|
|
74
|
+
this.recoveryAttempts = options.recoveryAttempt ?? 0;
|
|
75
|
+
const generation = this.generation;
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
const deadline = setTimeout(() => controller.abort(new DOMException('Tuning exceeded 90 seconds.', 'TimeoutError')), 90_000);
|
|
78
|
+
deadline.unref();
|
|
79
|
+
this.tuneAbort = controller;
|
|
80
|
+
const abort = () => controller.abort(options.signal?.reason);
|
|
81
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
82
|
+
const queue = options.queue?.length ? options.queue : [station];
|
|
83
|
+
let next = station;
|
|
84
|
+
try {
|
|
85
|
+
while (true) {
|
|
86
|
+
controller.signal.throwIfAborted();
|
|
87
|
+
this.state.tuningStation = next;
|
|
88
|
+
this.state.nowPlaying = null;
|
|
89
|
+
this.emit();
|
|
90
|
+
try {
|
|
91
|
+
// A ready receiver can keep its connection while resolving a new URL.
|
|
92
|
+
// Pending startup and local processes are canceled before replacement.
|
|
93
|
+
const playback = this.player.getState();
|
|
94
|
+
if (!(playback.backend === 'airplay' && playback.ready && playback.state === 'playing'))
|
|
95
|
+
await this.player.stop();
|
|
96
|
+
controller.signal.throwIfAborted();
|
|
97
|
+
const resolved = await this.providers.resolve(next, { signal: controller.signal });
|
|
98
|
+
controller.signal.throwIfAborted();
|
|
99
|
+
if (generation !== this.generation)
|
|
100
|
+
throw new DOMException('Tuning canceled.', 'AbortError');
|
|
101
|
+
this.candidate = { station: next, url: resolved.url, generation };
|
|
102
|
+
this.lastTrack = undefined;
|
|
103
|
+
await this.player.play(next, resolved.url, { startPaused: options.startPaused, signal: controller.signal });
|
|
104
|
+
controller.signal.throwIfAborted();
|
|
105
|
+
this.onPlayback(this.player.getState());
|
|
106
|
+
this.state.station = next;
|
|
107
|
+
this.state.queue = [...queue];
|
|
108
|
+
this.state.tuningStation = null;
|
|
109
|
+
this.state.message = undefined;
|
|
110
|
+
const tuned = next;
|
|
111
|
+
this.persist(() => this.store.addRecent(tuned));
|
|
112
|
+
this.emit();
|
|
113
|
+
await this.flushHistory();
|
|
114
|
+
controller.signal.throwIfAborted();
|
|
115
|
+
if (generation !== this.generation)
|
|
116
|
+
throw new DOMException('Tuning canceled.', 'AbortError');
|
|
117
|
+
return next;
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (controller.signal.aborted || generation !== this.generation)
|
|
121
|
+
throw controller.signal.reason ?? error;
|
|
122
|
+
const index = queue.findIndex(item => stationKey(item) === stationKey(next));
|
|
123
|
+
const following = index >= 0 ? queue[index + 1] : undefined;
|
|
124
|
+
if (!following || options.startPaused || options.skipBroken === false || !this.settings().skipBrokenStreams || error instanceof PlaybackOutputError)
|
|
125
|
+
throw error;
|
|
126
|
+
this.state.message = `${error instanceof Error ? error.message : 'Could not tune station.'} Skipping to ${following.name}.`;
|
|
127
|
+
this.state.tuningStation = following;
|
|
128
|
+
this.emit();
|
|
129
|
+
await abortableDelay(1000, controller.signal);
|
|
130
|
+
next = following;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
clearTimeout(deadline);
|
|
136
|
+
options.signal?.removeEventListener('abort', abort);
|
|
137
|
+
if (generation === this.generation) {
|
|
138
|
+
this.tuneAbort = undefined;
|
|
139
|
+
this.state.tuningStation = null;
|
|
140
|
+
this.emit();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async stop(options = {}) {
|
|
145
|
+
this.cancel();
|
|
146
|
+
this.candidate = undefined;
|
|
147
|
+
this.finishListening();
|
|
148
|
+
this.state.nowPlaying = null;
|
|
149
|
+
if (!options.retainSelection) {
|
|
150
|
+
this.state.station = null;
|
|
151
|
+
this.state.queue = [];
|
|
152
|
+
}
|
|
153
|
+
await this.player.stop();
|
|
154
|
+
this.onPlayback(this.player.getState());
|
|
155
|
+
this.emit();
|
|
156
|
+
await this.flushHistory();
|
|
157
|
+
}
|
|
158
|
+
async pause() { const result = await this.player.pause(); this.onPlayback(this.player.getState()); await this.flushHistory(); return result; }
|
|
159
|
+
async resume() { const result = await this.player.resume(); this.onPlayback(this.player.getState()); await this.flushHistory(); return result; }
|
|
160
|
+
select(station, queue = this.state.queue) { this.state.station = station; this.state.queue = [...queue]; this.emit(); }
|
|
161
|
+
async flushHistory() { await this.history; }
|
|
162
|
+
close() {
|
|
163
|
+
return this.closing ??= (async () => {
|
|
164
|
+
this.closed = true;
|
|
165
|
+
clearInterval(this.checkpoint);
|
|
166
|
+
try {
|
|
167
|
+
await this.stop();
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
for (const unsubscribe of this.unsubscribe)
|
|
171
|
+
unsubscribe();
|
|
172
|
+
this.listeners.clear();
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
}
|
|
176
|
+
onPlayback(playback) {
|
|
177
|
+
this.state.playback = playback;
|
|
178
|
+
const active = this.candidate;
|
|
179
|
+
const belongs = active && active.generation === this.generation && (!playback.streamUrl || playback.streamUrl === active.url);
|
|
180
|
+
if (belongs && playback.ready && playback.state === 'playing' && !playback.buffering) {
|
|
181
|
+
if (this.recoveryAttempts > 0 && !this.healthyPlaybackTimer) {
|
|
182
|
+
const generation = this.generation;
|
|
183
|
+
this.healthyPlaybackTimer = setTimeout(() => {
|
|
184
|
+
this.healthyPlaybackTimer = undefined;
|
|
185
|
+
if (generation === this.generation && !this.closed)
|
|
186
|
+
this.recoveryAttempts = 0;
|
|
187
|
+
}, 60_000);
|
|
188
|
+
this.healthyPlaybackTimer.unref();
|
|
189
|
+
}
|
|
190
|
+
const key = stationKey(active.station);
|
|
191
|
+
if (this.listeningKey !== key) {
|
|
192
|
+
this.finishListening();
|
|
193
|
+
this.listeningKey = key;
|
|
194
|
+
const at = new Date();
|
|
195
|
+
this.persist(() => this.store.startListeningSession(active.station, at));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
clearTimeout(this.healthyPlaybackTimer);
|
|
200
|
+
this.healthyPlaybackTimer = undefined;
|
|
201
|
+
if (!playback.ready || playback.state !== 'playing' || playback.buffering)
|
|
202
|
+
this.finishListening();
|
|
203
|
+
}
|
|
204
|
+
if (belongs && playback.state === 'error' && !this.state.tuningStation && !this.closed && this.player.recoverableFailure())
|
|
205
|
+
this.scheduleRecovery(active.station);
|
|
206
|
+
this.emit();
|
|
207
|
+
}
|
|
208
|
+
onMetadata(metadata) {
|
|
209
|
+
const active = this.candidate;
|
|
210
|
+
if (!active || active.generation !== this.generation || this.state.playback.streamUrl && this.state.playback.streamUrl !== active.url)
|
|
211
|
+
return;
|
|
212
|
+
this.state.nowPlaying = metadata;
|
|
213
|
+
const key = `${stationKey(active.station)}:${metadata.title}`;
|
|
214
|
+
if (metadata.title && key !== this.lastTrack) {
|
|
215
|
+
this.lastTrack = key;
|
|
216
|
+
this.persist(() => this.store.recordTrack(active.station, metadata.title));
|
|
217
|
+
}
|
|
218
|
+
this.emit();
|
|
219
|
+
}
|
|
220
|
+
finishListening() {
|
|
221
|
+
if (!this.listeningKey)
|
|
222
|
+
return;
|
|
223
|
+
this.listeningKey = undefined;
|
|
224
|
+
const at = new Date();
|
|
225
|
+
this.persist(() => this.store.finishActiveListeningSession(at));
|
|
226
|
+
}
|
|
227
|
+
scheduleRecovery(station) {
|
|
228
|
+
if (this.recoveryTimer)
|
|
229
|
+
return;
|
|
230
|
+
if (this.recoveryAttempts >= 3) {
|
|
231
|
+
this.state.message = 'Reconnection stopped after 3 attempts. Select the station to try again.';
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const attempt = ++this.recoveryAttempts;
|
|
235
|
+
const generation = this.generation;
|
|
236
|
+
this.state.message = `Stream connection interrupted. Reconnecting ${station.name} (${attempt}/3).`;
|
|
237
|
+
this.recoveryTimer = setTimeout(() => {
|
|
238
|
+
this.recoveryTimer = undefined;
|
|
239
|
+
if (this.closed || generation !== this.generation)
|
|
240
|
+
return;
|
|
241
|
+
void this.tune(station, { queue: this.state.queue, recoveryAttempt: attempt, skipBroken: false }).catch(error => {
|
|
242
|
+
if (this.closed || error.name === 'AbortError')
|
|
243
|
+
return;
|
|
244
|
+
if (this.player.recoverableFailure() && this.recoveryAttempts < 3)
|
|
245
|
+
this.scheduleRecovery(station);
|
|
246
|
+
else {
|
|
247
|
+
this.state.message = `Could not reconnect ${station.name}: ${error instanceof Error ? error.message : String(error)}`;
|
|
248
|
+
this.emit();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}, 1000 * 2 ** (attempt - 1));
|
|
252
|
+
this.recoveryTimer.unref();
|
|
253
|
+
}
|
|
254
|
+
persist(write) {
|
|
255
|
+
this.history = this.history.then(async () => {
|
|
256
|
+
try {
|
|
257
|
+
const saved = await write();
|
|
258
|
+
this.onSaved?.(saved);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
const code = error.code;
|
|
262
|
+
this.state.warning = `Listening history was not saved${code ? ` (${code})` : ''}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`;
|
|
263
|
+
this.emit();
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
emit() { for (const listener of this.listeners) {
|
|
268
|
+
try {
|
|
269
|
+
listener(this.snapshot());
|
|
270
|
+
}
|
|
271
|
+
catch { /* Isolate adapters. */ }
|
|
272
|
+
} }
|
|
273
|
+
}
|
|
274
|
+
function abortableDelay(ms, signal) {
|
|
275
|
+
signal.throwIfAborted();
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
const finish = () => { signal.removeEventListener('abort', abort); resolve(); };
|
|
278
|
+
const timer = setTimeout(finish, ms);
|
|
279
|
+
const abort = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); reject(signal.reason); };
|
|
280
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
281
|
+
});
|
|
282
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { customStationId } from '../storage/station-identity.js';
|
|
3
4
|
export function parsePlaylistFile(filePath, depth = 0) {
|
|
4
5
|
if (!existsSync(filePath)) {
|
|
5
6
|
throw new Error(`Playlist not found: ${filePath}`);
|
|
@@ -34,7 +35,7 @@ export function writeM3u(filePath, stations) {
|
|
|
34
35
|
export function stationFromUrl(url, name = url) {
|
|
35
36
|
const cleanedName = cleanName(name) || url;
|
|
36
37
|
return {
|
|
37
|
-
id:
|
|
38
|
+
id: customStationId(url),
|
|
38
39
|
provider: 'playlist',
|
|
39
40
|
name: cleanedName,
|
|
40
41
|
tags: ['custom'],
|
|
@@ -110,27 +111,28 @@ function firstTag(text, tag) {
|
|
|
110
111
|
return new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(text)?.[1]?.trim();
|
|
111
112
|
}
|
|
112
113
|
function decodeXml(value) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
114
|
+
// Decode in one pass: CDATA is literal text, and &lt; represents <,
|
|
115
|
+
// not a second reference to expand. No DTD or external entity is evaluated.
|
|
116
|
+
const entities = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
|
|
117
|
+
return value?.replace(/<!\[CDATA\[([\s\S]*?)\]\]>|&(amp|lt|gt|quot|apos|#\d+|#x[\da-fA-F]+);/g, (reference, cdata, entity) => {
|
|
118
|
+
if (cdata !== undefined)
|
|
119
|
+
return cdata;
|
|
120
|
+
if (!entity)
|
|
121
|
+
return reference;
|
|
122
|
+
if (!entity.startsWith('#'))
|
|
123
|
+
return entities[entity] ?? reference;
|
|
124
|
+
const point = entity.startsWith('#x') ? Number.parseInt(entity.slice(2), 16) : Number(entity.slice(1));
|
|
125
|
+
return Number.isInteger(point) && point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
|
|
126
|
+
? String.fromCodePoint(point) : reference;
|
|
127
|
+
}).trim();
|
|
127
128
|
}
|
|
128
129
|
function normalizePlaylistTarget(target, root) {
|
|
129
130
|
const trimmed = target.trim();
|
|
130
131
|
if (isSupportedTarget(trimmed)) {
|
|
131
132
|
return trimmed;
|
|
132
133
|
}
|
|
133
|
-
|
|
134
|
+
// On Windows a native drive path has a colon but is not a URL scheme.
|
|
135
|
+
if (isAbsolute(trimmed) || !/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
|
|
134
136
|
const filePath = isAbsolute(trimmed) ? trimmed : resolve(root, trimmed);
|
|
135
137
|
if (existsSync(filePath)) {
|
|
136
138
|
return filePath;
|