@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
@@ -0,0 +1,160 @@
1
+ /** Never persist or mutate a default-sink alias: it can change during an alarm. */
2
+ export function linuxVolumeBackend(run, resolve) {
3
+ const wpctl = resolve('wpctl');
4
+ if (wpctl) {
5
+ const identity = async (id) => {
6
+ const [status, detail] = await Promise.all([
7
+ checked(run(wpctl, ['status']), 'identify the PipeWire server'),
8
+ checked(run(wpctl, ['inspect', id]), 'identify the PipeWire output')
9
+ ]);
10
+ const server = /^PipeWire .+cookie:\s*(\d{1,20})\]/m.exec(status)?.[1];
11
+ const node = /^id (\d{1,20}), type PipeWire:Interface:Node\s*$/m.exec(detail)?.[1];
12
+ const property = (name) => new RegExp(`^\\s*\\*?\\s*${name.replaceAll('.', '\\.')} = "([^"\\r\\n]+)"\\s*$`, 'm').exec(detail)?.[1];
13
+ const serial = property('object.serial');
14
+ const name = property('node.name');
15
+ if (!server || !node || !serial || !/^\d{1,20}$/.test(serial) || !validName(name) || property('media.class') !== 'Audio/Sink')
16
+ throw new Error('PipeWire returned an unreadable output identity; no default-output mutation is safe.');
17
+ return { kind: 'pipewire', server, id: node, serial, name };
18
+ };
19
+ const verify = async (output) => {
20
+ if (!output || output.kind === 'pulse')
21
+ throw changedOutput();
22
+ const id = output.kind === 'pipewire' ? output.id : output.nodeId;
23
+ const current = await identity(id);
24
+ if (current.server !== output.server || current.id !== id || current.serial !== output.serial || current.name !== output.name)
25
+ throw changedOutput();
26
+ return id;
27
+ };
28
+ return {
29
+ select: () => identity('@DEFAULT_AUDIO_SINK@'),
30
+ async read(output) {
31
+ const id = await verify(output);
32
+ const text = await checked(run(wpctl, ['get-volume', id]), 'read PipeWire output volume');
33
+ const match = /Volume:\s*(\d+(?:\.\d+)?)(?:\s|$)/i.exec(text);
34
+ if (!match)
35
+ throw new Error('wpctl returned an unreadable output-volume state.');
36
+ await verify(output);
37
+ return { volume: clamp(Number(match[1]) * 100), muted: /\[MUTED\]/i.test(text) };
38
+ },
39
+ async write(value, execute = run, output) {
40
+ const id = await verify(output);
41
+ await checked(execute(wpctl, ['set-volume', id, `${clamp(value.volume)}%`]), 'set PipeWire output volume');
42
+ // A hot-unplug between the two commands must not unmute a reused ID.
43
+ await verify(output);
44
+ await checked(execute(wpctl, ['set-mute', id, value.muted ? '1' : '0']), 'set PipeWire mute state');
45
+ }
46
+ };
47
+ }
48
+ const pactl = resolve('pactl');
49
+ if (pactl) {
50
+ // The C-locale textual interface also supports PulseAudio versions predating
51
+ // pactl's JSON output. Its public server cookie changes across restarts.
52
+ const info = async () => {
53
+ const text = await checked(run(pactl, ['info']), 'identify the PulseAudio server');
54
+ const server = /^Cookie:\s*([\da-f]+(?::[\da-f]+)*)\s*$/im.exec(text)?.[1]?.toLowerCase();
55
+ const name = /^Default Sink:\s*(.+)\s*$/m.exec(text)?.[1]?.trim();
56
+ if (!validName(server))
57
+ throw new Error('PulseAudio returned an unreadable server identity; no default-output mutation is safe.');
58
+ const pipewire = /^Server Name: PulseAudio \(on PipeWire [\w.+-]+\)\s*$/m.test(text);
59
+ const cookie = /^([\da-f]{1,4}):([\da-f]{1,4})$/.exec(server);
60
+ if (pipewire && !cookie)
61
+ throw new Error('PulseAudio returned an unreadable PipeWire server cookie.');
62
+ // PipeWire's Pulse server exposes the same core cookie as wpctl, rendered
63
+ // as two hexadecimal words. Classic PulseAudio stays in a separate domain.
64
+ return { pipewire, server: pipewire ? Number.parseInt(`${cookie[1].padStart(4, '0')}${cookie[2].padStart(4, '0')}`, 16).toString() : server, name: validName(name) ? name : undefined };
65
+ };
66
+ const sinks = async (server) => {
67
+ const text = await checked(run(pactl, server.pipewire ? ['list', 'sinks'] : ['list', 'short', 'sinks']), 'identify PulseAudio outputs');
68
+ const outputs = server.pipewire ? parsePipeWireSinks(text, server.server) : text.split(/\r?\n/).filter(Boolean).map(line => {
69
+ const [id, name] = line.split('\t');
70
+ if (!id || !/^\d{1,20}$/.test(id) || !validName(name))
71
+ throw new Error('PulseAudio returned an unreadable output identity.');
72
+ return { kind: 'pulse', server: server.server, id, name };
73
+ });
74
+ if (new Set(outputs.map(item => item.id)).size !== outputs.length || new Set(outputs.map(item => item.name)).size !== outputs.length)
75
+ throw new Error('PulseAudio returned ambiguous output identities.');
76
+ return outputs;
77
+ };
78
+ const verify = async (output) => {
79
+ if (!output)
80
+ throw changedOutput();
81
+ const server = await info();
82
+ const outputs = await sinks(server);
83
+ if (server.server !== output.server)
84
+ throw changedOutput();
85
+ const current = outputs.find(item => {
86
+ if (output.kind === 'pulse')
87
+ return item.kind === 'pulse' && item.id === output.id && item.name === output.name;
88
+ if (item.kind !== 'pulse-pipewire' || item.serial !== output.serial || item.name !== output.name)
89
+ return false;
90
+ return output.kind === 'pipewire' ? item.nodeId === output.id : item.nodeId === output.nodeId && item.id === output.id && item.sinkName === output.sinkName;
91
+ });
92
+ if (!current)
93
+ throw changedOutput();
94
+ return current.id;
95
+ };
96
+ return {
97
+ async select() {
98
+ const server = await info();
99
+ const outputs = await sinks(server);
100
+ const output = outputs.find(item => (item.kind === 'pulse-pipewire' ? item.sinkName : item.name) === server.name);
101
+ if (!output)
102
+ throw new Error('The default PulseAudio output is unavailable.');
103
+ return output;
104
+ },
105
+ async read(output) {
106
+ const id = await verify(output);
107
+ const [volume, mute] = await Promise.all([
108
+ checked(run(pactl, ['get-sink-volume', id]), 'read PulseAudio output volume'),
109
+ checked(run(pactl, ['get-sink-mute', id]), 'read PulseAudio mute state')
110
+ ]);
111
+ const match = /(\d+)%/.exec(volume);
112
+ const muted = /^Mute:\s*(yes|no)\s*$/im.exec(mute);
113
+ if (!match || !muted)
114
+ throw new Error('pactl returned an unreadable output-volume state.');
115
+ await verify(output);
116
+ return { volume: clamp(Number(match[1])), muted: muted[1].toLowerCase() === 'yes' };
117
+ },
118
+ async write(value, execute = run, output) {
119
+ const id = await verify(output);
120
+ await checked(execute(pactl, ['set-sink-volume', id, `${clamp(value.volume)}%`]), 'set PulseAudio output volume');
121
+ await verify(output);
122
+ await checked(execute(pactl, ['set-sink-mute', id, value.muted ? '1' : '0']), 'set PulseAudio mute state');
123
+ }
124
+ };
125
+ }
126
+ const amixer = resolve('amixer');
127
+ if (amixer)
128
+ return {
129
+ async read() {
130
+ const text = await checked(run(amixer, ['get', 'Master']), 'read ALSA output volume');
131
+ const last = [...text.matchAll(/\[(\d+)%\]/g)].at(-1);
132
+ if (!last)
133
+ throw new Error('amixer returned an unreadable output-volume state.');
134
+ return { volume: clamp(Number(last[1])), muted: /\[off\]/i.test(text) };
135
+ },
136
+ async write(value, execute = run) { await checked(execute(amixer, ['set', 'Master', `${clamp(value.volume)}%`, value.muted ? 'mute' : 'unmute']), 'set ALSA output volume'); }
137
+ };
138
+ return undefined;
139
+ }
140
+ function parsePipeWireSinks(text, server) {
141
+ if (!text.trim())
142
+ return [];
143
+ return text.split(/(?=^Sink #)/m).filter(block => block.trim()).map(block => {
144
+ const id = /^Sink #(\d{1,20})\s*$/m.exec(block)?.[1];
145
+ const sinkName = /^\s+Name:\s*(.+)\s*$/m.exec(block)?.[1]?.trim();
146
+ const property = (name) => new RegExp(`^\\s+${name.replaceAll('.', '\\.')} = "([^"\\r\\n]+)"\\s*$`, 'm').exec(block)?.[1];
147
+ const nodeId = property('object.id');
148
+ const serial = property('object.serial');
149
+ const name = property('node.name');
150
+ if (!id || !nodeId || !/^\d{1,20}$/.test(nodeId) || !serial || !/^\d{1,20}$/.test(serial) || !validName(name) || !validName(sinkName))
151
+ throw new Error('PulseAudio returned an unreadable PipeWire output identity.');
152
+ return { kind: 'pulse-pipewire', server, id, nodeId, serial, name, sinkName };
153
+ });
154
+ }
155
+ function changedOutput() { return new Error('The original alarm output disconnected or its audio-server identity changed. Its saved volume was retained; no replacement output was changed.'); }
156
+ function validName(value) { return typeof value === 'string' && value.length > 0 && value.length <= 1024 && !/[\u0000-\u001f\u007f]/.test(value); }
157
+ function clamp(value) { return Math.max(0, Math.min(100, Math.round(Number.isFinite(value) ? value : 0))); }
158
+ async function checked(promise, label) { const result = await promise; if (result.code !== 0)
159
+ throw new Error(`${label} failed: ${(result.stderr || result.stdout || `exit ${result.code}`).trim()}`); if (Buffer.byteLength(result.stdout) > 64 * 1024)
160
+ throw new Error(`${label} returned too much data.`); return result.stdout; }
@@ -13,9 +13,11 @@ export function defaultSystemVolumeDirectory() {
13
13
  export class SystemVolumeOwnership {
14
14
  directory;
15
15
  timeoutMs;
16
- constructor(directory, timeoutMs = 30_000) {
16
+ output;
17
+ constructor(directory, timeoutMs = 30_000, output) {
17
18
  this.directory = directory;
18
19
  this.timeoutMs = timeoutMs;
20
+ this.output = output;
19
21
  }
20
22
  async transaction(work) {
21
23
  const release = await this.lock();
@@ -27,6 +29,25 @@ export class SystemVolumeOwnership {
27
29
  }
28
30
  }
29
31
  read() {
32
+ const value = this.readDocument();
33
+ if (!value)
34
+ return undefined;
35
+ if (value.version === 1) {
36
+ if (!this.output)
37
+ return value;
38
+ this.checkLegacy(value);
39
+ return undefined;
40
+ }
41
+ if (!this.output)
42
+ throw new Error('Saved output-volume ownership belongs to an identified Linux audio device; its recovery state was left intact.');
43
+ return value.outputs.find(item => sameOutput(item.identity, this.output))?.lease;
44
+ }
45
+ /** Enumerate other recovery records while holding the shared root lock. */
46
+ otherOutputs() {
47
+ const value = this.readDocument();
48
+ return this.output && value?.version === 2 ? value.outputs.map(item => item.identity).filter(identity => !sameOutput(identity, this.output)) : [];
49
+ }
50
+ readDocument() {
30
51
  let text;
31
52
  try {
32
53
  text = readFileSync(join(this.directory, 'state.json'), 'utf8');
@@ -38,7 +59,11 @@ export class SystemVolumeOwnership {
38
59
  }
39
60
  try {
40
61
  const value = JSON.parse(text);
41
- if (value?.version !== 1 || !validId(value.generation) || typeof value.changed !== 'boolean' || !(value.baseline === null ? !value.changed : validSnapshot(value.baseline)) || !Array.isArray(value.participants) || !value.participants.every(item => item && validId(item.id) && validPid(item.pid) && (item.phase === 'pending' || item.phase === 'active')) || new Set(value.participants.map(item => item.id)).size !== value.participants.length)
62
+ if (value?.version === 2) {
63
+ if (!Array.isArray(value.outputs) || value.outputs.length > 128 || !value.outputs.every(item => item && validOutput(item.identity) && validLease(item.lease)) || new Set(value.outputs.map(item => outputKey(item.identity))).size !== value.outputs.length)
64
+ throw new Error();
65
+ }
66
+ else if (!validLease(value))
42
67
  throw new Error();
43
68
  return value;
44
69
  }
@@ -47,6 +72,19 @@ export class SystemVolumeOwnership {
47
72
  }
48
73
  }
49
74
  save(value) {
75
+ if (!this.output) {
76
+ this.writeDocument(value);
77
+ return;
78
+ }
79
+ const existing = this.readDocument();
80
+ if (existing?.version === 1)
81
+ this.checkLegacy(existing);
82
+ const outputs = existing?.version === 2 ? existing.outputs.filter(item => !sameOutput(item.identity, this.output)) : [];
83
+ if (outputs.length >= 128)
84
+ throw new Error('Too many unresolved output-volume recovery records; saved output states were retained.');
85
+ this.writeDocument({ version: 2, outputs: [...outputs, { identity: this.output, lease: value }] });
86
+ }
87
+ writeDocument(value) {
50
88
  const temporary = join(this.directory, `.state-${process.pid}-${randomUUID()}`);
51
89
  try {
52
90
  writeFileSync(temporary, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
@@ -60,13 +98,33 @@ export class SystemVolumeOwnership {
60
98
  throw error;
61
99
  }
62
100
  }
63
- clear() { try {
64
- unlinkSync(join(this.directory, 'state.json'));
101
+ clear() {
102
+ if (this.output) {
103
+ const value = this.readDocument();
104
+ if (value?.version === 1)
105
+ this.checkLegacy(value);
106
+ if (value?.version === 2) {
107
+ const outputs = value.outputs.filter(item => !sameOutput(item.identity, this.output));
108
+ if (outputs.length) {
109
+ this.writeDocument({ version: 2, outputs });
110
+ return;
111
+ }
112
+ }
113
+ }
114
+ try {
115
+ unlinkSync(join(this.directory, 'state.json'));
116
+ }
117
+ catch (error) {
118
+ if (codeOf(error) !== 'ENOENT')
119
+ throw error;
120
+ }
121
+ }
122
+ checkLegacy(value) {
123
+ // Older Linux jobs recorded only the default sink's volume. That baseline
124
+ // cannot safely be assigned to whichever device happens to be default now.
125
+ if (value.changed || value.participants.some(item => item.phase === 'active' && volumeProcessAlive(item.pid)))
126
+ throw new Error('An earlier alarm saved output-volume ownership without a device identity. Finish its active alarms or repair the saved state.json after checking the original output; the saved state was retained and no output was changed.');
65
127
  }
66
- catch (error) {
67
- if (codeOf(error) !== 'ENOENT')
68
- throw error;
69
- } }
70
128
  /** A null PID is intentional: a crash before PID persistence is ambiguous. */
71
129
  beginNativeWrite() { this.saveNativeWrite(null); }
72
130
  recordNativeWritePid(pid) { if (!validPid(pid))
@@ -263,5 +321,15 @@ function validPid(value) { return Number.isSafeInteger(value) && Number(value) >
263
321
  function validId(value) { return typeof value === 'string' && /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/.test(value); }
264
322
  function validSnapshot(value) { if (!value || typeof value !== 'object')
265
323
  return false; const state = value; return Number.isInteger(state.volume) && state.volume >= 0 && state.volume <= 100 && typeof state.muted === 'boolean'; }
324
+ function validLease(value) { if (!value || typeof value !== 'object')
325
+ return false; const state = value; return state.version === 1 && validId(state.generation) && typeof state.changed === 'boolean' && (state.baseline === null ? !state.changed : validSnapshot(state.baseline)) && Array.isArray(state.participants) && state.participants.every(item => item && validId(item.id) && validPid(item.pid) && (item.phase === 'pending' || item.phase === 'active')) && new Set(state.participants.map(item => item.id)).size === state.participants.length; }
326
+ function validOutput(value) { if (!value || typeof value !== 'object')
327
+ return false; const output = value; return ['pipewire', 'pulse', 'pulse-pipewire'].includes(output.kind) && validLabel(output.server) && validLabel(output.name) && decimalId(output.id) && (output.kind === 'pulse' || decimalId(output.serial)) && (output.kind !== 'pulse-pipewire' || decimalId(output.nodeId) && validLabel(output.sinkName)); }
328
+ function validLabel(value) { return typeof value === 'string' && value.length > 0 && value.length <= 1024 && !/[\u0000-\u001f\u007f]/.test(value); }
329
+ function decimalId(value) { return typeof value === 'string' && /^\d{1,20}$/.test(value); }
330
+ // The PipeWire core, object serial and name identify a sink across both CLIs.
331
+ // Backend-specific IDs remain in the record for independent mutation checks.
332
+ function outputKey(value) { return JSON.stringify(value.kind === 'pulse' ? ['pulse', value.server, value.id, value.name] : ['pipewire', value.server, value.serial, value.name]); }
333
+ function sameOutput(first, second) { return outputKey(first) === outputKey(second); }
266
334
  function codeOf(error) { return error?.code; }
267
335
  function pause() { return new Promise(resolve => setTimeout(resolve, 20)); }
@@ -3,17 +3,41 @@ import { randomUUID } from 'node:crypto';
3
3
  import { resolveCommand } from '../platform/executables.js';
4
4
  import { identifyPlatform, nativeAdapters } from '../platform/runtime.js';
5
5
  import { defaultSystemVolumeDirectory, SystemVolumeOwnership, volumeProcessAlive } from './system-volume-ownership.js';
6
+ import { linuxVolumeBackend } from './system-volume-linux.js';
6
7
  export function createSystemVolumeController(platform = process.platform, run = runCommand, resolve = resolveCommand, options = {}) {
7
8
  const host = identifyPlatform({ platform });
8
9
  const adapter = nativeAdapters(host).volume;
9
- const backend = adapter === 'macos' ? macBackend(run) : adapter === 'unix-audio' ? linuxBackend(run, resolve) : adapter === 'windows' ? windowsBackend(run, resolve) : undefined;
10
+ const backend = adapter === 'macos' ? macBackend(run) : adapter === 'unix-audio' ? linuxVolumeBackend(run, resolve) : adapter === 'windows' ? windowsBackend(run, resolve) : undefined;
10
11
  return { async acquireMinimum(volume) {
11
12
  if (!backend)
12
13
  throw new Error(`System output volume control is unavailable on ${host.id === 'unknown' ? platform : host.id}.`);
13
- const ownership = new SystemVolumeOwnership(options.directory ?? defaultSystemVolumeDirectory(), options.lockTimeoutMs);
14
- const write = (value) => backend.write(value, run === runCommand ? (command, args) => runCommand(command, args, ownership) : run);
14
+ const output = await backend.select?.();
15
+ const directory = options.directory ?? defaultSystemVolumeDirectory();
16
+ const ownership = new SystemVolumeOwnership(directory, options.lockTimeoutMs, output);
17
+ const execute = run === runCommand ? (command, args) => runCommand(command, args, ownership) : run;
18
+ const write = (value) => backend.write(value, execute, output);
15
19
  return ownership.transaction(async () => {
16
20
  let state = ownership.read();
21
+ let pendingRecovery = 0;
22
+ // Recover dead owners on their still-connected original outputs even if
23
+ // the default has moved. An unplugged output remains repairable without
24
+ // preventing a different output from ringing.
25
+ for (const identity of ownership.otherOutputs()) {
26
+ const priorOutput = new SystemVolumeOwnership(directory, options.lockTimeoutMs, identity);
27
+ const prior = priorOutput.read();
28
+ if (!prior || prior.participants.some(activeParticipant))
29
+ continue;
30
+ if (prior.changed && prior.baseline) {
31
+ try {
32
+ await backend.write(prior.baseline, execute, identity);
33
+ }
34
+ catch {
35
+ pendingRecovery += 1;
36
+ continue;
37
+ }
38
+ }
39
+ priorOutput.clear();
40
+ }
17
41
  if (state) {
18
42
  state.participants = state.participants.filter(activeParticipant);
19
43
  if (state.participants.length === 0) {
@@ -26,7 +50,7 @@ export function createSystemVolumeController(platform = process.platform, run =
26
50
  }
27
51
  }
28
52
  const target = clamp(volume);
29
- const before = await backend.read();
53
+ const before = await backend.read(output);
30
54
  const changed = before.muted || before.volume < target;
31
55
  const prior = state;
32
56
  const id = randomUUID();
@@ -66,7 +90,7 @@ export function createSystemVolumeController(platform = process.platform, run =
66
90
  }
67
91
  let released = false;
68
92
  return {
69
- message: changed ? `Local output raised to at least ${target}% and unmuted for the alarm.` : `Local output was already at least ${target}% and unmuted.`,
93
+ message: (changed ? `Local output raised to at least ${target}% and unmuted for the alarm.` : `Local output was already at least ${target}% and unmuted.`) + (pendingRecovery ? ` ${pendingRecovery} earlier output recovery record${pendingRecovery === 1 ? ' remains' : 's remain'} unresolved and retained; its original device must be checked.` : ''),
70
94
  async release({ preserve = false } = {}) {
71
95
  if (released)
72
96
  return;
@@ -101,21 +125,6 @@ export function createSystemVolumeController(platform = process.platform, run =
101
125
  function activeParticipant(item) { return item.phase === 'active' && volumeProcessAlive(item.pid); }
102
126
  function macBackend(run) { return { async read() { const result = await checked(run('/usr/bin/osascript', ['-e', 'set s to get volume settings', '-e', 'return (output volume of s as text) & "," & (output muted of s as text)']), 'read macOS output volume'); const match = /^(\d+)\s*,\s*(true|false)/i.exec(result.stdout.trim()); if (!match)
103
127
  throw new Error('macOS returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: match[2]?.toLowerCase() === 'true' }; }, async write(value, execute = run) { await checked(execute('/usr/bin/osascript', ['-e', `set volume output volume ${clamp(value.volume)} ${value.muted ? 'with' : 'without'} output muted`]), 'set macOS output volume'); } }; }
104
- function linuxBackend(run, resolve) {
105
- const wpctl = resolve('wpctl');
106
- if (wpctl)
107
- return { async read() { const result = await checked(run(wpctl, ['get-volume', '@DEFAULT_AUDIO_SINK@']), 'read PipeWire output volume'); const match = /Volume:\s*([\d.]+)/i.exec(result.stdout); if (!match)
108
- throw new Error('wpctl returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1]) * 100), muted: /\[MUTED\]/i.test(result.stdout) }; }, async write(value, execute = run) { await checked(execute(wpctl, ['set-volume', '@DEFAULT_AUDIO_SINK@', `${clamp(value.volume)}%`]), 'set PipeWire output volume'); await checked(execute(wpctl, ['set-mute', '@DEFAULT_AUDIO_SINK@', value.muted ? '1' : '0']), 'set PipeWire mute state'); } };
109
- const pactl = resolve('pactl');
110
- if (pactl)
111
- return { async read() { const [volume, mute] = await Promise.all([checked(run(pactl, ['get-sink-volume', '@DEFAULT_SINK@']), 'read PulseAudio output volume'), checked(run(pactl, ['get-sink-mute', '@DEFAULT_SINK@']), 'read PulseAudio mute state')]); const match = /(\d+)%/.exec(volume.stdout); if (!match)
112
- throw new Error('pactl returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: /yes/i.test(mute.stdout) }; }, async write(value, execute = run) { await checked(execute(pactl, ['set-sink-volume', '@DEFAULT_SINK@', `${clamp(value.volume)}%`]), 'set PulseAudio output volume'); await checked(execute(pactl, ['set-sink-mute', '@DEFAULT_SINK@', value.muted ? '1' : '0']), 'set PulseAudio mute state'); } };
113
- const amixer = resolve('amixer');
114
- if (amixer)
115
- return { async read() { const result = await checked(run(amixer, ['get', 'Master']), 'read ALSA output volume'); const matches = [...result.stdout.matchAll(/\[(\d+)%\]/g)]; const last = matches.at(-1); if (!last)
116
- throw new Error('amixer returned an unreadable output-volume state.'); return { volume: clamp(Number(last[1])), muted: /\[off\]/i.test(result.stdout) }; }, async write(value, execute = run) { await checked(execute(amixer, ['set', 'Master', `${clamp(value.volume)}%`, value.muted ? 'mute' : 'unmute']), 'set ALSA output volume'); } };
117
- return undefined;
118
- }
119
128
  function windowsBackend(run, resolve) { const powershell = resolve('powershell.exe') ?? resolve('pwsh.exe'); if (!powershell)
120
129
  return undefined; return { async read() { const result = await checked(run(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `${windowsCoreAudio}; [RadioCliVolume]::Get()`]), 'read Windows output volume'); const match = /^(\d+)\s*,\s*(true|false)/im.exec(result.stdout); if (!match)
121
130
  throw new Error('Windows returned an unreadable output-volume state.'); return { volume: clamp(Number(match[1])), muted: match[2]?.toLowerCase() === 'true' }; }, async write(value, execute = run) { await checked(execute(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `${windowsCoreAudio}; [RadioCliVolume]::Set(${clamp(value.volume)}, $${value.muted ? 'true' : 'false'})`]), 'set Windows output volume'); } }; }
@@ -137,7 +146,7 @@ function runCommand(command, args, ownership) {
137
146
  ownership?.beginNativeWrite();
138
147
  let child;
139
148
  try {
140
- child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 10_000, killSignal: 'SIGKILL' });
149
+ child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, timeout: 10_000, killSignal: 'SIGKILL', env: { ...process.env, LC_ALL: 'C' } });
141
150
  }
142
151
  catch (error) {
143
152
  let message = messageOf(error);
@@ -153,8 +162,20 @@ function runCommand(command, args, ownership) {
153
162
  let stdout = '';
154
163
  let stderr = '';
155
164
  let failure;
156
- child.stdout.on('data', value => stdout += String(value));
157
- child.stderr.on('data', value => stderr += String(value));
165
+ const append = (value, output) => { const text = String(value); if (output === 'stdout')
166
+ stdout += text;
167
+ else
168
+ stderr += text; if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > 64 * 1024) {
169
+ stdout = stdout.slice(0, 32768);
170
+ stderr = stderr.slice(0, 32768);
171
+ failure = 'The output-volume command returned too much data.';
172
+ try {
173
+ child.kill('SIGKILL');
174
+ }
175
+ catch { }
176
+ } };
177
+ child.stdout.on('data', value => append(value, 'stdout'));
178
+ child.stderr.on('data', value => append(value, 'stderr'));
158
179
  // Spawn/kill errors do not prove the helper has closed. Keep ownership until
159
180
  // close, including when recording its PID fails after the process starts.
160
181
  child.on('error', error => { failure = error.message; });
@@ -0,0 +1,212 @@
1
+ export const listenerCommands = ['play', 'pause', 'resume', 'stop', 'next', 'previous', 'volume', 'mute', 'unmute', 'now', 'status', 'search', 'countries', 'favorites', 'recent', 'favorite', 'unfavorite', 'preset', 'presets', 'stats', 'completion'];
2
+ const commonFlags = ['--json', '--plain', '--quiet', '-q', '--no-input', '--no-color'];
3
+ const filterFlags = ['--country', '--language', '--codec', '--min-bitrate', '--limit'];
4
+ const commandOptions = {
5
+ play: ['--favorites', '--recent', '--popular', '--pick', '--source', '--random', '--keep-playing', ...filterFlags],
6
+ search: ['--pick', ...filterFlags],
7
+ favorites: ['--pick', '--limit'], recent: ['--pick', '--limit'],
8
+ countries: ['--limit'], now: ['--watch'], status: ['--watch']
9
+ };
10
+ const booleans = new Set([...commonFlags, '--favorites', '--recent', '--popular', '--pick', '--watch', '--random', '--keep-playing']);
11
+ export function isListenerCommand(value) {
12
+ return listenerCommands.includes(value);
13
+ }
14
+ export function parseListenerArgs(command, args) {
15
+ const options = new Map();
16
+ const words = [];
17
+ const allowed = new Set([...commonFlags, ...commandOptions[command] ?? []]);
18
+ for (let index = 0; index < args.length; index += 1) {
19
+ const arg = args[index];
20
+ if (arg === '--') {
21
+ words.push(...args.slice(index + 1));
22
+ break;
23
+ }
24
+ if (!arg.startsWith('-')) {
25
+ words.push(arg);
26
+ continue;
27
+ }
28
+ const equal = arg.indexOf('=');
29
+ const key = equal < 0 ? arg : arg.slice(0, equal);
30
+ if (!allowed.has(key))
31
+ throw new Error(`Unknown option: ${key}\nRun radiocli ${command} --help.`);
32
+ if (options.has(key))
33
+ throw new Error(`Option ${key} was supplied more than once.`);
34
+ if (booleans.has(key)) {
35
+ if (equal >= 0)
36
+ throw new Error(`${key} does not take a value.`);
37
+ options.set(key, true);
38
+ }
39
+ else {
40
+ const value = equal >= 0 ? arg.slice(equal + 1) : args[++index];
41
+ if (!value || value.startsWith('--'))
42
+ throw new Error(`${key} requires a value. Run radiocli ${command} --help.`);
43
+ options.set(key, value);
44
+ }
45
+ }
46
+ const flags = { json: options.has('--json'), plain: options.has('--plain'), quiet: options.has('--quiet') || options.has('-q'), noInput: options.has('--no-input'), noColor: options.has('--no-color') };
47
+ if (flags.json && flags.plain)
48
+ throw new Error('Choose either --json or --plain.');
49
+ if ((options.has('--pick') || options.has('--watch')) && (flags.json || flags.plain || flags.quiet || flags.noInput)) {
50
+ throw new Error('--pick and --watch require interactive output. Omit --json, --plain, --quiet, and --no-input.');
51
+ }
52
+ const sources = ['--favorites', '--recent', '--popular', '--source'].filter(option => options.has(option));
53
+ if (sources.length > 1)
54
+ throw new Error('Choose one station queue: --favorites, --recent, --popular, or --source.');
55
+ if (sources[0] && sources[0] !== '--source')
56
+ options.set('--source', sources[0] === '--favorites' ? 'favorite' : sources[0].slice(2));
57
+ if (options.get('--source') === 'favorites')
58
+ options.set('--source', 'favorite');
59
+ return { command, words, options, flags };
60
+ }
61
+ export function integerOption(value, fallback, label, minimum, maximum) {
62
+ if (value === undefined)
63
+ return fallback;
64
+ if (typeof value !== 'string' || !/^\d+$/.test(value) || Number(value) < minimum || Number(value) > maximum)
65
+ throw new Error(`${label} must be a whole number from ${minimum} through ${maximum}.`);
66
+ return Number(value);
67
+ }
68
+ const help = {
69
+ play: `play [station name|provider:id|preset] [options]
70
+ Start background playback, or control the open TUI. With no station, continue
71
+ the current station or play your most recent station when nothing is selected.
72
+ Paused playback resumes; playing audio continues uninterrupted.
73
+ Exact saved names and presets work offline.
74
+ Ambiguous names open a numbered chooser only in an interactive terminal.
75
+
76
+ --favorites Play from your favorites
77
+ --recent Play from your recently played stations
78
+ --popular Play from the directory's popular stations
79
+ --random Pick randomly from the chosen queue or search
80
+ --pick Choose a station by number
81
+ --keep-playing Keep an already playing or paused station
82
+ --country CODE --language NAME --codec NAME --min-bitrate KBPS
83
+ --limit 1-100 Search result count (default 20)
84
+
85
+ --source recent|favorites|popular|country also works; favorite is accepted
86
+ for compatibility. Explicit queue or filter options select a station anew.
87
+
88
+ Examples:
89
+ radiocli play jazz
90
+ radiocli pause
91
+ radiocli play
92
+ radiocli play "Groove Salad"
93
+ radiocli play --favorites --random
94
+ radiocli play --country JP
95
+ radiocli play work`,
96
+ search: `search <query> [options]
97
+ Find stations. Terminal tables mark favorites with *; piped output is TSV.
98
+ --pick Choose a result and start background playback
99
+ --country CODE Filter by two-letter country code
100
+ --language NAME Filter by language
101
+ --codec NAME Filter by audio codec
102
+ --min-bitrate KBPS Minimum bitrate (0-10000)
103
+ --limit 1-100 Maximum results (default 20)
104
+
105
+ Examples:
106
+ radiocli search jazz --country US --pick
107
+ radiocli search jazz --json
108
+ radiocli search jazz --plain`,
109
+ now: `now [--watch]
110
+ Show station, track metadata, output, playback state, and volume.
111
+ --watch updates a single terminal line. Ctrl+C exits the view; audio continues.
112
+
113
+ Examples:
114
+ radiocli now
115
+ radiocli now --watch
116
+ radiocli now --json`,
117
+ status: `status [--watch]
118
+ Alias for radiocli now. Use --json for the current playback snapshot.`,
119
+ favorites: `favorites [--limit 1-100] [--pick]
120
+ List favorite stations, or select one to play with --pick.
121
+ Save the active station with radiocli favorite.`,
122
+ recent: `recent [--limit 1-100] [--pick]
123
+ List recently played stations, newest first. Use --pick to tune one again.`,
124
+ favorite: `favorite [station name|provider:id|preset]
125
+ Save a station to favorites. With no argument, save the active station.
126
+ Example: radiocli favorite "Groove Salad"`,
127
+ unfavorite: `unfavorite [station name|provider:id|preset]
128
+ Remove a saved favorite. With no argument, use the active station.`,
129
+ preset: `preset <save|list|remove> [name] [station name|provider:id|preset]
130
+ save NAME [STATION] saves a shortcut; omit STATION to save the active station.
131
+ Saving an existing name replaces its station. Names are case-insensitive,
132
+ 1-40 letters, numbers, hyphens, or underscores, starting with a letter/number.
133
+ remove NAME removes only the shortcut. Presets are included in library backups.
134
+
135
+ Examples:
136
+ radiocli preset save work
137
+ radiocli preset save jazz "Example FM"
138
+ radiocli play work
139
+ radiocli preset remove work`,
140
+ presets: `presets
141
+ List saved station shortcuts. Alias for radiocli preset list.`,
142
+ countries: `countries [--limit 1-100]
143
+ Show the most popular countries (default 30).
144
+ Example: radiocli play --country JP`,
145
+ volume: `volume <0-100>
146
+ Set playback volume, or save it for the next session when playback is closed.
147
+ Example: radiocli volume 40`,
148
+ pause: `pause
149
+ Pause the active station. Use radiocli play to continue.`,
150
+ resume: `resume
151
+ Resume paused playback. radiocli play also resumes, and can restart after stop.`,
152
+ stop: `stop
153
+ Stop ordinary playback and close its background player. Alarms are separate.`,
154
+ next: `next
155
+ Play the next station in the current search, favorites, or recent queue.`,
156
+ previous: `previous
157
+ Play the previous station in the current queue.`,
158
+ mute: `mute
159
+ Mute active playback. Use radiocli unmute to restore sound.`,
160
+ unmute: `unmute
161
+ Restore sound without changing the saved volume.`,
162
+ stats: `stats
163
+ Show listening time, sessions, streaks, and your most-listened station.
164
+ Example: radiocli stats --json`,
165
+ completion: `completion <bash|zsh|fish|powershell>
166
+ Print a shell completion script. No shell configuration is changed.
167
+
168
+ Bash: source <(radiocli completion bash)
169
+ Zsh: source <(radiocli completion zsh) # after compinit
170
+ Fish: radiocli completion fish | source
171
+ PowerShell: radiocli completion powershell | Out-String | Invoke-Expression
172
+
173
+ Add the matching line to your shell profile to enable it in future sessions.`
174
+ };
175
+ export function listenerHelp(command) {
176
+ return `RadioCLI · ${command}\n\nUsage: radiocli ${help[command]}\n\nOutput options:\n --json Structured JSON\n --plain Stable tab-separated text without decoration\n --quiet, -q Suppress successful output\n --no-input Never prompt; use an exact station ID for ambiguous matches\n --no-color Disable color\n --help, -h Show this help\n`;
177
+ }
178
+ export function completionCandidates(words, presets) {
179
+ const current = words.at(-1) ?? '';
180
+ const command = words[0] ?? '';
181
+ const previous = words.at(-2);
182
+ let candidates;
183
+ if (words.length <= 1)
184
+ candidates = [...listenerCommands, 'help', 'version', 'check', 'doctor', 'setup', 'mcp', 'agent', 'media-controls', 'update', 'import', 'export', 'alarm'];
185
+ else if (command === 'help')
186
+ candidates = ['--all', ...listenerCommands, 'check', 'doctor', 'setup', 'mcp', 'agent', 'media-controls', 'update', 'import', 'export', 'alarm', 'version'];
187
+ else if (command === '--help' || command === '-h')
188
+ candidates = ['--all'];
189
+ else if (previous === '--source')
190
+ candidates = ['recent', 'favorites', 'favorite', 'popular', 'country'];
191
+ else if (previous === '--country')
192
+ candidates = ['US', 'GB', 'DE', 'FR', 'JP', 'CA', 'AU', 'BR', 'IN'];
193
+ else if (previous === '--codec')
194
+ candidates = ['MP3', 'AAC', 'OGG', 'FLAC'];
195
+ else if (previous === '--language')
196
+ candidates = ['english', 'spanish', 'french', 'german', 'japanese'];
197
+ else if (previous === '--limit')
198
+ candidates = ['10', '20', '50', '100'];
199
+ else if (previous === '--min-bitrate')
200
+ candidates = ['64', '128', '192', '320'];
201
+ else if (command === 'completion')
202
+ candidates = ['bash', 'zsh', 'fish', 'powershell'];
203
+ else if (command === 'preset' && words.length === 2)
204
+ candidates = ['save', 'list', 'remove'];
205
+ else if (command === 'volume')
206
+ candidates = ['0', '20', '40', '60', '80', '100'];
207
+ else if (isListenerCommand(command))
208
+ candidates = [...commonFlags, '--help', ...commandOptions[command] ?? [], ...(['play', 'favorite', 'unfavorite', 'preset'].includes(command) ? presets : [])];
209
+ else
210
+ candidates = ['--help'];
211
+ return [...new Set(candidates)].filter(candidate => candidate.startsWith(current));
212
+ }