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(
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
50pub(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
64pub(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
89pub(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
120pub(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 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}