1pub mod events;
2pub mod player_settings;
3pub mod query;
4
5use crate::cmus::query::CmusQueryResponse;
6#[cfg(feature = "debug")]
7use log::{debug, info};
8use parse_display::Display;
9use std::collections::HashMap;
10use std::fmt::Debug;
11use std::num::ParseIntError;
12use std::str::FromStr;
13use thiserror::Error;
14use typed_builder::TypedBuilder;
15
16pub trait TemplateProcessor {
17 fn process(&self, template: String) -> String;
18
19 fn get_keys(template: &str) -> Vec<String> {
22 let mut keys = Vec::new(); let mut key = String::new(); for c in template.chars() {
26 if c == '{' {
27 key = String::new();
28 } else if c == '}' {
29 #[cfg(feature = "debug")]
30 debug!("Found key: {}", key);
31 keys.push(key.clone());
32 } else {
33 key.push(c);
34 }
35 }
36
37 #[cfg(feature = "debug")]
38 debug!("Found keys: {:?}", keys);
39
40 keys
41 }
42}
43
44#[derive(PartialEq, Default, Clone)]
45#[cfg_attr(any(feature = "debug", test), derive(Debug))]
46pub struct TrackMetadata {
47 tags: HashMap<String, String>,
48}
49
50#[derive(Display, PartialEq, Default, Clone)]
51#[cfg_attr(any(feature = "debug", test), derive(Debug))]
52pub enum TrackStatus {
53 Playing,
54 Paused,
55 #[default]
56 Stopped,
57}
58
59#[derive(TypedBuilder, PartialEq, Default, Clone)]
60#[cfg_attr(any(feature = "debug", test), derive(Debug))]
61pub struct Track {
62 pub status: TrackStatus,
63 pub path: String,
64 pub metadata: TrackMetadata,
65 pub duration: u32,
66 pub position: u32,
67}
68
69#[derive(Debug, PartialEq, Error)]
70pub enum CmusError {
71 #[error("Cmus running error: {0}")]
72 CmusRunningError(String),
73 #[error("Unknown status")]
74 UnknownStatus,
75 #[error("No status")]
76 NoStatus,
77 #[error("Empty path")]
78 EmptyPath,
79 #[error("Duration error: {0}")]
80 DurationError(String),
81 #[error("Position error: {0}")]
82 PositionError(String),
83 #[error("Unknown error: {0}")]
84 UnknownError(String),
85 #[error("Unknown AAA mode: {0}")]
86 UnknownAAAMode(String),
87 #[error("Unknown shuffle mode: {0}")]
88 UnknownShuffleMode(String),
89 #[error("No events")]
90 NoEvents,
91}
92
93impl TemplateProcessor for Track {
94 #[inline]
98 fn process(&self, template: String) -> String {
99 #[cfg(feature = "debug")]
100 {
101 info!("Processing the template placeholders.");
102 debug!("Template: {template}");
103 debug!("Track: {self:?}");
104 }
105 let mut processed = template.to_string();
106
107 Self::get_keys(template.as_str()).iter().for_each(|key| {
108 #[cfg(feature = "debug")]
109 debug!("Replacing the placeholder {{{key}}} with its matching value.");
110 if let Some(value) = match key.as_str() {
112 "status" => Some(self.status.to_string()),
113 "title" => Some(self.get_name().to_string()),
114 "progress" => Some(format!("{:.2}/{:.2}", self.duration as f32 / 60.0, self.position as f32 / 60.0)),
115 _ => self.metadata.get(key).map(|r| r.to_string()),
116 } {
117 processed = processed.replace(&format!("{{{key}}}"), value.as_str());
118 }
119 });
120
121 #[cfg(feature = "debug")]
122 debug!("Processed template: {processed}");
123
124 processed
125 }
126}
127
128impl FromStr for TrackStatus {
129 type Err = CmusError;
130
131 fn from_str(s: &str) -> Result<Self, Self::Err> {
132 match s {
133 "playing" => Ok(TrackStatus::Playing),
134 "paused" => Ok(TrackStatus::Paused),
135 "stopped" => Ok(TrackStatus::Stopped),
136 _ => Err(CmusError::UnknownStatus),
137 }
138 }
139}
140
141impl FromStr for Track {
142 type Err = CmusError;
143
144 fn from_str(s: &str) -> Result<Self, Self::Err> {
150 #[cfg(feature = "debug")]
151 info!("Parsing track from string: {}", s);
152
153 let mut lines = s.lines();
154
155 Ok(Track::builder()
156 .status(TrackStatus::from_str(
157 lines
158 .next()
159 .ok_or(CmusError::NoStatus)?
160 .split_once(' ')
161 .ok_or(CmusError::NoStatus)?
162 .1,
163 )?)
164 .path(
165 lines
166 .next()
167 .ok_or(CmusError::EmptyPath)?
168 .split_once(' ')
169 .ok_or(CmusError::EmptyPath)?
170 .1
171 .to_string(),
172 )
173 .duration(
174 lines
175 .next()
176 .ok_or(CmusError::DurationError("Missing duration".to_string()))?
177 .split_once(' ')
178 .ok_or(CmusError::DurationError("Empty duration".to_string()))?
179 .1
180 .parse()
181 .map_err(|e: ParseIntError| CmusError::DurationError(e.to_string()))?,
182 )
183 .position(
184 lines
185 .next()
186 .ok_or(CmusError::PositionError("Missing position".to_string()))?
187 .split_once(' ')
188 .ok_or(CmusError::PositionError("Empty position".to_string()))?
189 .1
190 .parse()
191 .map_err(|e: ParseIntError| CmusError::PositionError(e.to_string()))?,
192 )
193 .metadata(TrackMetadata::parse(lines))
194 .build())
195 }
196}
197
198impl TrackMetadata {
199 fn parse<'a>(lines: impl Iterator<Item = &'a str> + Debug) -> Self {
204 #[cfg(feature = "debug")]
205 info!("Parsing track metadata from lines: {:?}", lines);
206
207 let mut tags = HashMap::new();
208
209 for line in lines {
210 #[cfg(feature = "debug")]
211 debug!("Parsing line: {}", line);
212 match line.trim().split_once(' ') {
213 Some(("tag", rest)) => {
214 let Some((key, value)) = rest.split_once(' ') else {
215 continue; };
217 #[cfg(feature = "debug")]
218 debug!("Inserting tag: {} = {}", key, value);
219 tags.insert(key.to_string(), value.to_string());
220 }
221 _ => break, }
223 }
224
225 TrackMetadata { tags }
226 }
227
228 pub fn get(&self, key: &str) -> Option<&str> {
229 self.tags.get(key).map(|s| s.as_str())
230 }
231}
232
233impl Track {
234 pub fn get_name(&self) -> &str {
238 self.metadata.get("title").unwrap_or_else(|| {
239 self.path
240 .split('/')
241 .last()
242 .unwrap_or("")
243 .split_once('.')
244 .unwrap_or(("", ""))
245 .0
246 })
247 }
248}
249
250#[inline]
254pub fn ping_cmus(
255 query_command: &mut std::process::Command,
256) -> Result<CmusQueryResponse, CmusError> {
257 let output = query_command
259 .output()
260 .map_err(|e| CmusError::CmusRunningError(e.to_string()))?;
261
262 if !output.status.success() {
263 return Err(CmusError::CmusRunningError(
264 String::from_utf8(output.stderr).map_err(|e| CmusError::UnknownError(e.to_string()))?,
265 ));
266 }
267
268 let output =
269 String::from_utf8(output.stdout).map_err(|e| CmusError::UnknownError(e.to_string()))?;
270
271 CmusQueryResponse::from_str(&output).map_err(CmusError::UnknownError)
272}
273
274#[inline(always)]
278pub fn build_query_command(
279 cmus_remote_bin: &str,
280 socket_addr: &Option<String>,
281 socket_pass: &Option<String>,
282) -> std::process::Command {
283 let cmd_arr = cmus_remote_bin.split_whitespace().collect::<Vec<_>>();
284 let mut command = std::process::Command::new(cmd_arr[0]);
285
286 if cmd_arr.len() > 1 {
288 command.args(&cmd_arr[1..]);
289 }
290
291 if let Some(socket_addr) = socket_addr {
292 command.arg("--server").arg(socket_addr); }
294
295 if let Some(socket_pass) = socket_pass {
296 command.arg("--passwd").arg(socket_pass);
297 }
298
299 command.arg("-Q");
300
301 command
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 const OUTPUT_WITH_ALL_TAGS: &str =
309 include_str!("../../tests/samples/cmus-remote-output-with-all-tags.txt");
310
311 const SOME_TAGS: &str = r#"tag artist Alex Goot
312 tag album Alex Goot & Friends, Vol. 3
313 tag title Photograph
314 tag date 2014
315 tag genre Pop
316 tag discnumber 1
317 tag tracknumber 8
318 tag albumartist Alex Goot
319 tag replaygain_track_gain -9.4 dB
320 tag composer Chad Kroeger
321 tag label mudhutdigital.com
322 tag publisher mudhutdigital.com
323 tag bpm 146
324 set aaa_mode artist
325 ..."#;
326
327 #[test]
328 fn test_create_track_from_str() {
329 let track = Track::from_str(OUTPUT_WITH_ALL_TAGS);
330
331 assert!(track.is_ok());
333
334 let track = track.unwrap();
335
336 assert_eq!(track.status, TrackStatus::Playing);
337 assert_eq!(track.path, "/mnt/Data/Music/FLAC/Alex Goot/Alex Goot - Alex Goot & Friends, Vol. 3/08 - Photograph.mp3");
338 assert_eq!(track.duration, 284);
339 assert_eq!(track.position, 226);
340 assert_eq!(
341 track.metadata.tags.get("artist"),
342 Some(&"Alex Goot".to_string())
343 );
344 assert_eq!(
345 track.metadata.tags.get("album"),
346 Some(&"Alex Goot & Friends, Vol. 3".to_string())
347 );
348 assert_eq!(
349 track.metadata.tags.get("title"),
350 Some(&"Photograph".to_string())
351 );
352 assert_eq!(track.metadata.tags.get("date"), Some(&"2014".to_string()));
353 assert_eq!(track.metadata.tags.get("genre"), Some(&"Pop".to_string()));
354 assert_eq!(
355 track.metadata.tags.get("discnumber"),
356 Some(&"1".to_string())
357 );
358 assert_eq!(
359 track.metadata.tags.get("tracknumber"),
360 Some(&"8".to_string())
361 );
362 assert_eq!(
363 track.metadata.tags.get("albumartist"),
364 Some(&"Alex Goot".to_string())
365 );
366 assert_eq!(
367 track.metadata.tags.get("replaygain_track_gain"),
368 Some(&"-9.4 dB".to_string())
369 );
370 assert_eq!(
371 track.metadata.tags.get("composer"),
372 Some(&"Chad Kroeger".to_string())
373 );
374 assert_eq!(
375 track.metadata.tags.get("label"),
376 Some(&"mudhutdigital.com".to_string())
377 );
378 assert_eq!(
379 track.metadata.tags.get("publisher"),
380 Some(&"mudhutdigital.com".to_string())
381 );
382 assert_eq!(track.metadata.tags.get("bpm"), Some(&"146".to_string()));
383 }
384
385 #[test]
386 fn test_parse_metadata_from_the_string() {
387 let metadata = TrackMetadata::parse(SOME_TAGS.lines());
388
389 assert_eq!(metadata.tags.get("artist"), Some(&"Alex Goot".to_string()));
390 assert_eq!(
391 metadata.tags.get("album"),
392 Some(&"Alex Goot & Friends, Vol. 3".to_string())
393 );
394 assert_eq!(metadata.tags.get("title"), Some(&"Photograph".to_string()));
395 assert_eq!(metadata.tags.get("date"), Some(&"2014".to_string()));
396 assert_eq!(metadata.tags.get("genre"), Some(&"Pop".to_string()));
397 assert_eq!(metadata.tags.get("discnumber"), Some(&"1".to_string()));
398 assert_eq!(metadata.tags.get("tracknumber"), Some(&"8".to_string()));
399 assert_eq!(
400 metadata.tags.get("albumartist"),
401 Some(&"Alex Goot".to_string())
402 );
403 assert_eq!(
404 metadata.tags.get("replaygain_track_gain"),
405 Some(&"-9.4 dB".to_string())
406 );
407 assert_eq!(
408 metadata.tags.get("composer"),
409 Some(&"Chad Kroeger".to_string())
410 );
411 assert_eq!(
412 metadata.tags.get("label"),
413 Some(&"mudhutdigital.com".to_string())
414 );
415 assert_eq!(
416 metadata.tags.get("publisher"),
417 Some(&"mudhutdigital.com".to_string())
418 );
419 assert_eq!(metadata.tags.get("bpm"), Some(&"146".to_string()));
420 }
421
422 #[test]
423 fn test_build_the_query_command_with_no_custom_socket_and_no_pass() {
424 let command = build_query_command("cmus-remote", &None, &None);
425
426 assert_eq!(command.get_program(), "cmus-remote");
427 assert_eq!(command.get_args().collect::<Vec<_>>(), &["-Q"]);
428 }
429
430 #[test]
431 fn test_build_the_query_command_with_custom_socket_and_no_pass() {
432 let command =
433 build_query_command("cmus-remote", &Some("/tmp/cmus-socket".to_string()), &None);
434
435 assert_eq!(command.get_program(), "cmus-remote");
436 assert_eq!(
437 command.get_args().collect::<Vec<_>>(),
438 &["--server", "/tmp/cmus-socket", "-Q"]
439 );
440 }
441
442 #[test]
443 fn test_build_the_query_command_with_custom_socket_and_pass() {
444 let command = build_query_command(
445 "cmus-remote",
446 &Some("/tmp/cmus-socket".to_string()),
447 &Some("pass".to_string()),
448 );
449
450 assert_eq!(command.get_program(), "cmus-remote");
451 assert_eq!(
452 command.get_args().collect::<Vec<_>>(),
453 &["--server", "/tmp/cmus-socket", "--passwd", "pass", "-Q"]
454 );
455 }
456
457 #[test]
458 fn test_build_the_query_command_with_custom_bin_path() {
459 let command = build_query_command("flatpak run io.github.cmus.cmus", &None, &None);
460
461 assert_eq!(command.get_program(), "flatpak");
462 assert_eq!(
463 command.get_args().collect::<Vec<_>>(),
464 &["run", "io.github.cmus.cmus", "-Q"]
465 );
466 }
467}