use std::{
ops::{Range, RangeInclusive},
path::PathBuf,
str::FromStr,
sync::Arc,
time::Duration,
};
use anyhow::Result;
use lofty::{config::WriteOptions, file::TaggedFileExt, prelude::*, probe::Probe, tag::Accessor};
use one_or_many::OneOrMany;
use rand::{Rng, seq::IteratorRandom};
#[cfg(feature = "db")]
use surrealdb::{
Connection, Surreal,
engine::local::{Db, Mem},
opt::Config,
sql::Id,
};
#[cfg(not(feature = "db"))]
use crate::db::schemas::Id;
#[cfg(feature = "analysis")]
use crate::db::schemas::analysis::Analysis;
use crate::db::schemas::{
album::Album,
artist::Artist,
collection::Collection,
playlist::Playlist,
song::{Song, SongChangeSet, SongMetadata},
};
pub const ARTIST_NAME_SEPARATOR: &str = ", ";
#[cfg(feature = "db")]
#[allow(clippy::missing_inline_in_public_items)]
pub async fn init_test_database() -> surrealqlx::Result<Surreal<Db>> {
use crate::db::{
queries::relations::define_relation_tables, schemas::dynamic::DynamicPlaylist,
};
let config = Config::new().strict();
let db = Surreal::new::<Mem>(config).await?;
db.query("DEFINE NAMESPACE IF NOT EXISTS test").await?;
db.use_ns("test").await?;
db.query("DEFINE DATABASE IF NOT EXISTS test").await?;
db.use_db("test").await?;
crate::db::register_custom_analyzer(&db).await?;
surrealqlx::register_tables!(
&db,
Album,
Artist,
Song,
Collection,
Playlist,
DynamicPlaylist
)?;
#[cfg(feature = "analysis")]
surrealqlx::register_tables!(&db, Analysis)?;
define_relation_tables(&db).await?;
Ok(db)
}
#[cfg(feature = "db")]
#[allow(clippy::missing_inline_in_public_items)]
pub async fn init_test_database_with_state<SCF>(
song_count: std::num::NonZero<usize>,
mut song_case_func: SCF,
dynamic: Option<crate::db::schemas::dynamic::DynamicPlaylist>,
tempdir: &tempfile::TempDir,
) -> Arc<Surreal<Db>>
where
SCF: FnMut(usize) -> (SongCase, bool, bool) + Send + Sync,
{
use anyhow::Context;
use crate::db::schemas::dynamic::DynamicPlaylist;
let db = Arc::new(init_test_database().await.unwrap());
let playlist = Playlist {
id: Playlist::generate_id(),
name: "Playlist 0".into(),
runtime: Duration::from_secs(0),
song_count: 0,
};
let playlist = Playlist::create(&db, playlist).await.unwrap().unwrap();
let collection = Collection {
id: Collection::generate_id(),
name: "Collection 0".into(),
runtime: Duration::from_secs(0),
song_count: 0,
};
let collection = Collection::create(&db, collection).await.unwrap().unwrap();
if let Some(dynamic) = dynamic {
let _ = DynamicPlaylist::create(&db, dynamic)
.await
.unwrap()
.unwrap();
}
for i in 0..(song_count.get()) {
let (song_case, add_to_playlist, add_to_collection) = song_case_func(i);
let metadata = create_song_metadata(tempdir, song_case.clone())
.context(format!(
"failed to create metadata for song case {song_case:?}"
))
.unwrap();
let song = Song::try_load_into_db(&db, metadata)
.await
.context(format!(
"Failed to load into db the song case: {song_case:?}"
))
.unwrap();
if add_to_playlist {
Playlist::add_songs(&db, playlist.id.clone(), vec![song.id.clone()])
.await
.unwrap();
}
if add_to_collection {
Collection::add_songs(&db, collection.id.clone(), vec![song.id.clone()])
.await
.unwrap();
}
}
db
}
#[cfg(feature = "db")]
#[allow(clippy::missing_inline_in_public_items)]
pub async fn create_song_with_overrides<C: Connection>(
db: &Surreal<C>,
SongCase {
song,
artists,
album_artists,
album,
genre,
}: SongCase,
overrides: SongChangeSet,
) -> Result<Song> {
let id = Song::generate_id();
let song = Song {
id: id.clone(),
title: Into::into(format!("Song {song}").as_str()),
artist: artists
.iter()
.map(|a| format!("Artist {a}"))
.collect::<Vec<_>>()
.into(),
album_artist: album_artists
.iter()
.map(|a| format!("Artist {a}"))
.collect::<Vec<_>>()
.into(),
album: format!("Album {album}"),
genre: format!("Genre {genre}").into(),
runtime: Duration::from_secs(120),
track: None,
disc: None,
release_year: None,
extension: "mp3".into(),
path: PathBuf::from_str(&format!("{}.mp3", id.key()))?,
};
Song::create(db, song.clone()).await?;
if overrides != SongChangeSet::default() {
Song::update(db, song.id.clone(), overrides).await?;
}
let song = Song::read(db, song.id).await?.expect("Song should exist");
Ok(song)
}
#[allow(clippy::missing_inline_in_public_items)]
pub fn create_song_metadata(
tempdir: &tempfile::TempDir,
SongCase {
song,
artists,
album_artists,
album,
genre,
}: SongCase,
) -> Result<SongMetadata> {
let base_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../assets/music.mp3")
.canonicalize()?;
let mut tagged_file = Probe::open(&base_path)?.read()?;
let tag = match tagged_file.primary_tag_mut() {
Some(primary_tag) => primary_tag,
None => tagged_file
.first_tag_mut()
.ok_or_else(|| anyhow::anyhow!("ERROR: No tags found"))?,
};
tag.insert_text(
ItemKey::AlbumArtist,
album_artists
.iter()
.map(|a| format!("Artist {a}"))
.collect::<Vec<_>>()
.join(ARTIST_NAME_SEPARATOR),
);
tag.remove_artist();
tag.set_artist(
artists
.iter()
.map(|a| format!("Artist {a}"))
.collect::<Vec<_>>()
.join(ARTIST_NAME_SEPARATOR),
);
tag.remove_album();
tag.set_album(format!("Album {album}"));
tag.remove_title();
tag.set_title(format!("Song {song}"));
tag.remove_genre();
tag.set_genre(format!("Genre {genre}"));
let new_path = tempdir.path().join(format!("song_{}.mp3", Id::ulid()));
std::fs::copy(&base_path, &new_path)?;
tag.save_to_path(&new_path, WriteOptions::default())?;
Ok(SongMetadata::load_from_path(
new_path,
&ARTIST_NAME_SEPARATOR.to_string().into(),
&OneOrMany::None,
None,
)?)
}
#[derive(Debug, Clone)]
pub struct SongCase {
pub song: u8,
pub artists: Vec<u8>,
pub album_artists: Vec<u8>,
pub album: u8,
pub genre: u8,
}
impl SongCase {
#[must_use]
#[inline]
pub const fn new(
song: u8,
artists: Vec<u8>,
album_artists: Vec<u8>,
album: u8,
genre: u8,
) -> Self {
Self {
song,
artists,
album_artists,
album,
genre,
}
}
}
#[inline]
pub const fn arb_song_case() -> impl Fn() -> SongCase {
|| {
let artist_item_strategy = move || {
(0..=10u8)
.choose(&mut rand::thread_rng())
.unwrap_or_default()
};
let rng = &mut rand::thread_rng();
let artists = arb_vec(&artist_item_strategy, 1..=10)()
.into_iter()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let album_artists = arb_vec(&artist_item_strategy, 1..=10)()
.into_iter()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
let song = (0..=10u8).choose(rng).unwrap_or_default();
let album = (0..=10u8).choose(rng).unwrap_or_default();
let genre = (0..=10u8).choose(rng).unwrap_or_default();
SongCase::new(song, artists, album_artists, album, genre)
}
}
#[inline]
pub const fn arb_vec<T>(
item_strategy: &impl Fn() -> T,
range: RangeInclusive<usize>,
) -> impl Fn() -> Vec<T> + '_
where
T: Clone + std::fmt::Debug + Sized,
{
move || {
let size = range
.clone()
.choose(&mut rand::thread_rng())
.unwrap_or_default();
std::iter::repeat_with(item_strategy).take(size).collect()
}
}
pub enum IndexMode {
InBounds,
OutOfBounds,
}
#[inline]
pub const fn arb_vec_and_index<T>(
item_strategy: &impl Fn() -> T,
range: RangeInclusive<usize>,
index_mode: IndexMode,
) -> impl Fn() -> (Vec<T>, usize) + '_
where
T: Clone + std::fmt::Debug + Sized,
{
move || {
let vec = arb_vec(item_strategy, range.clone())();
let index = match index_mode {
IndexMode::InBounds => 0..vec.len(),
#[allow(clippy::range_plus_one)]
IndexMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
}
.choose(&mut rand::thread_rng())
.unwrap_or_default();
(vec, index)
}
}
pub enum RangeStartMode {
Standard,
Zero,
OutOfBounds,
}
pub enum RangeEndMode {
Start,
Standard,
OutOfBounds,
}
pub enum RangeIndexMode {
InBounds,
InRange,
AfterRangeInBounds,
OutOfBounds,
BeforeRange,
}
#[inline]
pub const fn arb_vec_and_range_and_index<T>(
item_strategy: &impl Fn() -> T,
range: RangeInclusive<usize>,
range_start_mode: RangeStartMode,
range_end_mode: RangeEndMode,
index_mode: RangeIndexMode,
) -> impl Fn() -> (Vec<T>, Range<usize>, Option<usize>) + '_
where
T: Clone + std::fmt::Debug + Sized,
{
move || {
let rng = &mut rand::thread_rng();
let vec = arb_vec(item_strategy, range.clone())();
let start = match range_start_mode {
RangeStartMode::Standard => 0..vec.len(),
#[allow(clippy::range_plus_one)]
RangeStartMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
RangeStartMode::Zero => 0..1,
}
.choose(rng)
.unwrap_or_default();
let end = match range_end_mode {
RangeEndMode::Standard => start..vec.len(),
#[allow(clippy::range_plus_one)]
RangeEndMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1).max(start),
#[allow(clippy::range_plus_one)]
RangeEndMode::Start => start..(start + 1),
}
.choose(rng)
.unwrap_or_default();
let index = match index_mode {
RangeIndexMode::InBounds => 0..vec.len(),
RangeIndexMode::InRange => start..end,
RangeIndexMode::AfterRangeInBounds => end..vec.len(),
#[allow(clippy::range_plus_one)]
RangeIndexMode::OutOfBounds => vec.len()..(vec.len() + vec.len() / 2 + 1),
RangeIndexMode::BeforeRange => 0..start,
}
.choose(rng);
(vec, start..end, index)
}
}
#[inline]
pub const fn arb_feature_array<const N: usize>() -> impl Fn() -> [f32; N] {
move || {
let rng = &mut rand::thread_rng();
let mut features = [0.0; N];
for feature in &mut features {
*feature = rng.gen_range(-1.0..1.0);
}
features
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn test_create_song() {
let db = init_test_database().await.unwrap();
let song_case = SongCase::new(0, vec![0], vec![0], 0, 0);
let result = create_song_with_overrides(&db, song_case, SongChangeSet::default()).await;
if let Err(e) = result {
panic!("Error creating song: {e:?}");
}
let song = result.unwrap();
let song_from_db = Song::read(&db, song.id.clone()).await.unwrap().unwrap();
assert_eq!(song, song_from_db);
}
}