@ciphore/radiocli 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -1
- package/CONTRIBUTING.md +36 -6
- package/README.md +84 -10
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +165 -0
- package/dist/agent/launcher.js +10 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +232 -0
- package/dist/alarms/active-session.js +9 -12
- package/dist/alarms/cli.js +4 -1
- package/dist/alarms/guard.js +79 -36
- package/dist/alarms/inhibitor.js +27 -22
- package/dist/alarms/power-guard-store.js +2 -10
- package/dist/alarms/runner.js +96 -48
- package/dist/alarms/schedule.js +9 -2
- package/dist/alarms/scheduler.js +194 -52
- package/dist/alarms/setup-verification.js +1 -2
- package/dist/alarms/system-volume-ownership.js +267 -0
- package/dist/alarms/system-volume.js +155 -14
- package/dist/alarms/terminal-launcher.js +76 -136
- package/dist/alarms/tui-presence.js +2 -4
- package/dist/cli.js +157 -35
- package/dist/platform/capabilities.js +53 -0
- package/dist/platform/desktop.js +36 -0
- package/dist/{player/command.js → platform/executables.js} +43 -9
- package/dist/platform/ipc.js +14 -0
- package/dist/platform/launch-command.js +135 -0
- package/dist/platform/loopback.js +44 -0
- package/dist/platform/network.js +312 -0
- package/dist/platform/packages.js +216 -0
- package/dist/platform/paths.js +40 -0
- package/dist/platform/runtime.js +67 -0
- package/dist/platform/shell.js +24 -0
- package/dist/platform/storage.js +48 -0
- package/dist/platform/support.js +214 -0
- package/dist/platform/terminal.js +63 -0
- package/dist/platform/terminals.js +203 -0
- package/dist/player/airplay-discovery.js +4 -2
- package/dist/player/backend-install.js +13 -94
- package/dist/player/command-diagnostics.js +2 -2
- package/dist/player/mpv-ipc-client.js +2 -1
- package/dist/player/player-controller.js +220 -31
- package/dist/providers/cache.js +4 -26
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +156 -54
- package/dist/providers/radio-garden.js +15 -22
- package/dist/setup.js +149 -152
- package/dist/storage/store.js +138 -53
- package/dist/streams/import-stream.js +163 -0
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +54 -30
- package/dist/ui/App.js +466 -98
- package/dist/ui/AppContent.js +4 -4
- package/dist/ui/app-state.js +8 -1
- package/dist/ui/ascii.js +11 -2
- package/dist/ui/components/AdaptiveMarquee.js +6 -3
- package/dist/ui/components/Logo.js +5 -2
- package/dist/ui/components/Menu.js +2 -2
- package/dist/ui/components/ScreenHeader.js +1 -1
- package/dist/ui/components/StationList.js +8 -8
- package/dist/ui/components/TopTabs.js +1 -1
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/display-context.js +8 -11
- package/dist/ui/help-content.js +5 -5
- package/dist/ui/layout.js +5 -2
- package/dist/ui/page-footer.js +5 -3
- package/dist/ui/screen-items.js +42 -11
- package/dist/ui/screen-meta.js +1 -1
- package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
- package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
- package/dist/ui/screens/AlarmsScreen.js +2 -1
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +2 -1
- package/dist/ui/screens/HelpScreen.js +5 -1
- package/dist/ui/screens/HomeScreen.js +7 -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 +80 -56
- package/dist/ui/screens/StatsScreen.js +3 -1
- package/dist/ui/system-actions.js +80 -52
- package/dist/ui/terminal-renderer.js +16 -0
- package/dist/ui/use-alarm-tui.js +40 -21
- package/dist/ui/use-app-input.js +69 -23
- package/dist/ui/use-command-executor.js +31 -7
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +12 -2
- package/dist/ui/visualizers/receiver-visualizers.js +3 -0
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +33 -23
- package/package.json +4 -1
package/dist/storage/store.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { platformPaths } from '../platform/paths.js';
|
|
2
|
+
import { assertStorageWritable } from '../platform/storage.js';
|
|
1
3
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
4
|
import { homedir } from 'node:os';
|
|
3
5
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
@@ -5,7 +7,6 @@ import { randomUUID } from 'node:crypto';
|
|
|
5
7
|
import { z } from 'zod';
|
|
6
8
|
import { defaultReceiverStyle, receiverStyleNames, themeNames } from '../types.js';
|
|
7
9
|
import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone } from '../alarms/schedule.js';
|
|
8
|
-
import { backupBadFile } from '../providers/cache.js';
|
|
9
10
|
import { migrateReceiverStyle } from '../ui/visualizers/receiver-style-registry.js';
|
|
10
11
|
const stationSchema = z
|
|
11
12
|
.object({
|
|
@@ -61,7 +62,25 @@ const settingsSchema = z.object({
|
|
|
61
62
|
transparentBackground: z.boolean().default(false),
|
|
62
63
|
asciiMode: z.boolean().default(false),
|
|
63
64
|
reduceMotion: z.boolean().default(false),
|
|
64
|
-
mouseSupport: z.boolean().default(true)
|
|
65
|
+
mouseSupport: z.boolean().default(true),
|
|
66
|
+
automaticUpdateChecks: z.boolean().default(true),
|
|
67
|
+
agentControl: z.object({
|
|
68
|
+
enabled: z.boolean().default(false),
|
|
69
|
+
openUiOnPlay: z.boolean().default(true),
|
|
70
|
+
focusNowPlaying: z.boolean().default(true),
|
|
71
|
+
completionPreset: z.object({
|
|
72
|
+
action: z.enum(['play', 'pause', 'resume', 'stop']).default('play'),
|
|
73
|
+
source: z.enum(['recent', 'favorite', 'popular', 'country']).default('recent'),
|
|
74
|
+
countryCode: z.string().length(2).optional(),
|
|
75
|
+
ifPlaying: z.enum(['keep', 'replace']).default('keep'),
|
|
76
|
+
openUi: z.boolean().optional()
|
|
77
|
+
}).default({ action: 'play', source: 'recent', ifPlaying: 'keep' })
|
|
78
|
+
}).default({
|
|
79
|
+
enabled: false,
|
|
80
|
+
openUiOnPlay: true,
|
|
81
|
+
focusNowPlaying: true,
|
|
82
|
+
completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
|
|
83
|
+
})
|
|
65
84
|
});
|
|
66
85
|
const absoluteInstantSchema = z.string().refine(value => /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)), { message: 'Expected an absolute ISO-8601 instant with a timezone offset.' });
|
|
67
86
|
const alarmMinuteInstantSchema = absoluteInstantSchema.refine(value => new Date(value).getUTCSeconds() === 0 && new Date(value).getUTCMilliseconds() === 0, { message: 'Alarm occurrences use minute precision; seconds must be zero.' });
|
|
@@ -194,6 +213,13 @@ const librarySchema = z.object({
|
|
|
194
213
|
tuneTimeoutSeconds: 12,
|
|
195
214
|
skipBrokenStreams: true,
|
|
196
215
|
mouseSupport: true,
|
|
216
|
+
automaticUpdateChecks: true,
|
|
217
|
+
agentControl: {
|
|
218
|
+
enabled: false,
|
|
219
|
+
openUiOnPlay: true,
|
|
220
|
+
focusNowPlaying: true,
|
|
221
|
+
completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
|
|
222
|
+
},
|
|
197
223
|
mediaKeys: defaultMediaKeys
|
|
198
224
|
})
|
|
199
225
|
});
|
|
@@ -209,7 +235,7 @@ export class JsonLibraryStore {
|
|
|
209
235
|
state;
|
|
210
236
|
idGenerator;
|
|
211
237
|
now;
|
|
212
|
-
constructor(filePath =
|
|
238
|
+
constructor(filePath = defaultLibraryPath(), options = {}) {
|
|
213
239
|
this.filePath = filePath;
|
|
214
240
|
this.idGenerator = options.idGenerator ?? randomUUID;
|
|
215
241
|
this.now = options.now ?? (() => new Date());
|
|
@@ -471,13 +497,21 @@ export class JsonLibraryStore {
|
|
|
471
497
|
return this.commit({ ...this.state, favorites });
|
|
472
498
|
}
|
|
473
499
|
addImported(stations) {
|
|
474
|
-
const
|
|
500
|
+
const incoming = [];
|
|
501
|
+
const incomingKeys = new Set();
|
|
475
502
|
for (const station of stations) {
|
|
476
|
-
|
|
503
|
+
const key = stationKey(station);
|
|
504
|
+
if (incomingKeys.has(key))
|
|
505
|
+
continue;
|
|
506
|
+
incomingKeys.add(key);
|
|
507
|
+
incoming.push(station);
|
|
477
508
|
}
|
|
478
509
|
return this.commit({
|
|
479
510
|
...this.state,
|
|
480
|
-
imported: [
|
|
511
|
+
imported: [
|
|
512
|
+
...incoming,
|
|
513
|
+
...this.state.imported.filter(station => !incomingKeys.has(stationKey(station)))
|
|
514
|
+
].slice(0, 1000)
|
|
481
515
|
});
|
|
482
516
|
}
|
|
483
517
|
isFavorite(station) {
|
|
@@ -510,14 +544,27 @@ export class JsonLibraryStore {
|
|
|
510
544
|
}
|
|
511
545
|
}
|
|
512
546
|
read() {
|
|
513
|
-
|
|
514
|
-
|
|
547
|
+
let contents;
|
|
548
|
+
try {
|
|
549
|
+
contents = readFileSync(this.filePath, 'utf8');
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
if (error.code === 'ENOENT')
|
|
553
|
+
return defaultState();
|
|
554
|
+
throw storageError('read the RadioCLI library', error);
|
|
515
555
|
}
|
|
516
556
|
try {
|
|
517
|
-
return migrateLibraryState(librarySchema.parse(JSON.parse(
|
|
557
|
+
return migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
|
|
518
558
|
}
|
|
519
559
|
catch {
|
|
520
|
-
|
|
560
|
+
try {
|
|
561
|
+
assertStorageWritable(this.filePath);
|
|
562
|
+
renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`);
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
// Reset only after preserving the original bytes. A failed read never reaches here.
|
|
566
|
+
throw storageError('preserve the corrupt RadioCLI library', error);
|
|
567
|
+
}
|
|
521
568
|
return defaultState();
|
|
522
569
|
}
|
|
523
570
|
}
|
|
@@ -535,8 +582,14 @@ export class JsonLibraryStore {
|
|
|
535
582
|
}
|
|
536
583
|
}
|
|
537
584
|
write(state) {
|
|
538
|
-
|
|
539
|
-
|
|
585
|
+
try {
|
|
586
|
+
assertStorageWritable(this.filePath);
|
|
587
|
+
mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
588
|
+
writeJsonAtomically(this.filePath, libraryStateForDisk(state));
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
throw storageError('write the RadioCLI library', error);
|
|
592
|
+
}
|
|
540
593
|
}
|
|
541
594
|
}
|
|
542
595
|
export function stationKey(station) {
|
|
@@ -577,31 +630,21 @@ function recoverActiveListeningSessionInState(state) {
|
|
|
577
630
|
: Date.now();
|
|
578
631
|
return finishActiveListeningSessionInState(state, new Date(recoveredEnd));
|
|
579
632
|
}
|
|
580
|
-
function
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json');
|
|
594
|
-
}
|
|
595
|
-
if (process.platform === 'win32') {
|
|
596
|
-
return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'RadioCLI', 'radiocli.json');
|
|
597
|
-
}
|
|
598
|
-
return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json');
|
|
599
|
-
}
|
|
600
|
-
function legacyDefaultStorePath() {
|
|
601
|
-
if (process.platform === 'darwin') {
|
|
602
|
-
return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas.json');
|
|
633
|
+
export function defaultLibraryPath() {
|
|
634
|
+
const { library, legacyLibrary } = platformPaths();
|
|
635
|
+
for (const filePath of [library, legacyLibrary]) {
|
|
636
|
+
try {
|
|
637
|
+
statSync(filePath);
|
|
638
|
+
return filePath;
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
// A permission failure must not select an older library or empty defaults.
|
|
642
|
+
if (error.code !== 'ENOENT') {
|
|
643
|
+
throw storageError('read the RadioCLI library', error);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
603
646
|
}
|
|
604
|
-
return
|
|
647
|
+
return library;
|
|
605
648
|
}
|
|
606
649
|
function resolveUserPath(requestedPath) {
|
|
607
650
|
const unquoted = requestedPath.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, (_match, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted ?? '');
|
|
@@ -636,6 +679,13 @@ function defaultState() {
|
|
|
636
679
|
tuneTimeoutSeconds: 12,
|
|
637
680
|
skipBrokenStreams: true,
|
|
638
681
|
mouseSupport: true,
|
|
682
|
+
automaticUpdateChecks: true,
|
|
683
|
+
agentControl: {
|
|
684
|
+
enabled: false,
|
|
685
|
+
openUiOnPlay: true,
|
|
686
|
+
focusNowPlaying: true,
|
|
687
|
+
completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
|
|
688
|
+
},
|
|
639
689
|
mediaKeys: defaultMediaKeys
|
|
640
690
|
}
|
|
641
691
|
};
|
|
@@ -668,53 +718,87 @@ function libraryStateForDisk(state) {
|
|
|
668
718
|
: state;
|
|
669
719
|
}
|
|
670
720
|
function writeJsonAtomically(filePath, value) {
|
|
671
|
-
const tempPath = `${filePath}.tmp-${process.pid}-${
|
|
721
|
+
const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
|
|
672
722
|
try {
|
|
673
|
-
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
674
|
-
renameSync(tempPath, filePath);
|
|
723
|
+
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
675
724
|
if (process.platform !== 'win32') {
|
|
676
|
-
chmodSync(
|
|
725
|
+
chmodSync(tempPath, 0o600);
|
|
677
726
|
}
|
|
727
|
+
// No fallible persistence steps follow the atomic replacement.
|
|
728
|
+
renameSync(tempPath, filePath);
|
|
678
729
|
}
|
|
679
730
|
catch (error) {
|
|
680
|
-
|
|
681
|
-
|
|
731
|
+
try {
|
|
732
|
+
rmSync(tempPath, { force: true });
|
|
733
|
+
}
|
|
734
|
+
catch { /* Preserve the original write failure. */ }
|
|
735
|
+
throw storageError('write RadioCLI data', error);
|
|
682
736
|
}
|
|
683
737
|
}
|
|
684
738
|
function acquireStoreLock(filePath) {
|
|
685
739
|
const lockPath = `${filePath}.lock`;
|
|
686
|
-
|
|
740
|
+
try {
|
|
741
|
+
assertStorageWritable(filePath);
|
|
742
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
throw storageError('write the RadioCLI library', error);
|
|
746
|
+
}
|
|
687
747
|
const deadline = Date.now() + 1000;
|
|
688
748
|
while (true) {
|
|
689
749
|
try {
|
|
690
750
|
mkdirSync(lockPath, { mode: 0o700 });
|
|
691
|
-
return () =>
|
|
751
|
+
return () => {
|
|
752
|
+
// The write has already succeeded or failed. Lock cleanup cannot reverse that result.
|
|
753
|
+
try {
|
|
754
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
755
|
+
}
|
|
756
|
+
catch { /* Stale locks expire below. */ }
|
|
757
|
+
};
|
|
692
758
|
}
|
|
693
759
|
catch (error) {
|
|
694
760
|
const code = error.code;
|
|
695
761
|
if (code !== 'EEXIST')
|
|
696
|
-
throw error;
|
|
762
|
+
throw storageError('lock the RadioCLI library', error);
|
|
697
763
|
try {
|
|
698
764
|
if (Date.now() - statSync(lockPath).mtimeMs > 10_000) {
|
|
699
765
|
rmSync(lockPath, { recursive: true, force: true });
|
|
700
|
-
continue;
|
|
701
766
|
}
|
|
702
767
|
}
|
|
703
|
-
catch {
|
|
704
|
-
|
|
768
|
+
catch (lockError) {
|
|
769
|
+
if (lockError.code !== 'ENOENT') {
|
|
770
|
+
throw storageError('lock the RadioCLI library', lockError);
|
|
771
|
+
}
|
|
705
772
|
}
|
|
706
773
|
if (Date.now() >= deadline) {
|
|
707
|
-
throw new Error(
|
|
774
|
+
throw storageError('lock the RadioCLI library', Object.assign(new Error('Another writer holds the library lock.'), { code: 'EBUSY', cause: error }));
|
|
708
775
|
}
|
|
709
776
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
710
777
|
}
|
|
711
778
|
}
|
|
712
779
|
}
|
|
780
|
+
class LibraryStorageError extends Error {
|
|
781
|
+
code;
|
|
782
|
+
errno;
|
|
783
|
+
constructor(operation, cause) {
|
|
784
|
+
const code = cause?.code;
|
|
785
|
+
const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : '';
|
|
786
|
+
super(code === 'EBUSY'
|
|
787
|
+
? 'The RadioCLI library is busy. Retry after the other writer finishes, or set RADIOCLI_HOME to a private writable directory.'
|
|
788
|
+
: `Unable to ${operation}${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`, { cause });
|
|
789
|
+
this.name = 'LibraryStorageError';
|
|
790
|
+
this.code = code;
|
|
791
|
+
this.errno = cause?.errno;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function storageError(operation, cause) {
|
|
795
|
+
return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause);
|
|
796
|
+
}
|
|
713
797
|
function mergeLibraryChanges(base, disk, next) {
|
|
714
798
|
return {
|
|
715
799
|
recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50),
|
|
716
800
|
favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true),
|
|
717
|
-
imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true),
|
|
801
|
+
imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true, true),
|
|
718
802
|
trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
|
|
719
803
|
searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
|
|
720
804
|
alarms: mergeKeyedChanges(base.alarms, disk.alarms, next.alarms, alarm => alarm.id, MAX_ALARMS, true),
|
|
@@ -736,15 +820,16 @@ function normalizeAlarm(alarm) {
|
|
|
736
820
|
};
|
|
737
821
|
return { ...alarm, schedule };
|
|
738
822
|
}
|
|
739
|
-
function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false) {
|
|
823
|
+
function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false, applyOrderChanges = false) {
|
|
740
824
|
const baseByKey = new Map(base.map(item => [keyFor(item), item]));
|
|
825
|
+
const basePositions = new Map(base.map((item, index) => [keyFor(item), index]));
|
|
741
826
|
const nextKeys = new Set(next.map(keyFor));
|
|
742
827
|
const removedKeys = applyRemovals
|
|
743
828
|
? new Set(base.map(keyFor).filter(key => !nextKeys.has(key)))
|
|
744
829
|
: new Set();
|
|
745
|
-
const localChanges = next.filter(item => {
|
|
830
|
+
const localChanges = next.filter((item, index) => {
|
|
746
831
|
const prior = baseByKey.get(keyFor(item));
|
|
747
|
-
return prior === undefined || !sameValue(prior, item);
|
|
832
|
+
return prior === undefined || !sameValue(prior, item) || applyOrderChanges && basePositions.get(keyFor(item)) !== index;
|
|
748
833
|
});
|
|
749
834
|
const changedKeys = new Set(localChanges.map(keyFor));
|
|
750
835
|
return [
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { withExternalResponse } from '../platform/network.js';
|
|
2
|
+
import { safeExternalHttpUrl, sanitizeTerminalText } from '../safety.js';
|
|
3
|
+
import { stationFromUrl } from '../playlists/playlist.js';
|
|
4
|
+
class DefiniteStreamError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Build a custom station and enrich it from bounded HTTP/ICY response headers.
|
|
8
|
+
* The submitted URL is intentionally retained because a followed redirect may
|
|
9
|
+
* contain a short-lived playback token.
|
|
10
|
+
*/
|
|
11
|
+
export async function importStreamUrl(input, requestedName, options = {}) {
|
|
12
|
+
const url = validatedStreamUrl(input);
|
|
13
|
+
const explicitName = sanitizeTerminalText(requestedName);
|
|
14
|
+
try {
|
|
15
|
+
let metadata = await probeStreamHeaders(url, options);
|
|
16
|
+
const listing = await probeIHeartListing(url, options).catch(() => undefined);
|
|
17
|
+
if (listing)
|
|
18
|
+
metadata = { ...metadata, ...listing };
|
|
19
|
+
const name = explicitName ?? metadata.name ?? fallbackStationName(url);
|
|
20
|
+
return {
|
|
21
|
+
station: {
|
|
22
|
+
...stationFromUrl(url, name),
|
|
23
|
+
...(metadata.homepage ? { homepage: metadata.homepage } : {}),
|
|
24
|
+
...(metadata.codec ? { codec: metadata.codec } : {}),
|
|
25
|
+
...(metadata.bitrate ? { bitrate: metadata.bitrate } : {}),
|
|
26
|
+
...(metadata.hls !== undefined ? { hls: metadata.hls } : {})
|
|
27
|
+
},
|
|
28
|
+
identified: Boolean(metadata.name),
|
|
29
|
+
...(!metadata.name && !explicitName
|
|
30
|
+
? { warning: `The stream did not publish a station name; saved as ${name}.` }
|
|
31
|
+
: {})
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error instanceof DefiniteStreamError)
|
|
36
|
+
throw error;
|
|
37
|
+
const name = explicitName ?? fallbackStationName(url);
|
|
38
|
+
return {
|
|
39
|
+
station: stationFromUrl(url, name),
|
|
40
|
+
identified: false,
|
|
41
|
+
warning: `Could not read stream metadata; saved as ${name}.`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function probeStreamHeaders(url, options) {
|
|
46
|
+
return withExternalResponse(url, {
|
|
47
|
+
timeoutMs: options.timeoutMs ?? 7_000,
|
|
48
|
+
fetchImpl: options.fetchImpl,
|
|
49
|
+
init: {
|
|
50
|
+
method: 'GET',
|
|
51
|
+
redirect: 'follow',
|
|
52
|
+
headers: {
|
|
53
|
+
accept: 'audio/*, application/ogg, application/vnd.apple.mpegurl, application/x-mpegurl, */*;q=0.1',
|
|
54
|
+
'icy-metadata': '1'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}, response => {
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new DefiniteStreamError(`The stream returned HTTP ${response.status}.`);
|
|
60
|
+
}
|
|
61
|
+
const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
62
|
+
if (contentType === 'text/html' || contentType === 'application/json') {
|
|
63
|
+
throw new DefiniteStreamError('That URL points to a webpage or API response, not a direct radio stream.');
|
|
64
|
+
}
|
|
65
|
+
const icyName = sanitizeTerminalText(response.headers.get('icy-name'));
|
|
66
|
+
const icyDescription = sanitizeTerminalText(response.headers.get('icy-description'));
|
|
67
|
+
const homepage = safeOptionalHttpUrl(response.headers.get('icy-url'));
|
|
68
|
+
const bitrate = parseBitrate(response.headers.get('icy-br'), response.headers.get('icy-audio-info'));
|
|
69
|
+
const codec = codecFromContentType(contentType);
|
|
70
|
+
const hls = isHls(contentType, url);
|
|
71
|
+
return {
|
|
72
|
+
...(icyName || icyDescription ? { name: icyName ?? icyDescription } : {}),
|
|
73
|
+
...(homepage ? { homepage } : {}),
|
|
74
|
+
...(codec ? { codec } : {}),
|
|
75
|
+
...(bitrate ? { bitrate } : {}),
|
|
76
|
+
...(hls ? { hls: true } : {})
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** iHeart stream headers are sometimes unnamed, but their stable numeric stream
|
|
81
|
+
* id resolves to a canonical public station path without downloading the page. */
|
|
82
|
+
async function probeIHeartListing(url, options) {
|
|
83
|
+
const parsed = new URL(url);
|
|
84
|
+
if (!/(?:^|\.)ihrhls\.com$/i.test(parsed.hostname))
|
|
85
|
+
return undefined;
|
|
86
|
+
const stationId = /\/zc(\d+)(?:\/|$)/i.exec(parsed.pathname)?.[1]
|
|
87
|
+
?? /\/(\d+)_icy(?:\/|$)/i.exec(parsed.pathname)?.[1];
|
|
88
|
+
if (!stationId)
|
|
89
|
+
return undefined;
|
|
90
|
+
return withExternalResponse(`https://www.iheart.com/live/${stationId}/`, {
|
|
91
|
+
timeoutMs: Math.min(options.timeoutMs ?? 7_000, 4_000),
|
|
92
|
+
fetchImpl: options.fetchImpl,
|
|
93
|
+
init: { method: 'HEAD', redirect: 'manual' }
|
|
94
|
+
}, response => {
|
|
95
|
+
const location = response.headers.get('location');
|
|
96
|
+
const slug = location && new RegExp(`^/live/(.+)-${stationId}/?$`, 'i').exec(location)?.[1];
|
|
97
|
+
if (!slug)
|
|
98
|
+
return undefined;
|
|
99
|
+
const name = stationNameFromSlug(slug);
|
|
100
|
+
return name ? { name, homepage: `https://www.iheart.com${location}` } : undefined;
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function validatedStreamUrl(input) {
|
|
104
|
+
const cleaned = safeExternalHttpUrl(input);
|
|
105
|
+
if (!cleaned)
|
|
106
|
+
throw new Error('Import requires a direct HTTP(S) stream URL.');
|
|
107
|
+
const parsed = new URL(cleaned);
|
|
108
|
+
if (parsed.username || parsed.password)
|
|
109
|
+
throw new Error('Stream URLs containing credentials are not supported.');
|
|
110
|
+
return cleaned;
|
|
111
|
+
}
|
|
112
|
+
function safeOptionalHttpUrl(input) {
|
|
113
|
+
return input ? safeExternalHttpUrl(input) ?? undefined : undefined;
|
|
114
|
+
}
|
|
115
|
+
function parseBitrate(icyBitrate, audioInfo) {
|
|
116
|
+
const raw = icyBitrate?.match(/\d+/)?.[0] ?? audioInfo?.match(/(?:^|;)\s*bitrate=(\d+)/i)?.[1];
|
|
117
|
+
const bitrate = Number(raw);
|
|
118
|
+
return Number.isFinite(bitrate) && bitrate > 0 ? bitrate : undefined;
|
|
119
|
+
}
|
|
120
|
+
function codecFromContentType(contentType) {
|
|
121
|
+
if (!contentType)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (contentType === 'audio/mpeg' || contentType === 'audio/mp3')
|
|
124
|
+
return 'MP3';
|
|
125
|
+
if (contentType === 'audio/aac' || contentType === 'audio/aacp')
|
|
126
|
+
return 'AAC';
|
|
127
|
+
if (contentType === 'audio/ogg' || contentType === 'application/ogg')
|
|
128
|
+
return 'OGG';
|
|
129
|
+
if (contentType === 'audio/flac' || contentType === 'audio/x-flac')
|
|
130
|
+
return 'FLAC';
|
|
131
|
+
if (contentType.includes('mpegurl'))
|
|
132
|
+
return 'HLS';
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
function isHls(contentType, url) {
|
|
136
|
+
return Boolean(contentType?.includes('mpegurl') || /\.m3u8(?:$|[?#])/i.test(url));
|
|
137
|
+
}
|
|
138
|
+
function fallbackStationName(url) {
|
|
139
|
+
const parsed = new URL(url);
|
|
140
|
+
const segment = decodeURIComponent(parsed.pathname.split('/').filter(Boolean).at(-1) ?? '')
|
|
141
|
+
.replace(/\.(?:aac|flac|m3u8?|mp3|ogg|opus)$/i, '')
|
|
142
|
+
.replace(/[-_]+/g, ' ')
|
|
143
|
+
.trim();
|
|
144
|
+
return sanitizeTerminalText(segment) ?? parsed.hostname.replace(/^www\./i, '');
|
|
145
|
+
}
|
|
146
|
+
function stationNameFromSlug(slug) {
|
|
147
|
+
const tokens = slug.split('-').filter(Boolean);
|
|
148
|
+
const final = tokens.at(-1);
|
|
149
|
+
const previous = tokens.at(-2);
|
|
150
|
+
if (final && previous && /^\d$/.test(final) && /^\d{2,3}$/.test(previous)) {
|
|
151
|
+
tokens.splice(-2, 2, `${previous}.${final}`);
|
|
152
|
+
}
|
|
153
|
+
return sanitizeTerminalText(tokens.map(token => {
|
|
154
|
+
if (/^(?:am|fm|hd\d*)$/i.test(token))
|
|
155
|
+
return token.toUpperCase();
|
|
156
|
+
if (/^jamn$/i.test(token))
|
|
157
|
+
return "JAM'N";
|
|
158
|
+
if (/^\d{3,4}$/.test(token) && Number(token) >= 881 && Number(token) <= 1079) {
|
|
159
|
+
return `${token.slice(0, -1)}.${token.slice(-1)}`;
|
|
160
|
+
}
|
|
161
|
+
return token.charAt(0).toUpperCase() + token.slice(1);
|
|
162
|
+
}).join(' '));
|
|
163
|
+
}
|
package/dist/types.js
CHANGED
|
@@ -2,3 +2,9 @@ import { defaultReceiverStyle, receiverStyleNames } from './ui/visualizers/recei
|
|
|
2
2
|
export { defaultReceiverStyle, receiverStyleNames };
|
|
3
3
|
const providerIds = ['radio-browser', 'radio-garden', 'playlist'];
|
|
4
4
|
export const themeNames = ['green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper', 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'];
|
|
5
|
+
export const defaultAgentControlSettings = {
|
|
6
|
+
enabled: false,
|
|
7
|
+
openUiOnPlay: true,
|
|
8
|
+
focusNowPlaying: true,
|
|
9
|
+
completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
|
|
10
|
+
};
|