@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
@@ -1,6 +1,6 @@
1
1
  import { platformPaths } from '../platform/paths.js';
2
2
  import { assertStorageWritable } from '../platform/storage.js';
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
4
4
  import { homedir } from 'node:os';
5
5
  import { dirname, isAbsolute, join, resolve } from 'node:path';
6
6
  import { randomUUID } from 'node:crypto';
@@ -8,37 +8,46 @@ import { z } from 'zod';
8
8
  import { defaultReceiverStyle, receiverStyleNames, themeNames } from '../types.js';
9
9
  import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone } from '../alarms/schedule.js';
10
10
  import { migrateReceiverStyle } from '../ui/visualizers/receiver-style-registry.js';
11
- const stationSchema = z
12
- .object({
13
- id: z.string(),
14
- provider: z.enum(['radio-browser', 'radio-garden', 'playlist']),
15
- name: z.string(),
16
- country: z.string().optional(),
17
- countryCode: z.string().optional(),
18
- state: z.string().optional(),
19
- city: z.string().optional(),
20
- language: z.string().optional(),
21
- languageCodes: z.array(z.string()).optional(),
22
- tags: z.array(z.string()),
23
- codec: z.string().optional(),
24
- bitrate: z.number().optional(),
25
- homepage: z.string().optional(),
26
- favicon: z.string().optional(),
27
- streamUrl: z.string().optional(),
28
- clickCount: z.number().optional(),
29
- votes: z.number().optional(),
30
- latitude: z.number().optional(),
31
- longitude: z.number().optional(),
32
- distanceKm: z.number().optional(),
33
- hls: z.boolean().optional(),
34
- lastCheckedOk: z.boolean().optional()
35
- });
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';
36
15
  const defaultMediaKeys = {
37
16
  previous: [],
38
17
  playPause: [],
39
18
  next: []
40
19
  };
41
20
  const MAX_ALARMS = 500;
21
+ // Freeze the 0.2.5 enum independently of UI ordering. New styles must stay out
22
+ // of both its shared library and its strictly validated .receiver.json sidecar.
23
+ // The new namespace is ignored by old playback/MCP readers and writers.
24
+ const sharedLibraryReceiverStyles = new Set([
25
+ 'ultracode', 'pulse-grid', 'hex-pulse', 'moire', 'galaxy', 'cyclone', 'nebula', 'lava-lamp',
26
+ 'gallop-fine', 'motion-contour', 'ribbon', 'braille-wave', 'silk', 'lissajous', 'harmonograph',
27
+ 'spirograph', 'goniometer', 'radial-eq', 'spectrogram', 'caustics', 'ripple-tank', 'leds',
28
+ 'matrix', 'cascade', 'ordered-dither', 'xor-texture', 'pixel-crush', 'copper-bars',
29
+ 'rotozoomer', 'mesh', 'hologram', 'equalizer', 'tunnel', 'kaleidoscope', 'phosphene',
30
+ 'phyllotaxis', 'chladni', 'newton', 'cube', 'spinning-donut', 'tesseract', 'torus-knot',
31
+ 'twister', 'lorenz', 'fern', 'fractal-tree', 'julia', 'neural-net', 'constellation',
32
+ 'starfield', 'fireworks', 'fire', 'aurora', 'stormfront', 'planetarium', 'sumi-ocean',
33
+ 'liftoff', 'flyover', 'skyline', 'golden-gate', 'manhattan', 'alpine', 'crossfade'
34
+ ]);
35
+ const receiverPreferenceSchema = z.object({
36
+ version: z.literal(1),
37
+ receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames)),
38
+ libraryReceiverStyle: z.literal('ultracode')
39
+ });
40
+ // Older playback/MCP processes validate this original enum on every refresh.
41
+ // New colors must stay out of their shared JSON, even after unrelated saves.
42
+ const sharedLibraryThemes = new Set([
43
+ 'green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper',
44
+ 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'
45
+ ]);
46
+ const themePreferenceSchema = z.object({
47
+ version: z.literal(1),
48
+ theme: z.enum(themeNames),
49
+ libraryTheme: z.literal('green')
50
+ });
42
51
  const settingsSchema = z.object({
43
52
  theme: z.enum(themeNames).default('green'),
44
53
  receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
@@ -49,6 +58,7 @@ const settingsSchema = z.object({
49
58
  shareDirectoryVotes: z.boolean().default(true),
50
59
  preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
51
60
  preferredAirPlayDevice: z.string().min(1).optional(),
61
+ linuxAirPlayEnabled: z.boolean().optional(),
52
62
  tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
53
63
  skipBrokenStreams: z.boolean().default(true),
54
64
  mediaKeys: z
@@ -64,6 +74,7 @@ const settingsSchema = z.object({
64
74
  reduceMotion: z.boolean().default(false),
65
75
  mouseSupport: z.boolean().default(true),
66
76
  automaticUpdateChecks: z.boolean().default(true),
77
+ desktopMediaControls: z.enum(['auto', 'on', 'off']).default('auto'),
67
78
  agentControl: z.object({
68
79
  enabled: z.boolean().default(false),
69
80
  openUiOnPlay: z.boolean().default(true),
@@ -167,6 +178,10 @@ const librarySchema = z.object({
167
178
  }))
168
179
  .default([]),
169
180
  favorites: z.array(stationSchema).default([]),
181
+ presets: z.array(z.object({
182
+ name: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,39}$/),
183
+ station: stationSchema
184
+ })).max(200).default([]),
170
185
  imported: z.array(stationSchema).default([]),
171
186
  trackHistory: z
172
187
  .array(z.object({
@@ -235,16 +250,71 @@ export class JsonLibraryStore {
235
250
  state;
236
251
  idGenerator;
237
252
  now;
253
+ revision = '';
238
254
  constructor(filePath = defaultLibraryPath(), options = {}) {
239
255
  this.filePath = filePath;
240
256
  this.idGenerator = options.idGenerator ?? randomUUID;
241
257
  this.now = options.now ?? (() => new Date());
258
+ this.revision = this.diskRevision();
242
259
  this.state = this.read();
260
+ // Normalize libraries saved by an earlier preview before any legacy
261
+ // background reader encounters its new enum value again.
262
+ if ((!sharedLibraryReceiverStyles.has(this.state.settings.receiverStyle) && !existsSync(this.receiverPreferenceV2Path())) ||
263
+ (!sharedLibraryThemes.has(this.state.settings.theme) && !existsSync(this.themePreferencePath())))
264
+ this.commit(this.state);
243
265
  }
244
266
  snapshot() {
267
+ this.refresh();
268
+ return this.cachedSnapshot();
269
+ }
270
+ cachedSnapshot() {
245
271
  return structuredClone(this.state);
246
272
  }
273
+ /** Refresh atomically replaced files without recovering a live listening session. */
274
+ refresh() {
275
+ const revision = this.diskRevision();
276
+ if (revision === this.revision)
277
+ return false;
278
+ const next = this.readForCurrentSession();
279
+ this.state = next;
280
+ this.revision = revision;
281
+ return true;
282
+ }
283
+ settings() {
284
+ this.refresh();
285
+ return structuredClone(this.state.settings);
286
+ }
287
+ diskRevision() {
288
+ return [this.filePath, this.receiverPreferencePath(), this.receiverPreferenceV2Path(), this.themePreferencePath()].map(file => {
289
+ try {
290
+ const info = statSync(file, { bigint: true });
291
+ return `${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`;
292
+ }
293
+ catch (error) {
294
+ if (error.code === 'ENOENT')
295
+ return 'missing';
296
+ throw storageError('read the RadioCLI library', error);
297
+ }
298
+ }).join('|');
299
+ }
300
+ savePreset(name, station) {
301
+ this.refresh();
302
+ const key = normalizePresetName(name);
303
+ const presets = this.state.presets ?? [];
304
+ if (presets.length >= 200 && !presets.some(preset => preset.name === key))
305
+ throw new Error('RadioCLI supports at most 200 station presets. Remove one with radiocli preset remove <name>.');
306
+ this.commit({ ...this.state, presets: [{ name: key, station }, ...presets.filter(preset => preset.name !== key)] });
307
+ }
308
+ removePreset(name) {
309
+ this.refresh();
310
+ const key = normalizePresetName(name);
311
+ const presets = this.state.presets ?? [];
312
+ if (!presets.some(preset => preset.name === key))
313
+ throw new Error(`No preset named ${key}. Run radiocli presets to see saved names.`);
314
+ this.commit({ ...this.state, presets: presets.filter(preset => preset.name !== key) });
315
+ }
247
316
  exportBackup(requestedPath) {
317
+ this.refresh();
248
318
  const targetPath = requestedPath?.trim()
249
319
  ? resolveUserPath(requestedPath)
250
320
  : resolve(process.cwd(), `radiocli-backup-${fileTimestamp(new Date())}.json`);
@@ -284,7 +354,9 @@ export class JsonLibraryStore {
284
354
  ? `${this.filePath}.before-import-${fileTimestamp(new Date())}.bak`
285
355
  : undefined;
286
356
  if (safetyBackupPath) {
287
- writeJsonAtomically(safetyBackupPath, libraryStateForDisk(this.state));
357
+ // Another TUI or background player may have saved since this owner
358
+ // last refreshed. Preserve its latest data while holding the lock.
359
+ writeJsonAtomically(safetyBackupPath, libraryStateForDisk(this.read()));
288
360
  }
289
361
  this.write(importedState);
290
362
  this.state = importedState;
@@ -295,6 +367,7 @@ export class JsonLibraryStore {
295
367
  }
296
368
  }
297
369
  updateSettings(settings) {
370
+ this.refresh();
298
371
  return this.commit({
299
372
  ...this.state,
300
373
  settings: {
@@ -305,12 +378,13 @@ export class JsonLibraryStore {
305
378
  });
306
379
  }
307
380
  updateCheckState(updateCheck) {
381
+ this.refresh();
308
382
  return this.commit({ ...this.state, updateCheck });
309
383
  }
310
384
  addAlarm(input) {
311
385
  const release = acquireStoreLock(this.filePath);
312
386
  try {
313
- const latestState = this.read();
387
+ const latestState = this.readForCurrentSession();
314
388
  if (latestState.alarms.length >= MAX_ALARMS) {
315
389
  throw new Error(`RadioCLI supports at most ${MAX_ALARMS} alarms.`);
316
390
  }
@@ -352,7 +426,7 @@ export class JsonLibraryStore {
352
426
  removeAlarm(id) {
353
427
  const release = acquireStoreLock(this.filePath);
354
428
  try {
355
- const latestState = this.read();
429
+ const latestState = this.readForCurrentSession();
356
430
  if (!latestState.alarms.some(alarm => alarm.id === id)) {
357
431
  this.state = latestState;
358
432
  return false;
@@ -374,10 +448,12 @@ export class JsonLibraryStore {
374
448
  }));
375
449
  }
376
450
  getAlarm(id) {
451
+ this.refresh();
377
452
  const alarm = this.state.alarms.find(item => item.id === id);
378
453
  return alarm ? structuredClone(alarm) : undefined;
379
454
  }
380
455
  listAlarms() {
456
+ this.refresh();
381
457
  return structuredClone(this.state.alarms);
382
458
  }
383
459
  recordAlarmOutcome(id, outcome, options = {}) {
@@ -409,6 +485,7 @@ export class JsonLibraryStore {
409
485
  }));
410
486
  }
411
487
  addRecent(station) {
488
+ this.refresh();
412
489
  const key = stationKey(station);
413
490
  const recent = [
414
491
  { station, playedAt: new Date().toISOString() },
@@ -419,9 +496,10 @@ export class JsonLibraryStore {
419
496
  // Persist the ICY track titles a station announces so "what was that song?"
420
497
  // is answerable later. Consecutive duplicates on the same station are skipped.
421
498
  recordTrack(station, title) {
499
+ this.refresh();
422
500
  const cleaned = title.trim();
423
501
  if (!cleaned) {
424
- return this.snapshot();
502
+ return this.cachedSnapshot();
425
503
  }
426
504
  const key = stationKey(station);
427
505
  const last = this.state.trackHistory[0];
@@ -438,6 +516,7 @@ export class JsonLibraryStore {
438
516
  }
439
517
  // Most-recent-first search queries for up-arrow recall in the search box.
440
518
  addSearch(query) {
519
+ this.refresh();
441
520
  const cleaned = query.trim();
442
521
  if (!cleaned) {
443
522
  return this.snapshot();
@@ -446,6 +525,7 @@ export class JsonLibraryStore {
446
525
  return this.commit({ ...this.state, searchHistory });
447
526
  }
448
527
  startListeningSession(station, startedAt = new Date()) {
528
+ this.refresh();
449
529
  const finishedState = recoverActiveListeningSessionInState(this.state);
450
530
  const session = {
451
531
  id: `${startedAt.toISOString()}-${stationKey(station)}`,
@@ -462,6 +542,7 @@ export class JsonLibraryStore {
462
542
  });
463
543
  }
464
544
  checkpointActiveListeningSession(activeAt = new Date()) {
545
+ this.refresh();
465
546
  const active = this.state.activity.sessions[0];
466
547
  if (!active || active.endedAt) {
467
548
  return this.snapshot();
@@ -482,6 +563,7 @@ export class JsonLibraryStore {
482
563
  });
483
564
  }
484
565
  finishActiveListeningSession(endedAt = new Date()) {
566
+ this.refresh();
485
567
  const nextState = finishActiveListeningSessionInState(this.state, endedAt);
486
568
  if (nextState === this.state) {
487
569
  return this.snapshot();
@@ -489,6 +571,7 @@ export class JsonLibraryStore {
489
571
  return this.commit(nextState);
490
572
  }
491
573
  toggleFavorite(station) {
574
+ this.refresh();
492
575
  const key = stationKey(station);
493
576
  const exists = this.state.favorites.some(item => stationKey(item) === key);
494
577
  const favorites = exists
@@ -497,6 +580,7 @@ export class JsonLibraryStore {
497
580
  return this.commit({ ...this.state, favorites });
498
581
  }
499
582
  addImported(stations) {
583
+ this.refresh();
500
584
  const incoming = [];
501
585
  const incomingKeys = new Set();
502
586
  for (const station of stations) {
@@ -518,13 +602,14 @@ export class JsonLibraryStore {
518
602
  if (!station) {
519
603
  return false;
520
604
  }
605
+ this.refresh();
521
606
  const key = stationKey(station);
522
607
  return this.state.favorites.some(item => stationKey(item) === key);
523
608
  }
524
609
  mutateAlarm(id, change) {
525
610
  const release = acquireStoreLock(this.filePath);
526
611
  try {
527
- const latestState = this.read();
612
+ const latestState = this.readForCurrentSession();
528
613
  const existing = latestState.alarms.find(alarm => alarm.id === id);
529
614
  if (!existing) {
530
615
  this.state = latestState;
@@ -553,10 +638,17 @@ export class JsonLibraryStore {
553
638
  return defaultState();
554
639
  throw storageError('read the RadioCLI library', error);
555
640
  }
641
+ let state;
556
642
  try {
557
- return migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
643
+ state = migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
558
644
  }
559
- catch {
645
+ catch (error) {
646
+ if (error instanceof z.ZodError && error.issues.some(issue => issue.code === 'invalid_value' && issue.path.length === 2 &&
647
+ issue.path[0] === 'settings' && (issue.path[1] === 'receiverStyle' || issue.path[1] === 'theme'))) {
648
+ // Another running build may have saved a newer appearance. Its library
649
+ // is intact: never quarantine it or replace it with empty defaults.
650
+ throw new Error('The RadioCLI library uses a display color or receiver style this process does not support. Restart RadioCLI and its MCP clients to load the updated colors and styles.');
651
+ }
560
652
  try {
561
653
  assertStorageWritable(this.filePath);
562
654
  renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`);
@@ -567,15 +659,66 @@ export class JsonLibraryStore {
567
659
  }
568
660
  return defaultState();
569
661
  }
662
+ // A legacy writer can still change ordinary preferences and favorites. Its
663
+ // fallback style must not erase the selection saved by an updated process.
664
+ // The newer namespace wins over a leftover preview preference. Continue
665
+ // reading the old namespace when no newer selection has been saved.
666
+ for (const path of [this.receiverPreferenceV2Path(), this.receiverPreferencePath()]) {
667
+ let preference;
668
+ try {
669
+ preference = readFileSync(path, 'utf8');
670
+ }
671
+ catch (error) {
672
+ if (error.code === 'ENOENT')
673
+ continue;
674
+ throw storageError('read the receiver preference', error);
675
+ }
676
+ const parsed = receiverPreferenceSchema.safeParse(JSON.parse(preference));
677
+ if (!parsed.success)
678
+ 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.');
679
+ if (state.settings.receiverStyle === parsed.data.libraryReceiverStyle)
680
+ state.settings.receiverStyle = parsed.data.receiverStyle;
681
+ break;
682
+ }
683
+ return this.readThemePreference(state);
684
+ }
685
+ receiverPreferencePath() { return `${this.filePath}.receiver.json`; }
686
+ receiverPreferenceV2Path() { return `${this.filePath}.receiver-v2.json`; }
687
+ themePreferencePath() { return `${this.filePath}.theme.json`; }
688
+ readThemePreference(state) {
689
+ let preference;
690
+ try {
691
+ preference = readFileSync(this.themePreferencePath(), 'utf8');
692
+ }
693
+ catch (error) {
694
+ if (error.code === 'ENOENT')
695
+ return state;
696
+ throw storageError('read the display color preference', error);
697
+ }
698
+ const parsed = themePreferenceSchema.safeParse(JSON.parse(preference));
699
+ if (!parsed.success)
700
+ throw new Error('The RadioCLI display color preference is unsupported or invalid. Restart RadioCLI and its MCP clients with the latest version. Your library has been preserved.');
701
+ if (state.settings.theme === parsed.data.libraryTheme)
702
+ state.settings.theme = parsed.data.theme;
703
+ return state;
704
+ }
705
+ readForCurrentSession() {
706
+ const next = this.read();
707
+ // Disk intentionally stores auto instead of reopening a receiver on launch.
708
+ // Within this owner, unrelated durable changes retain its selected output.
709
+ if (this.state.settings.preferredBackend === 'airplay' && next.settings.preferredBackend === 'auto') {
710
+ next.settings.preferredBackend = 'airplay';
711
+ }
712
+ return next;
570
713
  }
571
714
  commit(nextState) {
572
715
  const release = acquireStoreLock(this.filePath);
573
716
  try {
574
- const latestState = this.read();
717
+ const latestState = this.readForCurrentSession();
575
718
  const mergedState = mergeLibraryChanges(this.state, latestState, nextState);
576
719
  this.write(mergedState);
577
720
  this.state = mergedState;
578
- return this.snapshot();
721
+ return this.cachedSnapshot();
579
722
  }
580
723
  finally {
581
724
  release();
@@ -585,7 +728,67 @@ export class JsonLibraryStore {
585
728
  try {
586
729
  assertStorageWritable(this.filePath);
587
730
  mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
588
- writeJsonAtomically(this.filePath, libraryStateForDisk(state));
731
+ const diskState = libraryStateForDisk(state);
732
+ const separateReceiver = !sharedLibraryReceiverStyles.has(state.settings.receiverStyle);
733
+ const separateTheme = !sharedLibraryThemes.has(state.settings.theme);
734
+ const preferences = [
735
+ {
736
+ path: this.receiverPreferenceV2Path(),
737
+ next: separateReceiver
738
+ ? `${JSON.stringify({ version: 1, receiverStyle: state.settings.receiverStyle, libraryReceiverStyle: 'ultracode' }, null, 2)}\n`
739
+ : undefined
740
+ },
741
+ { path: this.receiverPreferencePath(), next: undefined },
742
+ {
743
+ path: this.themePreferencePath(),
744
+ next: separateTheme
745
+ ? `${JSON.stringify({ version: 1, theme: state.settings.theme, libraryTheme: 'green' }, null, 2)}\n`
746
+ : undefined
747
+ }
748
+ ].map(preference => {
749
+ let previous;
750
+ try {
751
+ previous = readFileSync(preference.path, 'utf8');
752
+ }
753
+ catch (error) {
754
+ if (error.code !== 'ENOENT')
755
+ throw error;
756
+ }
757
+ return { ...preference, previous };
758
+ }).filter(preference => preference.previous !== preference.next);
759
+ const applied = [];
760
+ try {
761
+ for (const preference of preferences) {
762
+ if (preference.next === undefined)
763
+ rmSync(preference.path);
764
+ else
765
+ writeTextAtomically(preference.path, preference.next);
766
+ applied.push(preference);
767
+ }
768
+ writeJsonAtomically(this.filePath, {
769
+ ...diskState,
770
+ settings: {
771
+ ...diskState.settings,
772
+ ...(separateReceiver ? { receiverStyle: 'ultracode' } : {}),
773
+ ...(separateTheme ? { theme: 'green' } : {})
774
+ }
775
+ });
776
+ }
777
+ catch (error) {
778
+ for (const preference of applied.reverse()) {
779
+ if (preference.previous === undefined)
780
+ rmSync(preference.path, { force: true });
781
+ else
782
+ writeTextAtomically(preference.path, preference.previous);
783
+ }
784
+ throw error;
785
+ }
786
+ try {
787
+ this.revision = this.diskRevision();
788
+ }
789
+ catch {
790
+ this.revision = '';
791
+ }
589
792
  }
590
793
  catch (error) {
591
794
  throw storageError('write the RadioCLI library', error);
@@ -593,7 +796,7 @@ export class JsonLibraryStore {
593
796
  }
594
797
  }
595
798
  export function stationKey(station) {
596
- return `${station.provider}:${station.id}`;
799
+ return `${station.provider}:${migrateStationIdentity(station).id}`;
597
800
  }
598
801
  function finishActiveListeningSessionInState(state, endedAt) {
599
802
  const active = state.activity.sessions[0];
@@ -630,8 +833,8 @@ function recoverActiveListeningSessionInState(state) {
630
833
  : Date.now();
631
834
  return finishActiveListeningSessionInState(state, new Date(recoveredEnd));
632
835
  }
633
- export function defaultLibraryPath() {
634
- const { library, legacyLibrary } = platformPaths();
836
+ export function defaultLibraryPath(options = {}) {
837
+ const { library, legacyLibrary } = platformPaths(options);
635
838
  for (const filePath of [library, legacyLibrary]) {
636
839
  try {
637
840
  statSync(filePath);
@@ -662,6 +865,7 @@ function defaultState() {
662
865
  return {
663
866
  recent: [],
664
867
  favorites: [],
868
+ presets: [],
665
869
  imported: [],
666
870
  trackHistory: [],
667
871
  searchHistory: [],
@@ -691,9 +895,32 @@ function defaultState() {
691
895
  };
692
896
  }
693
897
  function migrateLibraryState(state) {
898
+ const aliases = new Map();
899
+ const migrate = (station) => {
900
+ const next = migrateStationIdentity(station);
901
+ if (next.id !== station.id) {
902
+ const key = `${station.provider}:${station.id}`;
903
+ const targets = aliases.get(key) ?? new Set();
904
+ targets.add(stationKey(next));
905
+ aliases.set(key, targets);
906
+ }
907
+ return next;
908
+ };
909
+ const recent = state.recent.map(item => ({ ...item, station: migrate(item.station) }));
910
+ const favorites = state.favorites.map(migrate);
911
+ const imported = state.imported.map(migrate);
912
+ const presets = state.presets?.map(item => ({ ...item, station: migrate(item.station) }));
913
+ const sessions = state.activity.sessions.map(item => ({ ...item, station: migrate(item.station) }));
914
+ const alarms = state.alarms.map(alarm => normalizeAlarm({ ...alarm, station: migrate(alarm.station),
915
+ playback: { ...alarm.playback, ...(alarm.playback.fallbackStation ? { fallbackStation: migrate(alarm.playback.fallbackStation) } : {}) } }));
694
916
  return {
695
917
  ...state,
696
- alarms: state.alarms.map(normalizeAlarm),
918
+ recent, favorites, imported, presets, alarms,
919
+ activity: { sessions },
920
+ trackHistory: state.trackHistory.map(item => {
921
+ const targets = aliases.get(item.stationKey);
922
+ return targets?.size === 1 ? { ...item, stationKey: [...targets][0] } : item;
923
+ }),
697
924
  settings: {
698
925
  ...state.settings,
699
926
  preferredBackend: state.settings.preferredBackend === 'airplay' ? 'auto' : state.settings.preferredBackend,
@@ -718,14 +945,27 @@ function libraryStateForDisk(state) {
718
945
  : state;
719
946
  }
720
947
  function writeJsonAtomically(filePath, value) {
948
+ writeTextAtomically(filePath, `${JSON.stringify(value, null, 2)}\n`);
949
+ }
950
+ function writeTextAtomically(filePath, text) {
721
951
  const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
722
952
  try {
723
- writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
724
- if (process.platform !== 'win32') {
725
- chmodSync(tempPath, 0o600);
953
+ const fd = openSync(tempPath, 'wx', 0o600);
954
+ try {
955
+ writeFileSync(fd, text, 'utf8');
956
+ if (process.platform !== 'win32')
957
+ chmodSync(tempPath, 0o600);
958
+ fsyncSync(fd);
959
+ }
960
+ catch (error) {
961
+ try {
962
+ closeSync(fd);
963
+ }
964
+ catch { /* Preserve the original persistence error. */ }
965
+ throw error;
726
966
  }
727
- // No fallible persistence steps follow the atomic replacement.
728
- renameSync(tempPath, filePath);
967
+ closeSync(fd);
968
+ replaceFileSync(tempPath, filePath);
729
969
  }
730
970
  catch (error) {
731
971
  try {
@@ -734,6 +974,23 @@ function writeJsonAtomically(filePath, value) {
734
974
  catch { /* Preserve the original write failure. */ }
735
975
  throw storageError('write RadioCLI data', error);
736
976
  }
977
+ // Flush the replacement directory where supported. Once rename succeeds the
978
+ // transaction is committed: a directory-sync failure cannot roll it back or
979
+ // leave the caller's in-memory state describing the previous file. Windows
980
+ // and some network/removable filesystems do not support syncing directories.
981
+ let directoryFd;
982
+ try {
983
+ directoryFd = openSync(dirname(filePath), 'r');
984
+ fsyncSync(directoryFd);
985
+ }
986
+ catch { /* The file itself was flushed before replacement. */ }
987
+ finally {
988
+ if (directoryFd !== undefined)
989
+ try {
990
+ closeSync(directoryFd);
991
+ }
992
+ catch { /* Already committed. */ }
993
+ }
737
994
  }
738
995
  function acquireStoreLock(filePath) {
739
996
  const lockPath = `${filePath}.lock`;
@@ -744,37 +1001,11 @@ function acquireStoreLock(filePath) {
744
1001
  catch (error) {
745
1002
  throw storageError('write the RadioCLI library', error);
746
1003
  }
747
- const deadline = Date.now() + 1000;
748
- while (true) {
749
- try {
750
- mkdirSync(lockPath, { mode: 0o700 });
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
- };
758
- }
759
- catch (error) {
760
- const code = error.code;
761
- if (code !== 'EEXIST')
762
- throw storageError('lock the RadioCLI library', error);
763
- try {
764
- if (Date.now() - statSync(lockPath).mtimeMs > 10_000) {
765
- rmSync(lockPath, { recursive: true, force: true });
766
- }
767
- }
768
- catch (lockError) {
769
- if (lockError.code !== 'ENOENT') {
770
- throw storageError('lock the RadioCLI library', lockError);
771
- }
772
- }
773
- if (Date.now() >= deadline) {
774
- throw storageError('lock the RadioCLI library', Object.assign(new Error('Another writer holds the library lock.'), { code: 'EBUSY', cause: error }));
775
- }
776
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
777
- }
1004
+ try {
1005
+ return acquireFileLock(lockPath);
1006
+ }
1007
+ catch (error) {
1008
+ throw storageError('lock the RadioCLI library', error);
778
1009
  }
779
1010
  }
780
1011
  class LibraryStorageError extends Error {
@@ -794,10 +1025,17 @@ class LibraryStorageError extends Error {
794
1025
  function storageError(operation, cause) {
795
1026
  return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause);
796
1027
  }
1028
+ function normalizePresetName(name) {
1029
+ const key = name.trim().toLowerCase();
1030
+ if (!/^[a-z0-9][a-z0-9_-]{0,39}$/.test(key))
1031
+ throw new Error('Preset names must be 1-40 letters, numbers, hyphens, or underscores, starting with a letter or number.');
1032
+ return key;
1033
+ }
797
1034
  function mergeLibraryChanges(base, disk, next) {
798
1035
  return {
799
1036
  recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50),
800
1037
  favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true),
1038
+ presets: mergeKeyedChanges(base.presets ?? [], disk.presets ?? [], next.presets ?? [], preset => preset.name, 200, true),
801
1039
  imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true, true),
802
1040
  trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
803
1041
  searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
package/dist/types.js CHANGED
@@ -1,7 +1,28 @@
1
1
  import { defaultReceiverStyle, receiverStyleNames } from './ui/visualizers/receiver-style-registry.js';
2
2
  export { defaultReceiverStyle, receiverStyleNames };
3
3
  const providerIds = ['radio-browser', 'radio-garden', 'playlist'];
4
- export const themeNames = ['green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper', 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'];
4
+ export const themeNames = [
5
+ // Reds
6
+ 'vermilion', 'scarlet',
7
+ // Oranges and earth tones
8
+ 'cocoa', 'copper', 'tangerine', 'blaze-orange', 'coral', 'apricot',
9
+ // Golds and creams
10
+ 'bronze', 'amber', 'sunflower', 'butter', 'parchment', 'ivory',
11
+ // Yellow greens
12
+ 'olive', 'lime', 'chartreuse', 'acid-yellow', 'pistachio',
13
+ // Greens
14
+ 'pine', 'laser-green', 'green',
15
+ // Teals and cyans
16
+ 'midnight-teal', 'jade', 'teal', 'cyan', 'ice',
17
+ // Blues
18
+ 'cobalt', 'blue', 'slate', 'periwinkle', 'ultramarine', 'indigo',
19
+ // Purples and mauves
20
+ 'neon-violet', 'violet', 'aubergine', 'plum', 'heather', 'dusty-mauve',
21
+ // Magentas and pinks
22
+ 'hot-magenta', 'wineberry', 'raspberry', 'ruby', 'dracula-pink', 'rose', 'blush',
23
+ // Neutrals
24
+ 'soft-silver', 'mono'
25
+ ];
5
26
  export const defaultAgentControlSettings = {
6
27
  enabled: false,
7
28
  openUiOnPlay: true,