1use std::{
2 ops::{Range, RangeInclusive},
3 path::PathBuf,
4 str::FromStr,
5 sync::Arc,
6 time::Duration,
7};
8
9use anyhow::Result;
10use lofty::{config::WriteOptions, file::TaggedFileExt, prelude::*, probe::Probe, tag::Accessor};
11use one_or_many::OneOrMany;
12use rand::{Rng, seq::IteratorRandom};
13#[cfg(feature = "db")]
14use surrealdb::{
15 Connection, Surreal,
16 engine::local::{Db, Mem},
17 sql::Id,
18};
19
20#[cfg(not(feature = "db"))]
21use crate::db::schemas::Id;
22#[cfg(feature = "analysis")]
23use crate::db::schemas::analysis::Analysis;
24use crate::db::schemas::{
25 album::Album,
26 artist::Artist,
27 collection::Collection,
28 playlist::Playlist,
29 song::{Song, SongChangeSet, SongMetadata},
30};
31
32pub const ARTIST_NAME_SEPARATOR: &str = ", ";
33
34#[cfg(feature = "db")]
41#[allow(clippy::missing_inline_in_public_items)]
42pub async fn init_test_database() -> surrealdb::Result<Surreal<Db>> {
43 use crate::db::{
44 queries::relations::define_relation_tables, schemas::dynamic::DynamicPlaylist,
45 };
46
47 let db = Surreal::new::<Mem>(()).await?;
48 db.use_ns("test").use_db("test").await?;
49
50 crate::db::register_custom_analyzer(&db).await?;
51 surrealqlx::register_tables!(
52 &db,
53 Album,
54 Artist,
55 Song,
56 Collection,
57 Playlist,
58 DynamicPlaylist
59 )?;
60 #[cfg(feature = "analysis")]
61 surrealqlx::register_tables!(&db, Analysis)?;
62
63 define_relation_tables(&db).await?;
64
65 Ok(db)
66}
67
68#[cfg(feature = "db")]
95#[allow(clippy::missing_inline_in_public_items)]
96pub async fn init_test_database_with_state<SCF>(
97 song_count: std::num::NonZero<usize>,
98 mut song_case_func: SCF,
99 dynamic: Option<crate::db::schemas::dynamic::DynamicPlaylist>,
100 tempdir: &tempfile::TempDir,
101) -> Arc<Surreal<Db>>
102where
103 SCF: FnMut(usize) -> (SongCase, bool, bool) + Send + Sync,
104{
105 use anyhow::Context;
106
107 use crate::db::schemas::dynamic::DynamicPlaylist;
108
109 let db = Arc::new(init_test_database().await.unwrap());
110
111 let playlist = Playlist {
113 id: Playlist::generate_id(),
114 name: "Playlist 0".into(),
115 runtime: Duration::from_secs(0),
116 song_count: 0,
117 };
118 let playlist = Playlist::create(&db, playlist).await.unwrap().unwrap();
119
120 let collection = Collection {
121 id: Collection::generate_id(),
122 name: "Collection 0".into(),
123 runtime: Duration::from_secs(0),
124 song_count: 0,
125 };
126 let collection = Collection::create(&db, collection).await.unwrap().unwrap();
127
128 if let Some(dynamic) = dynamic {
129 let _ = DynamicPlaylist::create(&db, dynamic)
130 .await
131 .unwrap()
132 .unwrap();
133 }
134
135 for i in 0..(song_count.get()) {
137 let (song_case, add_to_playlist, add_to_collection) = song_case_func(i);
138
139 let metadata = create_song_metadata(tempdir, song_case.clone())
140 .context(format!(
141 "failed to create metadata for song case {song_case:?}"
142 ))
143 .unwrap();
144
145 let song = Song::try_load_into_db(&db, metadata)
146 .await
147 .context(format!(
148 "Failed to load into db the song case: {song_case:?}"
149 ))
150 .unwrap();
151
152 if add_to_playlist {
153 Playlist::add_songs(&db, playlist.id.clone(), vec![song.id.clone()])
154 .await
155 .unwrap();
156 }
157 if add_to_collection {
158 Collection::add_songs(&db, collection.id.clone(), vec![song.id.clone()])
159 .await
160 .unwrap();
161 }
162 }
163
164 db
165}
166
167#[cfg(feature = "db")]
179#[allow(clippy::missing_inline_in_public_items)]
180pub async fn create_song_with_overrides<C: Connection>(
181 db: &Surreal<C>,
182 SongCase {
183 song,
184 artists,
185 album_artists,
186 album,
187 genre,
188 }: SongCase,
189 overrides: SongChangeSet,
190) -> Result<Song> {
191 let id = Song::generate_id();
192 let song = Song {
193 id: id.clone(),
194 title: Into::into(format!("Song {song}").as_str()),
195 artist: artists
196 .iter()
197 .map(|a| format!("Artist {a}"))
198 .collect::<Vec<_>>()
199 .into(),
200 album_artist: album_artists
201 .iter()
202 .map(|a| format!("Artist {a}"))
203 .collect::<Vec<_>>()
204 .into(),
205 album: format!("Album {album}"),
206 genre: OneOrMany::One(format!("Genre {genre}")),
207 runtime: Duration::from_secs(120),
208 track: None,
209 disc: None,
210 release_year: None,
211 extension: "mp3".into(),
212 path: PathBuf::from_str(&format!("{}.mp3", id.key()))?,
213 };
214
215 Song::create(db, song.clone()).await?;
216 if overrides != SongChangeSet::default() {
217 Song::update(db, song.id.clone(), overrides).await?;
218 }
219 let song = Song::read(db, song.id).await?.expect("Song should exist");
220 Ok(song)
221}
222
223#[allow(clippy::missing_inline_in_public_items)]
232pub fn create_song_metadata(
233 tempdir: &tempfile::TempDir,
234 SongCase {
235 song,
236 artists,
237 album_artists,
238 album,
239 genre,
240 }: SongCase,
241) -> Result<SongMetadata> {
242 let base_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
244 .join("../assets/music.mp3")
245 .canonicalize()?;
246
247 let mut tagged_file = Probe::open(&base_path)?.read()?;
248 let tag = match tagged_file.primary_tag_mut() {
249 Some(primary_tag) => primary_tag,
250 None => tagged_file
254 .first_tag_mut()
255 .ok_or_else(|| anyhow::anyhow!("ERROR: No tags found"))?,
256 };
257
258 tag.insert_text(
259 ItemKey::AlbumArtist,
260 album_artists
261 .iter()
262 .map(|a| format!("Artist {a}"))
263 .collect::<Vec<_>>()
264 .join(ARTIST_NAME_SEPARATOR),
265 );
266
267 tag.remove_artist();
268 tag.set_artist(
269 artists
270 .iter()
271 .map(|a| format!("Artist {a}"))
272 .collect::<Vec<_>>()
273 .join(ARTIST_NAME_SEPARATOR),
274 );
275
276 tag.remove_album();
277 tag.set_album(format!("Album {album}"));
278
279 tag.remove_title();
280 tag.set_title(format!("Song {song}"));
281
282 tag.remove_genre();
283 tag.set_genre(format!("Genre {genre}"));
284
285 let new_path = tempdir.path().join(format!("song_{}.mp3", Id::ulid()));
286 std::fs::copy(&base_path, &new_path)?;
288 tag.save_to_path(&new_path, WriteOptions::default())?;
290
291 Ok(SongMetadata::load_from_path(
293 new_path,
294 &OneOrMany::One(ARTIST_NAME_SEPARATOR.to_string()),
295 &OneOrMany::None,
296 None,
297 )?)
298}
299
300#[derive(Debug, Clone)]
301pub struct SongCase {
302 pub song: u8,
303 pub artists: Vec<u8>,
304 pub album_artists: Vec<u8>,
305 pub album: u8,
306 pub genre: u8,
307}
308
309impl SongCase {
310 #[must_use]
311 #[inline]
312 pub const fn new(
313 song: u8,
314 artists: Vec<u8>,
315 album_artists: Vec<u8>,
316 album: u8,
317 genre: u8,
318 ) -> Self {
319 Self {
320 song,
321 artists,
322 album_artists,
323 album,
324 genre,
325 }
326 }
327}
328
329#[inline]
330pub const fn arb_song_case() -> impl Fn() -> SongCase {
331 || {
332 let artist_item_strategy = move || {
333 (0..=10u8)
334 .choose(&mut rand::thread_rng())
335 .unwrap_or_default()
336 };
337 let rng = &mut rand::thread_rng();
338 let artists = arb_vec(&artist_item_strategy, 1..=10)()
339 .into_iter()
340 .collect::<std::collections::HashSet<_>>()
341 .into_iter()
342 .collect::<Vec<_>>();
343 let album_artists = arb_vec(&artist_item_strategy, 1..=10)()
344 .into_iter()
345 .collect::<std::collections::HashSet<_>>()
346 .into_iter()
347 .collect::<Vec<_>>();
348 let song = (0..=10u8).choose(rng).unwrap_or_default();
349 let album = (0..=10u8).choose(rng).unwrap_or_default();
350 let genre = (0..=10u8).choose(rng).unwrap_or_default();
351
352 SongCase::new(song, artists, album_artists, album, genre)
353 }
354}
355
356#[inline]
357pub const fn arb_vec<T>(
358 item_strategy: &impl Fn() -> T,
359 range: RangeInclusive<usize>,
360) -> impl Fn() -> Vec<T> + '_
361where
362 T: Clone + std::fmt::Debug + Sized,
363{
364 move || {
365 let size = range
366 .clone()
367 .choose(&mut rand::thread_rng())
368 .unwrap_or_default();
369 std::iter::repeat_with(item_strategy).take(size).collect()
370 }
371}
372
373pub enum IndexMode {
374 InBounds,
375 OutOfBounds,
376}
377
378#[inline]
379pub const fn arb_vec_and_index<T>(
380 item_strategy: &impl Fn() -> T,
381 range: RangeInclusive<usize>,
382 index_mode: IndexMode,
383) -> impl Fn() -> (Vec<T>, usize) + '_
384where
385 T: Clone + std::fmt::Debug + Sized,
386{
387 move || {
388 let vec = arb_vec(item_strategy, range.clone())();
389 let index = match index_mode {
390 IndexMode::InBounds => 0..vec.len(),
391 #[allow(clippy::range_plus_one)]
392 IndexMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
393 }
394 .choose(&mut rand::thread_rng())
395 .unwrap_or_default();
396 (vec, index)
397 }
398}
399
400pub enum RangeStartMode {
401 Standard,
402 Zero,
403 OutOfBounds,
404}
405
406pub enum RangeEndMode {
407 Start,
408 Standard,
409 OutOfBounds,
410}
411
412pub enum RangeIndexMode {
413 InBounds,
414 InRange,
415 AfterRangeInBounds,
416 OutOfBounds,
417 BeforeRange,
418}
419
420#[inline]
424pub const fn arb_vec_and_range_and_index<T>(
425 item_strategy: &impl Fn() -> T,
426 range: RangeInclusive<usize>,
427 range_start_mode: RangeStartMode,
428 range_end_mode: RangeEndMode,
429 index_mode: RangeIndexMode,
430) -> impl Fn() -> (Vec<T>, Range<usize>, Option<usize>) + '_
431where
432 T: Clone + std::fmt::Debug + Sized,
433{
434 move || {
435 let rng = &mut rand::thread_rng();
436 let vec = arb_vec(item_strategy, range.clone())();
437 let start = match range_start_mode {
438 RangeStartMode::Standard => 0..vec.len(),
439 #[allow(clippy::range_plus_one)]
440 RangeStartMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
441 RangeStartMode::Zero => 0..1,
442 }
443 .choose(rng)
444 .unwrap_or_default();
445 let end = match range_end_mode {
446 RangeEndMode::Standard => start..vec.len(),
447 #[allow(clippy::range_plus_one)]
448 RangeEndMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1).max(start),
449 #[allow(clippy::range_plus_one)]
450 RangeEndMode::Start => start..(start + 1),
451 }
452 .choose(rng)
453 .unwrap_or_default();
454
455 let index = match index_mode {
456 RangeIndexMode::InBounds => 0..vec.len(),
457 RangeIndexMode::InRange => start..end,
458 RangeIndexMode::AfterRangeInBounds => end..vec.len(),
459 #[allow(clippy::range_plus_one)]
460 RangeIndexMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
461 RangeIndexMode::BeforeRange => 0..start,
462 }
463 .choose(rng);
464
465 (vec, start..end, index)
466 }
467}
468
469#[inline]
470pub const fn arb_analysis_features() -> impl Fn() -> [f64; 20] {
471 move || {
472 let rng = &mut rand::thread_rng();
473 let mut features = [0.0; 20];
474 for feature in &mut features {
475 *feature = rng.gen_range(-1.0..1.0);
476 }
477 features
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use pretty_assertions::assert_eq;
485
486 #[tokio::test]
487 async fn test_create_song() {
488 let db = init_test_database().await.unwrap();
489 let song_case = SongCase::new(0, vec![0], vec![0], 0, 0);
491
492 let result = create_song_with_overrides(&db, song_case, SongChangeSet::default()).await;
494
495 if let Err(e) = result {
497 panic!("Error creating song: {e:?}");
498 }
499
500 let song = result.unwrap();
502
503 let song_from_db = Song::read(&db, song.id.clone()).await.unwrap().unwrap();
505
506 assert_eq!(song, song_from_db);
508 }
509}