@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +84 -10
  4. package/dist/agent/alarm-service.js +210 -0
  5. package/dist/agent/cli.js +193 -0
  6. package/dist/agent/headless-host.js +165 -0
  7. package/dist/agent/launcher.js +10 -0
  8. package/dist/agent/mcp-install.js +467 -0
  9. package/dist/agent/mcp-server.js +139 -0
  10. package/dist/agent/service.js +347 -0
  11. package/dist/agent/session.js +232 -0
  12. package/dist/alarms/active-session.js +9 -12
  13. package/dist/alarms/cli.js +4 -1
  14. package/dist/alarms/guard.js +79 -36
  15. package/dist/alarms/inhibitor.js +27 -22
  16. package/dist/alarms/power-guard-store.js +2 -10
  17. package/dist/alarms/runner.js +96 -48
  18. package/dist/alarms/schedule.js +9 -2
  19. package/dist/alarms/scheduler.js +194 -52
  20. package/dist/alarms/setup-verification.js +1 -2
  21. package/dist/alarms/system-volume-ownership.js +267 -0
  22. package/dist/alarms/system-volume.js +155 -14
  23. package/dist/alarms/terminal-launcher.js +76 -136
  24. package/dist/alarms/tui-presence.js +2 -4
  25. package/dist/cli.js +157 -35
  26. package/dist/platform/capabilities.js +53 -0
  27. package/dist/platform/desktop.js +36 -0
  28. package/dist/{player/command.js → platform/executables.js} +43 -9
  29. package/dist/platform/ipc.js +14 -0
  30. package/dist/platform/launch-command.js +135 -0
  31. package/dist/platform/loopback.js +44 -0
  32. package/dist/platform/network.js +312 -0
  33. package/dist/platform/packages.js +216 -0
  34. package/dist/platform/paths.js +40 -0
  35. package/dist/platform/runtime.js +67 -0
  36. package/dist/platform/shell.js +24 -0
  37. package/dist/platform/storage.js +48 -0
  38. package/dist/platform/support.js +214 -0
  39. package/dist/platform/terminal.js +63 -0
  40. package/dist/platform/terminals.js +203 -0
  41. package/dist/player/airplay-discovery.js +4 -2
  42. package/dist/player/backend-install.js +13 -94
  43. package/dist/player/command-diagnostics.js +2 -2
  44. package/dist/player/mpv-ipc-client.js +2 -1
  45. package/dist/player/player-controller.js +220 -31
  46. package/dist/providers/cache.js +4 -26
  47. package/dist/providers/provider-manager.js +5 -0
  48. package/dist/providers/radio-browser.js +156 -54
  49. package/dist/providers/radio-garden.js +15 -22
  50. package/dist/setup.js +149 -152
  51. package/dist/storage/store.js +138 -53
  52. package/dist/streams/import-stream.js +163 -0
  53. package/dist/types.js +6 -0
  54. package/dist/ui/AdaptiveContent.js +54 -30
  55. package/dist/ui/App.js +466 -98
  56. package/dist/ui/AppContent.js +4 -4
  57. package/dist/ui/app-state.js +8 -1
  58. package/dist/ui/ascii.js +11 -2
  59. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  60. package/dist/ui/components/Logo.js +5 -2
  61. package/dist/ui/components/Menu.js +2 -2
  62. package/dist/ui/components/ScreenHeader.js +1 -1
  63. package/dist/ui/components/StationList.js +8 -8
  64. package/dist/ui/components/TopTabs.js +1 -1
  65. package/dist/ui/components/VersionIndicator.js +19 -0
  66. package/dist/ui/display-context.js +8 -11
  67. package/dist/ui/help-content.js +5 -5
  68. package/dist/ui/layout.js +5 -2
  69. package/dist/ui/page-footer.js +5 -3
  70. package/dist/ui/screen-items.js +42 -11
  71. package/dist/ui/screen-meta.js +1 -1
  72. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  73. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  74. package/dist/ui/screens/AlarmsScreen.js +2 -1
  75. package/dist/ui/screens/CountriesScreen.js +8 -5
  76. package/dist/ui/screens/ExploreScreen.js +2 -1
  77. package/dist/ui/screens/HelpScreen.js +5 -1
  78. package/dist/ui/screens/HomeScreen.js +7 -1
  79. package/dist/ui/screens/MapScreen.js +1 -1
  80. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  81. package/dist/ui/screens/SearchScreen.js +3 -1
  82. package/dist/ui/screens/SettingsScreen.js +80 -56
  83. package/dist/ui/screens/StatsScreen.js +3 -1
  84. package/dist/ui/system-actions.js +80 -52
  85. package/dist/ui/terminal-renderer.js +16 -0
  86. package/dist/ui/use-alarm-tui.js +40 -21
  87. package/dist/ui/use-app-input.js +69 -23
  88. package/dist/ui/use-command-executor.js +31 -7
  89. package/dist/ui/visualizers/gallop.js +118 -0
  90. package/dist/ui/visualizers/horse-stride.js +20 -0
  91. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  92. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  93. package/dist/ui/visualizers/retro-receivers.js +4 -0
  94. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  95. package/dist/update-check.js +33 -23
  96. package/package.json +4 -1
@@ -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 = defaultStorePath(), options = {}) {
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 existing = new Map(this.state.imported.map(station => [stationKey(station), station]));
500
+ const incoming = [];
501
+ const incomingKeys = new Set();
475
502
  for (const station of stations) {
476
- existing.set(stationKey(station), station);
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: [...existing.values()].slice(0, 1000)
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
- if (!existsSync(this.filePath)) {
514
- return defaultState();
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(readFileSync(this.filePath, 'utf8'))));
557
+ return migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
518
558
  }
519
559
  catch {
520
- backupBadFile(this.filePath);
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
- mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
539
- writeJsonAtomically(this.filePath, libraryStateForDisk(state));
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 defaultStorePath() {
581
- if (process.env.RADIOCLI_HOME) {
582
- return join(process.env.RADIOCLI_HOME, 'radiocli.json');
583
- }
584
- if (process.env.RADIO_ATLAS_HOME) {
585
- return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas.json');
586
- }
587
- const currentPath = currentDefaultStorePath();
588
- const legacyPath = legacyDefaultStorePath();
589
- return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath;
590
- }
591
- function currentDefaultStorePath() {
592
- if (process.platform === 'darwin') {
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 join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radio-atlas', 'radio-atlas.json');
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}-${Date.now()}`;
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(filePath, 0o600);
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
- rmSync(tempPath, { force: true });
681
- throw error;
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
- mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
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 () => rmSync(lockPath, { recursive: true, force: true });
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
- continue;
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(`RadioCLI library is busy: ${filePath}`);
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
+ };