cmus_notify/
lib.rs

1use crate::cmus::{TemplateProcessor, Track};
2#[cfg(feature = "debug")]
3use log::{debug, info};
4use std::path::Path;
5
6pub mod cmus;
7pub mod notification;
8pub mod settings;
9
10/// Extracts the first embedded picture from an ID3 tag of an Audio file.
11///
12/// # Arguments
13///
14/// * `track_path` - The path to the Audio file.
15///
16/// # Returns
17///
18/// Returns a `Result` containing a `TempFile` object with the contents of the extracted picture, or `None` if the MP3 file doesn't have any embedded pictures.
19/// In case of error, the `Result` will contain an error value of type `std::io::Error`.
20///
21/// # Example
22///
23/// ```ignore
24/// # use image::GenericImageView;
25/// # use cmus_notify::get_embedded_art;
26/// let result = get_embedded_art("/path/to/track.mp3");
27///
28/// match result {
29///     Ok(Some(dynamic_image)) => {
30///         // Use the image....
31///         println!("Track has an embedded picture {dimensions:?}", dimensions = dynamic_image.dimensions());
32///     }
33///     Ok(None) => println!("Track does not have an embedded picture"),
34///     Err(error) => println!("Error: {}", error),
35/// }
36/// ```
37pub fn get_embedded_art(track_path: &str) -> std::io::Result<Option<image::DynamicImage>> {
38    let tags = id3::Tag::read_from_path(track_path)
39        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
40    let Some(picture) = tags.pictures().next() else { return Ok(None); };
41    Ok(Some(image::load_from_memory(&picture.data).map_err(
42        |e| std::io::Error::new(std::io::ErrorKind::Other, e),
43    )?))
44}
45
46/// Searches for a file that matches the provided regular expression in the specified search directory and its subdirectories.
47///
48/// # Arguments
49///
50/// * `search_directory` - The directory to start the search from.
51/// * `max_depth` - The maximum number of parent directories to search in.
52/// * `regx` - The regular expression to match against the file names.
53///
54/// # Returns
55///
56/// Returns a `Result` containing the absolute path of the first file that matches the regular expression, or `None` if no such file is found.
57/// In case of error, the `Result` will contain an error value of type `std::io::Error`.
58///
59/// # Example
60///
61/// ```ignore
62/// # use regex::Regex;
63/// # use cmus_notify::search_for;
64/// let regx = Regex::new(r".\.lrc$").unwrap(); // Match .lrc files
65/// let result = search_for("tests/samples/Owl City/Cinematic", 2, &regx);
66///
67/// assert_eq!(result.unwrap(), Some("tests/samples/Owl City/Cinematic/08 - Always.lrc".to_string()));
68/// ```
69pub fn search_for(
70    search_directory: &str,
71    mut max_depth: u8,
72    regx: &regex::Regex,
73) -> std::io::Result<Option<String>> {
74    let mut search_directory = if Path::new(search_directory).is_file() {
75        #[cfg(feature = "debug")]
76        {
77            info!("The provided search directory is a file, searching in the parent directory.");
78        }
79        let Some(parent) = Path::new(search_directory).parent() else { return Ok(None); };
80        let Some(parent) = parent.to_str() else { return Ok(None); };
81        parent
82    } else {
83        search_directory
84    };
85    #[cfg(feature = "debug")]
86    {
87        info!("Searching for a file that matches the regular {regx:?} expression in \"{search_directory}\" and its subdirectories.");
88        info!("Max depth: {max_depth}");
89    }
90
91    loop {
92        if let Some(path) = search(search_directory, regx)? {
93            return Ok(Some(path));
94        }
95
96        if max_depth == 0 {
97            break Ok(None);
98        } else {
99            #[cfg(feature = "debug")]
100            {
101                info!("Could not find a file that matches the regular {regx:?} expression in \"{search_directory}\", searching in the parent directory.");
102                info!("Max depth: {max_depth}");
103            }
104            // If the max depth is not reached, search in the parent directory.
105            max_depth -= 1;
106            search_directory = {
107                let Some(parent) = Path::new(search_directory).parent() else { return Ok(None); };
108                let Some(parent) = parent.to_str() else { return Ok(None); };
109                parent
110            };
111        }
112    }
113}
114
115pub struct CompleteStr {
116    pub template: String,
117    pub str: String
118}
119
120/// The cover of a track.
121#[derive(PartialEq)]
122#[cfg_attr(any(feature = "debug", test), derive(Debug))]
123pub enum TrackCover {
124    /// The cover is embedded in the track.
125    /// The `DynamicImage` object contains the contents of the embedded picture.
126    Embedded(image::DynamicImage),
127    /// The cover is an external file.
128    /// The `String` contains the absolute path of the external file.
129    External(String),
130    /// The track does not have a cover.
131    None,
132}
133
134impl TrackCover {
135    pub fn set_notification_image(&self, notification: &mut notify_rust::Notification) {
136        use TrackCover::*;
137        match self {
138            Embedded(cover) => {
139                #[cfg(feature = "debug")]
140                debug!("Setting the cover as the notification image.");
141                let Ok(image) = notify_rust::Image::try_from(cover.clone()) else { return; };
142                notification.image_data(image);
143            }
144            External(path) => {
145                #[cfg(feature = "debug")]
146                debug!("Setting the cover as the notification image.");
147                notification.image_path(path);
148            }
149            None => {
150                #[cfg(feature = "debug")]
151                debug!("The track does not have a cover.");
152            }
153        }
154    }
155}
156
157/// Returns the cover of a track.
158/// If the track has an embedded cover, and `force_use_external_cover` is `false`, the embedded cover will be returned.
159/// If the track does not have an embedded cover, and `no_use_external_cover` is `false`, the function will search for an external cover.
160/// If the track has an embedded cover, and `force_use_external_cover` is `true`, the function will search for an external cover.
161#[inline]
162pub fn track_cover(
163    mut path: String,
164    track_name: &str,
165    max_depth: u8,
166    force_use_external_cover: bool,
167    no_use_external_cover: bool,
168) -> TrackCover {
169    if !force_use_external_cover {
170        #[cfg(feature = "debug")]
171        info!("Trying to get the embedded cover of \"{path}\".");
172        if let Ok(Some(cover)) = get_embedded_art(&path) {
173            return TrackCover::Embedded(cover);
174        }
175    }
176
177    if !no_use_external_cover {
178        let (Ok(regx), path) = (match path.split('/').last() {
179            Some(last_pat) if last_pat.contains("r#") => {
180                (regex::Regex::new(&last_pat.replace("r#", "")),
181                // Remove the last part of the path
182                path.remove(path.len() - last_pat.len() - 1).to_string())
183            }
184            _ => (regex::Regex::new(&format!(r"({track_name}).*\.(jpg|jpeg|png|gif)$")), path),
185        }) else {
186            #[cfg(feature = "debug")]
187            info!("Could not get the cover.");
188            return TrackCover::None;
189        };
190        #[cfg(feature = "debug")]
191        info!("Trying to get the external cover of \"{path}\".");
192        if let Ok(Some(cover)) = search_for(&path, max_depth, &regx) {
193            #[cfg(feature = "debug")]
194            info!("Found the external cover \"{cover}\".");
195            return TrackCover::External(cover);
196        }
197    }
198
199    #[cfg(feature = "debug")]
200    info!("Could not get the cover.");
201
202    TrackCover::None
203}
204
205#[inline]
206fn search(search_directory: &str, matcher: &regex::Regex) -> std::io::Result<Option<String>> {
207    for entry in std::fs::read_dir(search_directory)? {
208        let Ok(entry) = entry else { continue; };
209        let Ok(file_type) = entry.file_type() else { continue; };
210        if file_type.is_file() {
211            let Ok(file_name) = entry.file_name().into_string() else { continue; };
212            // Check if the file name matches the regular expression.
213            if matcher.is_match(&file_name) {
214                let path = entry.path();
215                let Some(path) = path.to_str() else { continue; };
216                return Ok(Some(path.to_string()));
217            }
218        }
219    }
220    Ok(None)
221}
222
223/// Replace all the placeholders in the template with their matching value.
224#[inline(always)]
225pub fn process_template_placeholders(
226    template: String,
227    track: &Track,
228    player_settings: &cmus::player_settings::PlayerSettings,
229) -> String {
230    let res = track.process(template);
231    player_settings.process(res)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::cmus::player_settings::PlayerSettings;
238    use std::str::FromStr;
239    use test_context::{test_context, TestContext};
240
241    struct TestContextWithFullTrack {
242        track: Track,
243        player_settings: PlayerSettings,
244    }
245
246    impl TestContext for TestContextWithFullTrack {
247        fn setup() -> Self {
248            Self {
249                track: cmus::Track::from_str(include_str!(
250                    "../tests/samples/cmus-remote-output-with-all-tags.txt"
251                ))
252                .unwrap(),
253                player_settings: PlayerSettings::from_str(include_str!(
254                    "../tests/samples/player_settings_mode-artist_vol-46_repeat-false_repeat_current-false_shuffle-tracks.txt"
255                ))
256                .unwrap(),
257            }
258        }
259    }
260
261    #[test_context(TestContextWithFullTrack)]
262    #[test]
263    fn test_process_path_template(ctx: &TestContextWithFullTrack) {
264        let cover_path_template = String::from("{title}/{artist}/{album}/{tracknumber}");
265        let cover_path =
266            process_template_placeholders(cover_path_template, &ctx.track, &ctx.player_settings);
267
268        assert_eq!(
269            cover_path,
270            "Photograph/Alex Goot/Alex Goot & Friends, Vol. 3/8"
271        );
272    }
273
274    #[test]
275    fn test_search_for_cover_with_the_cover_key_world() {
276        let cover_path = search_for(
277            "tests/samples/Owl City/Cinematic/cover",
278            1,
279            &regex::Regex::new(r"cover|.\.jpg|.\.png").unwrap(),
280        );
281
282        // assert_matches!(cover_path, Ok(Some(_)));
283        assert!(cover_path.is_ok());
284        let cover_path = cover_path.unwrap();
285        assert!(cover_path.is_some());
286        assert_eq!(
287            cover_path.unwrap(),
288            "tests/samples/Owl City/Cinematic/cover/cover.jpg"
289        );
290    }
291
292    #[test]
293    fn test_search_for_cover_without_the_cover_key_world() {
294        let cover_path = search_for(
295            "tests/samples/Owl City/Cinematic/cover",
296            1,
297            &regex::Regex::new(r".\.jpg|.\.png").unwrap(),
298        );
299
300        // assert_matches!(cover_path, Ok(Some(_)));
301        assert!(cover_path.is_ok());
302        let cover_path = cover_path.unwrap();
303        assert!(cover_path.is_some());
304        assert_eq!(
305            cover_path.unwrap(),
306            "tests/samples/Owl City/Cinematic/cover/cover.jpg"
307        );
308    }
309
310    #[test]
311    fn test_search_for_cover_without_the_cover_key_world_and_jpg() {
312        let cover_path = search_for(
313            "tests/samples/Owl City/Cinematic/cover",
314            1,
315            &regex::Regex::new(r".\.png").unwrap(),
316        );
317
318        // assert_matches!(cover_path, Ok(Some(_)));
319        assert!(cover_path.is_ok());
320        let cover_path = cover_path.unwrap();
321        assert!(cover_path.is_some());
322        assert_eq!(
323            cover_path.unwrap(),
324            "tests/samples/Owl City/Cinematic/cover/cover.png"
325        );
326    }
327
328    #[test]
329    fn test_search_for_lrc_file_started_from_the_cover_directory() {
330        let lrc_path = search_for(
331            "tests/samples/Owl City/Cinematic/cover",
332            1,
333            &regex::Regex::new(r".\.lrc").unwrap(),
334        );
335
336        // assert_matches!(lrc_path, Ok(Some(_)));
337        assert!(lrc_path.is_ok());
338        let lrc_path = lrc_path.unwrap();
339        assert!(lrc_path.is_some());
340        assert_eq!(
341            lrc_path.unwrap(),
342            "tests/samples/Owl City/Cinematic/08 - Always.lrc"
343        );
344    }
345
346    #[test]
347    fn test_search_for_not_exits_file() {
348        let result = search_for(
349            "tests/samples/Owl City/Cinematic/cover",
350            3,
351            &regex::Regex::new(r".\.mp3").unwrap(),
352        );
353
354        // assert_matches!(result, Ok(None));
355        assert!(result.is_ok());
356        let result = result.unwrap();
357        assert!(result.is_none());
358    }
359}