@ciphore/radiocli 0.1.0

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/CODE_OF_CONDUCT.md +13 -0
  3. package/CONTRIBUTING.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +364 -0
  6. package/SECURITY.md +31 -0
  7. package/dist/activity/stats.js +122 -0
  8. package/dist/cli.js +143 -0
  9. package/dist/player/backend-install.js +122 -0
  10. package/dist/player/command.js +8 -0
  11. package/dist/player/player-controller.js +494 -0
  12. package/dist/playlists/playlist.js +169 -0
  13. package/dist/providers/cache.js +92 -0
  14. package/dist/providers/provider-manager.js +50 -0
  15. package/dist/providers/radio-browser.js +412 -0
  16. package/dist/providers/radio-garden.js +87 -0
  17. package/dist/storage/store.js +369 -0
  18. package/dist/types.js +55 -0
  19. package/dist/ui/App.js +770 -0
  20. package/dist/ui/AppContent.js +45 -0
  21. package/dist/ui/app-state.js +250 -0
  22. package/dist/ui/components/Logo.js +9 -0
  23. package/dist/ui/components/Menu.js +23 -0
  24. package/dist/ui/components/ScreenHeader.js +15 -0
  25. package/dist/ui/components/StationList.js +23 -0
  26. package/dist/ui/components/TopTabs.js +82 -0
  27. package/dist/ui/cosmo-land-data.js +4 -0
  28. package/dist/ui/cosmo-world-map.js +156 -0
  29. package/dist/ui/explore-map-layout.js +24 -0
  30. package/dist/ui/format.js +22 -0
  31. package/dist/ui/layout.js +27 -0
  32. package/dist/ui/list-window.js +8 -0
  33. package/dist/ui/page-footer.js +45 -0
  34. package/dist/ui/playback-footer.js +48 -0
  35. package/dist/ui/screen-items.js +26 -0
  36. package/dist/ui/screens/CountriesScreen.js +10 -0
  37. package/dist/ui/screens/ExploreScreen.js +44 -0
  38. package/dist/ui/screens/HomeScreen.js +9 -0
  39. package/dist/ui/screens/MapScreen.js +59 -0
  40. package/dist/ui/screens/NowPlayingScreen.js +79 -0
  41. package/dist/ui/screens/SearchScreen.js +12 -0
  42. package/dist/ui/screens/SettingsScreen.js +38 -0
  43. package/dist/ui/screens/StationScreen.js +7 -0
  44. package/dist/ui/screens/StatsScreen.js +90 -0
  45. package/dist/ui/terminal-mouse.js +38 -0
  46. package/dist/ui/theme.js +105 -0
  47. package/dist/ui/use-app-input.js +387 -0
  48. package/dist/ui/use-command-executor.js +156 -0
  49. package/dist/ui/visualizers/receiver-visualizers.js +2188 -0
  50. package/dist/ui/world-map.js +274 -0
  51. package/docs/THIRD_PARTY_NOTICES.md +33 -0
  52. package/package.json +83 -0
@@ -0,0 +1,494 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, unlinkSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { Socket } from 'node:net';
6
+ import { detectPlaybackBackends, playbackBackendInstallHint } from './backend-install.js';
7
+ export class PlayerController {
8
+ getSettings;
9
+ process = null;
10
+ backend = null;
11
+ ipcPath = null;
12
+ metadataTimer = null;
13
+ playbackStateTimer = null;
14
+ state = { backend: 'none', state: 'idle', volume: 70, muted: false, ready: false };
15
+ listeners = new Set();
16
+ metadataListeners = new Set();
17
+ availableBackends = null;
18
+ nextMpvRequestId = 1;
19
+ currentMpvMediaTitle = null;
20
+ constructor(getSettings) {
21
+ this.getSettings = getSettings;
22
+ }
23
+ onChange(listener) {
24
+ this.listeners.add(listener);
25
+ listener(this.state);
26
+ return () => this.listeners.delete(listener);
27
+ }
28
+ onMetadata(listener) {
29
+ this.metadataListeners.add(listener);
30
+ return () => this.metadataListeners.delete(listener);
31
+ }
32
+ getState() {
33
+ return { ...this.state };
34
+ }
35
+ diagnostics() {
36
+ return {
37
+ backend: this.state.backend,
38
+ availableBackends: this.detectedBackends(),
39
+ preferredBackend: this.getSettings().preferredBackend,
40
+ active: Boolean(this.process),
41
+ streamUrl: this.state.streamUrl,
42
+ stationName: this.state.stationName,
43
+ volume: this.state.volume,
44
+ muted: this.state.muted,
45
+ startedAt: this.state.startedAt,
46
+ ready: this.state.ready
47
+ };
48
+ }
49
+ detectedBackends() {
50
+ return [...(this.availableBackends ?? [])];
51
+ }
52
+ refreshDetectedBackends() {
53
+ this.availableBackends = detectPlaybackBackends();
54
+ return this.detectedBackends();
55
+ }
56
+ async play(station, url) {
57
+ await this.stop();
58
+ const backend = this.selectBackend();
59
+ if (!backend) {
60
+ throw new Error(`No playback backend found. ${playbackBackendInstallHint()}`);
61
+ }
62
+ this.backend = backend;
63
+ this.setState({
64
+ backend,
65
+ state: 'loading',
66
+ message: `Opening ${station.name}`,
67
+ volume: this.getSettings().volume,
68
+ muted: false,
69
+ stationName: station.name,
70
+ streamUrl: url,
71
+ ready: false
72
+ });
73
+ backend === 'mpv' ? this.playWithMpv(url, station.name) : this.playWithFfplay(url);
74
+ await this.waitForReady(backend);
75
+ this.setState({
76
+ backend,
77
+ state: 'playing',
78
+ message: station.name,
79
+ volume: this.getSettings().volume,
80
+ muted: false,
81
+ stationName: station.name,
82
+ streamUrl: url,
83
+ startedAt: new Date().toISOString(),
84
+ ready: true
85
+ });
86
+ if (backend === 'mpv') {
87
+ this.startMpvMetadataPolling();
88
+ }
89
+ }
90
+ async togglePause() {
91
+ if (!this.process || !this.backend || !this.state.ready) {
92
+ return;
93
+ }
94
+ if (this.backend === 'mpv') {
95
+ await this.sendMpv({ command: ['cycle', 'pause'] }).catch(() => undefined);
96
+ const synced = await this.syncMpvPlaybackState();
97
+ if (synced) {
98
+ return;
99
+ }
100
+ }
101
+ else {
102
+ this.process.stdin.write('p');
103
+ }
104
+ this.setState({
105
+ ...this.state,
106
+ state: this.state.state === 'paused' ? 'playing' : 'paused'
107
+ });
108
+ }
109
+ async setVolume(volume) {
110
+ const clamped = clampVolume(volume);
111
+ if (this.backend === 'mpv') {
112
+ await this.sendMpv({ command: ['set_property', 'volume', clamped] }).catch(() => undefined);
113
+ }
114
+ else if (this.backend === 'ffplay' && this.process) {
115
+ const delta = clamped - this.state.volume;
116
+ const key = delta > 0 ? '0' : '9';
117
+ const steps = Math.min(10, Math.ceil(Math.abs(delta) / 5));
118
+ for (let index = 0; index < steps; index += 1) {
119
+ this.process.stdin.write(key);
120
+ }
121
+ }
122
+ this.setState({ ...this.state, volume: clamped });
123
+ }
124
+ async adjustVolume(delta) {
125
+ await this.setVolume(this.state.volume + delta);
126
+ }
127
+ async toggleMute() {
128
+ const muted = !this.state.muted;
129
+ if (this.backend === 'mpv') {
130
+ await this.sendMpv({ command: ['set_property', 'mute', muted] }).catch(() => undefined);
131
+ }
132
+ else if (this.backend === 'ffplay' && this.process) {
133
+ this.process.stdin.write('m');
134
+ }
135
+ this.setState({ ...this.state, muted });
136
+ }
137
+ async stop() {
138
+ this.stopMpvPolling();
139
+ if (this.backend === 'mpv') {
140
+ await this.sendMpv({ command: ['quit'] }).catch(() => undefined);
141
+ }
142
+ if (this.process && !this.process.killed) {
143
+ this.process.kill('SIGTERM');
144
+ }
145
+ this.process = null;
146
+ this.cleanupIpc();
147
+ this.setState({
148
+ ...this.state,
149
+ backend: this.backend ?? 'none',
150
+ state: 'stopped',
151
+ ready: false
152
+ });
153
+ }
154
+ selectBackend() {
155
+ const preferred = this.getSettings().preferredBackend;
156
+ const backends = this.availableBackends ?? this.refreshDetectedBackends();
157
+ if (preferred === 'mpv') {
158
+ return backends.includes('mpv') ? 'mpv' : null;
159
+ }
160
+ if (preferred === 'ffplay') {
161
+ return backends.includes('ffplay') ? 'ffplay' : null;
162
+ }
163
+ if (backends.includes('mpv')) {
164
+ return 'mpv';
165
+ }
166
+ if (backends.includes('ffplay')) {
167
+ return 'ffplay';
168
+ }
169
+ return null;
170
+ }
171
+ playWithMpv(url, initialTitle) {
172
+ this.ipcPath = join(tmpdir(), `radiocli-${process.pid}-${Date.now()}.sock`);
173
+ this.currentMpvMediaTitle = cleanMediaTitle(initialTitle) ?? 'RadioCLI';
174
+ this.process = spawn('mpv', [
175
+ '--no-video',
176
+ '--really-quiet',
177
+ '--force-window=no',
178
+ `--force-media-title=${this.currentMpvMediaTitle}`,
179
+ `--volume=${this.getSettings().volume}`,
180
+ `--input-ipc-server=${this.ipcPath}`,
181
+ url
182
+ ], {
183
+ stdio: ['pipe', 'pipe', 'pipe']
184
+ });
185
+ this.wireProcess();
186
+ }
187
+ playWithFfplay(url) {
188
+ this.process = spawn('ffplay', ['-nodisp', '-hide_banner', '-loglevel', 'error', '-volume', String(this.getSettings().volume), '-autoexit', url], {
189
+ stdio: ['pipe', 'pipe', 'pipe']
190
+ });
191
+ this.wireProcess();
192
+ }
193
+ wireProcess() {
194
+ const child = this.process;
195
+ if (!child) {
196
+ return;
197
+ }
198
+ child.on('error', error => {
199
+ this.setState({
200
+ ...this.state,
201
+ backend: this.backend ?? 'none',
202
+ state: 'error',
203
+ message: error.message,
204
+ ready: false
205
+ });
206
+ });
207
+ child.on('exit', code => {
208
+ if (this.process === child) {
209
+ this.process = null;
210
+ this.stopMpvPolling();
211
+ this.cleanupIpc();
212
+ this.setState({
213
+ ...this.state,
214
+ backend: this.backend ?? 'none',
215
+ state: code === 0 || code === null ? 'stopped' : 'error',
216
+ message: code === 0 || code === null ? undefined : `player exited with code ${code}`,
217
+ ready: false
218
+ });
219
+ }
220
+ });
221
+ }
222
+ sendMpv(payload) {
223
+ return this.queryMpv(payload).then(() => undefined);
224
+ }
225
+ queryMpv(payload) {
226
+ if (!this.ipcPath) {
227
+ return Promise.resolve(null);
228
+ }
229
+ return new Promise((resolve, reject) => {
230
+ const socket = new Socket();
231
+ let buffer = '';
232
+ let settled = false;
233
+ const requestId = this.nextMpvRequestId;
234
+ this.nextMpvRequestId = this.nextMpvRequestId >= Number.MAX_SAFE_INTEGER ? 1 : this.nextMpvRequestId + 1;
235
+ const requestPayload = attachMpvRequestId(payload, requestId);
236
+ const settle = (callback) => {
237
+ if (settled) {
238
+ return;
239
+ }
240
+ settled = true;
241
+ clearTimeout(timeout);
242
+ socket.end();
243
+ callback();
244
+ };
245
+ const timeout = setTimeout(() => {
246
+ socket.destroy();
247
+ settle(() => reject(new Error('mpv IPC timed out.')));
248
+ }, 1000);
249
+ socket.once('error', error => {
250
+ settle(() => reject(error));
251
+ });
252
+ socket.on('data', chunk => {
253
+ buffer += chunk.toString('utf8');
254
+ let newlineIndex = buffer.indexOf('\n');
255
+ while (newlineIndex !== -1) {
256
+ const line = buffer.slice(0, newlineIndex);
257
+ buffer = buffer.slice(newlineIndex + 1);
258
+ newlineIndex = buffer.indexOf('\n');
259
+ if (!line.trim()) {
260
+ continue;
261
+ }
262
+ try {
263
+ const parsed = JSON.parse(line);
264
+ if (parsed.request_id !== requestId) {
265
+ continue;
266
+ }
267
+ if (parsed.error && parsed.error !== 'success') {
268
+ settle(() => reject(new Error(`mpv IPC failed: ${parsed.error}`)));
269
+ }
270
+ else {
271
+ settle(() => resolve(parsed.data ?? null));
272
+ }
273
+ }
274
+ catch {
275
+ settle(() => resolve(null));
276
+ }
277
+ }
278
+ });
279
+ socket.connect(this.ipcPath, () => {
280
+ socket.write(`${JSON.stringify(requestPayload)}\n`, error => {
281
+ if (error) {
282
+ settle(() => reject(error));
283
+ }
284
+ });
285
+ });
286
+ });
287
+ }
288
+ async waitForReady(backend) {
289
+ const timeoutMs = this.getSettings().tuneTimeoutSeconds * 1000;
290
+ const started = Date.now();
291
+ while (Date.now() - started < timeoutMs) {
292
+ if (!this.process) {
293
+ throw new Error('Player exited before the stream became ready.');
294
+ }
295
+ if (backend === 'ffplay') {
296
+ await waitForStartupWindow(() => this.process, Math.min(500, timeoutMs));
297
+ return;
298
+ }
299
+ if (this.ipcPath && existsSync(this.ipcPath)) {
300
+ try {
301
+ await this.queryMpv({ command: ['get_property', 'path'] });
302
+ return;
303
+ }
304
+ catch {
305
+ // The IPC socket can exist briefly before accepting commands.
306
+ }
307
+ }
308
+ await delay(150);
309
+ }
310
+ await this.stop();
311
+ throw new Error(`Timed out while opening stream after ${this.getSettings().tuneTimeoutSeconds}s.`);
312
+ }
313
+ startMpvMetadataPolling() {
314
+ this.stopMpvPolling();
315
+ this.metadataTimer = setInterval(() => {
316
+ void this.pollMpvMetadata();
317
+ }, 2500);
318
+ this.playbackStateTimer = setInterval(() => {
319
+ void this.syncMpvPlaybackState();
320
+ }, 500);
321
+ void this.pollMpvMetadata();
322
+ void this.syncMpvPlaybackState();
323
+ }
324
+ stopMpvPolling() {
325
+ if (this.metadataTimer) {
326
+ clearInterval(this.metadataTimer);
327
+ this.metadataTimer = null;
328
+ }
329
+ if (this.playbackStateTimer) {
330
+ clearInterval(this.playbackStateTimer);
331
+ this.playbackStateTimer = null;
332
+ }
333
+ }
334
+ async pollMpvMetadata() {
335
+ if (this.backend !== 'mpv' || !this.process) {
336
+ return;
337
+ }
338
+ const metadata = await this.queryMpv({ command: ['get_property', 'metadata'] }).catch(() => null);
339
+ const elapsed = await this.queryMpv({ command: ['get_property', 'time-pos'] }).catch(() => null);
340
+ if (typeof elapsed === 'number') {
341
+ this.setState({ ...this.state, elapsedSeconds: Math.floor(elapsed) });
342
+ }
343
+ const title = extractMpvTitle(metadata);
344
+ if (title) {
345
+ await this.setMpvMediaTitle(title);
346
+ this.emitMetadata({ title, raw: JSON.stringify(metadata), updatedAt: new Date().toISOString() });
347
+ }
348
+ }
349
+ async setMpvMediaTitle(title) {
350
+ const cleaned = cleanMediaTitle(title);
351
+ if (!cleaned || cleaned === this.currentMpvMediaTitle) {
352
+ return;
353
+ }
354
+ this.currentMpvMediaTitle = cleaned;
355
+ await this.sendMpv({ command: ['set_property', 'force-media-title', cleaned] }).catch(() => undefined);
356
+ }
357
+ async syncMpvPlaybackState() {
358
+ if (this.backend !== 'mpv' || !this.process || !this.state.ready) {
359
+ return false;
360
+ }
361
+ const paused = await this.queryMpv({ command: ['get_property', 'pause'] }).catch(() => null);
362
+ if (typeof paused !== 'boolean') {
363
+ return false;
364
+ }
365
+ const state = paused ? 'paused' : 'playing';
366
+ if (this.state.state !== state) {
367
+ this.setState({ ...this.state, state });
368
+ }
369
+ return true;
370
+ }
371
+ emitMetadata(metadata) {
372
+ for (const listener of this.metadataListeners) {
373
+ listener(metadata);
374
+ }
375
+ }
376
+ cleanupIpc() {
377
+ if (this.ipcPath && existsSync(this.ipcPath)) {
378
+ try {
379
+ unlinkSync(this.ipcPath);
380
+ }
381
+ catch {
382
+ // mpv may clean up the socket first.
383
+ }
384
+ }
385
+ this.ipcPath = null;
386
+ this.currentMpvMediaTitle = null;
387
+ }
388
+ setState(state) {
389
+ this.state = state;
390
+ for (const listener of this.listeners) {
391
+ listener(state);
392
+ }
393
+ }
394
+ }
395
+ function clampVolume(volume) {
396
+ return Math.min(100, Math.max(0, Math.round(volume)));
397
+ }
398
+ function delay(ms) {
399
+ return new Promise(resolve => setTimeout(resolve, ms));
400
+ }
401
+ export function extractMpvTitle(metadata) {
402
+ if (!metadata) {
403
+ return undefined;
404
+ }
405
+ const candidates = [
406
+ metadata['icy-title'],
407
+ metadata.StreamTitle,
408
+ metadata.title,
409
+ metadata.Title,
410
+ metadata['icy-name'],
411
+ metadata.Name
412
+ ];
413
+ for (const candidate of candidates) {
414
+ const title = cleanMetadataTitle(candidate);
415
+ if (title) {
416
+ return title;
417
+ }
418
+ }
419
+ return undefined;
420
+ }
421
+ function attachMpvRequestId(payload, requestId) {
422
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
423
+ return { ...payload, request_id: requestId };
424
+ }
425
+ return { command: payload, request_id: requestId };
426
+ }
427
+ async function waitForStartupWindow(getProcess, ms) {
428
+ const started = Date.now();
429
+ while (Date.now() - started < ms) {
430
+ if (!getProcess()) {
431
+ throw new Error('Player exited before the stream became ready.');
432
+ }
433
+ await delay(Math.min(100, ms - (Date.now() - started)));
434
+ }
435
+ }
436
+ function cleanMetadataTitle(value) {
437
+ const normalized = cleanMediaTitle(value);
438
+ if (!normalized) {
439
+ return undefined;
440
+ }
441
+ const fields = parseMetadataFields(normalized);
442
+ if (fields.size > 0) {
443
+ const title = firstField(fields, ['title', 'streamtitle', 'text', 'song', 'track', 'name']);
444
+ const artist = firstField(fields, ['artist', 'artists', 'performer', 'albumartist']) ?? leadingMetadataPrefix(normalized);
445
+ const album = firstField(fields, ['album']);
446
+ if (artist && title) {
447
+ return `${artist} - ${title}`;
448
+ }
449
+ return title ?? artist ?? album;
450
+ }
451
+ return stripIcyStreamTitleWrapper(normalized);
452
+ }
453
+ function parseMetadataFields(value) {
454
+ const fields = new Map();
455
+ const normalized = value.replace(/=\s*""([^",;][^,;]*?)"/g, '="$1"');
456
+ const pattern = /(?:^|[\s,;])([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^;,]*?)(?=\s+[A-Za-z][A-Za-z0-9_-]*\s*=|[;,]|$))/g;
457
+ for (const match of normalized.matchAll(pattern)) {
458
+ const key = normalizeMetadataKey(match[1] ?? '');
459
+ const rawValue = match[2] ?? match[3] ?? match[4] ?? '';
460
+ const cleanedValue = cleanMediaTitle(rawValue.replace(/\\"/g, '"').replace(/\\'/g, "'"));
461
+ if (key && cleanedValue && !fields.has(key)) {
462
+ fields.set(key, cleanedValue);
463
+ }
464
+ }
465
+ return fields;
466
+ }
467
+ function firstField(fields, keys) {
468
+ for (const key of keys) {
469
+ const value = fields.get(key);
470
+ if (value) {
471
+ return value;
472
+ }
473
+ }
474
+ return undefined;
475
+ }
476
+ function normalizeMetadataKey(value) {
477
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '');
478
+ }
479
+ function leadingMetadataPrefix(value) {
480
+ const firstFieldIndex = value.search(/[A-Za-z][A-Za-z0-9_-]*\s*=/);
481
+ if (firstFieldIndex <= 0) {
482
+ return undefined;
483
+ }
484
+ return cleanMediaTitle(value.slice(0, firstFieldIndex).replace(/[-–—:;,]\s*$/, ''));
485
+ }
486
+ function cleanMediaTitle(value) {
487
+ const cleaned = value?.replace(/\s+/g, ' ').trim().replace(/^"+|"+$/g, '').trim();
488
+ return cleaned || undefined;
489
+ }
490
+ function stripIcyStreamTitleWrapper(value) {
491
+ const wrapped = value.match(/^StreamTitle=['"]?([^'";]+)['"]?;?$/i);
492
+ const title = wrapped?.[1] ?? value;
493
+ return title.replace(/\s+/g, ' ').trim() || undefined;
494
+ }
@@ -0,0 +1,169 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
3
+ export function parsePlaylistFile(filePath, depth = 0) {
4
+ if (!existsSync(filePath)) {
5
+ throw new Error(`Playlist not found: ${filePath}`);
6
+ }
7
+ const text = readFileSync(filePath, 'utf8');
8
+ const root = dirname(filePath);
9
+ const lower = filePath.toLowerCase();
10
+ let stations;
11
+ if (lower.endsWith('.pls')) {
12
+ stations = parsePls(text, root, depth);
13
+ }
14
+ else if (lower.endsWith('.xspf')) {
15
+ stations = parseXspf(text);
16
+ }
17
+ else {
18
+ stations = parseM3u(text, basename(filePath), root, depth);
19
+ }
20
+ return dedupeStations(stations);
21
+ }
22
+ export function writeM3u(filePath, stations) {
23
+ const lines = ['#EXTM3U'];
24
+ for (const station of stations) {
25
+ const url = station.streamUrl;
26
+ if (!url) {
27
+ continue;
28
+ }
29
+ lines.push(`#EXTINF:-1,${station.name}`);
30
+ lines.push(url);
31
+ }
32
+ writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
33
+ }
34
+ export function stationFromUrl(url, name = url) {
35
+ const cleanedName = cleanName(name) || url;
36
+ return {
37
+ id: stableId(url),
38
+ provider: 'playlist',
39
+ name: cleanedName,
40
+ tags: ['custom'],
41
+ streamUrl: url
42
+ };
43
+ }
44
+ function parseM3u(text, fallbackName, root, depth) {
45
+ const stations = [];
46
+ let pendingName;
47
+ for (const rawLine of text.split(/\r?\n/)) {
48
+ const line = rawLine.trim();
49
+ if (!line) {
50
+ continue;
51
+ }
52
+ if (line.startsWith('#EXTINF')) {
53
+ pendingName = line.split(',').slice(1).join(',').trim() || undefined;
54
+ continue;
55
+ }
56
+ if (line.startsWith('#')) {
57
+ continue;
58
+ }
59
+ const nestedPath = nestedPlaylistPath(line, root);
60
+ if (nestedPath && depth < 2) {
61
+ stations.push(...parsePlaylistFile(nestedPath, depth + 1));
62
+ pendingName = undefined;
63
+ continue;
64
+ }
65
+ const url = normalizePlaylistTarget(line, root);
66
+ if (url) {
67
+ stations.push(stationFromUrl(url, pendingName ?? fallbackName));
68
+ pendingName = undefined;
69
+ }
70
+ }
71
+ return stations;
72
+ }
73
+ function parsePls(text, root, depth) {
74
+ const files = new Map();
75
+ const titles = new Map();
76
+ for (const rawLine of text.split(/\r?\n/)) {
77
+ const line = rawLine.trim();
78
+ const match = /^(File|Title)(\d+)=(.*)$/i.exec(line);
79
+ if (!match) {
80
+ continue;
81
+ }
82
+ const index = Number(match[2]);
83
+ if (match[1]?.toLowerCase() === 'file') {
84
+ files.set(index, match[3] ?? '');
85
+ }
86
+ else {
87
+ titles.set(index, match[3] ?? '');
88
+ }
89
+ }
90
+ return [...files.entries()].flatMap(([index, target]) => {
91
+ const nestedPath = nestedPlaylistPath(target, root);
92
+ if (nestedPath && depth < 2) {
93
+ return parsePlaylistFile(nestedPath, depth + 1);
94
+ }
95
+ const url = normalizePlaylistTarget(target, root);
96
+ return url ? [stationFromUrl(url, titles.get(index) || url)] : [];
97
+ });
98
+ }
99
+ function parseXspf(text) {
100
+ const tracks = [...text.matchAll(/<track\b[\s\S]*?<\/track>/gi)].map(match => match[0]);
101
+ return tracks.flatMap(track => {
102
+ const location = decodeXml(firstTag(track, 'location'));
103
+ if (!location || !isSupportedTarget(location)) {
104
+ return [];
105
+ }
106
+ return [stationFromUrl(location, decodeXml(firstTag(track, 'title')) || location)];
107
+ });
108
+ }
109
+ function firstTag(text, tag) {
110
+ return new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(text)?.[1]?.trim();
111
+ }
112
+ function decodeXml(value) {
113
+ return value
114
+ ?.replace(/&amp;/g, '&')
115
+ .replace(/&lt;/g, '<')
116
+ .replace(/&gt;/g, '>')
117
+ .replace(/&quot;/g, '"')
118
+ .replace(/&#39;/g, "'")
119
+ .trim();
120
+ }
121
+ function stableId(value) {
122
+ let hash = 0;
123
+ for (let index = 0; index < value.length; index += 1) {
124
+ hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
125
+ }
126
+ return `custom-${hash.toString(16)}`;
127
+ }
128
+ function normalizePlaylistTarget(target, root) {
129
+ const trimmed = target.trim();
130
+ if (isSupportedTarget(trimmed)) {
131
+ return trimmed;
132
+ }
133
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
134
+ const filePath = isAbsolute(trimmed) ? trimmed : resolve(root, trimmed);
135
+ if (existsSync(filePath)) {
136
+ return filePath;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ function nestedPlaylistPath(target, root) {
142
+ const trimmed = target.trim();
143
+ if (!/\.(m3u8?|pls|xspf)$/i.test(trimmed)) {
144
+ return null;
145
+ }
146
+ if (/^https?:\/\//i.test(trimmed)) {
147
+ return null;
148
+ }
149
+ const filePath = isAbsolute(trimmed) ? trimmed : join(root, trimmed);
150
+ return existsSync(filePath) ? filePath : null;
151
+ }
152
+ function isSupportedTarget(target) {
153
+ return /^(https?|file):\/\//i.test(target);
154
+ }
155
+ function cleanName(value) {
156
+ return value.replace(/\s+/g, ' ').trim();
157
+ }
158
+ function dedupeStations(stations) {
159
+ const seen = new Set();
160
+ const deduped = [];
161
+ for (const station of stations) {
162
+ if (!station.streamUrl || seen.has(station.streamUrl)) {
163
+ continue;
164
+ }
165
+ seen.add(station.streamUrl);
166
+ deduped.push(station);
167
+ }
168
+ return deduped;
169
+ }