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