@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.
- package/CHANGELOG.md +133 -1
- package/README.md +76 -6
- package/dist/agent/alarm-service.js +210 -0
- package/dist/agent/cli.js +193 -0
- package/dist/agent/headless-host.js +143 -0
- package/dist/agent/launcher.js +71 -0
- package/dist/agent/mcp-install.js +467 -0
- package/dist/agent/mcp-server.js +139 -0
- package/dist/agent/service.js +347 -0
- package/dist/agent/session.js +248 -0
- package/dist/alarms/active-session.js +183 -0
- package/dist/alarms/cli.js +312 -0
- package/dist/alarms/guard.js +343 -0
- package/dist/alarms/inhibitor.js +48 -0
- package/dist/alarms/power-guard-store.js +169 -0
- package/dist/alarms/runner.js +342 -0
- package/dist/alarms/runtime-health.js +79 -0
- package/dist/alarms/schedule.js +149 -0
- package/dist/alarms/scheduler.js +250 -0
- package/dist/alarms/setup-verification.js +187 -0
- package/dist/alarms/system-volume.js +43 -0
- package/dist/alarms/terminal-launcher.js +181 -0
- package/dist/alarms/tui-presence.js +38 -0
- package/dist/cli.js +113 -5
- package/dist/player/backend-install.js +2 -1
- package/dist/player/command-diagnostics.js +27 -0
- package/dist/player/command.js +123 -62
- package/dist/player/player-controller.js +32 -2
- package/dist/providers/provider-manager.js +5 -0
- package/dist/providers/radio-browser.js +36 -6
- package/dist/setup.js +462 -0
- package/dist/storage/store.js +262 -2
- package/dist/types.js +6 -0
- package/dist/ui/AdaptiveContent.js +111 -26
- package/dist/ui/App.js +401 -67
- package/dist/ui/AppContent.js +24 -7
- package/dist/ui/adaptive-explore-layout.js +47 -0
- package/dist/ui/alarm-editor.js +174 -0
- package/dist/ui/alarm-tui-service.js +84 -0
- package/dist/ui/app-state.js +3 -0
- package/dist/ui/ascii.js +8 -0
- package/dist/ui/components/AdaptiveMarquee.js +28 -0
- package/dist/ui/components/StationList.js +10 -5
- package/dist/ui/components/VersionIndicator.js +19 -0
- package/dist/ui/cosmo-world-map.js +5 -2
- package/dist/ui/explore-map-layout.js +18 -6
- package/dist/ui/format.js +21 -0
- package/dist/ui/help-content.js +14 -2
- package/dist/ui/layout.js +1 -1
- package/dist/ui/page-footer.js +120 -2
- package/dist/ui/receiver-animation.js +68 -0
- package/dist/ui/screen-items.js +41 -9
- package/dist/ui/screen-meta.js +4 -0
- package/dist/ui/screens/AlarmsScreen.js +202 -0
- package/dist/ui/screens/CountriesScreen.js +8 -5
- package/dist/ui/screens/ExploreScreen.js +8 -3
- package/dist/ui/screens/HomeScreen.js +3 -1
- package/dist/ui/screens/NowPlayingScreen.js +6 -2
- package/dist/ui/screens/SettingsScreen.js +70 -53
- package/dist/ui/screens/StationScreen.js +3 -2
- package/dist/ui/selection-state.js +10 -0
- package/dist/ui/terminal-mouse.js +18 -3
- package/dist/ui/use-alarm-tui.js +727 -0
- package/dist/ui/use-app-input.js +107 -46
- package/dist/ui/visualizers/gallop.js +118 -0
- package/dist/ui/visualizers/horse-stride.js +20 -0
- package/dist/ui/visualizers/receiver-style-registry.js +14 -7
- package/dist/ui/visualizers/receiver-visualizers.js +233 -128
- package/dist/ui/visualizers/retro-receivers.js +4 -0
- package/dist/ui/visualizers/terminal-receivers.js +57 -0
- package/dist/update-check.js +26 -7
- package/package.json +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,137 @@ All notable changes to RadioCLI are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.2.3] - 2026-09-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Added opt-in local coding-agent control through both a typed stdio MCP server
|
|
13
|
+
and `radiocli agent` shell commands, covering discovery, playback, favorites,
|
|
14
|
+
listening stats, appearance, and configurable task-completion actions.
|
|
15
|
+
- Added automatic MCP setup for detected Codex, Claude Code, OpenCode, Cursor,
|
|
16
|
+
Gemini CLI, VS Code/Copilot Agent Host, and Orca-backed runtimes, plus a
|
|
17
|
+
portable configuration for other MCP clients.
|
|
18
|
+
- Added typed MCP and voice-accessible alarm management, active ringing
|
|
19
|
+
controls, and macOS AirPlay discovery, receiver selection, local-output
|
|
20
|
+
switching, and ephemeral passcode submission.
|
|
21
|
+
- Added `radiocli update --install`, package-manager-aware upgrade commands,
|
|
22
|
+
an in-app update indicator, read-only MCP update status, and
|
|
23
|
+
`radiocli mcp repair`.
|
|
24
|
+
- Added Agent settings for installing or repairing MCP integrations and choosing
|
|
25
|
+
between a visible terminal player and headless playback.
|
|
26
|
+
- Added the animated Gallop receiver as the new default and a color-field
|
|
27
|
+
Crossfade receiver.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Agent playback continues to open the terminal TUI by default on every platform,
|
|
32
|
+
while interactive setup now explains the macOS Automation prompt and offers an
|
|
33
|
+
explicit headless opt-out. MCP guidance prevents ordinary task-completion radio
|
|
34
|
+
actions from creating implicit goals or monitors.
|
|
35
|
+
- MCP registrations now use upgrade-stable, directly executable launchers,
|
|
36
|
+
including a shell-free Node command on Windows, and enabled registrations are
|
|
37
|
+
repaired after an in-app or CLI update.
|
|
38
|
+
- Active interactive or agent playback now yields cleanly when an alarm begins,
|
|
39
|
+
preventing overlapping streams while preserving the alarm if TUI handoff fails.
|
|
40
|
+
- Kept the Countries station-count column fixed while scrolling, added focused
|
|
41
|
+
marquee reading for overlong country and station names across Explore,
|
|
42
|
+
Library, and Nearby, and aligned Overview descriptions behind a compact
|
|
43
|
+
separator-free title column.
|
|
44
|
+
|
|
45
|
+
### Security
|
|
46
|
+
|
|
47
|
+
- Agent playback uses a single-owner, authenticated loopback session with
|
|
48
|
+
serialized commands, private discovery files, and strict isolation from
|
|
49
|
+
active alarm sessions. Agent tools accept saved or RadioCLI-discovered station
|
|
50
|
+
IDs rather than arbitrary stream URLs.
|
|
51
|
+
- Alarm removal requires an exact ID and explicit confirmation. AirPlay receiver
|
|
52
|
+
IDs must come from local discovery, and receiver passcodes are never persisted
|
|
53
|
+
or echoed by agent responses.
|
|
54
|
+
|
|
55
|
+
## [0.2.2] - 2026-09-04
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- Added one-time and IANA-timezone recurring radio alarms under Overview, with a
|
|
60
|
+
full-screen TUI editor, station picker, next-run preview, enable/disable,
|
|
61
|
+
delete confirmation, test tune, repair, and active ringing controls.
|
|
62
|
+
- Added native background registration through macOS launchd, Windows Task
|
|
63
|
+
Scheduler, and systemd user timers on Linux, plus `radiocli alarm` commands
|
|
64
|
+
for scripting, inspection, health checks, synchronization, and playback
|
|
65
|
+
control.
|
|
66
|
+
- Added optional per-alarm wake requests where the OS supports them and a
|
|
67
|
+
separate cross-platform Alarm Guard that prevents idle sleep until the next
|
|
68
|
+
occurrence.
|
|
69
|
+
- Added automatic ringing-control launch in the terminal captured when an alarm
|
|
70
|
+
is synchronized, with reuse of an already-running RadioCLI TUI.
|
|
71
|
+
- Added a navigable **Verify alarm setup** rehearsal that exercises a disposable
|
|
72
|
+
native job, the saved terminal, authenticated controls, sleep protection,
|
|
73
|
+
system volume, and a three-second configured-station sample before cleanup.
|
|
74
|
+
- Added `radiocli setup`, an interactive installer that detects supported
|
|
75
|
+
package managers, previews required playback tools, supports dry runs, guides
|
|
76
|
+
installation, and verifies the resulting local playback configuration.
|
|
77
|
+
- Added an adaptive Explore experience with a responsive Cosmo world map,
|
|
78
|
+
height-aware station lists in micro terminals, pointer-accurate map
|
|
79
|
+
selection, and safe scrolling marquees for long station details.
|
|
80
|
+
- Added richer `radiocli doctor` playback diagnostics, including the resolved
|
|
81
|
+
mpv path, discovery source, launchability, version output, and actionable
|
|
82
|
+
launch errors. Windows discovery now covers package-manager shims, common
|
|
83
|
+
application directories, and registered mpv installations.
|
|
84
|
+
|
|
85
|
+
### Changed
|
|
86
|
+
|
|
87
|
+
- Full library backup and restore now carries alarm definitions and latest run outcomes;
|
|
88
|
+
native jobs, Alarm Guard state, runtime health, and local control credentials
|
|
89
|
+
remain specific to each machine and are rebuilt by alarm synchronization.
|
|
90
|
+
- macOS terminal Automation permission is requested during alarm setup, and a
|
|
91
|
+
TUI opened by a ringing alarm now protects the active native job from startup,
|
|
92
|
+
repair, edit, and delete reconciliation until playback ends.
|
|
93
|
+
- Snooze now closes the completed ringing session, returns to Alarms, and shows
|
|
94
|
+
the next ring time. Alarm playback temporarily unmutes and raises a low local
|
|
95
|
+
system output to the configured alarm level on macOS, Windows, and supported
|
|
96
|
+
Linux audio stacks, restoring the previous state afterward.
|
|
97
|
+
- Alarm list and editor presentation is more concise: historical dismissal text,
|
|
98
|
+
duplicated controls, and long scheduler caveats were removed; specific field
|
|
99
|
+
guidance is now inline and Save is presented as a display-color action.
|
|
100
|
+
- Alarm ringing controls now use Enter to transfer the station into normal
|
|
101
|
+
interactive playback and Space to stop and snooze for ten minutes. The alarm
|
|
102
|
+
UI and documentation identify scheduling as experimental beta functionality,
|
|
103
|
+
explain when the terminal may close, and recommend a secondary device for
|
|
104
|
+
critical wake-ups.
|
|
105
|
+
- Playback-backend guidance now directs users to `radiocli setup` when local
|
|
106
|
+
tools are missing, with updated CLI help, setup documentation, and status
|
|
107
|
+
messages covering the guided workflow.
|
|
108
|
+
- Receiver animation updates are isolated from the rest of the application,
|
|
109
|
+
respect reduced-motion preferences, and use cached visualizer geometry and
|
|
110
|
+
compact color runs to reduce rendering work without changing visual output.
|
|
111
|
+
- Radio Browser requests now use atlas-aware timeouts and retain clearer
|
|
112
|
+
provider failure details when station directories are slow or unavailable.
|
|
113
|
+
|
|
114
|
+
### Fixed
|
|
115
|
+
|
|
116
|
+
- Alarm lists now scroll long text only on the focused interactive row, keep
|
|
117
|
+
selection and enabled-state markers fixed, and avoid repeating station and
|
|
118
|
+
next-run details. Compact and micro layouts use shorter next-wake, scheduler,
|
|
119
|
+
and beta-safety messages instead of clipping desktop-length copy.
|
|
120
|
+
- Footer shortcut legends now balance actions across every available row,
|
|
121
|
+
reserve an additional row in compact terminals when space permits, and show
|
|
122
|
+
concise key-only hints in micro layouts instead of ending in truncated text.
|
|
123
|
+
The version and update indicator remains independently anchored at bottom right.
|
|
124
|
+
- Rapid keyboard and mouse-wheel navigation now commits the selected row
|
|
125
|
+
immediately, cancels pending automatic station skips, and preserves alarm
|
|
126
|
+
editor scrolling; search input keeps ownership of typed alarm-shortcut
|
|
127
|
+
characters.
|
|
128
|
+
- Player shutdown now coalesces concurrent stop requests, while delayed
|
|
129
|
+
automatic skipping can be canceled during retuning to avoid duplicate
|
|
130
|
+
process termination and unintended station changes.
|
|
131
|
+
- Explore maps, station lists, and screen rendering now remain aligned and
|
|
132
|
+
navigable across compact and true-micro terminal sizes, with improved map
|
|
133
|
+
centering, cursor mapping, and visible provider errors.
|
|
134
|
+
|
|
135
|
+
### Security
|
|
136
|
+
|
|
137
|
+
- Active alarm controls and Alarm Guard ownership checks use authenticated
|
|
138
|
+
loopback-only endpoints with machine-local private token files.
|
|
9
139
|
|
|
10
140
|
## [0.2.1] - 2026-08-22
|
|
11
141
|
|
|
@@ -265,6 +395,8 @@ Initial public release.
|
|
|
265
395
|
[0.1.9]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.9
|
|
266
396
|
[0.2.0]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.0
|
|
267
397
|
[0.2.1]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.1
|
|
398
|
+
[0.2.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.2
|
|
399
|
+
[0.2.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.2.3
|
|
268
400
|
[0.1.4]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.4
|
|
269
401
|
[0.1.3]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.3
|
|
270
402
|
[0.1.2]: https://github.com/Ciphore/RadioCLI/releases/tag/v0.1.2
|
package/README.md
CHANGED
|
@@ -18,9 +18,11 @@ Built with [Ink](https://github.com/vadimdemedes/ink),
|
|
|
18
18
|
- Discover stations through Explore, Search, Countries, or approximate-location Nearby.
|
|
19
19
|
- Listen with a receiver-style Now Playing screen, live metadata, sleep timer, diagnostics, and selectable visualizers.
|
|
20
20
|
- Keep favorites, recents, imports, track history, and listening stats on your machine.
|
|
21
|
+
- Create one-time or recurring radio alarms from the TUI, with native background
|
|
22
|
+
scheduling, station fallback, missed-run grace, snooze, and optional Alarm Guard.
|
|
21
23
|
- Move through the exact station list you tuned from with previous and next controls.
|
|
22
24
|
- Use `mpv` for complete playback control, with `ffplay` and VLC as limited fallbacks.
|
|
23
|
-
- Resize freely: full, compact, and micro layouts preserve navigation and essential controls.
|
|
25
|
+
- Resize freely: full, compact, and micro layouts preserve navigation and essential controls; Explore retains its interactive world map at every usable size.
|
|
24
26
|
|
|
25
27
|
## Quick start
|
|
26
28
|
|
|
@@ -35,13 +37,14 @@ macOS, Linux, or Windows with npm:
|
|
|
35
37
|
|
|
36
38
|
```bash
|
|
37
39
|
npm install -g @ciphore/radiocli
|
|
38
|
-
radiocli
|
|
40
|
+
radiocli setup
|
|
39
41
|
radiocli
|
|
40
42
|
```
|
|
41
43
|
|
|
42
44
|
RadioCLI requires Node.js 22 or newer. The Homebrew formula installs `mpv` and
|
|
43
|
-
FFmpeg
|
|
44
|
-
|
|
45
|
+
FFmpeg. After an npm install, `radiocli setup` detects the operating system and
|
|
46
|
+
package manager, lets you choose `mpv`, FFmpeg, and VLC, installs the selected
|
|
47
|
+
native tools with branded progress feedback, and verifies playback readiness.
|
|
45
48
|
|
|
46
49
|
See the [installation guide](apps/docs/content/docs/getting-started/install.mdx)
|
|
47
50
|
for Windows, Linux distributions, AirPlay prerequisites, and fallback players.
|
|
@@ -91,6 +94,24 @@ covers filters, playback, media-key learning, AirPlay, and every command.
|
|
|
91
94
|
|
|
92
95
|
## CLI
|
|
93
96
|
|
|
97
|
+
RadioCLI can also be controlled by local coding agents. Run `radiocli setup`
|
|
98
|
+
and opt into MCP, or use `radiocli setup --mcp`, to configure detected Codex,
|
|
99
|
+
Claude, OpenCode, Cursor, Gemini, VS Code/Copilot, and Orca-backed runtimes
|
|
100
|
+
automatically. From the TUI, open **Settings → Agent control & MCP** and turn on
|
|
101
|
+
**Allow local agent control** for the same one-step setup—no terminal command is
|
|
102
|
+
required. The same actions are
|
|
103
|
+
available to non-MCP harnesses through `radiocli agent` and `radiocli alarm`:
|
|
104
|
+
search and play stations, browse recents/favorites/nearby stations, favorite the
|
|
105
|
+
current station, inspect listening stats, create and control alarms, control
|
|
106
|
+
macOS AirPlay, change the receiver look, or run a user-configured completion
|
|
107
|
+
preset. Codex Voice can invoke those same typed MCP tools. Agent playback opens
|
|
108
|
+
the normal RadioCLI TUI in a terminal window by default on macOS, Windows, and
|
|
109
|
+
Linux. Interactive setup explains that macOS may require the agent host to approve
|
|
110
|
+
a one-time Automation prompt; users can explicitly opt into headless playback.
|
|
111
|
+
Scheduled alarms remain isolated and always use local output rather than
|
|
112
|
+
unattended AirPlay. See the
|
|
113
|
+
[CLI guide](apps/docs/content/docs/getting-started/cli.mdx#local-agent-and-mcp-control).
|
|
114
|
+
|
|
94
115
|
```bash
|
|
95
116
|
radiocli # Start the TUI
|
|
96
117
|
radiocli check # Check providers, playback tools, and the local store
|
|
@@ -100,11 +121,46 @@ radiocli countries
|
|
|
100
121
|
radiocli import stations.m3u
|
|
101
122
|
radiocli export favorites.m3u
|
|
102
123
|
radiocli add-url <stream-url> [station name]
|
|
124
|
+
radiocli alarm list
|
|
125
|
+
radiocli alarm doctor
|
|
126
|
+
radiocli update --install # Upgrade and repair enabled MCP registrations
|
|
103
127
|
```
|
|
104
128
|
|
|
129
|
+
Existing installations keep their library and preferences when upgraded. Use
|
|
130
|
+
`radiocli update` to inspect the detected Homebrew, npm, pnpm, or Bun command,
|
|
131
|
+
or `radiocli update --install` to run it. MCP registrations use an
|
|
132
|
+
upgrade-stable launcher so versioned package paths do not break them. On
|
|
133
|
+
Windows this is a shell-free Node command; on Unix it uses the `radiocli` shim.
|
|
134
|
+
Run `radiocli mcp status` to inspect registrations and `radiocli mcp repair`
|
|
135
|
+
after moving an installation or when an agent client reports a stale server;
|
|
136
|
+
fully quit and reopen agent clients after enabling, repairing, or upgrading.
|
|
137
|
+
Until that restart, even a newly created task in an already-running client will
|
|
138
|
+
not have RadioCLI's tools and may incorrectly fall back to browser playback.
|
|
139
|
+
|
|
105
140
|
RadioCLI imports `.m3u`, `.pls`, and `.xspf` playlists. It exports favorites
|
|
106
141
|
and imported streams as `.m3u`.
|
|
107
142
|
|
|
143
|
+
Alarms are experimental beta functionality and live under **Overview**,
|
|
144
|
+
immediately before Settings. Saving an enabled
|
|
145
|
+
alarm registers it with launchd on macOS, a systemd user timer on Linux, or Task
|
|
146
|
+
Scheduler on Windows, so the terminal does not need to remain open. When it
|
|
147
|
+
fires, RadioCLI reopens the saved supported terminal and shows the ringing
|
|
148
|
+
screen. Enter transfers the station into normal interactive playback; Space
|
|
149
|
+
stops it and snoozes for 10 minutes. If a
|
|
150
|
+
RadioCLI TUI is already open, that instance shows the controls instead. The
|
|
151
|
+
computer must still be powered on with a logged-in interactive audio session; wake timing
|
|
152
|
+
depends on hardware and OS power policy. See the [CLI guide](apps/docs/content/docs/getting-started/cli.mdx)
|
|
153
|
+
and [reliability notes](apps/docs/content/docs/reliability.mdx) before relying on
|
|
154
|
+
an alarm for something critical. Use a secondary device as the primary alarm
|
|
155
|
+
for safety-critical, medical, travel, or emergency timing. See the dedicated
|
|
156
|
+
[Alarms guide](apps/docs/content/docs/alarms.mdx) for the full workflow.
|
|
157
|
+
|
|
158
|
+
The **Verify alarm setup** row performs a disposable native-scheduler
|
|
159
|
+
registration, an authenticated terminal/control handshake, sleep-inhibitor and
|
|
160
|
+
system-volume checks, and a short sample from the next configured alarm. It
|
|
161
|
+
cleans up the temporary job and reports hard blockers separately from hardware-
|
|
162
|
+
or policy-dependent wake limitations.
|
|
163
|
+
|
|
108
164
|
See the [CLI guide](apps/docs/content/docs/getting-started/cli.mdx) for all
|
|
109
165
|
arguments and examples.
|
|
110
166
|
|
|
@@ -120,14 +176,27 @@ Playback waits for backend readiness, applies tune timeouts, and can skip broken
|
|
|
120
176
|
streams. Corrupt library and cache files are backed up instead of silently
|
|
121
177
|
overwritten.
|
|
122
178
|
|
|
179
|
+
Scheduled playback retries the primary station once, then tries an optional
|
|
180
|
+
fallback. A missed-run grace window controls catch-up after sleep or logout.
|
|
181
|
+
True fade-in requires `mpv`; `ffplay` and VLC start audibly at the configured
|
|
182
|
+
target volume. Scheduled alarms always use local speakers, never unattended
|
|
183
|
+
AirPlay.
|
|
184
|
+
|
|
185
|
+
On macOS, an npm or source installation is executed by Node, so System Settings
|
|
186
|
+
may identify its background item as `node`. Showing RadioCLI as the providing
|
|
187
|
+
application requires a future signed macOS app bundle with a bundled helper;
|
|
188
|
+
changing a launchd label cannot safely relabel the executable.
|
|
189
|
+
|
|
123
190
|
Read the [reliability notes](apps/docs/content/docs/reliability.mdx) for failure
|
|
124
191
|
handling and troubleshooting.
|
|
125
192
|
|
|
126
193
|
## Privacy
|
|
127
194
|
|
|
128
195
|
RadioCLI does not require an account, proxy audio, or upload your listening
|
|
129
|
-
history. Favorites, recents, imports, settings, track history, and
|
|
130
|
-
in a local JSON library.
|
|
196
|
+
history. Favorites, recents, imports, settings, alarms, track history, and
|
|
197
|
+
activity stay in a local JSON library. Full JSON library backups include alarm
|
|
198
|
+
definitions, while native scheduler registrations, runtime health, Alarm Guard
|
|
199
|
+
state, and authenticated loopback-control tokens remain machine-local.
|
|
131
200
|
|
|
132
201
|
Nearby contacts `ipapi.co` only when you open that screen, then uses an
|
|
133
202
|
approximate location to sort the local station atlas. Disable the lookup with
|
|
@@ -145,6 +214,7 @@ complete data-flow description.
|
|
|
145
214
|
- `src/ui` — screens, input, layout, and terminal rendering
|
|
146
215
|
- `src/providers` — station directories, resolution, and caches
|
|
147
216
|
- `src/player` — playback backends, metadata, and AirPlay
|
|
217
|
+
- `src/alarms` — schedules, native registration, Alarm Guard, and active controls
|
|
148
218
|
- `src/storage` — local library persistence and migration
|
|
149
219
|
- `apps/docs` — documentation website and manual
|
|
150
220
|
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { canonicalizeAlarmTime, canonicalizeIsoWeekdays, canonicalizeTimeZone, nextOccurrenceForAlarm } from '../alarms/schedule.js';
|
|
2
|
+
import { connectActiveAlarms } from '../alarms/active-session.js';
|
|
3
|
+
import { createSchedulerService } from '../alarms/scheduler.js';
|
|
4
|
+
export class AgentAlarmService {
|
|
5
|
+
store;
|
|
6
|
+
resolveStation;
|
|
7
|
+
scheduler;
|
|
8
|
+
handoffToInteractive;
|
|
9
|
+
constructor(store, resolveStation, scheduler = createSchedulerService(), handoffToInteractive) {
|
|
10
|
+
this.store = store;
|
|
11
|
+
this.resolveStation = resolveStation;
|
|
12
|
+
this.scheduler = scheduler;
|
|
13
|
+
this.handoffToInteractive = handoffToInteractive;
|
|
14
|
+
}
|
|
15
|
+
async list() {
|
|
16
|
+
return Promise.all(this.store.listAlarms().map(alarm => this.describe(alarm)));
|
|
17
|
+
}
|
|
18
|
+
async status() {
|
|
19
|
+
const alarms = this.store.listAlarms();
|
|
20
|
+
return {
|
|
21
|
+
active: await this.activeStatuses(),
|
|
22
|
+
scheduler: await this.scheduler.runtimeStatus(alarms),
|
|
23
|
+
alarms: await Promise.all(alarms.map(alarm => this.describe(alarm)))
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
async create(input) {
|
|
27
|
+
const station = await this.requiredStation(input.stationId);
|
|
28
|
+
const fallbackStation = input.fallbackStationId
|
|
29
|
+
? await this.requiredStation(input.fallbackStationId)
|
|
30
|
+
: undefined;
|
|
31
|
+
const alarmInput = {
|
|
32
|
+
label: cleanLabel(input.label ?? station.name),
|
|
33
|
+
enabled: input.enabled ?? true,
|
|
34
|
+
station,
|
|
35
|
+
schedule: normalizeSchedule(input.schedule),
|
|
36
|
+
playback: {
|
|
37
|
+
volume: boundedInteger(input.volume ?? 40, 'volume', 0, 100),
|
|
38
|
+
fadeSeconds: boundedInteger(input.fadeSeconds ?? 0, 'fadeSeconds', 0, 3600),
|
|
39
|
+
stopAfterMinutes: boundedInteger(input.stopAfterMinutes ?? 60, 'stopAfterMinutes', 1, 10080),
|
|
40
|
+
...(fallbackStation ? { fallbackStation } : {})
|
|
41
|
+
},
|
|
42
|
+
reliability: {
|
|
43
|
+
missedRunGraceMinutes: boundedInteger(input.missedRunGraceMinutes ?? 10, 'missedRunGraceMinutes', 0, 10080),
|
|
44
|
+
wakeIfSupported: input.wakeIfSupported ?? false,
|
|
45
|
+
keepAwakeUntilAlarm: input.keepAwakeUntilAlarm ?? false
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const alarm = this.store.addAlarm(alarmInput);
|
|
49
|
+
await this.syncSaved(alarm);
|
|
50
|
+
return this.describe(alarm);
|
|
51
|
+
}
|
|
52
|
+
async update(id, input) {
|
|
53
|
+
const alarm = this.requiredAlarm(id);
|
|
54
|
+
const station = input.stationId ? await this.requiredStation(input.stationId) : alarm.station;
|
|
55
|
+
const fallbackStation = input.clearFallback
|
|
56
|
+
? undefined
|
|
57
|
+
: input.fallbackStationId ? await this.requiredStation(input.fallbackStationId) : alarm.playback.fallbackStation;
|
|
58
|
+
const updated = this.store.updateAlarm(alarm.id, {
|
|
59
|
+
label: input.label === undefined ? alarm.label : cleanLabel(input.label),
|
|
60
|
+
enabled: input.enabled ?? alarm.enabled,
|
|
61
|
+
station,
|
|
62
|
+
schedule: input.schedule ? normalizeSchedule(input.schedule) : alarm.schedule,
|
|
63
|
+
playback: {
|
|
64
|
+
volume: boundedInteger(input.volume ?? alarm.playback.volume, 'volume', 0, 100),
|
|
65
|
+
fadeSeconds: boundedInteger(input.fadeSeconds ?? alarm.playback.fadeSeconds, 'fadeSeconds', 0, 3600),
|
|
66
|
+
stopAfterMinutes: boundedInteger(input.stopAfterMinutes ?? alarm.playback.stopAfterMinutes, 'stopAfterMinutes', 1, 10080),
|
|
67
|
+
...(fallbackStation ? { fallbackStation } : {})
|
|
68
|
+
},
|
|
69
|
+
reliability: {
|
|
70
|
+
missedRunGraceMinutes: boundedInteger(input.missedRunGraceMinutes ?? alarm.reliability.missedRunGraceMinutes, 'missedRunGraceMinutes', 0, 10080),
|
|
71
|
+
wakeIfSupported: input.wakeIfSupported ?? alarm.reliability.wakeIfSupported,
|
|
72
|
+
keepAwakeUntilAlarm: input.keepAwakeUntilAlarm ?? alarm.reliability.keepAwakeUntilAlarm
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
await this.syncSaved(updated);
|
|
76
|
+
return this.describe(updated);
|
|
77
|
+
}
|
|
78
|
+
async setEnabled(id, enabled) {
|
|
79
|
+
const alarm = this.store.toggleAlarm(this.requiredAlarm(id).id, enabled);
|
|
80
|
+
await this.syncSaved(alarm);
|
|
81
|
+
return this.describe(alarm);
|
|
82
|
+
}
|
|
83
|
+
async remove(id, confirm) {
|
|
84
|
+
if (!confirm)
|
|
85
|
+
throw new Error('Alarm removal requires confirm=true. List alarms first and confirm the exact alarm ID with the user.');
|
|
86
|
+
const alarm = this.requiredAlarm(id);
|
|
87
|
+
await this.scheduler.remove(alarm.id);
|
|
88
|
+
if (!this.store.removeAlarm(alarm.id))
|
|
89
|
+
throw new Error(`Alarm not found: ${id}`);
|
|
90
|
+
return { ok: true, removed: alarm.id };
|
|
91
|
+
}
|
|
92
|
+
async sync() {
|
|
93
|
+
return this.scheduler.syncAll(this.store.listAlarms());
|
|
94
|
+
}
|
|
95
|
+
async controlActive(input) {
|
|
96
|
+
const { client, status } = await this.selectActive(input.alarmId, input.occurrenceAt);
|
|
97
|
+
if (input.action === 'dismiss')
|
|
98
|
+
await client.dismiss();
|
|
99
|
+
else if (input.action === 'keep-playing')
|
|
100
|
+
await client.keepPlaying();
|
|
101
|
+
else if (input.action === 'handoff') {
|
|
102
|
+
if (status.state !== 'playing')
|
|
103
|
+
throw new Error('Alarm playback is still starting.');
|
|
104
|
+
if (!this.handoffToInteractive)
|
|
105
|
+
throw new Error('Interactive playback handoff is unavailable.');
|
|
106
|
+
await this.handoffToInteractive(status);
|
|
107
|
+
await client.handoff();
|
|
108
|
+
}
|
|
109
|
+
else
|
|
110
|
+
await client.snooze(boundedInteger(input.snoozeMinutes ?? 10, 'snoozeMinutes', 1, 1440));
|
|
111
|
+
return { ok: true, action: input.action, alarm: activeStatusForAgent(status) };
|
|
112
|
+
}
|
|
113
|
+
async describe(alarm) {
|
|
114
|
+
return {
|
|
115
|
+
id: alarm.id,
|
|
116
|
+
label: alarm.label,
|
|
117
|
+
enabled: alarm.enabled,
|
|
118
|
+
station: { id: `${alarm.station.provider}:${alarm.station.id}`, name: alarm.station.name },
|
|
119
|
+
schedule: alarm.schedule,
|
|
120
|
+
nextOccurrence: nextOccurrenceForAlarm(alarm, new Date())?.toISOString() ?? null,
|
|
121
|
+
playback: {
|
|
122
|
+
volume: alarm.playback.volume,
|
|
123
|
+
fadeSeconds: alarm.playback.fadeSeconds,
|
|
124
|
+
stopAfterMinutes: alarm.playback.stopAfterMinutes,
|
|
125
|
+
...(alarm.playback.fallbackStation ? { fallbackStation: {
|
|
126
|
+
id: `${alarm.playback.fallbackStation.provider}:${alarm.playback.fallbackStation.id}`,
|
|
127
|
+
name: alarm.playback.fallbackStation.name
|
|
128
|
+
} } : {})
|
|
129
|
+
},
|
|
130
|
+
reliability: alarm.reliability,
|
|
131
|
+
lastRun: alarm.lastRun,
|
|
132
|
+
snoozedUntil: alarm.nextOverride?.at
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async activeStatuses() {
|
|
136
|
+
return (await Promise.all((await connectActiveAlarms()).map(client => client.status()))).map(activeStatusForAgent);
|
|
137
|
+
}
|
|
138
|
+
async selectActive(alarmId, occurrenceAt) {
|
|
139
|
+
const matches = [];
|
|
140
|
+
for (const client of await connectActiveAlarms()) {
|
|
141
|
+
const status = await client.status();
|
|
142
|
+
if ((!alarmId || status.alarmId === alarmId) && (!occurrenceAt || status.scheduledAt === occurrenceAt))
|
|
143
|
+
matches.push({ client, status });
|
|
144
|
+
}
|
|
145
|
+
if (!matches.length)
|
|
146
|
+
throw new Error('No matching alarm is currently ringing.');
|
|
147
|
+
if (matches.length > 1)
|
|
148
|
+
throw new Error('Multiple alarms are ringing. List alarm status, then provide alarm_id and optionally occurrence_at.');
|
|
149
|
+
return matches[0];
|
|
150
|
+
}
|
|
151
|
+
requiredAlarm(id) {
|
|
152
|
+
const alarm = this.store.getAlarm(id);
|
|
153
|
+
if (!alarm)
|
|
154
|
+
throw new Error(`Alarm not found: ${id}`);
|
|
155
|
+
return alarm;
|
|
156
|
+
}
|
|
157
|
+
async requiredStation(id) {
|
|
158
|
+
const station = await this.resolveStation(id);
|
|
159
|
+
if (!station)
|
|
160
|
+
throw new Error(`Unknown station ID: ${id}. Search or browse first.`);
|
|
161
|
+
return station;
|
|
162
|
+
}
|
|
163
|
+
async syncSaved(alarm) {
|
|
164
|
+
try {
|
|
165
|
+
await this.scheduler.sync(alarm);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
throw new Error(`Alarm ${alarm.id} was saved, but scheduler setup is degraded: ${error instanceof Error ? error.message : String(error)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function normalizeSchedule(schedule) {
|
|
173
|
+
if (schedule.type === 'once') {
|
|
174
|
+
const instant = new Date(schedule.at);
|
|
175
|
+
if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(schedule.at) || !Number.isFinite(instant.getTime()) || instant.getUTCSeconds() !== 0 || instant.getUTCMilliseconds() !== 0) {
|
|
176
|
+
throw new Error('One-time alarms require an absolute ISO-8601 minute with an offset or Z and zero seconds.');
|
|
177
|
+
}
|
|
178
|
+
if (instant.getTime() <= Date.now())
|
|
179
|
+
throw new Error('One-time alarms must be scheduled in the future.');
|
|
180
|
+
return { type: 'once', at: instant.toISOString() };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
type: 'recurring',
|
|
184
|
+
time: canonicalizeAlarmTime(schedule.time),
|
|
185
|
+
weekdays: canonicalizeIsoWeekdays(schedule.weekdays),
|
|
186
|
+
timezone: canonicalizeTimeZone(schedule.timezone)
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function boundedInteger(value, label, min, max) {
|
|
190
|
+
if (!Number.isInteger(value) || value < min || value > max)
|
|
191
|
+
throw new Error(`${label} must be an integer from ${min} to ${max}.`);
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
function cleanLabel(value) {
|
|
195
|
+
const label = value.trim();
|
|
196
|
+
if (!label || label.length > 120 || /[\u0000-\u001F\u007F-\u009F]/.test(label))
|
|
197
|
+
throw new Error('Alarm label must be 1-120 printable characters.');
|
|
198
|
+
return label;
|
|
199
|
+
}
|
|
200
|
+
function activeStatusForAgent(status) {
|
|
201
|
+
return {
|
|
202
|
+
alarmId: status.alarmId,
|
|
203
|
+
scheduledAt: status.scheduledAt,
|
|
204
|
+
stationName: status.stationName,
|
|
205
|
+
...(status.station ? { station: { id: `${status.station.provider}:${status.station.id}`, name: status.station.name } } : {}),
|
|
206
|
+
startedAt: status.startedAt,
|
|
207
|
+
state: status.state,
|
|
208
|
+
keepPlaying: status.keepPlaying
|
|
209
|
+
};
|
|
210
|
+
}
|