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(
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
55pub(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
69pub(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
94pub(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
125pub(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 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}