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