1use 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
18pub(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
49pub(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
63pub(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
88pub(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
119pub(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 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}