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::{seq::IteratorRandom, Rng};
13#[cfg(feature = "db")]
14use surrealdb::{
15 engine::local::{Db, Mem},
16 sql::Id,
17 Connection, Surreal,
18};
19
20#[cfg(feature = "analysis")]
21use crate::db::schemas::analysis::Analysis;
22#[cfg(not(feature = "db"))]
23use crate::db::schemas::Id;
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 None,
296 )?)
297}
298
299#[derive(Debug, Clone)]
300pub struct SongCase {
301 pub song: u8,
302 pub artists: Vec<u8>,
303 pub album_artists: Vec<u8>,
304 pub album: u8,
305 pub genre: u8,
306}
307
308impl SongCase {
309 #[must_use]
310 #[inline]
311 pub const fn new(
312 song: u8,
313 artists: Vec<u8>,
314 album_artists: Vec<u8>,
315 album: u8,
316 genre: u8,
317 ) -> Self {
318 Self {
319 song,
320 artists,
321 album_artists,
322 album,
323 genre,
324 }
325 }
326}
327
328#[inline]
329pub const fn arb_song_case() -> impl Fn() -> SongCase {
330 || {
331 let artist_item_strategy = move || {
332 (0..=10u8)
333 .choose(&mut rand::thread_rng())
334 .unwrap_or_default()
335 };
336 let rng = &mut rand::thread_rng();
337 let artists = arb_vec(&artist_item_strategy, 1..=10)()
338 .into_iter()
339 .collect::<std::collections::HashSet<_>>()
340 .into_iter()
341 .collect::<Vec<_>>();
342 let album_artists = arb_vec(&artist_item_strategy, 1..=10)()
343 .into_iter()
344 .collect::<std::collections::HashSet<_>>()
345 .into_iter()
346 .collect::<Vec<_>>();
347 let song = (0..=10u8).choose(rng).unwrap_or_default();
348 let album = (0..=10u8).choose(rng).unwrap_or_default();
349 let genre = (0..=10u8).choose(rng).unwrap_or_default();
350
351 SongCase::new(song, artists, album_artists, album, genre)
352 }
353}
354
355#[inline]
356pub const fn arb_vec<T>(
357 item_strategy: &impl Fn() -> T,
358 range: RangeInclusive<usize>,
359) -> impl Fn() -> Vec<T> + '_
360where
361 T: Clone + std::fmt::Debug + Sized,
362{
363 move || {
364 let size = range
365 .clone()
366 .choose(&mut rand::thread_rng())
367 .unwrap_or_default();
368 std::iter::repeat_with(item_strategy).take(size).collect()
369 }
370}
371
372pub enum IndexMode {
373 InBounds,
374 OutOfBounds,
375}
376
377#[inline]
378pub const fn arb_vec_and_index<T>(
379 item_strategy: &impl Fn() -> T,
380 range: RangeInclusive<usize>,
381 index_mode: IndexMode,
382) -> impl Fn() -> (Vec<T>, usize) + '_
383where
384 T: Clone + std::fmt::Debug + Sized,
385{
386 move || {
387 let vec = arb_vec(item_strategy, range.clone())();
388 let index = match index_mode {
389 IndexMode::InBounds => 0..vec.len(),
390 #[allow(clippy::range_plus_one)]
391 IndexMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
392 }
393 .choose(&mut rand::thread_rng())
394 .unwrap_or_default();
395 (vec, index)
396 }
397}
398
399pub enum RangeStartMode {
400 Standard,
401 Zero,
402 OutOfBounds,
403}
404
405pub enum RangeEndMode {
406 Start,
407 Standard,
408 OutOfBounds,
409}
410
411pub enum RangeIndexMode {
412 InBounds,
413 InRange,
414 AfterRangeInBounds,
415 OutOfBounds,
416 BeforeRange,
417}
418
419#[inline]
423pub const fn arb_vec_and_range_and_index<T>(
424 item_strategy: &impl Fn() -> T,
425 range: RangeInclusive<usize>,
426 range_start_mode: RangeStartMode,
427 range_end_mode: RangeEndMode,
428 index_mode: RangeIndexMode,
429) -> impl Fn() -> (Vec<T>, Range<usize>, Option<usize>) + '_
430where
431 T: Clone + std::fmt::Debug + Sized,
432{
433 move || {
434 let rng = &mut rand::thread_rng();
435 let vec = arb_vec(item_strategy, range.clone())();
436 let start = match range_start_mode {
437 RangeStartMode::Standard => 0..vec.len(),
438 #[allow(clippy::range_plus_one)]
439 RangeStartMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
440 RangeStartMode::Zero => 0..1,
441 }
442 .choose(rng)
443 .unwrap_or_default();
444 let end = match range_end_mode {
445 RangeEndMode::Standard => start..vec.len(),
446 #[allow(clippy::range_plus_one)]
447 RangeEndMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1).max(start),
448 #[allow(clippy::range_plus_one)]
449 RangeEndMode::Start => start..(start + 1),
450 }
451 .choose(rng)
452 .unwrap_or_default();
453
454 let index = match index_mode {
455 RangeIndexMode::InBounds => 0..vec.len(),
456 RangeIndexMode::InRange => start..end,
457 RangeIndexMode::AfterRangeInBounds => end..vec.len(),
458 #[allow(clippy::range_plus_one)]
459 RangeIndexMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
460 RangeIndexMode::BeforeRange => 0..start,
461 }
462 .choose(rng);
463
464 (vec, start..end, index)
465 }
466}
467
468#[inline]
469pub const fn arb_analysis_features() -> impl Fn() -> [f64; 20] {
470 move || {
471 let rng = &mut rand::thread_rng();
472 let mut features = [0.0; 20];
473 for feature in &mut features {
474 *feature = rng.gen_range(-1.0..1.0);
475 }
476 features
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use pretty_assertions::assert_eq;
484
485 #[tokio::test]
486 async fn test_create_song() {
487 let db = init_test_database().await.unwrap();
488 let song_case = SongCase::new(0, vec![0], vec![0], 0, 0);
490
491 let result = create_song_with_overrides(&db, song_case, SongChangeSet::default()).await;
493
494 if let Err(e) = result {
496 panic!("Error creating song: {e:?}");
497 }
498
499 let song = result.unwrap();
501
502 let song_from_db = Song::read(&db, song.id.clone()).await.unwrap().unwrap();
504
505 assert_eq!(song, song_from_db);
507 }
508}