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