• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dariusbakunas / audio-bridge / 21781950427

07 Feb 2026 02:56PM UTC coverage: 35.53% (-0.6%) from 36.117%
21781950427

push

github

dariusbakunas
add playback actions hook

3656 of 10290 relevant lines covered (35.53%)

1.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/crates/hub-cli/src/server_api.rs
1
use std::io::BufRead;
2
use std::path::{Path, PathBuf};
3

4
use anyhow::{Context, Result};
5
use serde::{Deserialize, Serialize, de::DeserializeOwned};
6
use audio_bridge_types::PlaybackStatus;
7

8
use crate::library::{LibraryItem, Track};
9

10
#[derive(Clone, Debug, Serialize, Deserialize)]
11
#[serde(tag = "kind", rename_all = "snake_case")]
12
enum LibraryEntry {
13
    Dir { path: String, name: String },
14
    Track {
15
        path: String,
16
        file_name: String,
17
        duration_ms: Option<u64>,
18
        sample_rate: Option<u32>,
19
        album: Option<String>,
20
        artist: Option<String>,
21
        format: String,
22
    },
23
}
24

25
#[derive(Clone, Debug, Serialize, Deserialize)]
26
struct LibraryResponse {
27
    dir: String,
28
    entries: Vec<LibraryEntry>,
29
}
30

31
type StatusResponse = PlaybackStatus;
32

33
#[derive(Clone, Debug, Serialize, Deserialize)]
34
struct OutputsResponse {
35
    active_id: Option<String>,
36
    outputs: Vec<OutputInfo>,
37
}
38

39
#[derive(Clone, Debug, Serialize, Deserialize)]
40
struct OutputInfo {
41
    id: String,
42
    kind: String,
43
    name: String,
44
    state: String,
45
    provider_id: Option<String>,
46
    provider_name: Option<String>,
47
    supported_rates: Option<SupportedRates>,
48
}
49

50
#[derive(Clone, Debug, Serialize, Deserialize)]
51
struct SupportedRates {
52
    min_hz: u32,
53
    max_hz: u32,
54
}
55

56
#[derive(Clone, Debug, Serialize, Deserialize)]
57
#[serde(tag = "kind", rename_all = "snake_case")]
58
enum QueueItem {
59
    Track {
60
        path: String,
61
        file_name: String,
62
        duration_ms: Option<u64>,
63
        sample_rate: Option<u32>,
64
        album: Option<String>,
65
        artist: Option<String>,
66
        format: String,
67
    },
68
    Missing { path: String },
69
}
70

71
#[derive(Clone, Debug, Serialize, Deserialize)]
72
struct QueueResponse {
73
    items: Vec<QueueItem>,
74
}
75

76
#[derive(Clone, Debug, Serialize, Deserialize)]
77
struct QueueAddRequest {
78
    paths: Vec<String>,
79
}
80

81
#[derive(Clone, Debug, Serialize, Deserialize)]
82
struct QueueRemoveRequest {
83
    path: String,
84
}
85

86
#[derive(Clone, Debug, Serialize, Deserialize)]
87
struct SeekRequest {
88
    ms: u64,
89
}
90

91
#[derive(Clone, Debug, Serialize, Deserialize)]
92
#[serde(rename_all = "snake_case")]
93
enum QueueMode {
94
    Keep,
95
    Replace,
96
    Append,
97
}
98

99
#[derive(Clone, Debug, Serialize, Deserialize)]
100
struct PlayRequest {
101
    path: String,
102
    #[serde(skip_serializing_if = "Option::is_none")]
103
    queue_mode: Option<QueueMode>,
104
}
105

106
pub(crate) fn list_entries(server: &str, dir: &Path) -> Result<Vec<LibraryItem>> {
×
107
    let dir_str = dir.to_string_lossy();
×
108
    let url = format!(
×
109
        "{}/library?dir={}",
110
        server.trim_end_matches('/'),
×
111
        urlencoding::encode(&dir_str)
×
112
    );
113
    let resp: LibraryResponse = read_json(
×
114
        ureq::get(&url)
×
115
            .call()
×
116
            .context("request /library")?,
×
117
        "library",
×
118
    )?;
×
119

120
    let mut out = Vec::with_capacity(resp.entries.len());
×
121
    for entry in resp.entries {
×
122
        match entry {
×
123
            LibraryEntry::Dir { path, name } => {
×
124
                out.push(LibraryItem::Dir { path: PathBuf::from(path), name });
×
125
            }
×
126
            LibraryEntry::Track { path, file_name, duration_ms, sample_rate, album, artist, format } => {
×
127
                out.push(LibraryItem::Track(Track {
×
128
                    path: PathBuf::from(path),
×
129
                    file_name,
×
130
                    duration_ms,
×
131
                    sample_rate,
×
132
                    album,
×
133
                    artist,
×
134
                    format,
×
135
                }));
×
136
            }
×
137
        }
138
    }
139
    Ok(out)
×
140
}
×
141

142
pub(crate) fn rescan(server: &str) -> Result<()> {
×
143
    let url = format!("{}/library/rescan", server.trim_end_matches('/'));
×
144
    let resp = ureq::post(&url)
×
145
        .send_empty()
×
146
        .context("request /library/rescan")?;
×
147
    if !resp.status().is_success() {
×
148
        return Err(anyhow::anyhow!("rescan failed with {}", resp.status()));
×
149
    }
×
150
    Ok(())
×
151
}
×
152

153
pub(crate) fn pause_toggle(server: &str) -> Result<()> {
×
154
    let url = format!("{}/pause", server.trim_end_matches('/'));
×
155
    let resp = ureq::post(&url)
×
156
        .send_empty()
×
157
        .context("request /pause")?;
×
158
    if !resp.status().is_success() {
×
159
        return Err(anyhow::anyhow!("pause failed with {}", resp.status()));
×
160
    }
×
161
    Ok(())
×
162
}
×
163

164
pub(crate) fn stop(server: &str) -> Result<()> {
×
165
    let url = format!("{}/stop", server.trim_end_matches('/'));
×
166
    let resp = ureq::post(&url)
×
167
        .send_empty()
×
168
        .context("request /stop")?;
×
169
    if !resp.status().is_success() {
×
170
        return Err(anyhow::anyhow!("stop failed with {}", resp.status()));
×
171
    }
×
172
    Ok(())
×
173
}
×
174

175
pub(crate) fn seek(server: &str, ms: u64) -> Result<()> {
×
176
    let url = format!("{}/seek", server.trim_end_matches('/'));
×
177
    let resp = ureq::post(&url)
×
178
        .send_json(SeekRequest { ms })
×
179
        .context("request /seek")?;
×
180
    if !resp.status().is_success() {
×
181
        return Err(anyhow::anyhow!("seek failed with {}", resp.status()));
×
182
    }
×
183
    Ok(())
×
184
}
×
185

186
pub(crate) type RemoteStatus = PlaybackStatus;
187

188
#[derive(Clone, Debug)]
189
pub(crate) struct RemoteOutput {
190
    pub(crate) id: String,
191
    pub(crate) name: String,
192
    pub(crate) provider_id: Option<String>,
193
    pub(crate) provider_name: Option<String>,
194
    pub(crate) supported_rates: Option<(u32, u32)>,
195
}
196

197
#[derive(Clone, Debug)]
198
pub(crate) struct RemoteOutputs {
199
    pub(crate) active_id: Option<String>,
200
    pub(crate) outputs: Vec<RemoteOutput>,
201
}
202

203
#[derive(Clone, Debug)]
204
pub(crate) struct RemoteQueueItem {
205
    pub(crate) path: PathBuf,
206
    pub(crate) meta: Option<crate::library::TrackMeta>,
207
}
208

209
pub(crate) struct RemoteQueue {
210
    pub(crate) items: Vec<RemoteQueueItem>,
211
}
212

213
pub(crate) fn status(server: &str) -> Result<RemoteStatus> {
×
214
    let base = server.trim_end_matches('/');
×
215
    let outputs = outputs(base)?;
×
216
    let Some(active_id) = outputs.active_id else {
×
217
        return Err(anyhow::anyhow!("no active output selected"));
×
218
    };
219
    status_for_output(base, &active_id)
×
220
}
×
221

222
pub(crate) fn status_for_output(server: &str, output_id: &str) -> Result<RemoteStatus> {
×
223
    let base = server.trim_end_matches('/');
×
224
    let url = format!(
×
225
        "{}/outputs/{}/status",
226
        base,
227
        urlencoding::encode(output_id)
×
228
    );
229
    let resp: StatusResponse = read_json(
×
230
        ureq::get(&url)
×
231
            .call()
×
232
            .context("request /outputs/{id}/status")?,
×
233
        "status",
×
234
    )?;
×
235
    Ok(resp)
×
236
}
×
237

238
pub(crate) fn outputs(server: &str) -> Result<RemoteOutputs> {
×
239
    let url = format!("{}/outputs", server.trim_end_matches('/'));
×
240
    let resp: OutputsResponse = read_json(
×
241
        ureq::get(&url)
×
242
            .call()
×
243
            .context("request /outputs")?,
×
244
        "outputs",
×
245
    )?;
×
246
    Ok(to_remote_outputs(resp))
×
247
}
×
248

249
pub(crate) fn outputs_stream<F>(server: &str, mut on_event: F) -> Result<()>
×
250
where
×
251
    F: FnMut(RemoteOutputs),
×
252
{
253
    let url = format!("{}/outputs/stream", server.trim_end_matches('/'));
×
254
    let resp = ureq::get(&url)
×
255
        .call()
×
256
        .context("request /outputs/stream")?;
×
257
    if !resp.status().is_success() {
×
258
        return Err(anyhow::anyhow!(
×
259
            "outputs stream failed with {}",
×
260
            resp.status()
×
261
        ));
×
262
    }
×
263
    let mut reader = std::io::BufReader::new(resp.into_body().into_reader());
×
264
    let mut line = String::new();
×
265
    let mut event_name = String::new();
×
266
    let mut data = String::new();
×
267
    loop {
268
        line.clear();
×
269
        let count = reader.read_line(&mut line)?;
×
270
        if count == 0 {
×
271
            break;
×
272
        }
×
273
        let trimmed = line.trim_end_matches(&['\n', '\r'][..]);
×
274
        if trimmed.is_empty() {
×
275
            if event_name == "outputs" && !data.is_empty() {
×
276
                let parsed: OutputsResponse = serde_json::from_str(&data)?;
×
277
                on_event(to_remote_outputs(parsed));
×
278
            }
×
279
            event_name.clear();
×
280
            data.clear();
×
281
            continue;
×
282
        }
×
283
        if trimmed.starts_with(':') {
×
284
            continue;
×
285
        }
×
286
        if let Some(rest) = trimmed.strip_prefix("event:") {
×
287
            event_name = rest.trim().to_string();
×
288
            continue;
×
289
        }
×
290
        if let Some(rest) = trimmed.strip_prefix("data:") {
×
291
            if !data.is_empty() {
×
292
                data.push('\n');
×
293
            }
×
294
            data.push_str(rest.trim_start());
×
295
        }
×
296
    }
297
    Ok(())
×
298
}
×
299

300
fn to_remote_outputs(resp: OutputsResponse) -> RemoteOutputs {
×
301
    let outputs = resp
×
302
        .outputs
×
303
        .into_iter()
×
304
        .map(|o| RemoteOutput {
×
305
            id: o.id,
×
306
            name: o.name,
×
307
            provider_id: o.provider_id,
×
308
            provider_name: o.provider_name,
×
309
            supported_rates: o.supported_rates.map(|r| (r.min_hz, r.max_hz)),
×
310
        })
×
311
        .collect();
×
312
    RemoteOutputs {
×
313
        active_id: resp.active_id,
×
314
        outputs,
×
315
    }
×
316
}
×
317

318
pub(crate) fn outputs_select(server: &str, id: &str) -> Result<()> {
×
319
    let url = format!("{}/outputs/select", server.trim_end_matches('/'));
×
320
    let body = serde_json::json!({ "id": id });
×
321
    let resp = ureq::post(&url)
×
322
        .send_json(body)
×
323
        .context("request /outputs/select")?;
×
324
    if !resp.status().is_success() {
×
325
        return Err(anyhow::anyhow!("outputs select failed with {}", resp.status()));
×
326
    }
×
327
    Ok(())
×
328
}
×
329

330
pub(crate) fn queue_list(server: &str) -> Result<RemoteQueue> {
×
331
    let url = format!("{}/queue", server.trim_end_matches('/'));
×
332
    let resp: QueueResponse = read_json(
×
333
        ureq::get(&url)
×
334
            .call()
×
335
            .context("request /queue")?,
×
336
        "queue",
×
337
    )?;
×
338
    let items = resp
×
339
        .items
×
340
        .into_iter()
×
341
        .map(|item| match item {
×
342
            QueueItem::Track {
343
                path,
×
344
                duration_ms,
×
345
                sample_rate,
×
346
                album,
×
347
                artist,
×
348
                format,
×
349
                ..
350
            } => RemoteQueueItem {
×
351
                path: PathBuf::from(path),
×
352
                meta: Some(crate::library::TrackMeta {
×
353
                    duration_ms,
×
354
                    sample_rate,
×
355
                    album,
×
356
                    artist,
×
357
                    format: Some(format),
×
358
                }),
×
359
            },
×
360
            QueueItem::Missing { path } => RemoteQueueItem {
×
361
                path: PathBuf::from(path),
×
362
                meta: None,
×
363
            },
×
364
        })
×
365
        .collect();
×
366
    Ok(RemoteQueue {
×
367
        items,
×
368
    })
×
369
}
×
370

371
pub(crate) fn queue_add(server: &str, paths: &[PathBuf]) -> Result<()> {
×
372
    let url = format!("{}/queue", server.trim_end_matches('/'));
×
373
    let body = QueueAddRequest {
×
374
        paths: paths
×
375
            .iter()
×
376
            .map(|p| p.to_string_lossy().to_string())
×
377
            .collect(),
×
378
    };
379
    let resp = ureq::post(&url)
×
380
        .send_json(body)
×
381
        .context("request /queue")?;
×
382
    if !resp.status().is_success() {
×
383
        return Err(anyhow::anyhow!("queue add failed with {}", resp.status()));
×
384
    }
×
385
    Ok(())
×
386
}
×
387

388
pub(crate) fn queue_add_next(server: &str, paths: &[PathBuf]) -> Result<()> {
×
389
    let url = format!("{}/queue/next/add", server.trim_end_matches('/'));
×
390
    let body = QueueAddRequest {
×
391
        paths: paths
×
392
            .iter()
×
393
            .map(|p| p.to_string_lossy().to_string())
×
394
            .collect(),
×
395
    };
396
    let resp = ureq::post(&url)
×
397
        .send_json(body)
×
398
        .context("request /queue/next/add")?;
×
399
    if !resp.status().is_success() {
×
400
        return Err(anyhow::anyhow!("queue add-next failed with {}", resp.status()));
×
401
    }
×
402
    Ok(())
×
403
}
×
404

405
pub(crate) fn queue_remove(server: &str, path: &Path) -> Result<()> {
×
406
    let url = format!("{}/queue/remove", server.trim_end_matches('/'));
×
407
    let body = QueueRemoveRequest {
×
408
        path: path.to_string_lossy().to_string(),
×
409
    };
×
410
    let resp = ureq::post(&url)
×
411
        .send_json(body)
×
412
        .context("request /queue/remove")?;
×
413
    if !resp.status().is_success() {
×
414
        return Err(anyhow::anyhow!("queue remove failed with {}", resp.status()));
×
415
    }
×
416
    Ok(())
×
417
}
×
418

419
pub(crate) fn queue_next(server: &str) -> Result<bool> {
×
420
    let url = format!("{}/queue/next", server.trim_end_matches('/'));
×
421
    let resp = ureq::post(&url)
×
422
        .send_empty()
×
423
        .context("request /queue/next")?;
×
424
    Ok(resp.status().is_success())
×
425
}
×
426

427
pub(crate) fn queue_clear(server: &str) -> Result<()> {
×
428
    let url = format!("{}/queue/clear", server.trim_end_matches('/'));
×
429
    let resp = ureq::post(&url)
×
430
        .send_empty()
×
431
        .context("request /queue/clear")?;
×
432
    if !resp.status().is_success() {
×
433
        return Err(anyhow::anyhow!("queue clear failed with {}", resp.status()));
×
434
    }
×
435
    Ok(())
×
436
}
×
437

438
pub(crate) fn play_replace(server: &str, path: &Path) -> Result<()> {
×
439
    let url = format!("{}/play", server.trim_end_matches('/'));
×
440
    let resp = ureq::post(&url)
×
441
        .send_json(PlayRequest {
×
442
            path: path.to_string_lossy().to_string(),
×
443
            queue_mode: Some(QueueMode::Replace),
×
444
        })
×
445
        .context("request /play (replace)")?;
×
446
    if !resp.status().is_success() {
×
447
        return Err(anyhow::anyhow!("play replace failed with {}", resp.status()));
×
448
    }
×
449
    Ok(())
×
450
}
×
451

452
pub(crate) fn play_keep(server: &str, path: &Path) -> Result<()> {
×
453
    let url = format!("{}/play", server.trim_end_matches('/'));
×
454
    let resp = ureq::post(&url)
×
455
        .send_json(PlayRequest {
×
456
            path: path.to_string_lossy().to_string(),
×
457
            queue_mode: Some(QueueMode::Keep),
×
458
        })
×
459
        .context("request /play (keep)")?;
×
460
    if !resp.status().is_success() {
×
461
        return Err(anyhow::anyhow!("play failed with {}", resp.status()));
×
462
    }
×
463
    Ok(())
×
464
}
×
465

466
fn read_json<T: DeserializeOwned>(
×
467
    mut resp: ureq::http::Response<ureq::Body>,
×
468
    label: &str,
×
469
) -> Result<T> {
×
470
    let body = resp
×
471
        .body_mut()
×
472
        .read_to_string()
×
473
        .with_context(|| format!("read /{label} response body"))?;
×
474
    serde_json::from_str(&body).with_context(|| format!("decode /{label} response"))
×
475
}
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc