@ciphore/radiocli 0.2.1 → 0.2.3

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 (72) hide show
  1. package/CHANGELOG.md +133 -1
  2. package/README.md +76 -6
  3. package/dist/agent/alarm-service.js +210 -0
  4. package/dist/agent/cli.js +193 -0
  5. package/dist/agent/headless-host.js +143 -0
  6. package/dist/agent/launcher.js +71 -0
  7. package/dist/agent/mcp-install.js +467 -0
  8. package/dist/agent/mcp-server.js +139 -0
  9. package/dist/agent/service.js +347 -0
  10. package/dist/agent/session.js +248 -0
  11. package/dist/alarms/active-session.js +183 -0
  12. package/dist/alarms/cli.js +312 -0
  13. package/dist/alarms/guard.js +343 -0
  14. package/dist/alarms/inhibitor.js +48 -0
  15. package/dist/alarms/power-guard-store.js +169 -0
  16. package/dist/alarms/runner.js +342 -0
  17. package/dist/alarms/runtime-health.js +79 -0
  18. package/dist/alarms/schedule.js +149 -0
  19. package/dist/alarms/scheduler.js +250 -0
  20. package/dist/alarms/setup-verification.js +187 -0
  21. package/dist/alarms/system-volume.js +43 -0
  22. package/dist/alarms/terminal-launcher.js +181 -0
  23. package/dist/alarms/tui-presence.js +38 -0
  24. package/dist/cli.js +113 -5
  25. package/dist/player/backend-install.js +2 -1
  26. package/dist/player/command-diagnostics.js +27 -0
  27. package/dist/player/command.js +123 -62
  28. package/dist/player/player-controller.js +32 -2
  29. package/dist/providers/provider-manager.js +5 -0
  30. package/dist/providers/radio-browser.js +36 -6
  31. package/dist/setup.js +462 -0
  32. package/dist/storage/store.js +262 -2
  33. package/dist/types.js +6 -0
  34. package/dist/ui/AdaptiveContent.js +111 -26
  35. package/dist/ui/App.js +401 -67
  36. package/dist/ui/AppContent.js +24 -7
  37. package/dist/ui/adaptive-explore-layout.js +47 -0
  38. package/dist/ui/alarm-editor.js +174 -0
  39. package/dist/ui/alarm-tui-service.js +84 -0
  40. package/dist/ui/app-state.js +3 -0
  41. package/dist/ui/ascii.js +8 -0
  42. package/dist/ui/components/AdaptiveMarquee.js +28 -0
  43. package/dist/ui/components/StationList.js +10 -5
  44. package/dist/ui/components/VersionIndicator.js +19 -0
  45. package/dist/ui/cosmo-world-map.js +5 -2
  46. package/dist/ui/explore-map-layout.js +18 -6
  47. package/dist/ui/format.js +21 -0
  48. package/dist/ui/help-content.js +14 -2
  49. package/dist/ui/layout.js +1 -1
  50. package/dist/ui/page-footer.js +120 -2
  51. package/dist/ui/receiver-animation.js +68 -0
  52. package/dist/ui/screen-items.js +41 -9
  53. package/dist/ui/screen-meta.js +4 -0
  54. package/dist/ui/screens/AlarmsScreen.js +202 -0
  55. package/dist/ui/screens/CountriesScreen.js +8 -5
  56. package/dist/ui/screens/ExploreScreen.js +8 -3
  57. package/dist/ui/screens/HomeScreen.js +3 -1
  58. package/dist/ui/screens/NowPlayingScreen.js +6 -2
  59. package/dist/ui/screens/SettingsScreen.js +70 -53
  60. package/dist/ui/screens/StationScreen.js +3 -2
  61. package/dist/ui/selection-state.js +10 -0
  62. package/dist/ui/terminal-mouse.js +18 -3
  63. package/dist/ui/use-alarm-tui.js +727 -0
  64. package/dist/ui/use-app-input.js +107 -46
  65. package/dist/ui/visualizers/gallop.js +118 -0
  66. package/dist/ui/visualizers/horse-stride.js +20 -0
  67. package/dist/ui/visualizers/receiver-style-registry.js +14 -7
  68. package/dist/ui/visualizers/receiver-visualizers.js +233 -128
  69. package/dist/ui/visualizers/retro-receivers.js +4 -0
  70. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  71. package/dist/update-check.js +26 -7
  72. package/package.json +6 -1
@@ -1,8 +1,10 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { z } from 'zod';
5
6
  import { defaultReceiverStyle, receiverStyleNames, themeNames } from '../types.js';
7
+ import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone } from '../alarms/schedule.js';
6
8
  import { backupBadFile } from '../providers/cache.js';
7
9
  import { migrateReceiverStyle } from '../ui/visualizers/receiver-style-registry.js';
8
10
  const stationSchema = z
@@ -35,6 +37,7 @@ const defaultMediaKeys = {
35
37
  playPause: [],
36
38
  next: []
37
39
  };
40
+ const MAX_ALARMS = 500;
38
41
  const settingsSchema = z.object({
39
42
  theme: z.enum(themeNames).default('green'),
40
43
  receiverStyle: z.preprocess(migrateReceiverStyle, z.enum(receiverStyleNames).default(defaultReceiverStyle)),
@@ -58,7 +61,102 @@ const settingsSchema = z.object({
58
61
  transparentBackground: z.boolean().default(false),
59
62
  asciiMode: z.boolean().default(false),
60
63
  reduceMotion: z.boolean().default(false),
61
- mouseSupport: z.boolean().default(true)
64
+ mouseSupport: z.boolean().default(true),
65
+ automaticUpdateChecks: z.boolean().default(true),
66
+ agentControl: z.object({
67
+ enabled: z.boolean().default(false),
68
+ openUiOnPlay: z.boolean().default(true),
69
+ focusNowPlaying: z.boolean().default(true),
70
+ completionPreset: z.object({
71
+ action: z.enum(['play', 'pause', 'resume', 'stop']).default('play'),
72
+ source: z.enum(['recent', 'favorite', 'popular', 'country']).default('recent'),
73
+ countryCode: z.string().length(2).optional(),
74
+ ifPlaying: z.enum(['keep', 'replace']).default('keep'),
75
+ openUi: z.boolean().optional()
76
+ }).default({ action: 'play', source: 'recent', ifPlaying: 'keep' })
77
+ }).default({
78
+ enabled: false,
79
+ openUiOnPlay: true,
80
+ focusNowPlaying: true,
81
+ completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
82
+ })
83
+ });
84
+ const absoluteInstantSchema = z.string().refine(value => /(?:Z|[+-]\d{2}:\d{2})$/.test(value) && Number.isFinite(Date.parse(value)), { message: 'Expected an absolute ISO-8601 instant with a timezone offset.' });
85
+ const alarmMinuteInstantSchema = absoluteInstantSchema.refine(value => new Date(value).getUTCSeconds() === 0 && new Date(value).getUTCMilliseconds() === 0, { message: 'Alarm occurrences use minute precision; seconds must be zero.' });
86
+ const alarmScheduleSchema = z.discriminatedUnion('type', [
87
+ z.object({ type: z.literal('once'), at: alarmMinuteInstantSchema }),
88
+ z.object({
89
+ type: z.literal('recurring'),
90
+ time: z.string().refine(value => {
91
+ try {
92
+ canonicalizeAlarmTime(value);
93
+ return true;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }, { message: 'Invalid alarm time; expected HH:mm.' }),
99
+ weekdays: z.array(z.number().int().min(1).max(7).transform(value => value)).min(1, 'Choose at least one ISO weekday.'),
100
+ timezone: z.string().refine(value => {
101
+ try {
102
+ canonicalizeTimeZone(value);
103
+ return true;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }, { message: 'Invalid IANA timezone.' })
109
+ })
110
+ ]);
111
+ const alarmPlaybackSchema = z.object({
112
+ volume: z.number().int().min(0).max(100),
113
+ fadeSeconds: z.number().int().min(0).max(3600),
114
+ stopAfterMinutes: z.number().int().min(1).max(7 * 24 * 60),
115
+ fallbackStation: stationSchema.optional()
116
+ });
117
+ const alarmReliabilitySchema = z.object({
118
+ missedRunGraceMinutes: z.number().int().min(0).max(7 * 24 * 60),
119
+ wakeIfSupported: z.boolean(),
120
+ keepAwakeUntilAlarm: z.boolean().default(false)
121
+ });
122
+ const alarmRunRecordSchema = z.object({
123
+ status: z.enum(['played', 'failed', 'missed', 'dismissed']),
124
+ scheduledAt: absoluteInstantSchema,
125
+ firedAt: absoluteInstantSchema.optional(),
126
+ finishedAt: absoluteInstantSchema.optional(),
127
+ message: z.string().max(1000).optional()
128
+ });
129
+ const alarmSchema = z.object({
130
+ id: z.string().min(1),
131
+ label: z.string().trim().min(1).max(120),
132
+ enabled: z.boolean(),
133
+ station: stationSchema,
134
+ schedule: alarmScheduleSchema,
135
+ playback: alarmPlaybackSchema,
136
+ reliability: alarmReliabilitySchema,
137
+ createdAt: absoluteInstantSchema,
138
+ updatedAt: absoluteInstantSchema,
139
+ lastRun: alarmRunRecordSchema.optional(),
140
+ nextOverride: z.object({
141
+ at: alarmMinuteInstantSchema,
142
+ createdAt: absoluteInstantSchema,
143
+ reason: z.literal('snooze')
144
+ }).optional()
145
+ });
146
+ const alarmArraySchema = z.array(alarmSchema)
147
+ .max(MAX_ALARMS, `RadioCLI supports at most ${MAX_ALARMS} alarms.`)
148
+ .superRefine((alarms, context) => {
149
+ const seen = new Set();
150
+ for (const [index, alarm] of alarms.entries()) {
151
+ if (seen.has(alarm.id)) {
152
+ context.addIssue({
153
+ code: 'custom',
154
+ message: `Duplicate alarm ID: ${alarm.id}`,
155
+ path: [index, 'id']
156
+ });
157
+ }
158
+ seen.add(alarm.id);
159
+ }
62
160
  });
63
161
  const librarySchema = z.object({
64
162
  recent: z
@@ -78,6 +176,7 @@ const librarySchema = z.object({
78
176
  }))
79
177
  .default([]),
80
178
  searchHistory: z.array(z.string()).default([]),
179
+ alarms: alarmArraySchema.default([]),
81
180
  updateCheck: z
82
181
  .object({
83
182
  checkedAt: z.string(),
@@ -113,6 +212,13 @@ const librarySchema = z.object({
113
212
  tuneTimeoutSeconds: 12,
114
213
  skipBrokenStreams: true,
115
214
  mouseSupport: true,
215
+ automaticUpdateChecks: true,
216
+ agentControl: {
217
+ enabled: false,
218
+ openUiOnPlay: true,
219
+ focusNowPlaying: true,
220
+ completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
221
+ },
116
222
  mediaKeys: defaultMediaKeys
117
223
  })
118
224
  });
@@ -126,8 +232,12 @@ const legacyLibraryBackupSchema = z.object({ settings: z.unknown() }).passthroug
126
232
  export class JsonLibraryStore {
127
233
  filePath;
128
234
  state;
129
- constructor(filePath = defaultStorePath()) {
235
+ idGenerator;
236
+ now;
237
+ constructor(filePath = defaultStorePath(), options = {}) {
130
238
  this.filePath = filePath;
239
+ this.idGenerator = options.idGenerator ?? randomUUID;
240
+ this.now = options.now ?? (() => new Date());
131
241
  this.state = this.read();
132
242
  }
133
243
  snapshot() {
@@ -162,6 +272,8 @@ export class JsonLibraryStore {
162
272
  }
163
273
  const parsed = JSON.parse(readFileSync(sourcePath, 'utf8'));
164
274
  const backupResult = libraryBackupSchema.safeParse(parsed);
275
+ if (!backupResult.success && isRadioCliBackup(parsed))
276
+ throw backupResult.error;
165
277
  const importedState = migrateLibraryState(backupResult.success
166
278
  ? backupResult.data.library
167
279
  : librarySchema.parse(legacyLibraryBackupSchema.parse(parsed)));
@@ -194,6 +306,107 @@ export class JsonLibraryStore {
194
306
  updateCheckState(updateCheck) {
195
307
  return this.commit({ ...this.state, updateCheck });
196
308
  }
309
+ addAlarm(input) {
310
+ const release = acquireStoreLock(this.filePath);
311
+ try {
312
+ const latestState = this.read();
313
+ if (latestState.alarms.length >= MAX_ALARMS) {
314
+ throw new Error(`RadioCLI supports at most ${MAX_ALARMS} alarms.`);
315
+ }
316
+ const existingIds = new Set([...this.state.alarms, ...latestState.alarms].map(alarm => alarm.id));
317
+ let id;
318
+ for (let attempt = 0; attempt < 100; attempt += 1) {
319
+ const candidate = this.idGenerator();
320
+ if (candidate && !existingIds.has(candidate)) {
321
+ id = candidate;
322
+ break;
323
+ }
324
+ }
325
+ if (!id)
326
+ throw new Error('Unable to allocate a unique alarm ID after 100 attempts.');
327
+ const timestamp = this.now().toISOString();
328
+ const alarm = normalizeAlarm(alarmSchema.parse({
329
+ ...input,
330
+ id,
331
+ createdAt: timestamp,
332
+ updatedAt: timestamp
333
+ }));
334
+ const nextState = { ...this.state, alarms: [alarm, ...this.state.alarms] };
335
+ const mergedState = mergeLibraryChanges(this.state, latestState, nextState);
336
+ this.write(mergedState);
337
+ this.state = mergedState;
338
+ return structuredClone(alarm);
339
+ }
340
+ finally {
341
+ release();
342
+ }
343
+ }
344
+ updateAlarm(id, updates) {
345
+ return this.mutateAlarm(id, existing => normalizeAlarm(alarmSchema.parse({
346
+ ...existing,
347
+ ...updates,
348
+ updatedAt: this.now().toISOString()
349
+ })));
350
+ }
351
+ removeAlarm(id) {
352
+ const release = acquireStoreLock(this.filePath);
353
+ try {
354
+ const latestState = this.read();
355
+ if (!latestState.alarms.some(alarm => alarm.id === id)) {
356
+ this.state = latestState;
357
+ return false;
358
+ }
359
+ const nextState = { ...latestState, alarms: latestState.alarms.filter(alarm => alarm.id !== id) };
360
+ this.write(nextState);
361
+ this.state = nextState;
362
+ return true;
363
+ }
364
+ finally {
365
+ release();
366
+ }
367
+ }
368
+ toggleAlarm(id, enabled) {
369
+ return this.mutateAlarm(id, existing => alarmSchema.parse({
370
+ ...existing,
371
+ enabled: enabled ?? !existing.enabled,
372
+ updatedAt: this.now().toISOString()
373
+ }));
374
+ }
375
+ getAlarm(id) {
376
+ const alarm = this.state.alarms.find(item => item.id === id);
377
+ return alarm ? structuredClone(alarm) : undefined;
378
+ }
379
+ listAlarms() {
380
+ return structuredClone(this.state.alarms);
381
+ }
382
+ recordAlarmOutcome(id, outcome, options = {}) {
383
+ return this.mutateAlarm(id, existing => alarmSchema.parse({
384
+ ...existing,
385
+ lastRun: outcome,
386
+ nextOverride: options.clearNextOverride === false ? existing.nextOverride : undefined,
387
+ updatedAt: this.now().toISOString()
388
+ }));
389
+ }
390
+ snoozeAlarm(id, until) {
391
+ if (!Number.isFinite(until.getTime()))
392
+ throw new Error('Invalid alarm snooze instant.');
393
+ const now = this.now();
394
+ const canonicalUntil = new Date(Math.ceil(until.getTime() / 60_000) * 60_000);
395
+ if (canonicalUntil.getTime() <= now.getTime())
396
+ throw new Error('Alarm snooze must be set to a future time.');
397
+ return this.mutateAlarm(id, existing => alarmSchema.parse({
398
+ ...existing,
399
+ nextOverride: { at: canonicalUntil.toISOString(), createdAt: now.toISOString(), reason: 'snooze' },
400
+ updatedAt: now.toISOString()
401
+ }));
402
+ }
403
+ clearAlarmSnooze(id) {
404
+ return this.mutateAlarm(id, existing => alarmSchema.parse({
405
+ ...existing,
406
+ nextOverride: undefined,
407
+ updatedAt: this.now().toISOString()
408
+ }));
409
+ }
197
410
  addRecent(station) {
198
411
  const key = stationKey(station);
199
412
  const recent = [
@@ -299,6 +512,28 @@ export class JsonLibraryStore {
299
512
  const key = stationKey(station);
300
513
  return this.state.favorites.some(item => stationKey(item) === key);
301
514
  }
515
+ mutateAlarm(id, change) {
516
+ const release = acquireStoreLock(this.filePath);
517
+ try {
518
+ const latestState = this.read();
519
+ const existing = latestState.alarms.find(alarm => alarm.id === id);
520
+ if (!existing) {
521
+ this.state = latestState;
522
+ throw new Error(`Alarm not found: ${id}`);
523
+ }
524
+ const alarm = change(existing);
525
+ const nextState = {
526
+ ...latestState,
527
+ alarms: latestState.alarms.map(item => item.id === id ? alarm : item)
528
+ };
529
+ this.write(nextState);
530
+ this.state = nextState;
531
+ return structuredClone(alarm);
532
+ }
533
+ finally {
534
+ release();
535
+ }
536
+ }
302
537
  read() {
303
538
  if (!existsSync(this.filePath)) {
304
539
  return defaultState();
@@ -412,6 +647,7 @@ function defaultState() {
412
647
  imported: [],
413
648
  trackHistory: [],
414
649
  searchHistory: [],
650
+ alarms: [],
415
651
  activity: { sessions: [] },
416
652
  settings: {
417
653
  theme: 'green',
@@ -425,6 +661,13 @@ function defaultState() {
425
661
  tuneTimeoutSeconds: 12,
426
662
  skipBrokenStreams: true,
427
663
  mouseSupport: true,
664
+ automaticUpdateChecks: true,
665
+ agentControl: {
666
+ enabled: false,
667
+ openUiOnPlay: true,
668
+ focusNowPlaying: true,
669
+ completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
670
+ },
428
671
  mediaKeys: defaultMediaKeys
429
672
  }
430
673
  };
@@ -432,6 +675,7 @@ function defaultState() {
432
675
  function migrateLibraryState(state) {
433
676
  return {
434
677
  ...state,
678
+ alarms: state.alarms.map(normalizeAlarm),
435
679
  settings: {
436
680
  ...state.settings,
437
681
  preferredBackend: state.settings.preferredBackend === 'airplay' ? 'auto' : state.settings.preferredBackend,
@@ -440,6 +684,10 @@ function migrateLibraryState(state) {
440
684
  }
441
685
  };
442
686
  }
687
+ function isRadioCliBackup(value) {
688
+ return typeof value === 'object' && value !== null && 'format' in value
689
+ && value.format === 'radiocli-backup';
690
+ }
443
691
  function libraryStateForDisk(state) {
444
692
  return state.settings.preferredBackend === 'airplay'
445
693
  ? {
@@ -501,6 +749,7 @@ function mergeLibraryChanges(base, disk, next) {
501
749
  imported: mergeKeyedChanges(base.imported, disk.imported, next.imported, stationKey, 1000, true),
502
750
  trackHistory: mergeKeyedChanges(base.trackHistory, disk.trackHistory, next.trackHistory, item => `${item.at}:${item.stationKey}:${item.title}`, 100),
503
751
  searchHistory: mergeSearchHistory(base.searchHistory, disk.searchHistory, next.searchHistory),
752
+ alarms: mergeKeyedChanges(base.alarms, disk.alarms, next.alarms, alarm => alarm.id, MAX_ALARMS, true),
504
753
  updateCheck: sameValue(base.updateCheck, next.updateCheck) ? disk.updateCheck : next.updateCheck,
505
754
  activity: {
506
755
  sessions: mergeKeyedChanges(base.activity.sessions, disk.activity.sessions, next.activity.sessions, item => item.id, 2000)
@@ -508,6 +757,17 @@ function mergeLibraryChanges(base, disk, next) {
508
757
  settings: mergeSettings(base.settings, disk.settings, next.settings)
509
758
  };
510
759
  }
760
+ function normalizeAlarm(alarm) {
761
+ const schedule = alarm.schedule.type === 'once'
762
+ ? { type: 'once', at: new Date(alarm.schedule.at).toISOString() }
763
+ : {
764
+ type: 'recurring',
765
+ time: canonicalizeAlarmTime(alarm.schedule.time),
766
+ weekdays: canonicalizeIsoWeekdays(alarm.schedule.weekdays),
767
+ timezone: canonicalizeTimeZone(alarm.schedule.timezone)
768
+ };
769
+ return { ...alarm, schedule };
770
+ }
511
771
  function mergeKeyedChanges(base, disk, next, keyFor, limit, applyRemovals = false) {
512
772
  const baseByKey = new Map(base.map(item => [keyFor(item), item]));
513
773
  const nextKeys = new Set(next.map(keyFor));
package/dist/types.js CHANGED
@@ -2,3 +2,9 @@ import { defaultReceiverStyle, receiverStyleNames } from './ui/visualizers/recei
2
2
  export { defaultReceiverStyle, receiverStyleNames };
3
3
  const providerIds = ['radio-browser', 'radio-garden', 'playlist'];
4
4
  export const themeNames = ['green', 'amber', 'blue', 'ruby', 'ice', 'teal', 'violet', 'copper', 'cyan', 'lime', 'coral', 'rose', 'slate', 'mono'];
5
+ export const defaultAgentControlSettings = {
6
+ enabled: false,
7
+ openUiOnPlay: true,
8
+ focusNowPlaying: true,
9
+ completionPreset: { action: 'play', source: 'recent', ifPlaying: 'keep' }
10
+ };