mecomp_daemon/services/
backup.rs

1//! This module contains functions for:
2//! - importing/exporting specific playlists from/to .m3u files
3//! - importing/exporting all your dynamic playlists from/to .csv files
4
5use std::{
6    path::{Path, PathBuf},
7    str::FromStr,
8};
9
10use mecomp_core::errors::BackupError;
11use mecomp_storage::db::schemas::{
12    dynamic::{
13        DynamicPlaylist,
14        query::{Compile, Query},
15    },
16    song::Song,
17};
18
19use csv::{Reader, Writer};
20
21/// Validate a file path
22///
23/// # Arguments
24///
25/// * `path` - The path to validate
26/// * `extension` - The expected file extension
27/// * `exists` - Whether the file should exist or not
28///   * if true, the file must exist
29///   * if false, the file may not exist but will be overwritten if it does
30pub(crate) fn validate_file_path<P: AsRef<Path>>(
31    path: P,
32    extension: &str,
33    exists: bool,
34) -> Result<(), BackupError> {
35    let path = path.as_ref();
36    if path.is_dir() {
37        log::warn!("Path is a directory: {}", path.display());
38        Err(BackupError::PathIsDirectory(path.to_path_buf()))
39    } else if path.extension().is_none() || path.extension().unwrap() != extension {
40        log::warn!(
41            "Path has the wrong extension (wanted {extension}): {}",
42            path.display()
43        );
44        Err(BackupError::WrongExtension(
45            path.to_path_buf(),
46            extension.to_string(),
47        ))
48    } else if exists && !path.exists() {
49        log::warn!("Path does not exist: {}", path.display());
50        Err(BackupError::FileNotFound(path.to_path_buf()))
51    } else {
52        Ok(())
53    }
54}
55
56/// Exports the given dynamic playlists with the given `csv::Writer`
57pub(crate) fn export_dynamic_playlists<W: std::io::Write>(
58    dynamic_playlists: &[DynamicPlaylist],
59    mut writer: Writer<W>,
60) -> Result<(), BackupError> {
61    writer.write_record(["dynamic playlist name", "query"])?;
62    for dp in dynamic_playlists {
63        writer.write_record(&[dp.name.clone(), dp.query.compile_for_storage()])?;
64    }
65    writer.flush()?;
66
67    Ok(())
68}
69
70/// Import dynamic playlists from the given writer
71///
72/// Does not actually write the `DynamicPlaylist`s to the database
73pub(crate) fn import_dynamic_playlists<R: std::io::Read>(
74    mut reader: Reader<R>,
75) -> Result<Vec<DynamicPlaylist>, BackupError> {
76    let mut dynamic_playlists = Vec::new();
77    for (i, result) in reader.records().enumerate() {
78        let record = result?;
79        if record.len() != 2 {
80            return Err(BackupError::InvalidDynamicPlaylistFormat);
81        }
82        let name = record[0].to_string();
83        let query = record[1].to_string();
84        let query = Query::from_str(&query)
85            .map_err(|e| BackupError::InvalidDynamicPlaylistQuery(e.to_string(), i + 1))?;
86        dynamic_playlists.push(DynamicPlaylist {
87            name,
88            query,
89            id: DynamicPlaylist::generate_id(),
90        });
91    }
92    Ok(dynamic_playlists)
93}
94
95/// Export the given playlist (name and songs) to the given buffer as a .m3u file
96pub(crate) fn export_playlist<W: std::io::Write>(
97    playlist_name: &str,
98    songs: &[Song],
99    mut writer: W,
100) -> Result<(), BackupError> {
101    writeln!(writer, "#EXTM3U\n")?;
102    writeln!(writer, "#PLAYLIST:{playlist_name}\n")?;
103    for song in songs {
104        writeln!(
105            writer,
106            "#EXTINF:{},{} - {}",
107            song.runtime.as_secs(),
108            song.title,
109            song.artist.as_slice().join("; "),
110        )?;
111        if !song.genre.is_none() {
112            writeln!(writer, "#EXTGENRE:{}", song.genre.as_slice().join("; "))?;
113        }
114        if !song.album_artist.is_none() {
115            writeln!(
116                writer,
117                "#EXTALB:{}",
118                song.album_artist.as_slice().join("; ")
119            )?;
120        }
121        writeln!(writer, "{}\n", song.path.display())?;
122    }
123    Ok(())
124}
125
126/// Import a playlist from the given reader
127///
128/// Returns the playlist name a list of paths to the songs in the playlist
129pub(crate) fn import_playlist<R: std::io::Read>(
130    mut reader: R,
131) -> Result<(Option<String>, Vec<PathBuf>), BackupError> {
132    let mut playlist_name = None;
133    let mut songs = Vec::new();
134    let mut buffer = String::new();
135    reader.read_to_string(&mut buffer)?;
136    for (i, record) in buffer.lines().enumerate() {
137        if let Some(name) = record.strip_prefix("#PLAYLIST:") {
138            if name.is_empty() || playlist_name.is_some() {
139                // Playlist name is empty or already set
140                return Err(BackupError::PlaylistNameInvalidOrAlreadySet(i + 1));
141            }
142            playlist_name = Some(name.to_string());
143            continue;
144        }
145        if record.is_empty() || record.starts_with('#') {
146            continue;
147        }
148
149        songs.push(PathBuf::from(record));
150    }
151    Ok((playlist_name, songs))
152}
153
154#[cfg(test)]
155mod tests {
156    use std::time::Duration;
157
158    use super::*;
159    use mecomp_storage::db::schemas::dynamic::query::Query;
160    use one_or_many::OneOrMany;
161
162    use pretty_assertions::{assert_eq, assert_str_eq};
163    use rstest::rstest;
164
165    #[test]
166    fn test_export_import() {
167        let dynamic_playlists = vec![
168            DynamicPlaylist {
169                name: "test".into(),
170                query: Query::from_str("title = \"a song\"").unwrap(),
171                id: DynamicPlaylist::generate_id(),
172            },
173            DynamicPlaylist {
174                name: "test2".into(),
175                query: Query::from_str("artist CONTAINS \"an artist\"").unwrap(),
176                id: DynamicPlaylist::generate_id(),
177            },
178        ];
179
180        let mut buffer = Vec::new();
181        let writer = Writer::from_writer(&mut buffer);
182        export_dynamic_playlists(&dynamic_playlists, writer).unwrap();
183
184        let reader = Reader::from_reader(buffer.as_slice());
185        let imported_dynamic_playlists = import_dynamic_playlists(reader).unwrap();
186
187        assert_eq!(imported_dynamic_playlists.len(), 2);
188        assert_eq!(imported_dynamic_playlists[0].name, "test");
189        assert_eq!(
190            imported_dynamic_playlists[0].query.compile_for_storage(),
191            "title = \"a song\""
192        );
193        assert_eq!(imported_dynamic_playlists[1].name, "test2");
194        assert_eq!(
195            imported_dynamic_playlists[1].query.compile_for_storage(),
196            "artist CONTAINS \"an artist\""
197        );
198    }
199
200    #[test]
201    fn test_import_invalid() {
202        let buffer = r#"dynamic playlist name,query
203valid,title = "test"
204invalid"#
205            .as_bytes()
206            .to_vec();
207
208        let reader = Reader::from_reader(buffer.as_slice());
209        let result = import_dynamic_playlists(reader);
210        assert!(result.is_err());
211        assert_str_eq!(
212            result.unwrap_err().to_string(),
213            "CSV error: CSV error: record 2 (line: 3, byte: 49): found record with 1 fields, but the previous record has 2 fields"
214        );
215    }
216
217    #[test]
218    fn test_import_invalid_query() {
219        let buffer = r#"dynamic playlist name,query
220valid,title = "test"
221invalid,invalid query
222"#
223        .as_bytes()
224        .to_vec();
225
226        let reader = Reader::from_reader(buffer.as_slice());
227        let result = import_dynamic_playlists(reader);
228        assert!(result.is_err());
229        assert_str_eq!(
230            result.unwrap_err().to_string(),
231            "Error parsing dynamic playlist query in record 2: failed to parse field at 0, (inner: Mismatch at 0: seq [114, 101, 108, 101, 97, 115, 101, 95, 121, 101, 97, 114] expect: 114, found: 105)"
232        );
233    }
234
235    #[test]
236    fn test_export_playlist() {
237        let songs = vec![
238            Song {
239                id: Song::generate_id(),
240                title: "A Song".into(),
241                artist: OneOrMany::Many(vec!["Artist1".into(), "Artist2".into()]),
242                album_artist: "Album Artist".to_string().into(),
243                album: "Album1".into(),
244                genre: OneOrMany::Many(vec!["Genre1".into(), "Genre2".into()]),
245                runtime: Duration::from_secs(10),
246                track: None,
247                disc: None,
248                release_year: None,
249                extension: "mp3".into(),
250                path: PathBuf::from("foo/bar.mp3"),
251            },
252            Song {
253                id: Song::generate_id(),
254                title: "B Song".into(),
255                artist: "Artist1".to_string().into(),
256                album_artist: "Album Artist".to_string().into(),
257                album: "Album2".into(),
258                genre: "Genre1".to_string().into(),
259                runtime: Duration::from_secs(20),
260                track: None,
261                disc: None,
262                release_year: None,
263                extension: "mp3".into(),
264                path: PathBuf::from("foo/bar2.mp3"),
265            },
266            Song {
267                id: Song::generate_id(),
268                title: "C Song".into(),
269                artist: "Artist1".to_string().into(),
270                album_artist: "Album Artist".to_string().into(),
271                album: "Album3".into(),
272                genre: "Genre1".to_string().into(),
273                runtime: Duration::from_secs(30),
274                track: None,
275                disc: None,
276                release_year: None,
277                extension: "mp3".into(),
278                path: PathBuf::from("foo/bar3.mp3"),
279            },
280        ];
281
282        let mut buffer = Vec::new();
283        export_playlist("Test Playlist", &songs, &mut buffer).unwrap();
284        let result = String::from_utf8(buffer).unwrap();
285        let expected = r"#EXTM3U
286
287#PLAYLIST:Test Playlist
288
289#EXTINF:10,A Song - Artist1; Artist2
290#EXTGENRE:Genre1; Genre2
291#EXTALB:Album Artist
292foo/bar.mp3
293
294#EXTINF:20,B Song - Artist1
295#EXTGENRE:Genre1
296#EXTALB:Album Artist
297foo/bar2.mp3
298
299#EXTINF:30,C Song - Artist1
300#EXTGENRE:Genre1
301#EXTALB:Album Artist
302foo/bar3.mp3
303
304";
305        assert_str_eq!(result, expected);
306
307        let (playlist_name, songs) = import_playlist(result.as_bytes()).unwrap();
308        assert_eq!(playlist_name, Some("Test Playlist".to_string()));
309        assert_eq!(songs.len(), 3);
310        assert_eq!(songs[0], PathBuf::from("foo/bar.mp3"));
311        assert_eq!(songs[1], PathBuf::from("foo/bar2.mp3"));
312        assert_eq!(songs[2], PathBuf::from("foo/bar3.mp3"));
313    }
314
315    #[rstest]
316    #[case(
317        Some("Test Playlist".to_string()),
318        r"#EXTM3U
319#PLAYLIST:Test Playlist
320#EXTINF:10,A Song - Artist1; Artist2
321#EXTGENRE:Genre1; Genre2
322#EXTALB:Album Artist
323foo/bar.mp3
324#EXTINF:20,B Song - Artist1
325#EXTGENRE:Genre1
326#EXTALB:Album Artist
327foo/bar2.mp3
328#EXTINF:30,C Song - Artist1
329#EXTGENRE:Genre1
330#EXTALB:Album Artist
331foo/bar3.mp3
332"
333    )]
334    #[case::no_name(
335        None,
336        r"#EXTM3U
337#EXTINF:10,A Song - Artist1; Artist2
338#EXTGENRE:Genre1; Genre2
339#EXTALB:Album Artist
340foo/bar.mp3
341#EXTINF:20,B Song - Artist1
342#EXTGENRE:Genre1
343#EXTALB:Album Artist
344foo/bar2.mp3
345#EXTINF:30,C Song - Artist1
346#EXTGENRE:Genre1
347#EXTALB:Album Artist
348foo/bar3.mp3
349"
350    )]
351    #[case::no_metadata(
352        Some("Test Playlist".to_string()),
353        r"#EXTM3U
354#PLAYLIST:Test Playlist
355foo/bar.mp3
356foo/bar2.mp3
357foo/bar3.mp3
358"
359    )]
360    #[case::no_name_no_metadata(
361        None,
362        r"#EXTM3U
363foo/bar.mp3
364foo/bar2.mp3
365foo/bar3.mp3
366"
367    )]
368    fn test_import_playlist(#[case] expected_name: Option<String>, #[case] playlist: &str) {
369        let (playlist_name, songs) = import_playlist(playlist.as_bytes()).unwrap();
370        assert_eq!(playlist_name, expected_name);
371        assert_eq!(songs.len(), 3);
372        assert_eq!(songs[0], PathBuf::from("foo/bar.mp3"));
373        assert_eq!(songs[1], PathBuf::from("foo/bar2.mp3"));
374        assert_eq!(songs[2], PathBuf::from("foo/bar3.mp3"));
375    }
376}