@ciphore/radiocli 0.2.4 → 0.2.6

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 (145) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/CONTRIBUTING.md +28 -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 +182 -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 +130 -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 +314 -76
  71. package/dist/types.js +22 -1
  72. package/dist/ui/AdaptiveContent.js +21 -24
  73. package/dist/ui/App.js +708 -454
  74. package/dist/ui/AppContent.js +5 -5
  75. package/dist/ui/airplay-settings.js +12 -5
  76. package/dist/ui/bidi-ranges.js +45 -0
  77. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  78. package/dist/ui/components/ReceiverVisualRow.js +39 -0
  79. package/dist/ui/format.js +37 -4
  80. package/dist/ui/help-content.js +5 -2
  81. package/dist/ui/rtl-output.js +137 -0
  82. package/dist/ui/screen-items.js +10 -4
  83. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  84. package/dist/ui/screens/AlarmsScreen.js +5 -2
  85. package/dist/ui/screens/HelpScreen.js +3 -3
  86. package/dist/ui/screens/NowPlayingScreen.js +2 -21
  87. package/dist/ui/screens/SettingsScreen.js +16 -16
  88. package/dist/ui/screens/StatsScreen.js +1 -1
  89. package/dist/ui/text-direction.js +9 -0
  90. package/dist/ui/theme.js +46 -0
  91. package/dist/ui/use-alarm-tui.js +24 -24
  92. package/dist/ui/use-app-input.js +42 -8
  93. package/dist/ui/use-command-executor.js +21 -14
  94. package/dist/ui/visualizers/boat-lake-kernel.js +325 -0
  95. package/dist/ui/visualizers/boat-lake.js +161 -0
  96. package/dist/ui/visualizers/gallop.js +2 -2
  97. package/dist/ui/visualizers/receiver-style-registry.js +3 -1
  98. package/dist/ui/visualizers/receiver-visualizers.js +6 -108
  99. package/dist/ui/visualizers/sumi-ocean.js +183 -0
  100. package/dist/ui/visualizers/terminal-cell.js +45 -0
  101. package/dist/update-check.js +2 -1
  102. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  103. package/docs/boat-lake.md +88 -0
  104. package/docs/sumi-ocean.md +80 -0
  105. package/package.json +36 -12
  106. package/scripts/build-airplay-native.mjs +22 -0
  107. package/vendor/node-airtunes2/LICENSE +208 -0
  108. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  109. package/vendor/node-airtunes2/README.md +225 -0
  110. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  111. package/vendor/node-airtunes2/binding.gyp +41 -0
  112. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  113. package/vendor/node-airtunes2/lib/atv.js +307 -0
  114. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  115. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  116. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  117. package/vendor/node-airtunes2/lib/config.js +26 -0
  118. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  119. package/vendor/node-airtunes2/lib/devices.js +266 -0
  120. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  121. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  122. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  123. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  124. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  125. package/vendor/node-airtunes2/lib/http.js +189 -0
  126. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  127. package/vendor/node-airtunes2/lib/index.js +127 -0
  128. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  129. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  130. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  131. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  132. package/vendor/node-airtunes2/lib/srp.js +158 -0
  133. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  134. package/vendor/node-airtunes2/lib/util.js +12 -0
  135. package/vendor/node-airtunes2/package.json +31 -0
  136. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  137. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  138. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  139. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  140. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  141. package/vendor/node-airtunes2/src/base64.c +136 -0
  142. package/vendor/node-airtunes2/src/base64.h +42 -0
  143. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  144. package/vendor/node-airtunes2/src/codec.cc +170 -0
  145. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -0,0 +1,247 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ import { stationKey } from './store.js';
3
+ const methods = ['snapshot', 'savePreset', 'removePreset', 'exportBackup', 'importBackup', 'updateSettings', 'updateCheckState',
4
+ 'addAlarm', 'updateAlarm', 'removeAlarm', 'toggleAlarm', 'recordAlarmOutcome', 'snoozeAlarm', 'clearAlarmSnooze',
5
+ 'addRecent', 'recordTrack', 'addSearch', 'startListeningSession', 'checkpointActiveListeningSession',
6
+ 'finishActiveListeningSession', 'toggleFavorite', 'addImported'];
7
+ /** One ordered transactional writer, off the UI/audio event loop. Sync stores remain
8
+ * usable for short-lived commands and injected adapters; success follows persistence. */
9
+ export class LibraryRepository {
10
+ store;
11
+ filePath;
12
+ state;
13
+ listeners = new Set();
14
+ errors = new Set();
15
+ pending = new Map();
16
+ worker;
17
+ nextId = 1;
18
+ inlineQueue = Promise.resolve();
19
+ closed = false;
20
+ failure;
21
+ closing;
22
+ workerReady = false;
23
+ fallbackRefresh;
24
+ constructor(store, options = {}) {
25
+ this.store = store;
26
+ this.filePath = store.filePath;
27
+ this.state = store.snapshot();
28
+ if (options.worker === false) {
29
+ this.startFallbackRefresh();
30
+ return;
31
+ }
32
+ const source = import.meta.url.endsWith('.ts');
33
+ const storeUrl = new URL(source ? './store.ts' : './store.js', import.meta.url).href;
34
+ try {
35
+ this.worker = new Worker(repositoryWorker, { eval: true, workerData: { storeUrl, source, filePath: this.filePath, methods }, execArgv: [] });
36
+ }
37
+ catch {
38
+ this.startFallbackRefresh();
39
+ return; /* Some restricted hosts disable threads; transactional storage still works. */
40
+ }
41
+ this.worker.on('message', (reply) => this.receive(reply));
42
+ this.worker.on('error', error => this.fail(error));
43
+ this.worker.on('exit', code => { if (!this.closed)
44
+ this.fail(new Error(`Library worker exited (${code}).`)); });
45
+ this.worker.unref();
46
+ }
47
+ snapshot() { return structuredClone(this.state); }
48
+ getSnapshot() { return this.state; }
49
+ settings() { return structuredClone(this.state.settings); }
50
+ listAlarms() { return structuredClone(this.state.alarms); }
51
+ getAlarm(id) { return structuredClone(this.state.alarms.find(alarm => alarm.id === id)); }
52
+ isFavorite(station) { return Boolean(station && this.state.favorites.some(item => stationKey(item) === stationKey(station))); }
53
+ subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
54
+ onError(listener) { this.errors.add(listener); return () => this.errors.delete(listener); }
55
+ readFresh() { return this.call('snapshot'); }
56
+ savePreset(name, station) { return this.call('savePreset', name, station); }
57
+ removePreset(name) { return this.call('removePreset', name); }
58
+ exportBackup(path) { return this.call('exportBackup', path); }
59
+ importBackup(path) { return this.call('importBackup', path); }
60
+ updateSettings(settings) { return this.call('updateSettings', settings); }
61
+ updateCheckState(state) { return this.call('updateCheckState', state); }
62
+ addAlarm(input) { return this.call('addAlarm', input); }
63
+ updateAlarm(id, updates) { return this.call('updateAlarm', id, updates); }
64
+ removeAlarm(id) { return this.call('removeAlarm', id); }
65
+ toggleAlarm(id, enabled) { return this.call('toggleAlarm', id, enabled); }
66
+ recordAlarmOutcome(id, outcome, options) { return this.call('recordAlarmOutcome', id, outcome, options); }
67
+ snoozeAlarm(id, until) { return this.call('snoozeAlarm', id, until); }
68
+ clearAlarmSnooze(id) { return this.call('clearAlarmSnooze', id); }
69
+ addRecent(station) { return this.call('addRecent', station); }
70
+ recordTrack(station, title) { return this.call('recordTrack', station, title); }
71
+ addSearch(query) { return this.call('addSearch', query); }
72
+ startListeningSession(station, at = new Date()) { return this.call('startListeningSession', station, at); }
73
+ checkpointActiveListeningSession(at = new Date()) { return this.call('checkpointActiveListeningSession', at); }
74
+ finishActiveListeningSession(at = new Date()) { return this.call('finishActiveListeningSession', at); }
75
+ toggleFavorite(station) { return this.call('toggleFavorite', station); }
76
+ addImported(stations) { return this.call('addImported', stations); }
77
+ async flush() {
78
+ // A barrier command follows every previously submitted semantic mutation.
79
+ await this.readFresh();
80
+ }
81
+ close() {
82
+ return this.closing ??= (async () => {
83
+ try {
84
+ await this.flush();
85
+ }
86
+ finally {
87
+ this.closed = true;
88
+ clearInterval(this.fallbackRefresh);
89
+ await this.worker?.terminate();
90
+ for (const pending of this.pending.values())
91
+ pending.reject(new Error('RadioCLI library repository is closed.'));
92
+ this.pending.clear();
93
+ this.listeners.clear();
94
+ this.errors.clear();
95
+ }
96
+ })();
97
+ }
98
+ call(method, ...args) {
99
+ if (this.closed || this.closing)
100
+ return Promise.reject(new Error('RadioCLI library repository is closed.'));
101
+ return this.enqueue(method, args);
102
+ }
103
+ enqueue(method, args) {
104
+ if (this.failure)
105
+ return Promise.reject(this.failure);
106
+ if (!this.worker) {
107
+ const result = this.inlineQueue.catch(() => undefined).then(() => {
108
+ const operation = this.store[method];
109
+ const value = operation.apply(this.store, args);
110
+ this.publish(this.store.cachedSnapshot());
111
+ return value;
112
+ });
113
+ this.inlineQueue = result;
114
+ return result;
115
+ }
116
+ const id = this.nextId++;
117
+ this.worker.ref();
118
+ return new Promise((resolve, reject) => {
119
+ const request = { id, method, args };
120
+ this.pending.set(id, { request, resolve: value => resolve(value), reject });
121
+ try {
122
+ if (this.workerReady)
123
+ this.worker.postMessage(request);
124
+ }
125
+ catch (error) {
126
+ this.pending.delete(id);
127
+ reject(error);
128
+ if (!this.pending.size)
129
+ this.worker.unref();
130
+ }
131
+ });
132
+ }
133
+ receive(reply) {
134
+ if (reply.state)
135
+ this.publish(reply.state);
136
+ if (reply.ready) {
137
+ this.workerReady = true;
138
+ for (const { request } of this.pending.values())
139
+ this.worker.postMessage(request);
140
+ return;
141
+ }
142
+ if (reply.id === undefined) {
143
+ if (reply.error)
144
+ this.report(deserializeError(reply.error));
145
+ return;
146
+ }
147
+ const pending = this.pending.get(reply.id);
148
+ this.pending.delete(reply.id);
149
+ if (reply.error)
150
+ pending?.reject(deserializeError(reply.error));
151
+ else
152
+ pending?.resolve(reply.result);
153
+ if (!this.pending.size)
154
+ this.worker?.unref();
155
+ }
156
+ publish(state) {
157
+ this.state = state;
158
+ for (const listener of this.listeners) {
159
+ try {
160
+ listener(state);
161
+ }
162
+ catch { /* Consumers cannot invalidate a durable commit. */ }
163
+ }
164
+ }
165
+ report(error) { for (const listener of this.errors) {
166
+ try {
167
+ listener(error);
168
+ }
169
+ catch { /* Isolate observers. */ }
170
+ } }
171
+ fail(error) {
172
+ if (!this.workerReady && this.worker) {
173
+ const worker = this.worker;
174
+ this.worker = undefined;
175
+ void worker.terminate();
176
+ this.startFallbackRefresh();
177
+ // No operation is sent before the ready handshake, so this fallback cannot
178
+ // replay a possibly committed toggle or alarm mutation.
179
+ const waiting = [...this.pending.values()];
180
+ this.pending.clear();
181
+ for (const pending of waiting)
182
+ void this.enqueue(pending.request.method, pending.request.args).then(pending.resolve, pending.reject);
183
+ this.report(new Error(`Library worker unavailable; using transactional storage: ${error.message}`));
184
+ return;
185
+ }
186
+ if (!this.workerReady && !this.worker)
187
+ return;
188
+ this.failure = error;
189
+ for (const request of this.pending.values())
190
+ request.reject(error);
191
+ this.pending.clear();
192
+ this.worker?.unref();
193
+ this.report(error);
194
+ }
195
+ startFallbackRefresh() {
196
+ this.fallbackRefresh ??= setInterval(() => {
197
+ if (this.closed || this.closing)
198
+ return;
199
+ const refresh = this.inlineQueue.catch(() => undefined).then(() => {
200
+ if (this.store.refresh())
201
+ this.publish(this.store.cachedSnapshot());
202
+ }).catch(error => this.report(error instanceof Error ? error : new Error(String(error))));
203
+ this.inlineQueue = refresh;
204
+ }, 1000);
205
+ this.fallbackRefresh.unref();
206
+ }
207
+ }
208
+ function deserializeError(value) {
209
+ return Object.assign(new Error(value.message), { name: value.name, ...(value.code ? { code: value.code } : {}) });
210
+ }
211
+ // Only enumerated semantic operations cross the worker boundary. No user data
212
+ // becomes executable code. Source mode uses the existing development runner;
213
+ // the packed build loads ordinary ESM and has no development dependency.
214
+ const repositoryWorker = `
215
+ const {parentPort, workerData} = require('node:worker_threads');
216
+ const {watch} = require('node:fs');
217
+ const {dirname} = require('node:path');
218
+ (async () => {
219
+ const module = workerData.source
220
+ ? await (await import('tsx/esm/api')).tsImport(workerData.storeUrl, {parentURL: workerData.storeUrl})
221
+ : await import(workerData.storeUrl);
222
+ const store = new module.JsonLibraryStore(workerData.filePath);
223
+ const allowed = new Set(workerData.methods);
224
+ const errorValue = error => ({message: error.message || 'Library operation failed.', name: error.name || 'Error', code: error.code});
225
+ let queue = Promise.resolve();
226
+ let watcher;
227
+ const refresh = () => {
228
+ try { if (store.refresh()) parentPort.postMessage({state: store.snapshot()}); }
229
+ catch (error) { parentPort.postMessage({error: errorValue(error)}); }
230
+ };
231
+ try { watcher = watch(dirname(workerData.filePath), {persistent: false}, () => { queue = queue.catch(() => {}).then(refresh); }); }
232
+ catch { /* Missing folders and platforms without watchers use the revision fallback. */ }
233
+ const timer = setInterval(() => { queue = queue.catch(() => {}).then(refresh); }, 1000);
234
+ timer.unref();
235
+ parentPort.on('message', request => {
236
+ queue = queue.catch(() => {}).then(async () => {
237
+ try {
238
+ if (!allowed.has(request.method)) throw new Error('Unknown library operation.');
239
+ const result = await store[request.method](...request.args);
240
+ const state = result && result.settings && result.activity ? result : store.cachedSnapshot();
241
+ parentPort.postMessage({id: request.id, result, state});
242
+ } catch (error) { parentPort.postMessage({id: request.id, error: errorValue(error)}); }
243
+ });
244
+ });
245
+ parentPort.postMessage({ready: true, state: store.snapshot()});
246
+ })().catch(error => { throw error; });
247
+ `;
@@ -0,0 +1,21 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** Paths and signed query parameters are significant: hash the exact target. */
3
+ export function customStationId(target) {
4
+ return `custom-v2-${createHash('sha256').update(target).digest('hex')}`;
5
+ }
6
+ function legacyCustomStationId(target) {
7
+ let hash = 0;
8
+ for (let index = 0; index < target.length; index += 1)
9
+ hash = (hash * 31 + target.charCodeAt(index)) >>> 0;
10
+ return `custom-${hash.toString(16)}`;
11
+ }
12
+ /** Only migrate IDs this application generated; preserve user-assigned IDs. */
13
+ export function migrateStationIdentity(station) {
14
+ if (station.provider !== 'playlist' || !station.streamUrl || station.id !== legacyCustomStationId(station.streamUrl))
15
+ return station;
16
+ return { ...station, id: customStationId(station.streamUrl), legacyIds: [...new Set([...(station.legacyIds ?? []), station.id])] };
17
+ }
18
+ export function matchesStationId(station, id) {
19
+ const normalized = id.replace(new RegExp(`^${station.provider}:`), '');
20
+ return station.id === normalized || Boolean(station.legacyIds?.includes(normalized));
21
+ }
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ export const stationSchema = z
3
+ .object({
4
+ id: z.string(),
5
+ legacyIds: z.array(z.string()).optional(),
6
+ provider: z.enum(['radio-browser', 'radio-garden', 'playlist']),
7
+ name: z.string(),
8
+ country: z.string().optional(),
9
+ countryCode: z.string().optional(),
10
+ state: z.string().optional(),
11
+ city: z.string().optional(),
12
+ language: z.string().optional(),
13
+ languageCodes: z.array(z.string()).optional(),
14
+ tags: z.array(z.string()),
15
+ codec: z.string().optional(),
16
+ bitrate: z.number().optional(),
17
+ homepage: z.string().optional(),
18
+ favicon: z.string().optional(),
19
+ streamUrl: z.string().optional(),
20
+ clickCount: z.number().optional(),
21
+ votes: z.number().optional(),
22
+ latitude: z.number().optional(),
23
+ longitude: z.number().optional(),
24
+ distanceKm: z.number().optional(),
25
+ hls: z.boolean().optional(),
26
+ lastCheckedOk: z.boolean().optional()
27
+ });