@ciphore/radiocli 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +70 -2
  2. package/CONTRIBUTING.md +10 -0
  3. package/README.md +108 -253
  4. package/SECURITY.md +23 -1
  5. package/dist/agent/alarm-service.js +10 -7
  6. package/dist/agent/cli.js +16 -1
  7. package/dist/agent/command-schema.js +47 -0
  8. package/dist/agent/headless-host.js +226 -81
  9. package/dist/agent/launcher.js +2 -1
  10. package/dist/agent/mcp-server.js +10 -5
  11. package/dist/agent/service.js +75 -40
  12. package/dist/agent/session.js +214 -24
  13. package/dist/agent/tui-session.js +213 -0
  14. package/dist/alarms/cli.js +19 -3
  15. package/dist/alarms/occurrence-cache.js +25 -0
  16. package/dist/alarms/runner.js +87 -23
  17. package/dist/alarms/schedule.js +29 -1
  18. package/dist/alarms/scheduler.js +11 -3
  19. package/dist/alarms/system-volume-linux.js +160 -0
  20. package/dist/alarms/system-volume-ownership.js +76 -8
  21. package/dist/alarms/system-volume.js +44 -23
  22. package/dist/cli/arguments.js +212 -0
  23. package/dist/cli/completion.js +44 -0
  24. package/dist/cli/doctor.js +156 -0
  25. package/dist/cli/interactive.js +79 -0
  26. package/dist/cli/listener.js +227 -0
  27. package/dist/cli/output.js +121 -0
  28. package/dist/cli/render-tui.js +34 -0
  29. package/dist/cli.js +178 -199
  30. package/dist/platform/capabilities.js +8 -1
  31. package/dist/platform/desktop-media.js +61 -0
  32. package/dist/platform/diagnostics.js +273 -0
  33. package/dist/platform/json-worker.js +111 -0
  34. package/dist/platform/launch-command.js +9 -6
  35. package/dist/platform/mpris.js +356 -0
  36. package/dist/platform/network.js +69 -1
  37. package/dist/platform/omarchy.js +147 -0
  38. package/dist/platform/packages.js +7 -1
  39. package/dist/platform/runtime.js +1 -1
  40. package/dist/platform/terminals.js +24 -4
  41. package/dist/player/airplay-discovery.js +144 -61
  42. package/dist/player/airplay-policy.js +15 -0
  43. package/dist/player/airplay-process.js +63 -0
  44. package/dist/player/airplay-readiness.js +42 -0
  45. package/dist/player/airplay-sender-health.js +49 -49
  46. package/dist/player/airplay-sender-package.js +5 -0
  47. package/dist/player/airplay-sender-patch.js +102 -4
  48. package/dist/player/airplay-setup.js +51 -0
  49. package/dist/player/airplay-worker.js +97 -41
  50. package/dist/player/backend-install.js +9 -6
  51. package/dist/player/ffmpeg-input.js +37 -0
  52. package/dist/player/mpv-desktop-media.js +194 -0
  53. package/dist/player/mpv-ipc-client.js +51 -2
  54. package/dist/player/player-controller.js +390 -73
  55. package/dist/player/radio-engine.js +282 -0
  56. package/dist/playlists/playlist.js +18 -16
  57. package/dist/providers/cache.js +291 -75
  58. package/dist/providers/discovery-session.js +111 -0
  59. package/dist/providers/provider-manager.js +106 -28
  60. package/dist/providers/radio-browser.js +272 -41
  61. package/dist/providers/radio-garden.js +4 -2
  62. package/dist/setup-playback.js +152 -0
  63. package/dist/setup.js +88 -21
  64. package/dist/storage/file-lock.js +113 -0
  65. package/dist/storage/read-settings.js +33 -0
  66. package/dist/storage/replace-file.js +20 -0
  67. package/dist/storage/repository.js +247 -0
  68. package/dist/storage/station-identity.js +21 -0
  69. package/dist/storage/station-schema.js +27 -0
  70. package/dist/storage/store.js +249 -76
  71. package/dist/ui/AdaptiveContent.js +18 -10
  72. package/dist/ui/App.js +708 -454
  73. package/dist/ui/AppContent.js +5 -5
  74. package/dist/ui/airplay-settings.js +12 -5
  75. package/dist/ui/bidi-ranges.js +45 -0
  76. package/dist/ui/components/AdaptiveMarquee.js +16 -9
  77. package/dist/ui/format.js +37 -4
  78. package/dist/ui/help-content.js +5 -2
  79. package/dist/ui/rtl-output.js +137 -0
  80. package/dist/ui/screen-items.js +10 -4
  81. package/dist/ui/screens/AirPlaySettingsScreen.js +3 -3
  82. package/dist/ui/screens/AlarmsScreen.js +5 -2
  83. package/dist/ui/screens/HelpScreen.js +3 -3
  84. package/dist/ui/screens/SettingsScreen.js +16 -16
  85. package/dist/ui/screens/StatsScreen.js +1 -1
  86. package/dist/ui/text-direction.js +9 -0
  87. package/dist/ui/use-alarm-tui.js +24 -24
  88. package/dist/ui/use-app-input.js +42 -8
  89. package/dist/ui/use-command-executor.js +21 -14
  90. package/dist/ui/visualizers/gallop.js +1 -1
  91. package/dist/ui/visualizers/receiver-style-registry.js +2 -1
  92. package/dist/update-check.js +2 -1
  93. package/docs/THIRD_PARTY_NOTICES.md +6 -4
  94. package/package.json +34 -11
  95. package/scripts/build-airplay-native.mjs +22 -0
  96. package/vendor/node-airtunes2/LICENSE +208 -0
  97. package/vendor/node-airtunes2/RADIOCLI_PATCH.md +29 -0
  98. package/vendor/node-airtunes2/README.md +225 -0
  99. package/vendor/node-airtunes2/UPSTREAM.json +50 -0
  100. package/vendor/node-airtunes2/binding.gyp +41 -0
  101. package/vendor/node-airtunes2/lib/ap2_test.js +4 -0
  102. package/vendor/node-airtunes2/lib/atv.js +307 -0
  103. package/vendor/node-airtunes2/lib/atvAuthenticator.js +136 -0
  104. package/vendor/node-airtunes2/lib/audio_out.js +69 -0
  105. package/vendor/node-airtunes2/lib/circular_buffer.js +133 -0
  106. package/vendor/node-airtunes2/lib/config.js +26 -0
  107. package/vendor/node-airtunes2/lib/device_airtunes.js +532 -0
  108. package/vendor/node-airtunes2/lib/devices.js +266 -0
  109. package/vendor/node-airtunes2/lib/ed25519.js +26 -0
  110. package/vendor/node-airtunes2/lib/homekit/credentials.js +86 -0
  111. package/vendor/node-airtunes2/lib/homekit/encryption.js +69 -0
  112. package/vendor/node-airtunes2/lib/homekit/number.js +44 -0
  113. package/vendor/node-airtunes2/lib/homekit/tlv.js +95 -0
  114. package/vendor/node-airtunes2/lib/http.js +189 -0
  115. package/vendor/node-airtunes2/lib/index.d.ts +141 -0
  116. package/vendor/node-airtunes2/lib/index.js +127 -0
  117. package/vendor/node-airtunes2/lib/ntp.js +37 -0
  118. package/vendor/node-airtunes2/lib/num_util.js +21 -0
  119. package/vendor/node-airtunes2/lib/packet_pool.js +43 -0
  120. package/vendor/node-airtunes2/lib/rtsp.js +1807 -0
  121. package/vendor/node-airtunes2/lib/srp.js +158 -0
  122. package/vendor/node-airtunes2/lib/udp_servers.js +214 -0
  123. package/vendor/node-airtunes2/lib/util.js +12 -0
  124. package/vendor/node-airtunes2/package.json +31 -0
  125. package/vendor/node-airtunes2/src/CADebugMacros.h +257 -0
  126. package/vendor/node-airtunes2/src/CAHostTimeBase.cpp +105 -0
  127. package/vendor/node-airtunes2/src/CAHostTimeBase.h +224 -0
  128. package/vendor/node-airtunes2/src/aes_utils.c +864 -0
  129. package/vendor/node-airtunes2/src/aes_utils.h +24 -0
  130. package/vendor/node-airtunes2/src/base64.c +136 -0
  131. package/vendor/node-airtunes2/src/base64.h +42 -0
  132. package/vendor/node-airtunes2/src/bindings.cc +29 -0
  133. package/vendor/node-airtunes2/src/codec.cc +170 -0
  134. package/vendor/node-airtunes2/src/coreaudio.cc +332 -0
@@ -1,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,26 @@ 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
+ // 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
+ });
42
31
  const settingsSchema = z.object({
43
32
  theme: z.enum(themeNames).default('green'),
44
33
  receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
@@ -49,6 +38,7 @@ const settingsSchema = z.object({
49
38
  shareDirectoryVotes: z.boolean().default(true),
50
39
  preferredBackend: z.enum(['auto', 'mpv', 'ffplay', 'vlc', 'airplay']).default('auto'),
51
40
  preferredAirPlayDevice: z.string().min(1).optional(),
41
+ linuxAirPlayEnabled: z.boolean().optional(),
52
42
  tuneTimeoutSeconds: z.number().min(3).max(45).default(12),
53
43
  skipBrokenStreams: z.boolean().default(true),
54
44
  mediaKeys: z
@@ -64,6 +54,7 @@ const settingsSchema = z.object({
64
54
  reduceMotion: z.boolean().default(false),
65
55
  mouseSupport: z.boolean().default(true),
66
56
  automaticUpdateChecks: z.boolean().default(true),
57
+ desktopMediaControls: z.enum(['auto', 'on', 'off']).default('auto'),
67
58
  agentControl: z.object({
68
59
  enabled: z.boolean().default(false),
69
60
  openUiOnPlay: z.boolean().default(true),
@@ -167,6 +158,10 @@ const librarySchema = z.object({
167
158
  }))
168
159
  .default([]),
169
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([]),
170
165
  imported: z.array(stationSchema).default([]),
171
166
  trackHistory: z
172
167
  .array(z.object({
@@ -235,16 +230,70 @@ export class JsonLibraryStore {
235
230
  state;
236
231
  idGenerator;
237
232
  now;
233
+ revision = '';
238
234
  constructor(filePath = defaultLibraryPath(), options = {}) {
239
235
  this.filePath = filePath;
240
236
  this.idGenerator = options.idGenerator ?? randomUUID;
241
237
  this.now = options.now ?? (() => new Date());
238
+ this.revision = this.diskRevision();
242
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);
243
244
  }
244
245
  snapshot() {
246
+ this.refresh();
247
+ return this.cachedSnapshot();
248
+ }
249
+ cachedSnapshot() {
245
250
  return structuredClone(this.state);
246
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
+ }
247
295
  exportBackup(requestedPath) {
296
+ this.refresh();
248
297
  const targetPath = requestedPath?.trim()
249
298
  ? resolveUserPath(requestedPath)
250
299
  : resolve(process.cwd(), `radiocli-backup-${fileTimestamp(new Date())}.json`);
@@ -284,7 +333,9 @@ export class JsonLibraryStore {
284
333
  ? `${this.filePath}.before-import-${fileTimestamp(new Date())}.bak`
285
334
  : undefined;
286
335
  if (safetyBackupPath) {
287
- 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()));
288
339
  }
289
340
  this.write(importedState);
290
341
  this.state = importedState;
@@ -295,6 +346,7 @@ export class JsonLibraryStore {
295
346
  }
296
347
  }
297
348
  updateSettings(settings) {
349
+ this.refresh();
298
350
  return this.commit({
299
351
  ...this.state,
300
352
  settings: {
@@ -305,12 +357,13 @@ export class JsonLibraryStore {
305
357
  });
306
358
  }
307
359
  updateCheckState(updateCheck) {
360
+ this.refresh();
308
361
  return this.commit({ ...this.state, updateCheck });
309
362
  }
310
363
  addAlarm(input) {
311
364
  const release = acquireStoreLock(this.filePath);
312
365
  try {
313
- const latestState = this.read();
366
+ const latestState = this.readForCurrentSession();
314
367
  if (latestState.alarms.length >= MAX_ALARMS) {
315
368
  throw new Error(`RadioCLI supports at most ${MAX_ALARMS} alarms.`);
316
369
  }
@@ -352,7 +405,7 @@ export class JsonLibraryStore {
352
405
  removeAlarm(id) {
353
406
  const release = acquireStoreLock(this.filePath);
354
407
  try {
355
- const latestState = this.read();
408
+ const latestState = this.readForCurrentSession();
356
409
  if (!latestState.alarms.some(alarm => alarm.id === id)) {
357
410
  this.state = latestState;
358
411
  return false;
@@ -374,10 +427,12 @@ export class JsonLibraryStore {
374
427
  }));
375
428
  }
376
429
  getAlarm(id) {
430
+ this.refresh();
377
431
  const alarm = this.state.alarms.find(item => item.id === id);
378
432
  return alarm ? structuredClone(alarm) : undefined;
379
433
  }
380
434
  listAlarms() {
435
+ this.refresh();
381
436
  return structuredClone(this.state.alarms);
382
437
  }
383
438
  recordAlarmOutcome(id, outcome, options = {}) {
@@ -409,6 +464,7 @@ export class JsonLibraryStore {
409
464
  }));
410
465
  }
411
466
  addRecent(station) {
467
+ this.refresh();
412
468
  const key = stationKey(station);
413
469
  const recent = [
414
470
  { station, playedAt: new Date().toISOString() },
@@ -419,9 +475,10 @@ export class JsonLibraryStore {
419
475
  // Persist the ICY track titles a station announces so "what was that song?"
420
476
  // is answerable later. Consecutive duplicates on the same station are skipped.
421
477
  recordTrack(station, title) {
478
+ this.refresh();
422
479
  const cleaned = title.trim();
423
480
  if (!cleaned) {
424
- return this.snapshot();
481
+ return this.cachedSnapshot();
425
482
  }
426
483
  const key = stationKey(station);
427
484
  const last = this.state.trackHistory[0];
@@ -438,6 +495,7 @@ export class JsonLibraryStore {
438
495
  }
439
496
  // Most-recent-first search queries for up-arrow recall in the search box.
440
497
  addSearch(query) {
498
+ this.refresh();
441
499
  const cleaned = query.trim();
442
500
  if (!cleaned) {
443
501
  return this.snapshot();
@@ -446,6 +504,7 @@ export class JsonLibraryStore {
446
504
  return this.commit({ ...this.state, searchHistory });
447
505
  }
448
506
  startListeningSession(station, startedAt = new Date()) {
507
+ this.refresh();
449
508
  const finishedState = recoverActiveListeningSessionInState(this.state);
450
509
  const session = {
451
510
  id: `${startedAt.toISOString()}-${stationKey(station)}`,
@@ -462,6 +521,7 @@ export class JsonLibraryStore {
462
521
  });
463
522
  }
464
523
  checkpointActiveListeningSession(activeAt = new Date()) {
524
+ this.refresh();
465
525
  const active = this.state.activity.sessions[0];
466
526
  if (!active || active.endedAt) {
467
527
  return this.snapshot();
@@ -482,6 +542,7 @@ export class JsonLibraryStore {
482
542
  });
483
543
  }
484
544
  finishActiveListeningSession(endedAt = new Date()) {
545
+ this.refresh();
485
546
  const nextState = finishActiveListeningSessionInState(this.state, endedAt);
486
547
  if (nextState === this.state) {
487
548
  return this.snapshot();
@@ -489,6 +550,7 @@ export class JsonLibraryStore {
489
550
  return this.commit(nextState);
490
551
  }
491
552
  toggleFavorite(station) {
553
+ this.refresh();
492
554
  const key = stationKey(station);
493
555
  const exists = this.state.favorites.some(item => stationKey(item) === key);
494
556
  const favorites = exists
@@ -497,6 +559,7 @@ export class JsonLibraryStore {
497
559
  return this.commit({ ...this.state, favorites });
498
560
  }
499
561
  addImported(stations) {
562
+ this.refresh();
500
563
  const incoming = [];
501
564
  const incomingKeys = new Set();
502
565
  for (const station of stations) {
@@ -518,13 +581,14 @@ export class JsonLibraryStore {
518
581
  if (!station) {
519
582
  return false;
520
583
  }
584
+ this.refresh();
521
585
  const key = stationKey(station);
522
586
  return this.state.favorites.some(item => stationKey(item) === key);
523
587
  }
524
588
  mutateAlarm(id, change) {
525
589
  const release = acquireStoreLock(this.filePath);
526
590
  try {
527
- const latestState = this.read();
591
+ const latestState = this.readForCurrentSession();
528
592
  const existing = latestState.alarms.find(alarm => alarm.id === id);
529
593
  if (!existing) {
530
594
  this.state = latestState;
@@ -553,10 +617,17 @@ export class JsonLibraryStore {
553
617
  return defaultState();
554
618
  throw storageError('read the RadioCLI library', error);
555
619
  }
620
+ let state;
556
621
  try {
557
- return migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
622
+ state = migrateLibraryState(librarySchema.parse(JSON.parse(contents)));
558
623
  }
559
- catch {
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
+ }
560
631
  try {
561
632
  assertStorageWritable(this.filePath);
562
633
  renameSync(this.filePath, `${this.filePath}.bad-${Date.now()}-${randomUUID()}`);
@@ -567,15 +638,42 @@ export class JsonLibraryStore {
567
638
  }
568
639
  return defaultState();
569
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;
570
668
  }
571
669
  commit(nextState) {
572
670
  const release = acquireStoreLock(this.filePath);
573
671
  try {
574
- const latestState = this.read();
672
+ const latestState = this.readForCurrentSession();
575
673
  const mergedState = mergeLibraryChanges(this.state, latestState, nextState);
576
674
  this.write(mergedState);
577
675
  this.state = mergedState;
578
- return this.snapshot();
676
+ return this.cachedSnapshot();
579
677
  }
580
678
  finally {
581
679
  release();
@@ -585,7 +683,47 @@ export class JsonLibraryStore {
585
683
  try {
586
684
  assertStorageWritable(this.filePath);
587
685
  mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 });
588
- writeJsonAtomically(this.filePath, libraryStateForDisk(state));
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
+ }
589
727
  }
590
728
  catch (error) {
591
729
  throw storageError('write the RadioCLI library', error);
@@ -593,7 +731,7 @@ export class JsonLibraryStore {
593
731
  }
594
732
  }
595
733
  export function stationKey(station) {
596
- return `${station.provider}:${station.id}`;
734
+ return `${station.provider}:${migrateStationIdentity(station).id}`;
597
735
  }
598
736
  function finishActiveListeningSessionInState(state, endedAt) {
599
737
  const active = state.activity.sessions[0];
@@ -630,8 +768,8 @@ function recoverActiveListeningSessionInState(state) {
630
768
  : Date.now();
631
769
  return finishActiveListeningSessionInState(state, new Date(recoveredEnd));
632
770
  }
633
- export function defaultLibraryPath() {
634
- const { library, legacyLibrary } = platformPaths();
771
+ export function defaultLibraryPath(options = {}) {
772
+ const { library, legacyLibrary } = platformPaths(options);
635
773
  for (const filePath of [library, legacyLibrary]) {
636
774
  try {
637
775
  statSync(filePath);
@@ -662,6 +800,7 @@ function defaultState() {
662
800
  return {
663
801
  recent: [],
664
802
  favorites: [],
803
+ presets: [],
665
804
  imported: [],
666
805
  trackHistory: [],
667
806
  searchHistory: [],
@@ -691,9 +830,32 @@ function defaultState() {
691
830
  };
692
831
  }
693
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) } : {}) } }));
694
851
  return {
695
852
  ...state,
696
- 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
+ }),
697
859
  settings: {
698
860
  ...state.settings,
699
861
  preferredBackend: state.settings.preferredBackend === 'airplay' ? 'auto' : state.settings.preferredBackend,
@@ -718,14 +880,27 @@ function libraryStateForDisk(state) {
718
880
  : state;
719
881
  }
720
882
  function writeJsonAtomically(filePath, value) {
883
+ writeTextAtomically(filePath, `${JSON.stringify(value, null, 2)}\n`);
884
+ }
885
+ function writeTextAtomically(filePath, text) {
721
886
  const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
722
887
  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);
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;
726
901
  }
727
- // No fallible persistence steps follow the atomic replacement.
728
- renameSync(tempPath, filePath);
902
+ closeSync(fd);
903
+ replaceFileSync(tempPath, filePath);
729
904
  }
730
905
  catch (error) {
731
906
  try {
@@ -734,6 +909,23 @@ function writeJsonAtomically(filePath, value) {
734
909
  catch { /* Preserve the original write failure. */ }
735
910
  throw storageError('write RadioCLI data', error);
736
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)
924
+ try {
925
+ closeSync(directoryFd);
926
+ }
927
+ catch { /* Already committed. */ }
928
+ }
737
929
  }
738
930
  function acquireStoreLock(filePath) {
739
931
  const lockPath = `${filePath}.lock`;
@@ -744,37 +936,11 @@ function acquireStoreLock(filePath) {
744
936
  catch (error) {
745
937
  throw storageError('write the RadioCLI library', error);
746
938
  }
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
- }
939
+ try {
940
+ return acquireFileLock(lockPath);
941
+ }
942
+ catch (error) {
943
+ throw storageError('lock the RadioCLI library', error);
778
944
  }
779
945
  }
780
946
  class LibraryStorageError extends Error {
@@ -794,10 +960,17 @@ class LibraryStorageError extends Error {
794
960
  function storageError(operation, cause) {
795
961
  return cause instanceof LibraryStorageError ? cause : new LibraryStorageError(operation, cause);
796
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;
968
+ }
797
969
  function mergeLibraryChanges(base, disk, next) {
798
970
  return {
799
971
  recent: mergeKeyedChanges(base.recent, disk.recent, next.recent, item => stationKey(item.station), 50),
800
972
  favorites: mergeKeyedChanges(base.favorites, disk.favorites, next.favorites, stationKey, 200, true),
973
+ presets: mergeKeyedChanges(base.presets ?? [], disk.presets ?? [], next.presets ?? [], preset => preset.name, 200, true),
801
974
  imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true, true),
802
975
  trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
803
976
  searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
@@ -5,9 +5,9 @@ import { computeListeningStats } from '../activity/stats.js';
5
5
  import { playbackBackendLabel } from '../player/backend-install.js';
6
6
  import { visibleWindow } from './list-window.js';
7
7
  import { displayWidth, padDisplayEnd, stationLocation, stationTags, stationTech, truncate } from './format.js';
8
- import { homeItems, settingsGroup, settingsGroups, settingsItemsForPage } from './screen-items.js';
8
+ import { homeItems, settingsGroup, settingsGroupsForAirPlay, settingsItemsForPage } from './screen-items.js';
9
9
  import { screenTitle } from './screen-meta.js';
10
- import { keyHelpSections, commandHelp } from './help-content.js';
10
+ import { keyHelpSections, commandHelpForAirPlay } from './help-content.js';
11
11
  import { settingLabel, settingValue } from './screens/SettingsScreen.js';
12
12
  import { exploreMapLand, mapMarker, panelBorder, textDim, textMuted, themeAccent } from './theme.js';
13
13
  import { panelBorderStyle, useDisplay } from './display-context.js';
@@ -55,9 +55,11 @@ function AdaptiveContentBody(props) {
55
55
  width,
56
56
  mode,
57
57
  ascii,
58
- airPlaySupported
58
+ airPlaySupported,
59
+ platform: props.platform
59
60
  });
60
61
  const empty = adaptiveEmptyState({
62
+ airPlaySetupRequired: props.platform === 'linux' && !backends.includes('airplay'),
61
63
  screen,
62
64
  searchQuery,
63
65
  editingSearch,
@@ -105,9 +107,11 @@ function AdaptiveContentBody(props) {
105
107
  width,
106
108
  mode,
107
109
  ascii,
108
- airPlaySupported
110
+ airPlaySupported,
111
+ platform: props.platform
109
112
  });
110
113
  const empty = adaptiveEmptyState({
114
+ airPlaySetupRequired: props.platform === 'linux' && !backends.includes('airplay'),
111
115
  screen,
112
116
  searchQuery,
113
117
  editingSearch,
@@ -273,15 +277,15 @@ function adaptiveRows(input) {
273
277
  }));
274
278
  }
275
279
  if (screen === 'settings') {
276
- const pageItems = settingsItemsForPage(input.settingsPage);
280
+ const pageItems = settingsItemsForPage(input.settingsPage, input.airPlaySupported);
277
281
  const labels = pageItems.map(item => settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported));
278
282
  const labelWidth = pairedColumnWidth(labels, width, mode);
279
283
  return pageItems.map((item, index) => ({
280
284
  key: item,
281
285
  label: padDisplayEnd(truncate(settingLabel(item, updateCheck, input.appVersion, input.airPlaySupported), labelWidth), labelWidth),
282
286
  detail: input.settingsPage === 'root'
283
- ? adaptiveSettingsRootValue(item)
284
- : settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion),
287
+ ? adaptiveSettingsRootValue(item, input.airPlaySupported)
288
+ : settingValue(item, library.settings, diagnostics, backends, airPlayDevices, updateCheck, input.appVersion, input.platform),
285
289
  separator: ' ',
286
290
  index
287
291
  }));
@@ -312,7 +316,7 @@ function adaptiveRows(input) {
312
316
  }
313
317
  }
314
318
  rows.push({ key: 'section-commands', label: 'Commands', heading: true });
315
- for (const command of commandHelp) {
319
+ for (const command of commandHelpForAirPlay(input.airPlaySupported)) {
316
320
  rows.push({ key: `command-${command.name}`, label: `:${command.name}${command.args ? ` ${command.args}` : ''}`, detail: command.description, index });
317
321
  index += 1;
318
322
  }
@@ -320,8 +324,8 @@ function adaptiveRows(input) {
320
324
  }
321
325
  return stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii);
322
326
  }
323
- function adaptiveSettingsRootValue(item) {
324
- const group = settingsGroups.find(candidate => candidate.label === item);
327
+ function adaptiveSettingsRootValue(item, airPlayVisible) {
328
+ const group = settingsGroupsForAirPlay(airPlayVisible).find(candidate => candidate.label === item);
325
329
  return group ? `${group.items.length} settings ›` : undefined;
326
330
  }
327
331
  function stationAdaptiveRows(stations, favoriteKeys, selected, width, ascii) {
@@ -378,6 +382,8 @@ function adaptiveEmptyState(input) {
378
382
  return [{ key: 'error', label: stationError }, { key: 'hint', label: 'Try again later or reopen this view.' }];
379
383
  }
380
384
  if (screen === 'airplay-settings') {
385
+ if (input.airPlaySetupRequired)
386
+ return [{ key: 'empty', label: 'Setup required' }, { key: 'hint', label: 'radiocli setup' }, { key: 'option', label: '--airplay' }];
381
387
  return [{ key: 'empty', label: 'No AirPlay receivers found.' }, { key: 'hint', label: 'Press r to scan again.' }];
382
388
  }
383
389
  return [{ key: 'empty', label: `No stations in ${stationTitle.toLowerCase()}.` }, { key: 'hint', label: 'Try another view or clear filters.' }];
@@ -415,6 +421,8 @@ function adaptiveStatus(props) {
415
421
  return playingStation ? playingStation.name : 'Receiver authentication';
416
422
  }
417
423
  if (screen === 'airplay-settings') {
424
+ if (props.platform === 'linux' && !props.backends.includes('airplay'))
425
+ return 'Setup required';
418
426
  return `${props.airPlayDevices.length} receivers · ${playbackBackendLabel(playback.backend)}`;
419
427
  }
420
428
  return `${stations.length.toLocaleString()} stations${playingStation ? ` · playing ${playingStation.name}` : ''}`;