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