@ciphore/radiocli 0.2.3 → 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 (172) hide show
  1. package/CHANGELOG.md +147 -2
  2. package/CONTRIBUTING.md +46 -6
  3. package/README.md +108 -209
  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 +234 -67
  9. package/dist/agent/launcher.js +7 -67
  10. package/dist/agent/mcp-install.js +15 -15
  11. package/dist/agent/mcp-server.js +10 -5
  12. package/dist/agent/service.js +74 -39
  13. package/dist/agent/session.js +227 -53
  14. package/dist/agent/tui-session.js +213 -0
  15. package/dist/alarms/active-session.js +9 -12
  16. package/dist/alarms/cli.js +19 -3
  17. package/dist/alarms/guard.js +79 -36
  18. package/dist/alarms/inhibitor.js +27 -22
  19. package/dist/alarms/occurrence-cache.js +25 -0
  20. package/dist/alarms/power-guard-store.js +2 -10
  21. package/dist/alarms/runner.js +140 -45
  22. package/dist/alarms/schedule.js +37 -2
  23. package/dist/alarms/scheduler.js +202 -52
  24. package/dist/alarms/setup-verification.js +1 -2
  25. package/dist/alarms/system-volume-linux.js +160 -0
  26. package/dist/alarms/system-volume-ownership.js +335 -0
  27. package/dist/alarms/system-volume.js +188 -26
  28. package/dist/alarms/terminal-launcher.js +76 -136
  29. package/dist/alarms/tui-presence.js +2 -4
  30. package/dist/cli/arguments.js +212 -0
  31. package/dist/cli/completion.js +44 -0
  32. package/dist/cli/doctor.js +156 -0
  33. package/dist/cli/interactive.js +79 -0
  34. package/dist/cli/listener.js +227 -0
  35. package/dist/cli/output.js +121 -0
  36. package/dist/cli/render-tui.js +34 -0
  37. package/dist/cli.js +198 -154
  38. package/dist/platform/capabilities.js +60 -0
  39. package/dist/platform/desktop-media.js +61 -0
  40. package/dist/platform/desktop.js +36 -0
  41. package/dist/platform/diagnostics.js +273 -0
  42. package/dist/{player/command.js → platform/executables.js} +43 -9
  43. package/dist/platform/ipc.js +14 -0
  44. package/dist/platform/json-worker.js +111 -0
  45. package/dist/platform/launch-command.js +138 -0
  46. package/dist/platform/loopback.js +44 -0
  47. package/dist/platform/mpris.js +356 -0
  48. package/dist/platform/network.js +380 -0
  49. package/dist/platform/omarchy.js +147 -0
  50. package/dist/platform/packages.js +222 -0
  51. package/dist/platform/paths.js +40 -0
  52. package/dist/platform/runtime.js +67 -0
  53. package/dist/platform/shell.js +24 -0
  54. package/dist/platform/storage.js +48 -0
  55. package/dist/platform/support.js +214 -0
  56. package/dist/platform/terminal.js +63 -0
  57. package/dist/platform/terminals.js +223 -0
  58. package/dist/player/airplay-discovery.js +145 -60
  59. package/dist/player/airplay-policy.js +15 -0
  60. package/dist/player/airplay-process.js +63 -0
  61. package/dist/player/airplay-readiness.js +42 -0
  62. package/dist/player/airplay-sender-health.js +49 -49
  63. package/dist/player/airplay-sender-package.js +5 -0
  64. package/dist/player/airplay-sender-patch.js +102 -4
  65. package/dist/player/airplay-setup.js +51 -0
  66. package/dist/player/airplay-worker.js +97 -41
  67. package/dist/player/backend-install.js +17 -95
  68. package/dist/player/command-diagnostics.js +2 -2
  69. package/dist/player/ffmpeg-input.js +37 -0
  70. package/dist/player/mpv-desktop-media.js +194 -0
  71. package/dist/player/mpv-ipc-client.js +52 -2
  72. package/dist/player/player-controller.js +569 -82
  73. package/dist/player/radio-engine.js +282 -0
  74. package/dist/playlists/playlist.js +18 -16
  75. package/dist/providers/cache.js +293 -99
  76. package/dist/providers/discovery-session.js +111 -0
  77. package/dist/providers/provider-manager.js +106 -28
  78. package/dist/providers/radio-browser.js +418 -89
  79. package/dist/providers/radio-garden.js +18 -23
  80. package/dist/setup-playback.js +152 -0
  81. package/dist/setup.js +163 -168
  82. package/dist/storage/file-lock.js +113 -0
  83. package/dist/storage/read-settings.js +33 -0
  84. package/dist/storage/replace-file.js +20 -0
  85. package/dist/storage/repository.js +247 -0
  86. package/dist/storage/station-identity.js +21 -0
  87. package/dist/storage/station-schema.js +27 -0
  88. package/dist/storage/store.js +333 -107
  89. package/dist/streams/import-stream.js +163 -0
  90. package/dist/ui/AdaptiveContent.js +49 -32
  91. package/dist/ui/App.js +822 -481
  92. package/dist/ui/AppContent.js +5 -5
  93. package/dist/ui/airplay-settings.js +12 -5
  94. package/dist/ui/app-state.js +8 -1
  95. package/dist/ui/ascii.js +11 -2
  96. package/dist/ui/bidi-ranges.js +45 -0
  97. package/dist/ui/components/AdaptiveMarquee.js +20 -10
  98. package/dist/ui/components/Logo.js +5 -2
  99. package/dist/ui/components/Menu.js +2 -2
  100. package/dist/ui/components/ScreenHeader.js +1 -1
  101. package/dist/ui/components/StationList.js +6 -4
  102. package/dist/ui/components/TopTabs.js +1 -1
  103. package/dist/ui/display-context.js +8 -11
  104. package/dist/ui/format.js +37 -4
  105. package/dist/ui/help-content.js +9 -6
  106. package/dist/ui/layout.js +5 -2
  107. package/dist/ui/page-footer.js +3 -3
  108. package/dist/ui/rtl-output.js +137 -0
  109. package/dist/ui/screen-items.js +12 -6
  110. package/dist/ui/screen-meta.js +1 -1
  111. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  112. package/dist/ui/screens/AirPlaySettingsScreen.js +10 -6
  113. package/dist/ui/screens/AlarmsScreen.js +5 -2
  114. package/dist/ui/screens/ExploreScreen.js +2 -1
  115. package/dist/ui/screens/HelpScreen.js +8 -4
  116. package/dist/ui/screens/HomeScreen.js +5 -1
  117. package/dist/ui/screens/MapScreen.js +1 -1
  118. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  119. package/dist/ui/screens/SearchScreen.js +3 -1
  120. package/dist/ui/screens/SettingsScreen.js +28 -21
  121. package/dist/ui/screens/StatsScreen.js +3 -1
  122. package/dist/ui/system-actions.js +80 -52
  123. package/dist/ui/terminal-renderer.js +16 -0
  124. package/dist/ui/text-direction.js +9 -0
  125. package/dist/ui/use-alarm-tui.js +57 -42
  126. package/dist/ui/use-app-input.js +84 -30
  127. package/dist/ui/use-command-executor.js +50 -19
  128. package/dist/ui/visualizers/gallop.js +1 -1
  129. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  130. package/dist/update-check.js +10 -18
  131. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  132. package/package.json +34 -11
  133. package/scripts/build-airplay-native.mjs +22 -0
  134. package/vendor/node-airtunes2/LICENSE +208 -0
  135. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  136. package/vendor/node-airtunes2/README.md +225 -0
  137. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  138. package/vendor/node-airtunes2/binding.gyp +41 -0
  139. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  140. package/vendor/node-airtunes2/lib/atv.js +307 -0
  141. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  142. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  143. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  144. package/vendor/node-airtunes2/lib/config.js +26 -0
  145. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  146. package/vendor/node-airtunes2/lib/devices.js +266 -0
  147. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  148. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  149. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  150. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  151. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  152. package/vendor/node-airtunes2/lib/http.js +189 -0
  153. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  154. package/vendor/node-airtunes2/lib/index.js +127 -0
  155. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  156. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  157. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  158. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  159. package/vendor/node-airtunes2/lib/srp.js +158 -0
  160. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  161. package/vendor/node-airtunes2/lib/util.js +12 -0
  162. package/vendor/node-airtunes2/package.json +31 -0
  163. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  164. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  165. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  166. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  167. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  168. package/vendor/node-airtunes2/src/base64.c +136 -0
  169. package/vendor/node-airtunes2/src/base64.h +42 -0
  170. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  171. package/vendor/node-airtunes2/src/codec.cc +170 -0
  172. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,43 +1,33 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
1
+ import { platformPaths } from '../platform/paths.js';
2
+ import { assertStorageWritable } from '../platform/storage.js';
3
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, 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';
4
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
- const stationSchema = z
11
- .object({
12
- id: z.string(),
13
- provider: z.enum(['radio-browser', 'radio-garden', 'playlist']),
14
- name: z.string(),
15
- country: z.string().optional(),
16
- countryCode: z.string().optional(),
17
- state: z.string().optional(),
18
- city: z.string().optional(),
19
- language: z.string().optional(),
20
- languageCodes: z.array(z.string()).optional(),
21
- tags: z.array(z.string()),
22
- codec: z.string().optional(),
23
- bitrate: z.number().optional(),
24
- homepage: z.string().optional(),
25
- favicon: z.string().optional(),
26
- streamUrl: z.string().optional(),
27
- clickCount: z.number().optional(),
28
- votes: z.number().optional(),
29
- latitude: z.number().optional(),
30
- longitude: z.number().optional(),
31
- distanceKm: z.number().optional(),
32
- hls: z.boolean().optional(),
33
- lastCheckedOk: z.boolean().optional()
34
- });
11
+ import { migrateStationIdentity } from './station-identity.js';
12
+ import { stationSchema } from './station-schema.js';
13
+ import { acquireFileLock } from './file-lock.js';
14
+ import { replaceFileSync } from './replace-file.js';
35
15
  const defaultMediaKeys = {
36
16
  previous: [],
37
17
  playPause: [],
38
18
  next: []
39
19
  };
40
20
  const MAX_ALARMS = 500;
21
+ // Keep the shared library readable by pre-scene releases whose background MCP
22
+ // workers quarantine an entire library when they see an unfamiliar enum value.
23
+ // Crossfade is the last style understood by those releases. New styles live in
24
+ // a separate preference file that old writers never read or overwrite.
25
+ const sharedLibraryReceiverStyles = new Set(receiverStyleNames.slice(0, receiverStyleNames.indexOf('crossfade') + 1));
26
+ const receiverPreferenceSchema = z.object({
27
+ version: z.literal(1),
28
+ receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames)),
29
+ libraryReceiverStyle: z.literal('ultracode')
30
+ });
41
31
  const settingsSchema = z.object({
42
32
  theme: z.enum(themeNames).default('green'),
43
33
  receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
@@ -48,6 +38,7 @@ const settingsSchema = z.object({
48
38
  shareDirectoryVotes: z.boolean().default(true),
49
39
  preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
50
40
  preferredAirPlayDevice: z.string().min(1).optional(),
41
+ linuxAirPlayEnabled: z.boolean().optional(),
51
42
  tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
52
43
  skipBrokenStreams: z.boolean().default(true),
53
44
  mediaKeys: z
@@ -63,6 +54,7 @@ const settingsSchema = z.object({
63
54
  reduceMotion: z.boolean().default(false),
64
55
  mouseSupport: z.boolean().default(true),
65
56
  automaticUpdateChecks: z.boolean().default(true),
57
+ desktopMediaControls: z.enum(['auto', 'on', 'off']).default('auto'),
66
58
  agentControl: z.object({
67
59
  enabled: z.boolean().default(false),
68
60
  openUiOnPlay: z.boolean().default(true),
@@ -166,6 +158,10 @@ const librarySchema = z.object({
166
158
  }))
167
159
  .default([]),
168
160
  favorites: z.array(stationSchema).default([]),
161
+ presets: z.array(z.object({
162
+ name: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}$/),
163
+ station: stationSchema
164
+ })).max(200).default([]),
169
165
  imported: z.array(stationSchema).default([]),
170
166
  trackHistory: z
171
167
  .array(z.object({
@@ -234,16 +230,70 @@ export class JsonLibraryStore {
234
230
  state;
235
231
  idGenerator;
236
232
  now;
237
- constructor(filePath = defaultStorePath(), options = {}) {
233
+ revision = '';
234
+ constructor(filePath = defaultLibraryPath(), options = {}) {
238
235
  this.filePath = filePath;
239
236
  this.idGenerator = options.idGenerator ?? randomUUID;
240
237
  this.now = options.now ?? (() => new Date());
238
+ this.revision = this.diskRevision();
241
239
  this.state = this.read();
240
+ // Normalize libraries saved by an earlier preview before any legacy
241
+ // background reader encounters its new enum value again.
242
+ if (!sharedLibraryReceiverStyles.has(this.state.settings.receiverStyle) && !existsSync(this.receiverPreferencePath()))
243
+ this.commit(this.state);
242
244
  }
243
245
  snapshot() {
246
+ this.refresh();
247
+ return this.cachedSnapshot();
248
+ }
249
+ cachedSnapshot() {
244
250
  return structuredClone(this.state);
245
251
  }
252
+ /** Refresh atomically replaced files without recovering a live listening session. */
253
+ refresh() {
254
+ const revision = this.diskRevision();
255
+ if (revision === this.revision)
256
+ return false;
257
+ const next = this.readForCurrentSession();
258
+ this.state = next;
259
+ this.revision = revision;
260
+ return true;
261
+ }
262
+ settings() {
263
+ this.refresh();
264
+ return structuredClone(this.state.settings);
265
+ }
266
+ diskRevision() {
267
+ return [this.filePath, this.receiverPreferencePath()].map(file => {
268
+ try {
269
+ const info = statSync(file, { bigint: true });
270
+ return `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
271
+ }
272
+ catch (error) {
273
+ if (error.code === 'ENOENT')
274
+ return 'missing';
275
+ throw storageError('read the RadioCLI library', error);
276
+ }
277
+ }).join('|');
278
+ }
279
+ savePreset(name, station) {
280
+ this.refresh();
281
+ const key = normalizePresetName(name);
282
+ const presets = this.state.presets ?? [];
283
+ if (presets.length >= 200 && !presets.some(preset => preset.name === key))
284
+ throw new Error('RadioCLI supports at most 200 station presets. Remove one with radiocli preset remove <name>.');
285
+ this.commit({ ...this.state, presets: [{ name: key, station }, ...presets.filter(preset => preset.name !== key)] });
286
+ }
287
+ removePreset(name) {
288
+ this.refresh();
289
+ const key = normalizePresetName(name);
290
+ const presets = this.state.presets ?? [];
291
+ if (!presets.some(preset => preset.name === key))
292
+ throw new Error(`No preset named ${key}. Run radiocli presets to see saved names.`);
293
+ this.commit({ ...this.state, presets: presets.filter(preset => preset.name !== key) });
294
+ }
246
295
  exportBackup(requestedPath) {
296
+ this.refresh();
247
297
  const targetPath = requestedPath?.trim()
248
298
  ? resolveUserPath(requestedPath)
249
299
  : resolve(process.cwd(), `radiocli-backup-${fileTimestamp(new Date())}.json`);
@@ -283,7 +333,9 @@ export class JsonLibraryStore {
283
333
  ? `${this.filePath}.before-import-${fileTimestamp(new Date())}.bak`
284
334
  : undefined;
285
335
  if (safetyBackupPath) {
286
- writeJsonAtomically(safetyBackupPath, libraryStateForDisk(this.state));
336
+ // Another TUI or background player may have saved since this owner
337
+ // last refreshed. Preserve its latest data while holding the lock.
338
+ writeJsonAtomically(safetyBackupPath, libraryStateForDisk(this.read()));
287
339
  }
288
340
  this.write(importedState);
289
341
  this.state = importedState;
@@ -294,6 +346,7 @@ export class JsonLibraryStore {
294
346
  }
295
347
  }
296
348
  updateSettings(settings) {
349
+ this.refresh();
297
350
  return this.commit({
298
351
  ...this.state,
299
352
  settings: {
@@ -304,12 +357,13 @@ export class JsonLibraryStore {
304
357
  });
305
358
  }
306
359
  updateCheckState(updateCheck) {
360
+ this.refresh();
307
361
  return this.commit({ ...this.state, updateCheck });
308
362
  }
309
363
  addAlarm(input) {
310
364
  const release = acquireStoreLock(this.filePath);
311
365
  try {
312
- const latestState = this.read();
366
+ const latestState = this.readForCurrentSession();
313
367
  if (latestState.alarms.length >= MAX_ALARMS) {
314
368
  throw new Error(`RadioCLI supports at most ${MAX_ALARMS} alarms.`);
315
369
  }
@@ -351,7 +405,7 @@ export class JsonLibraryStore {
351
405
  removeAlarm(id) {
352
406
  const release = acquireStoreLock(this.filePath);
353
407
  try {
354
- const latestState = this.read();
408
+ const latestState = this.readForCurrentSession();
355
409
  if (!latestState.alarms.some(alarm => alarm.id === id)) {
356
410
  this.state = latestState;
357
411
  return false;
@@ -373,10 +427,12 @@ export class JsonLibraryStore {
373
427
  }));
374
428
  }
375
429
  getAlarm(id) {
430
+ this.refresh();
376
431
  const alarm = this.state.alarms.find(item => item.id === id);
377
432
  return alarm ? structuredClone(alarm) : undefined;
378
433
  }
379
434
  listAlarms() {
435
+ this.refresh();
380
436
  return structuredClone(this.state.alarms);
381
437
  }
382
438
  recordAlarmOutcome(id, outcome, options = {}) {
@@ -408,6 +464,7 @@ export class JsonLibraryStore {
408
464
  }));
409
465
  }
410
466
  addRecent(station) {
467
+ this.refresh();
411
468
  const key = stationKey(station);
412
469
  const recent = [
413
470
  { station, playedAt: new Date().toISOString() },
@@ -418,9 +475,10 @@ export class JsonLibraryStore {
418
475
  // Persist the ICY track titles a station announces so "what was that song?"
419
476
  // is answerable later. Consecutive duplicates on the same station are skipped.
420
477
  recordTrack(station, title) {
478
+ this.refresh();
421
479
  const cleaned = title.trim();
422
480
  if (!cleaned) {
423
- return this.snapshot();
481
+ return this.cachedSnapshot();
424
482
  }
425
483
  const key = stationKey(station);
426
484
  const last = this.state.trackHistory[0];
@@ -437,6 +495,7 @@ export class JsonLibraryStore {
437
495
  }
438
496
  // Most-recent-first search queries for up-arrow recall in the search box.
439
497
  addSearch(query) {
498
+ this.refresh();
440
499
  const cleaned = query.trim();
441
500
  if (!cleaned) {
442
501
  return this.snapshot();
@@ -445,6 +504,7 @@ export class JsonLibraryStore {
445
504
  return this.commit({ ...this.state, searchHistory });
446
505
  }
447
506
  startListeningSession(station, startedAt = new Date()) {
507
+ this.refresh();
448
508
  const finishedState = recoverActiveListeningSessionInState(this.state);
449
509
  const session = {
450
510
  id: `${startedAt.toISOString()}-${stationKey(station)}`,
@@ -461,6 +521,7 @@ export class JsonLibraryStore {
461
521
  });
462
522
  }
463
523
  checkpointActiveListeningSession(activeAt = new Date()) {
524
+ this.refresh();
464
525
  const active = this.state.activity.sessions[0];
465
526
  if (!active || active.endedAt) {
466
527
  return this.snapshot();
@@ -481,6 +542,7 @@ export class JsonLibraryStore {
481
542
  });
482
543
  }
483
544
  finishActiveListeningSession(endedAt = new Date()) {
545
+ this.refresh();
484
546
  const nextState = finishActiveListeningSessionInState(this.state, endedAt);
485
547
  if (nextState === this.state) {
486
548
  return this.snapshot();
@@ -488,6 +550,7 @@ export class JsonLibraryStore {
488
550
  return this.commit(nextState);
489
551
  }
490
552
  toggleFavorite(station) {
553
+ this.refresh();
491
554
  const key = stationKey(station);
492
555
  const exists = this.state.favorites.some(item => stationKey(item) === key);
493
556
  const favorites = exists
@@ -496,26 +559,36 @@ export class JsonLibraryStore {
496
559
  return this.commit({ ...this.state, favorites });
497
560
  }
498
561
  addImported(stations) {
499
- const existing = new Map(this.state.imported.map(station => [stationKey(station), station]));
562
+ this.refresh();
563
+ const incoming = [];
564
+ const incomingKeys = new Set();
500
565
  for (const station of stations) {
501
- existing.set(stationKey(station), station);
566
+ const key = stationKey(station);
567
+ if (incomingKeys.has(key))
568
+ continue;
569
+ incomingKeys.add(key);
570
+ incoming.push(station);
502
571
  }
503
572
  return this.commit({
504
573
  ...this.state,
505
- imported: [...existing.values()].slice(0, 1000)
574
+ imported: [
575
+ ...incoming,
576
+ ...this.state.imported.filter(station => !incomingKeys.has(stationKey(station)))
577
+ ].slice(0, 1000)
506
578
  });
507
579
  }
508
580
  isFavorite(station) {
509
581
  if (!station) {
510
582
  return false;
511
583
  }
584
+ this.refresh();
512
585
  const key = stationKey(station);
513
586
  return this.state.favorites.some(item => stationKey(item) === key);
514
587
  }
515
588
  mutateAlarm(id, change) {
516
589
  const release = acquireStoreLock(this.filePath);
517
590
  try {
518
- const latestState = this.read();
591
+ const latestState = this.readForCurrentSession();
519
592
  const existing = latestState.alarms.find(alarm => alarm.id === id);
520
593
  if (!existing) {
521
594
  this.state = latestState;
@@ -535,37 +608,130 @@ export class JsonLibraryStore {
535
608
  }
536
609
  }
537
610
  read() {
538
- if (!existsSync(this.filePath)) {
539
- return defaultState();
611
+ let contents;
612
+ try {
613
+ contents = readFileSync(this.filePath, 'utf8');
614
+ }
615
+ catch (error) {
616
+ if (error.code === 'ENOENT')
617
+ return defaultState();
618
+ throw storageError('read the RadioCLI library', error);
540
619
  }
620
+ let state;
541
621
  try {
542
- return migrateLibraryState(librarySchema.parse(JSON.parse(readFileSync(this.filePath, 'utf8'))));
622
+ state = migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
543
623
  }
544
- catch {
545
- backupBadFile(this.filePath);
624
+ catch (error) {
625
+ if (error instanceof z.ZodError && error.issues.some(issue => issue.code === 'invalid_value' && issue.path.length === 2 &&
626
+ issue.path[0] === 'settings' && issue.path[1] === 'receiverStyle')) {
627
+ // Another running build may have saved a newer receiver. Its library
628
+ // is intact: never quarantine it or replace it with empty defaults.
629
+ throw new Error('The RadioCLI library uses a receiver style this process does not support. Restart RadioCLI and its MCP clients to load the updated styles.');
630
+ }
631
+ try {
632
+ assertStorageWritable(this.filePath);
633
+ renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`);
634
+ }
635
+ catch (error) {
636
+ // Reset only after preserving the original bytes. A failed read never reaches here.
637
+ throw storageError('preserve the corrupt RadioCLI library', error);
638
+ }
546
639
  return defaultState();
547
640
  }
641
+ // A legacy writer can still change ordinary preferences and favorites. Its
642
+ // fallback style must not erase the selection saved by an updated process.
643
+ let preference;
644
+ try {
645
+ preference = readFileSync(this.receiverPreferencePath(), 'utf8');
646
+ }
647
+ catch (error) {
648
+ if (error.code === 'ENOENT')
649
+ return state;
650
+ throw storageError('read the receiver preference', error);
651
+ }
652
+ const parsed = receiverPreferenceSchema.safeParse(JSON.parse(preference));
653
+ if (!parsed.success)
654
+ throw new Error('The RadioCLI receiver preference is unsupported or invalid. Restart RadioCLI and its MCP clients with the latest version. Your library has been preserved.');
655
+ if (state.settings.receiverStyle === parsed.data.libraryReceiverStyle)
656
+ state.settings.receiverStyle = parsed.data.receiverStyle;
657
+ return state;
658
+ }
659
+ receiverPreferencePath() { return `${this.filePath}.receiver.json`; }
660
+ readForCurrentSession() {
661
+ const next = this.read();
662
+ // Disk intentionally stores auto instead of reopening a receiver on launch.
663
+ // Within this owner, unrelated durable changes retain its selected output.
664
+ if (this.state.settings.preferredBackend === 'airplay' && next.settings.preferredBackend === 'auto') {
665
+ next.settings.preferredBackend = 'airplay';
666
+ }
667
+ return next;
548
668
  }
549
669
  commit(nextState) {
550
670
  const release = acquireStoreLock(this.filePath);
551
671
  try {
552
- const latestState = this.read();
672
+ const latestState = this.readForCurrentSession();
553
673
  const mergedState = mergeLibraryChanges(this.state, latestState, nextState);
554
674
  this.write(mergedState);
555
675
  this.state = mergedState;
556
- return this.snapshot();
676
+ return this.cachedSnapshot();
557
677
  }
558
678
  finally {
559
679
  release();
560
680
  }
561
681
  }
562
682
  write(state) {
563
- mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
564
- writeJsonAtomically(this.filePath, libraryStateForDisk(state));
683
+ try {
684
+ assertStorageWritable(this.filePath);
685
+ mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
686
+ const diskState = libraryStateForDisk(state);
687
+ const separateReceiver = !sharedLibraryReceiverStyles.has(state.settings.receiverStyle);
688
+ const preferencePath = this.receiverPreferencePath();
689
+ let previousPreference;
690
+ try {
691
+ previousPreference = readFileSync(preferencePath, 'utf8');
692
+ }
693
+ catch (error) {
694
+ if (error.code !== 'ENOENT')
695
+ throw error;
696
+ }
697
+ const nextPreference = separateReceiver
698
+ ? `${JSON.stringify({ version: 1, receiverStyle: state.settings.receiverStyle, libraryReceiverStyle: 'ultracode' }, null, 2)}\n`
699
+ : undefined;
700
+ const preferenceChanged = previousPreference !== nextPreference;
701
+ if (preferenceChanged) {
702
+ if (nextPreference === undefined)
703
+ rmSync(preferencePath);
704
+ else
705
+ writeTextAtomically(preferencePath, nextPreference);
706
+ }
707
+ try {
708
+ writeJsonAtomically(this.filePath, separateReceiver
709
+ ? { ...diskState, settings: { ...diskState.settings, receiverStyle: 'ultracode' } }
710
+ : diskState);
711
+ }
712
+ catch (error) {
713
+ if (preferenceChanged) {
714
+ if (previousPreference === undefined)
715
+ rmSync(preferencePath, { force: true });
716
+ else
717
+ writeTextAtomically(preferencePath, previousPreference);
718
+ }
719
+ throw error;
720
+ }
721
+ try {
722
+ this.revision = this.diskRevision();
723
+ }
724
+ catch {
725
+ this.revision = '';
726
+ }
727
+ }
728
+ catch (error) {
729
+ throw storageError('write the RadioCLI library', error);
730
+ }
565
731
  }
566
732
  }
567
733
  export function stationKey(station) {
568
- return `${station.provider}:${station.id}`;
734
+ return `${station.provider}:${migrateStationIdentity(station).id}`;
569
735
  }
570
736
  function finishActiveListeningSessionInState(state, endedAt) {
571
737
  const active = state.activity.sessions[0];
@@ -602,31 +768,21 @@ function recoverActiveListeningSessionInState(state) {
602
768
  : Date.now();
603
769
  return finishActiveListeningSessionInState(state, new Date(recoveredEnd));
604
770
  }
605
- function defaultStorePath() {
606
- if (process.env.RADIOCLI_HOME) {
607
- return join(process.env.RADIOCLI_HOME, 'radiocli.json');
608
- }
609
- if (process.env.RADIO_ATLAS_HOME) {
610
- return join(process.env.RADIO_ATLAS_HOME, 'radio-atlas.json');
611
- }
612
- const currentPath = currentDefaultStorePath();
613
- const legacyPath = legacyDefaultStorePath();
614
- return existsSync(currentPath) || !existsSync(legacyPath) ? currentPath : legacyPath;
615
- }
616
- function currentDefaultStorePath() {
617
- if (process.platform === 'darwin') {
618
- return join(homedir(), 'Library', 'Application Support', 'radiocli', 'radiocli.json');
619
- }
620
- if (process.platform === 'win32') {
621
- return join(process.env.APPDATA ?? join(homedir(), 'AppData', 'Roaming'), 'RadioCLI', 'radiocli.json');
622
- }
623
- return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radiocli', 'radiocli.json');
624
- }
625
- function legacyDefaultStorePath() {
626
- if (process.platform === 'darwin') {
627
- return join(homedir(), 'Library', 'Application Support', 'radio-atlas', 'radio-atlas.json');
771
+ export function defaultLibraryPath(options = {}) {
772
+ const { library, legacyLibrary } = platformPaths(options);
773
+ for (const filePath of [library, legacyLibrary]) {
774
+ try {
775
+ statSync(filePath);
776
+ return filePath;
777
+ }
778
+ catch (error) {
779
+ // A permission failure must not select an older library or empty defaults.
780
+ if (error.code !== 'ENOENT') {
781
+ throw storageError('read the RadioCLI library', error);
782
+ }
783
+ }
628
784
  }
629
- return join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'radio-atlas', 'radio-atlas.json');
785
+ return library;
630
786
  }
631
787
  function resolveUserPath(requestedPath) {
632
788
  const unquoted = requestedPath.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, (_match, doubleQuoted, singleQuoted) => doubleQuoted ?? singleQuoted ?? '');
@@ -644,6 +800,7 @@ function defaultState() {
644
800
  return {
645
801
  recent: [],
646
802
  favorites: [],
803
+ presets: [],
647
804
  imported: [],
648
805
  trackHistory: [],
649
806
  searchHistory: [],
@@ -673,9 +830,32 @@ function defaultState() {
673
830
  };
674
831
  }
675
832
  function migrateLibraryState(state) {
833
+ const aliases = new Map();
834
+ const migrate = (station) => {
835
+ const next = migrateStationIdentity(station);
836
+ if (next.id !== station.id) {
837
+ const key = `${station.provider}:${station.id}`;
838
+ const targets = aliases.get(key) ?? new Set();
839
+ targets.add(stationKey(next));
840
+ aliases.set(key, targets);
841
+ }
842
+ return next;
843
+ };
844
+ const recent = state.recent.map(item => ({ ...item, station: migrate(item.station) }));
845
+ const favorites = state.favorites.map(migrate);
846
+ const imported = state.imported.map(migrate);
847
+ const presets = state.presets?.map(item => ({ ...item, station: migrate(item.station) }));
848
+ const sessions = state.activity.sessions.map(item => ({ ...item, station: migrate(item.station) }));
849
+ const alarms = state.alarms.map(alarm => normalizeAlarm({ ...alarm, station: migrate(alarm.station),
850
+ playback: { ...alarm.playback, ...(alarm.playback.fallbackStation ? { fallbackStation: migrate(alarm.playback.fallbackStation) } : {}) } }));
676
851
  return {
677
852
  ...state,
678
- alarms: state.alarms.map(normalizeAlarm),
853
+ recent, favorites, imported, presets, alarms,
854
+ activity: { sessions },
855
+ trackHistory: state.trackHistory.map(item => {
856
+ const targets = aliases.get(item.stationKey);
857
+ return targets?.size === 1 ? { ...item, stationKey: [...targets][0] } : item;
858
+ }),
679
859
  settings: {
680
860
  ...state.settings,
681
861
  preferredBackend: state.settings.preferredBackend === 'airplay' ? 'auto' : state.settings.preferredBackend,
@@ -700,53 +880,98 @@ function libraryStateForDisk(state) {
700
880
  : state;
701
881
  }
702
882
  function writeJsonAtomically(filePath, value) {
703
- const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
883
+ writeTextAtomically(filePath, `${JSON.stringify(value, null, 2)}\n`);
884
+ }
885
+ function writeTextAtomically(filePath, text) {
886
+ const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
704
887
  try {
705
- writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
706
- renameSync(tempPath, filePath);
707
- if (process.platform !== 'win32') {
708
- chmodSync(filePath, 0o600);
888
+ const fd = openSync(tempPath, 'wx', 0o600);
889
+ try {
890
+ writeFileSync(fd, text, 'utf8');
891
+ if (process.platform !== 'win32')
892
+ chmodSync(tempPath, 0o600);
893
+ fsyncSync(fd);
894
+ }
895
+ catch (error) {
896
+ try {
897
+ closeSync(fd);
898
+ }
899
+ catch { /* Preserve the original persistence error. */ }
900
+ throw error;
709
901
  }
902
+ closeSync(fd);
903
+ replaceFileSync(tempPath, filePath);
710
904
  }
711
905
  catch (error) {
712
- rmSync(tempPath, { force: true });
713
- throw error;
714
- }
715
- }
716
- function acquireStoreLock(filePath) {
717
- const lockPath = `${filePath}.lock`;
718
- mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
719
- const deadline = Date.now() + 1000;
720
- while (true) {
721
906
  try {
722
- mkdirSync(lockPath, { mode: 0o700 });
723
- return () => rmSync(lockPath, { recursive: true, force: true });
907
+ rmSync(tempPath, { force: true });
724
908
  }
725
- catch (error) {
726
- const code = error.code;
727
- if (code !== 'EEXIST')
728
- throw error;
909
+ catch { /* Preserve the original write failure. */ }
910
+ throw storageError('write RadioCLI data', error);
911
+ }
912
+ // Flush the replacement directory where supported. Once rename succeeds the
913
+ // transaction is committed: a directory-sync failure cannot roll it back or
914
+ // leave the caller's in-memory state describing the previous file. Windows
915
+ // and some network/removable filesystems do not support syncing directories.
916
+ let directoryFd;
917
+ try {
918
+ directoryFd = openSync(dirname(filePath), 'r');
919
+ fsyncSync(directoryFd);
920
+ }
921
+ catch { /* The file itself was flushed before replacement. */ }
922
+ finally {
923
+ if (directoryFd !== undefined)
729
924
  try {
730
- if (Date.now() - statSync(lockPath).mtimeMs > 10_000) {
731
- rmSync(lockPath, { recursive: true, force: true });
732
- continue;
733
- }
734
- }
735
- catch {
736
- continue;
737
- }
738
- if (Date.now() >= deadline) {
739
- throw new Error(`RadioCLI library is busy: ${filePath}`);
925
+ closeSync(directoryFd);
740
926
  }
741
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
742
- }
927
+ catch { /* Already committed. */ }
928
+ }
929
+ }
930
+ function acquireStoreLock(filePath) {
931
+ const lockPath = `${filePath}.lock`;
932
+ try {
933
+ assertStorageWritable(filePath);
934
+ mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
935
+ }
936
+ catch (error) {
937
+ throw storageError('write the RadioCLI library', error);
938
+ }
939
+ try {
940
+ return acquireFileLock(lockPath);
743
941
  }
942
+ catch (error) {
943
+ throw storageError('lock the RadioCLI library', error);
944
+ }
945
+ }
946
+ class LibraryStorageError extends Error {
947
+ code;
948
+ errno;
949
+ constructor(operation, cause) {
950
+ const code = cause?.code;
951
+ const detail = typeof code === 'string' && /^[A-Z][A-Z0-9_]+$/.test(code) ? ` (${code})` : '';
952
+ super(code === 'EBUSY'
953
+ ? 'The RadioCLI library is busy. Retry after the other writer finishes, or set RADIOCLI_HOME to a private writable directory.'
954
+ : `Unable to ${operation}${detail}. Check storage permissions or set RADIOCLI_HOME to a private writable directory.`, { cause });
955
+ this.name = 'LibraryStorageError';
956
+ this.code = code;
957
+ this.errno = cause?.errno;
958
+ }
959
+ }
960
+ function storageError(operation, cause) {
961
+ return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause);
962
+ }
963
+ function normalizePresetName(name) {
964
+ const key = name.trim().toLowerCase();
965
+ if (!/^[a-z0-9][a-z0-9_-]{0,39}$/.test(key))
966
+ throw new Error('Preset names must be 1-40 letters, numbers, hyphens, or underscores, starting with a letter or number.');
967
+ return key;
744
968
  }
745
969
  function mergeLibraryChanges(base, disk, next) {
746
970
  return {
747
971
  recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50),
748
972
  favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true),
749
- imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true),
973
+ presets: mergeKeyedChanges(base.presets ?? [], disk.presets ?? [], next.presets ?? [], preset => preset.name, 200, true),
974
+ imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true, true),
750
975
  trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
751
976
  searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
752
977
  alarms: mergeKeyedChanges(base.alarms, disk.alarms, next.alarms, alarm => alarm.id, MAX_ALARMS, true),
@@ -768,15 +993,16 @@ function normalizeAlarm(alarm) {
768
993
  };
769
994
  return { ...alarm, schedule };
770
995
  }
771
- function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false) {
996
+ function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false, applyOrderChanges = false) {
772
997
  const baseByKey = new Map(base.map(item => [keyFor(item), item]));
998
+ const basePositions = new Map(base.map((item, index) => [keyFor(item), index]));
773
999
  const nextKeys = new Set(next.map(keyFor));
774
1000
  const removedKeys = applyRemovals
775
1001
  ? new Set(base.map(keyFor).filter(key => !nextKeys.has(key)))
776
1002
  : new Set();
777
- const localChanges = next.filter(item => {
1003
+ const localChanges = next.filter((item, index) => {
778
1004
  const prior = baseByKey.get(keyFor(item));
779
- return prior === undefined || !sameValue(prior, item);
1005
+ return prior === undefined || !sameValue(prior, item) || applyOrderChanges && basePositions.get(keyFor(item)) !== index;
780
1006
  });
781
1007
  const changedKeys = new Set(localChanges.map(keyFor));
782
1008
  return [