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