@ciphore/radiocli 0.2.4 → 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.
Files changed (134) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/CONTRIBUTING.md +10 -0
  3. package/README.md +108 -253
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +178 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +113 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +249 -76
  71. package/dist/ui/AdaptiveContent.js +18 -10
  72. package/dist/ui/App.js +708 -454
  73. package/dist/ui/AppContent.js +5 -5
  74. package/dist/ui/airplay-settings.js +12 -5
  75. package/dist/ui/bidi-ranges.js +45 -0
  76. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  77. package/dist/ui/format.js +37 -4
  78. package/dist/ui/help-content.js +5 -2
  79. package/dist/ui/rtl-output.js +137 -0
  80. package/dist/ui/screen-items.js +10 -4
  81. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  82. package/dist/ui/screens/AlarmsScreen.js +5 -2
  83. package/dist/ui/screens/HelpScreen.js +3 -3
  84. package/dist/ui/screens/SettingsScreen.js +16 -16
  85. package/dist/ui/screens/StatsScreen.js +1 -1
  86. package/dist/ui/text-direction.js +9 -0
  87. package/dist/ui/use-alarm-tui.js +24 -24
  88. package/dist/ui/use-app-input.js +42 -8
  89. package/dist/ui/use-command-executor.js +21 -14
  90. package/dist/ui/visualizers/gallop.js +1 -1
  91. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  92. package/dist/update-check.js +2 -1
  93. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  94. package/package.json +34 -11
  95. package/scripts/build-airplay-native.mjs +22 -0
  96. package/vendor/node-airtunes2/LICENSE +208 -0
  97. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  98. package/vendor/node-airtunes2/README.md +225 -0
  99. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  100. package/vendor/node-airtunes2/binding.gyp +41 -0
  101. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  102. package/vendor/node-airtunes2/lib/atv.js +307 -0
  103. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  104. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  105. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  106. package/vendor/node-airtunes2/lib/config.js +26 -0
  107. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  108. package/vendor/node-airtunes2/lib/devices.js +266 -0
  109. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  110. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  111. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  112. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  113. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  114. package/vendor/node-airtunes2/lib/http.js +189 -0
  115. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  116. package/vendor/node-airtunes2/lib/index.js +127 -0
  117. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  118. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  119. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  120. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  121. package/vendor/node-airtunes2/lib/srp.js +158 -0
  122. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  123. package/vendor/node-airtunes2/lib/util.js +12 -0
  124. package/vendor/node-airtunes2/package.json +31 -0
  125. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  126. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  127. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  128. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  129. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  130. package/vendor/node-airtunes2/src/base64.c +136 -0
  131. package/vendor/node-airtunes2/src/base64.h +42 -0
  132. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  133. package/vendor/node-airtunes2/src/codec.cc +170 -0
  134. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,8 +1,10 @@
1
- import { RadioBrowserProvider, dedupeStations } from './radio-browser.js';
1
+ import { RadioBrowserProvider } from './radio-browser.js';
2
2
  import { RadioGardenProvider } from './radio-garden.js';
3
+ import { abortable, DiscoverySessions } from './discovery-session.js';
3
4
  export class ProviderManager {
4
5
  radioBrowser;
5
6
  radioGarden;
7
+ discovery = new DiscoverySessions(state => JSON.stringify([state.browser, state.garden, state.browserSources, state.gardenSource, [...state.seen]]).length * 2 + state.seen.size * 128);
6
8
  constructor(radioBrowser = new RadioBrowserProvider(), radioGarden = new RadioGardenProvider()) {
7
9
  this.radioBrowser = radioBrowser;
8
10
  this.radioGarden = radioGarden;
@@ -16,38 +18,118 @@ export class ProviderManager {
16
18
  byCountry(countryCode, limit, offset) {
17
19
  return this.radioBrowser.byCountry(countryCode, limit, offset);
18
20
  }
21
+ byCountryPage(countryCode, options = {}) {
22
+ return this.radioBrowser.byCountryPage(countryCode, options);
23
+ }
19
24
  byId(provider, id) {
20
25
  if (provider !== 'radio-browser')
21
26
  return Promise.resolve(null);
22
27
  return this.radioBrowser.byId(id);
23
28
  }
24
- nearby(location, limit) {
25
- return this.radioBrowser.nearby(location, limit);
29
+ nearby(location, limit, options = {}) {
30
+ return this.radioBrowser.nearby(location, limit, options);
26
31
  }
27
- detectLocation() {
28
- return this.radioBrowser.detectLocation();
32
+ detectLocation(options = {}) {
33
+ return this.radioBrowser.detectLocation(options);
29
34
  }
30
35
  async search(query, settings, options = {}) {
31
- const includeGarden = (settings.enableRadioGarden || options.includeExperimental) && (options.offset ?? 0) === 0;
36
+ const includeGarden = settings.enableRadioGarden || options.includeExperimental;
32
37
  if (!includeGarden) {
33
38
  return this.radioBrowser.search(query, options);
34
39
  }
40
+ // Legacy array callers may still pass a displayed offset. Traverse the
41
+ // merged cursor so those offsets cannot skip Browser rows after Garden.
42
+ // Interactive clients keep nextCursor and avoid this repeated traversal.
43
+ let skip = Math.max(0, options.offset ?? 0);
44
+ const limit = options.limit ?? 80;
45
+ const items = [];
46
+ let cursor;
47
+ do {
48
+ const page = await this.searchPage(query, settings, { ...options, limit, cursor });
49
+ const start = Math.min(skip, page.items.length);
50
+ skip -= start;
51
+ items.push(...page.items.slice(start, start + limit - items.length));
52
+ cursor = page.nextCursor;
53
+ } while (items.length < limit && cursor);
54
+ return items;
55
+ }
56
+ searchPage(query, settings, options = {}) {
57
+ const includeGarden = settings.enableRadioGarden || options.includeExperimental;
58
+ if (!includeGarden)
59
+ return this.radioBrowser.searchPage(query, options);
60
+ const limit = options.limit ?? 80;
61
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
62
+ throw new Error('Discovery page size must be between 1 and 1000.');
63
+ const fingerprint = JSON.stringify([query.trim(), options.codec, options.language, options.countryCode?.toUpperCase(), options.minBitrate, limit]);
64
+ return this.discovery.page(fingerprint, options.cursor, () => ({
65
+ browserExhausted: false, browser: [], browserSources: [],
66
+ gardenLoaded: false, garden: [],
67
+ gardenSource: { provider: 'radio-garden', source: 'search-snapshot', consumed: 0, exhausted: false, status: 'available' },
68
+ seen: new Set(), primaryTurn: true
69
+ }), state => this.federatedPage(state, query, limit, options), options.signal);
70
+ }
71
+ async federatedPage(state, query, limit, options) {
72
+ const fetchBrowser = !state.browserExhausted && state.browser.length < limit;
73
+ const fetchGarden = !state.gardenLoaded;
35
74
  const [browserResult, gardenResult] = await Promise.allSettled([
36
- this.radioBrowser.search(query, options),
37
- this.radioGarden.search(query, options)
75
+ fetchBrowser ? abortable(this.radioBrowser.searchPage(query, { ...options, limit, cursor: state.browserCursor }), options.signal) : Promise.resolve(null),
76
+ // Garden currently exposes one finite search response, without a known
77
+ // offset contract. Retain all returned hits and page that snapshot.
78
+ fetchGarden ? abortable(this.radioGarden.search(query, { ...options, limit: Number.MAX_SAFE_INTEGER }), options.signal) : Promise.resolve(null)
38
79
  ]);
39
- const radioBrowser = browserResult.status === 'fulfilled' ? browserResult.value : [];
40
- const radioGarden = gardenResult.status === 'fulfilled' ? gardenResult.value : [];
41
- if (radioBrowser.length === 0 && radioGarden.length === 0 && browserResult.status === 'rejected') {
42
- throw browserResult.reason;
80
+ options.signal?.throwIfAborted();
81
+ if (browserResult.status === 'fulfilled' && browserResult.value) {
82
+ const page = browserResult.value;
83
+ state.browserCursor = page.nextCursor;
84
+ state.browserExhausted = page.exhausted;
85
+ state.browserSources = page.sources;
86
+ state.browser.push(...page.items);
87
+ }
88
+ else if (browserResult.status === 'rejected') {
89
+ state.browserSources = state.browserSources.length
90
+ ? state.browserSources.map(source => source.exhausted ? source : { ...source, status: 'unavailable', message: errorText(browserResult.reason) })
91
+ : [{ provider: 'radio-browser', source: 'search', consumed: 0, exhausted: false, status: 'unavailable', message: errorText(browserResult.reason) }];
92
+ }
93
+ if (gardenResult.status === 'fulfilled' && gardenResult.value) {
94
+ state.gardenLoaded = true;
95
+ state.gardenSource = { ...state.gardenSource, status: 'available', message: undefined, consumed: gardenResult.value.length, exhausted: true };
96
+ state.garden = gardenResult.value.filter(station => matchesFilters(station, options));
97
+ }
98
+ else if (gardenResult.status === 'rejected') {
99
+ state.gardenSource = { ...state.gardenSource, status: 'unavailable', message: errorText(gardenResult.reason) };
43
100
  }
44
- return dedupeStations(interleave(radioBrowser, radioGarden)).slice(0, options.limit ?? 80);
101
+ const items = [];
102
+ while (items.length < limit && (state.browser.length || state.garden.length)) {
103
+ const primary = state.primaryTurn ? state.browser : state.garden;
104
+ const secondary = state.primaryTurn ? state.garden : state.browser;
105
+ const station = primary.shift() ?? secondary.shift();
106
+ state.primaryTurn = !state.primaryTurn;
107
+ if (!station)
108
+ break;
109
+ const key = `${station.provider}:${station.id}`;
110
+ if (state.seen.has(key))
111
+ continue;
112
+ state.seen.add(key);
113
+ items.push(station);
114
+ }
115
+ if (!items.length) {
116
+ if (browserResult.status === 'rejected')
117
+ throw browserResult.reason;
118
+ if (gardenResult.status === 'rejected')
119
+ throw gardenResult.reason;
120
+ }
121
+ return {
122
+ items,
123
+ exhausted: state.browserExhausted && state.gardenLoaded && state.browser.length === 0 && state.garden.length === 0,
124
+ sources: [...state.browserSources, state.gardenSource]
125
+ };
45
126
  }
46
- async resolve(station) {
127
+ async resolve(station, options = {}) {
128
+ options.signal?.throwIfAborted();
47
129
  if (station.provider === 'radio-garden') {
48
- return this.radioGarden.resolve(station);
130
+ return abortable(this.radioGarden.resolve(station, options), options.signal);
49
131
  }
50
- return this.radioBrowser.resolve(station);
132
+ return abortable(this.radioBrowser.resolve(station, options), options.signal);
51
133
  }
52
134
  vote(station) {
53
135
  return this.radioBrowser.vote(station);
@@ -63,16 +145,12 @@ export class ProviderManager {
63
145
  };
64
146
  }
65
147
  }
66
- function interleave(primary, secondary) {
67
- const merged = [];
68
- const length = Math.max(primary.length, secondary.length);
69
- for (let index = 0; index < length; index += 1) {
70
- const primaryStation = primary[index];
71
- const secondaryStation = secondary[index];
72
- if (primaryStation)
73
- merged.push(primaryStation);
74
- if (secondaryStation)
75
- merged.push(secondaryStation);
76
- }
77
- return merged;
148
+ function matchesFilters(station, options) {
149
+ return !(options.minBitrate && (station.bitrate ?? 0) < options.minBitrate)
150
+ && !(options.codec && station.codec?.toLowerCase() !== options.codec.toLowerCase())
151
+ && !(options.language && !station.language?.toLowerCase().includes(options.language.toLowerCase()))
152
+ && !(options.countryCode && station.countryCode?.toUpperCase() !== options.countryCode.toUpperCase());
153
+ }
154
+ function errorText(error) {
155
+ return error instanceof Error ? error.message : String(error);
78
156
  }