cmus_notify/cmus/
mod.rs

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    /// Returns a vector of keys found in the template.
20    /// The keys are the strings between curly braces.
21    fn get_keys(template: &str) -> Vec<String> {
22        let mut keys = Vec::new(); // Just a buffer to store the keys.
23        let mut key = String::new(); // Just a buffer to build the key.
24
25        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    /// Process the template with the track metadata.
95    /// The template is a string with placeholders that will be replaced with the track metadata.
96    /// The unknown placeholders will be skipped (don't replaced with anything, because they are maybe placeholders for player settings).
97    #[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            // Replace the key with their matching value if exists
111            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    /// Creates a `Track` from the output of `cmus-remote -Q`.
145    ///
146    /// Pares the first 4 lines.
147    /// The first line is the status, the second is the path, the third is the duration, and the fourth is the position.
148    /// The rest of the lines are tags, and the player settings, so we'll send them to `TrackMetadata::parse`, to get the tags.
149    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    /// Parse the tags from the rest of `cmus-remote -Q` output.
200    /// This function will assume you processed the first 4 lines, and remove them from the iterator.
201    ///
202    /// and also assume the all tags is contained in the iterator.
203    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; // Ignore lines that don't have a key and a value.
216                    };
217                    #[cfg(feature = "debug")]
218                    debug!("Inserting tag: {} = {}", key, value);
219                    tags.insert(key.to_string(), value.to_string());
220                }
221                _ => break, // We've reached the end of the tags.
222            }
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    /// Returns the name of the track.
235    ///
236    /// This is the title, if it exists, otherwise it's the file name without the extension.
237    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/// Make a status request to cmus.
251/// And collect the output, and parse it into a `CmusQueryResponse`.
252/// If the cmus is not running, or the socket is not available, this function will return an error.
253#[inline]
254pub fn ping_cmus(
255    query_command: &mut std::process::Command,
256) -> Result<CmusQueryResponse, CmusError> {
257    // Just run the command, and collect the output.
258    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/// Build the query command.
275/// This function it should call only one time entire the program life time, So it makes sense to make it inline.
276/// This function will return a `std::process::Command` that can be used to query cmus, you should store it in a variable :).
277#[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 there are more than 1 slice, then add the rest of the slices as arguments.
287    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); // Use the socket instead of the default socket.
293    }
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_matches!(track, Ok(_));
332        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}