use crate::analyze_paths_with_cores;
use crate::cue::CueInfo;
use crate::playlist::closest_album_to_group_by_key;
use crate::playlist::closest_to_first_song_by_key;
use crate::playlist::dedup_playlist_by_key;
use crate::playlist::dedup_playlist_custom_distance_by_key;
use crate::playlist::euclidean_distance;
use crate::playlist::DistanceMetric;
use anyhow::{bail, Context, Result};
#[cfg(not(test))]
use dirs::data_local_dir;
use indicatif::{ProgressBar, ProgressStyle};
use log::warn;
use noisy_float::prelude::*;
use rusqlite::params;
use rusqlite::Connection;
use rusqlite::OptionalExtension;
use rusqlite::Params;
use rusqlite::Row;
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::fs::create_dir_all;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex;
use crate::Song;
use crate::FEATURES_VERSION;
use crate::{Analysis, BlissError, NUMBER_FEATURES};
use rusqlite::Error as RusqliteError;
use std::convert::TryInto;
use std::time::Duration;
pub trait AppConfigTrait: Serialize + Sized + DeserializeOwned {
fn base_config(&self) -> &BaseConfig;
fn base_config_mut(&mut self) -> &mut BaseConfig;
fn serialize_config(&self) -> Result<String> {
Ok(serde_json::to_string_pretty(&self)?)
}
fn set_number_cores(&mut self, number_cores: NonZeroUsize) -> Result<()> {
self.base_config_mut().number_cores = number_cores;
self.write()
}
fn get_number_cores(&self) -> NonZeroUsize {
self.base_config().number_cores
}
fn deserialize_config(data: &str) -> Result<Self> {
Ok(serde_json::from_str(data)?)
}
fn from_path(path: &str) -> Result<Self> {
let data = fs::read_to_string(path)?;
Self::deserialize_config(&data)
}
fn write(&self) -> Result<()> {
let serialized = self.serialize_config()?;
fs::write(&self.base_config().config_path, serialized)?;
Ok(())
}
}
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]
pub struct BaseConfig {
config_path: PathBuf,
database_path: PathBuf,
features_version: u16,
number_cores: NonZeroUsize,
}
impl BaseConfig {
pub(crate) fn get_default_data_folder() -> Result<PathBuf> {
let path = match env::var("XDG_DATA_HOME") {
Ok(path) => Path::new(&path).join("bliss-rs"),
Err(_) => {
data_local_dir()
.with_context(|| "No suitable path found to store bliss' song database. Consider specifying such a path.")?
.join("bliss-rs")
},
};
Ok(path)
}
pub fn new(
config_path: Option<PathBuf>,
database_path: Option<PathBuf>,
number_cores: Option<NonZeroUsize>,
) -> Result<Self> {
let config_path = {
if let Some(path) = config_path {
path
} else {
Self::get_default_data_folder()?.join(Path::new("config.json"))
}
};
let database_path = {
if let Some(path) = database_path {
path
} else {
Self::get_default_data_folder()?.join(Path::new("songs.db"))
}
};
let number_cores =
number_cores.unwrap_or_else(|| NonZeroUsize::new(num_cpus::get()).unwrap());
Ok(Self {
config_path,
database_path,
features_version: FEATURES_VERSION,
number_cores,
})
}
}
impl AppConfigTrait for BaseConfig {
fn base_config(&self) -> &BaseConfig {
self
}
fn base_config_mut(&mut self) -> &mut BaseConfig {
self
}
}
pub struct Library<Config> {
pub config: Config,
pub sqlite_conn: Arc<Mutex<Connection>>,
}
#[derive(Debug, PartialEq, Clone)]
pub struct LibrarySong<T: Serialize + DeserializeOwned> {
pub bliss_song: Song,
pub extra_info: T,
}
impl<Config: AppConfigTrait> Library<Config> {
pub fn new(config: Config) -> Result<Self> {
if !config
.base_config()
.config_path
.parent()
.ok_or_else(|| {
BlissError::ProviderError(format!(
"specified path {} is not a valid file path.",
config.base_config().config_path.display()
))
})?
.is_dir()
{
create_dir_all(config.base_config().config_path.parent().unwrap())?;
}
let sqlite_conn = Connection::open(&config.base_config().database_path)?;
sqlite_conn.execute(
"
create table if not exists song (
id integer primary key,
path text not null unique,
duration float,
album_artist text,
artist text,
title text,
album text,
track_number text,
genre text,
cue_path text,
audio_file_path text,
stamp timestamp default current_timestamp,
version integer,
analyzed boolean default false,
extra_info json,
error text
);
",
[],
)?;
sqlite_conn.execute("pragma foreign_keys = on;", [])?;
sqlite_conn.execute(
"
create table if not exists feature (
id integer primary key,
song_id integer not null,
feature real not null,
feature_index integer not null,
unique(song_id, feature_index),
foreign key(song_id) references song(id) on delete cascade
)
",
[],
)?;
config.write()?;
Ok(Library {
config,
sqlite_conn: Arc::new(Mutex::new(sqlite_conn)),
})
}
pub fn from_config_path(config_path: Option<PathBuf>) -> Result<Self> {
let config_path: Result<PathBuf> =
config_path.map_or_else(|| Ok(BaseConfig::new(None, None, None)?.config_path), Ok);
let config_path = config_path?;
let data = fs::read_to_string(config_path)?;
let config = Config::deserialize_config(&data)?;
let sqlite_conn = Connection::open(&config.base_config().database_path)?;
let mut library = Library {
config,
sqlite_conn: Arc::new(Mutex::new(sqlite_conn)),
};
if !library.version_sanity_check()? {
warn!(
"Songs have been analyzed with different versions of bliss; \
older versions will be ignored from playlists. Update your \
bliss library to correct the issue."
);
}
Ok(library)
}
pub fn version_sanity_check(&mut self) -> Result<bool> {
let connection = self
.sqlite_conn
.lock()
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
let count: u32 = connection
.query_row("select count(distinct version) from song", [], |row| {
row.get(0)
})
.optional()?
.unwrap_or(0);
Ok(count <= 1)
}
pub fn new_from_base(
config_path: Option<PathBuf>,
database_path: Option<PathBuf>,
number_cores: Option<NonZeroUsize>,
) -> Result<Self>
where
BaseConfig: Into<Config>,
{
let base = BaseConfig::new(config_path, database_path, number_cores)?;
let config = base.into();
Self::new(config)
}
pub fn playlist_from<T: Serialize + DeserializeOwned>(
&self,
song_path: &str,
playlist_length: usize,
) -> Result<Vec<LibrarySong<T>>> {
let first_song: LibrarySong<T> = self.song_from_path(song_path)?;
let mut songs = self.songs_from_library()?;
closest_to_first_song_by_key(
&first_song,
&mut songs,
euclidean_distance,
|s: &LibrarySong<T>| s.bliss_song.to_owned(),
);
songs.sort_by_cached_key(|song| n32(first_song.bliss_song.distance(&song.bliss_song)));
dedup_playlist_by_key(&mut songs, None, |s: &LibrarySong<T>| {
s.bliss_song.to_owned()
});
songs.truncate(playlist_length);
Ok(songs)
}
pub fn playlist_from_custom<F, G, T: Serialize + DeserializeOwned + std::fmt::Debug>(
&self,
song_path: &str,
playlist_length: usize,
distance: G,
mut sort_by: F,
dedup: bool,
) -> Result<Vec<LibrarySong<T>>>
where
F: FnMut(&LibrarySong<T>, &mut Vec<LibrarySong<T>>, G, fn(&LibrarySong<T>) -> Song),
G: DistanceMetric + Copy,
{
let first_song: LibrarySong<T> = self.song_from_path(song_path).map_err(|_| {
BlissError::ProviderError(format!("song '{}' has not been analyzed", song_path))
})?;
let mut songs = self.songs_from_library()?;
sort_by(&first_song, &mut songs, distance, |s: &LibrarySong<T>| {
s.bliss_song.to_owned()
});
if dedup {
dedup_playlist_custom_distance_by_key(
&mut songs,
None,
distance,
|s: &LibrarySong<T>| s.bliss_song.to_owned(),
);
}
songs.truncate(playlist_length);
Ok(songs)
}
pub fn album_playlist_from<T: Serialize + DeserializeOwned + Clone + PartialEq>(
&self,
album_title: String,
number_albums: usize,
) -> Result<Vec<LibrarySong<T>>> {
let album = self.songs_from_album(&album_title)?;
let songs = self.songs_from_library()?;
let playlist = closest_album_to_group_by_key(album, songs, |s| s.bliss_song.to_owned())?;
let mut album_count = 0;
let mut index = 0;
let mut current_album = Some(album_title);
for song in playlist.iter() {
if song.bliss_song.album != current_album {
album_count += 1;
if album_count > number_albums {
break;
}
current_album = song.bliss_song.album.to_owned();
}
index += 1;
}
let playlist = &playlist[..index];
Ok(playlist.to_vec())
}
pub fn update_library<P: Into<PathBuf>>(
&mut self,
paths: Vec<P>,
show_progress_bar: bool,
) -> Result<()> {
let paths_extra_info = paths.into_iter().map(|path| (path, ())).collect::<Vec<_>>();
self.update_library_convert_extra_info(paths_extra_info, show_progress_bar, |x, _, _| x)
}
pub fn update_library_extra_info<T: Serialize + DeserializeOwned, P: Into<PathBuf>>(
&mut self,
paths_extra_info: Vec<(P, T)>,
show_progress_bar: bool,
) -> Result<()> {
self.update_library_convert_extra_info(
paths_extra_info,
show_progress_bar,
|extra_info, _, _| extra_info,
)
}
pub fn update_library_convert_extra_info<
T: Serialize + DeserializeOwned,
U,
P: Into<PathBuf>,
>(
&mut self,
paths_extra_info: Vec<(P, U)>,
show_progress_bar: bool,
convert_extra_info: fn(U, &Song, &Self) -> T,
) -> Result<()> {
let existing_paths = {
let connection = self
.sqlite_conn
.lock()
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
let mut path_statement = connection.prepare(
"
select
path
from song where analyzed = true and version = ? order by id
",
)?;
#[allow(clippy::let_and_return)]
let return_value = path_statement
.query_map([FEATURES_VERSION], |row| {
Ok(row.get_unwrap::<usize, String>(0))
})?
.map(|x| PathBuf::from(x.unwrap()))
.collect::<HashSet<PathBuf>>();
return_value
};
let paths_to_analyze = paths_extra_info
.into_iter()
.map(|(x, y)| (x.into(), y))
.filter(|(path, _)| !existing_paths.contains(path))
.collect::<Vec<(PathBuf, U)>>();
self.analyze_paths_convert_extra_info(
paths_to_analyze,
show_progress_bar,
convert_extra_info,
)
}
pub fn analyze_paths<P: Into<PathBuf>>(
&mut self,
paths: Vec<P>,
show_progress_bar: bool,
) -> Result<()> {
let paths_extra_info = paths.into_iter().map(|path| (path, ())).collect::<Vec<_>>();
self.analyze_paths_convert_extra_info(paths_extra_info, show_progress_bar, |x, _, _| x)
}
pub fn analyze_paths_extra_info<
T: Serialize + DeserializeOwned + std::fmt::Debug,
P: Into<PathBuf>,
>(
&mut self,
paths_extra_info: Vec<(P, T)>,
show_progress_bar: bool,
) -> Result<()> {
self.analyze_paths_convert_extra_info(
paths_extra_info,
show_progress_bar,
|extra_info, _, _| extra_info,
)
}
pub fn analyze_paths_convert_extra_info<
T: Serialize + DeserializeOwned,
U,
P: Into<PathBuf>,
>(
&mut self,
paths_extra_info: Vec<(P, U)>,
show_progress_bar: bool,
convert_extra_info: fn(U, &Song, &Self) -> T,
) -> Result<()> {
let number_songs = paths_extra_info.len();
if number_songs == 0 {
log::info!("No (new) songs found.");
return Ok(());
}
log::info!(
"Analyzing {} songs, this might take some time…",
number_songs
);
let pb = if show_progress_bar {
ProgressBar::new(number_songs.try_into().unwrap())
} else {
ProgressBar::hidden()
};
let style = ProgressStyle::default_bar()
.template("[{elapsed_precise}] {bar:40} {pos:>7}/{len:7} {wide_msg}")?
.progress_chars("##-");
pb.set_style(style);
let mut paths_extra_info: HashMap<PathBuf, U> = paths_extra_info
.into_iter()
.map(|(x, y)| (x.into(), y))
.collect();
let mut cue_extra_info: HashMap<PathBuf, String> = HashMap::new();
let results = analyze_paths_with_cores(
paths_extra_info.keys(),
self.config.base_config().number_cores,
);
let mut success_count = 0;
let mut failure_count = 0;
for (path, result) in results {
if show_progress_bar {
pb.set_message(format!("Analyzing {}", path.display()));
}
match result {
Ok(song) => {
let is_cue = song.cue_info.is_some();
let path = {
if let Some(cue_info) = song.cue_info.to_owned() {
cue_info.cue_path
} else {
path
}
};
let extra = {
if is_cue && paths_extra_info.contains_key(&path) {
let extra = paths_extra_info.remove(&path).unwrap();
let e = convert_extra_info(extra, &song, self);
cue_extra_info.insert(
path,
serde_json::to_string(&e)
.map_err(|e| BlissError::ProviderError(e.to_string()))?,
);
e
} else if is_cue {
let serialized_extra_info =
cue_extra_info.get(&path).unwrap().to_owned();
serde_json::from_str(&serialized_extra_info).unwrap()
} else {
let extra = paths_extra_info.remove(&path).unwrap();
convert_extra_info(extra, &song, self)
}
};
let library_song = LibrarySong::<T> {
bliss_song: song,
extra_info: extra,
};
self.store_song(&library_song)?;
success_count += 1;
}
Err(e) => {
log::error!(
"Analysis of song '{}' failed: {} The error has been stored.",
path.display(),
e
);
self.store_failed_song(path, e)?;
failure_count += 1;
}
};
pb.inc(1);
}
pb.finish_with_message(format!(
"Analyzed {} song(s) successfully. {} Failure(s).",
success_count, failure_count
));
log::info!(
"Analyzed {} song(s) successfully. {} Failure(s).",
success_count,
failure_count,
);
self.config.base_config_mut().features_version = FEATURES_VERSION;
self.config.write()?;
Ok(())
}
fn _songs_from_statement<T: Serialize + DeserializeOwned, P: Params + Clone>(
&self,
songs_statement: &str,
features_statement: &str,
params: P,
) -> Result<Vec<LibrarySong<T>>> {
let connection = self
.sqlite_conn
.lock()
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
let mut songs_statement = connection.prepare(songs_statement)?;
let mut features_statement = connection.prepare(features_statement)?;
let song_rows = songs_statement.query_map(params.to_owned(), |row| {
Ok((row.get(12)?, Self::_song_from_row_closure(row)?))
})?;
let feature_rows =
features_statement.query_map(params, |row| Ok((row.get(1)?, row.get(0)?)))?;
let mut feature_iterator = feature_rows.into_iter().peekable();
let mut songs = Vec::new();
for row in song_rows {
let song_id: u32 = row.as_ref().unwrap().0;
let mut chunk: Vec<f32> = Vec::with_capacity(NUMBER_FEATURES);
while let Some(first_value) = feature_iterator.peek() {
let (song_feature_id, feature): (u32, f32) = *first_value.as_ref().unwrap();
if song_feature_id == song_id {
chunk.push(feature);
feature_iterator.next();
} else {
break;
};
}
let mut song = row.unwrap().1;
song.bliss_song.analysis = Analysis {
internal_analysis: chunk.try_into().map_err(|_| {
BlissError::ProviderError(format!(
"Song with ID {} and path {}Â has a different feature \
number than expected. Please rescan or update \
the song library.",
song_id,
song.bliss_song.path.display(),
))
})?,
};
songs.push(song);
}
Ok(songs)
}
pub fn songs_from_library<T: Serialize + DeserializeOwned>(
&self,
) -> Result<Vec<LibrarySong<T>>> {
let songs_statement = "
select
path, artist, title, album, album_artist,
track_number, genre, duration, version, extra_info, cue_path,
audio_file_path, id
from song where analyzed = true and version = ? order by id
";
let features_statement = "
select
feature, song.id from feature join song on song.id = feature.song_id
where song.analyzed = true and song.version = ? order by song_id, feature_index
";
let params = params![self.config.base_config().features_version];
self._songs_from_statement(songs_statement, features_statement, params)
}
pub fn songs_from_album<T: Serialize + DeserializeOwned>(
&self,
album_title: &str,
) -> Result<Vec<LibrarySong<T>>> {
let params = params![album_title, self.config.base_config().features_version];
let songs_statement = "
select
path, artist, title, album, album_artist,
track_number, genre, duration, version, extra_info, cue_path,
audio_file_path, id
from song where album = ? and analyzed = true and version = ?
order
by cast(track_number as integer);
";
let features_statement = "
select
feature, song.id from feature join song on song.id = feature.song_id
where album=? and analyzed = true and version = ?
order by cast(track_number as integer);
";
let songs = self._songs_from_statement(songs_statement, features_statement, params)?;
if songs.is_empty() {
bail!(BlissError::ProviderError(String::from(
"target album was not found in the database.",
)));
};
Ok(songs)
}
pub fn song_from_path<T: Serialize + DeserializeOwned>(
&self,
song_path: &str,
) -> Result<LibrarySong<T>> {
let connection = self
.sqlite_conn
.lock()
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
let mut song = connection.query_row(
"
select
path, artist, title, album, album_artist,
track_number, genre, duration, version, extra_info,
cue_path, audio_file_path
from song where path=? and analyzed = true
",
params![song_path],
Self::_song_from_row_closure,
)?;
let mut stmt = connection.prepare(
"
select
feature from feature join song on song.id = feature.song_id
where song.path = ? order by feature_index
",
)?;
let analysis_vector = Analysis {
internal_analysis: stmt
.query_map(params![song_path], |row| row.get(0))
.unwrap()
.into_iter()
.map(|x| x.unwrap())
.collect::<Vec<f32>>()
.try_into()
.map_err(|_| {
BlissError::ProviderError(format!(
"song has more or less than {} features",
NUMBER_FEATURES
))
})?,
};
song.bliss_song.analysis = analysis_vector;
Ok(song)
}
fn _song_from_row_closure<T: Serialize + DeserializeOwned>(
row: &Row,
) -> Result<LibrarySong<T>, RusqliteError> {
let path: String = row.get(0)?;
let cue_path: Option<String> = row.get(10)?;
let audio_file_path: Option<String> = row.get(11)?;
let mut cue_info = None;
if let Some(cue_path) = cue_path {
cue_info = Some(CueInfo {
cue_path: PathBuf::from(cue_path),
audio_file_path: PathBuf::from(audio_file_path.unwrap()),
})
};
let song = Song {
path: PathBuf::from(path),
artist: row.get(1).unwrap(),
title: row.get(2).unwrap(),
album: row.get(3).unwrap(),
album_artist: row.get(4).unwrap(),
track_number: row.get(5).unwrap(),
genre: row.get(6).unwrap(),
analysis: Analysis {
internal_analysis: [0.; NUMBER_FEATURES],
},
duration: Duration::from_secs_f64(row.get(7).unwrap()),
features_version: row.get(8).unwrap(),
cue_info,
};
let serialized: Option<String> = row.get(9).unwrap();
let serialized = serialized.unwrap_or_else(|| "null".into());
let extra_info = serde_json::from_str(&serialized).unwrap();
Ok(LibrarySong {
bliss_song: song,
extra_info,
})
}
pub fn store_song<T: Serialize + DeserializeOwned>(
&mut self,
library_song: &LibrarySong<T>,
) -> Result<(), BlissError> {
let mut sqlite_conn = self.sqlite_conn.lock().unwrap();
let tx = sqlite_conn
.transaction()
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
let song = &library_song.bliss_song;
let (cue_path, audio_file_path) = match &song.cue_info {
Some(c) => (
Some(c.cue_path.to_string_lossy()),
Some(c.audio_file_path.to_string_lossy()),
),
None => (None, None),
};
tx.execute(
"
insert into song (
path, artist, title, album, album_artist,
duration, track_number, genre, analyzed, version, extra_info,
cue_path, audio_file_path
)
values (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13
)
on conflict(path)
do update set
artist=excluded.artist,
title=excluded.title,
album=excluded.album,
track_number=excluded.track_number,
album_artist=excluded.album_artist,
duration=excluded.duration,
genre=excluded.genre,
analyzed=excluded.analyzed,
version=excluded.version,
extra_info=excluded.extra_info,
cue_path=excluded.cue_path,
audio_file_path=excluded.audio_file_path
",
params![
song.path.to_str(),
song.artist,
song.title,
song.album,
song.album_artist,
song.duration.as_secs_f64(),
song.track_number,
song.genre,
true,
song.features_version,
serde_json::to_string(&library_song.extra_info)
.map_err(|e| BlissError::ProviderError(e.to_string()))?,
cue_path,
audio_file_path,
],
)
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
tx.execute(
"delete from feature where song_id in (select id from song where path = ?1);",
params![song.path.to_str()],
)
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
for (index, feature) in song.analysis.as_vec().iter().enumerate() {
tx.execute(
"
insert into feature (song_id, feature, feature_index)
values ((select id from song where path = ?1), ?2, ?3)
on conflict(song_id, feature_index) do update set feature=excluded.feature;
",
params![song.path.to_str(), feature, index],
)
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
}
tx.commit()
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
Ok(())
}
pub fn store_failed_song<P: Into<PathBuf>>(
&mut self,
song_path: P,
e: BlissError,
) -> Result<()> {
self.sqlite_conn
.lock()
.unwrap()
.execute(
"
insert or replace into song (path, error) values (?1, ?2)
",
[
song_path.into().to_string_lossy().to_string(),
e.to_string(),
],
)
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
Ok(())
}
pub fn delete_song<P: Into<PathBuf>>(&mut self, song_path: P) -> Result<()> {
let song_path = song_path.into();
let count = self
.sqlite_conn
.lock()
.unwrap()
.execute(
"
delete from song where path = ?1;
",
[song_path.to_str()],
)
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
if count == 0 {
bail!(BlissError::ProviderError(format!(
"tried to delete song {}, not existing in the database.",
song_path.display(),
)));
}
Ok(())
}
}
#[cfg(test)]
fn data_local_dir() -> Option<PathBuf> {
Some(PathBuf::from("/local/directory"))
}
#[cfg(test)]
mod test {
use super::*;
use crate::{Analysis, NUMBER_FEATURES};
use ndarray::Array1;
use pretty_assertions::assert_eq;
use serde::{de::DeserializeOwned, Deserialize};
use std::{convert::TryInto, fmt::Debug, sync::MutexGuard, time::Duration};
use tempdir::TempDir;
#[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Default)]
struct ExtraInfo {
ignore: bool,
metadata_bliss_does_not_have: String,
}
#[derive(Deserialize, Serialize, PartialEq, Eq, Debug, Clone)]
struct CustomConfig {
#[serde(flatten)]
base_config: BaseConfig,
second_path_to_music_library: String,
ignore_wav_files: bool,
}
impl AppConfigTrait for CustomConfig {
fn base_config(&self) -> &BaseConfig {
&self.base_config
}
fn base_config_mut(&mut self) -> &mut BaseConfig {
&mut self.base_config
}
}
fn nzus(i: usize) -> NonZeroUsize {
NonZeroUsize::new(i).unwrap()
}
fn setup_test_library() -> (
Library<BaseConfig>,
TempDir,
(
LibrarySong<ExtraInfo>,
LibrarySong<ExtraInfo>,
LibrarySong<ExtraInfo>,
LibrarySong<ExtraInfo>,
LibrarySong<ExtraInfo>,
LibrarySong<ExtraInfo>,
LibrarySong<ExtraInfo>,
),
) {
let config_dir = TempDir::new("coucou").unwrap();
let config_file = config_dir.path().join("config.json");
let database_file = config_dir.path().join("bliss.db");
let library =
Library::<BaseConfig>::new_from_base(Some(config_file), Some(database_file), None)
.unwrap();
let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES)
.map(|x| x as f32 / 10.)
.collect::<Vec<f32>>()
.try_into()
.unwrap();
let song = Song {
path: "/path/to/song1001".into(),
artist: Some("Artist1001".into()),
title: Some("Title1001".into()),
album: Some("An Album1001".into()),
album_artist: Some("An Album Artist1001".into()),
track_number: Some("03".into()),
genre: Some("Electronica1001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
},
duration: Duration::from_secs(310),
features_version: 1,
cue_info: None,
};
let first_song = LibrarySong {
bliss_song: song,
extra_info: ExtraInfo {
ignore: true,
metadata_bliss_does_not_have: String::from("/path/to/charlie1001"),
},
};
let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES)
.map(|x| x as f32 + 10.)
.collect::<Vec<f32>>()
.try_into()
.unwrap();
let song = Song {
path: "/path/to/song2001".into(),
artist: Some("Artist2001".into()),
title: Some("Title2001".into()),
album: Some("An Album2001".into()),
album_artist: Some("An Album Artist2001".into()),
track_number: Some("02".into()),
genre: Some("Electronica2001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
},
duration: Duration::from_secs(410),
features_version: 1,
cue_info: None,
};
let second_song = LibrarySong {
bliss_song: song,
extra_info: ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("/path/to/charlie2001"),
},
};
let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES)
.map(|x| x as f32 / 2.)
.collect::<Vec<f32>>()
.try_into()
.unwrap();
let song = Song {
path: "/path/to/song5001".into(),
artist: Some("Artist5001".into()),
title: Some("Title5001".into()),
album: Some("An Album1001".into()),
album_artist: Some("An Album Artist5001".into()),
track_number: Some("01".into()),
genre: Some("Electronica5001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
},
duration: Duration::from_secs(610),
features_version: 1,
cue_info: None,
};
let third_song = LibrarySong {
bliss_song: song,
extra_info: ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("/path/to/charlie5001"),
},
};
let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES)
.map(|x| x as f32 * 0.9)
.collect::<Vec<f32>>()
.try_into()
.unwrap();
let song = Song {
path: "/path/to/song6001".into(),
artist: Some("Artist6001".into()),
title: Some("Title6001".into()),
album: Some("An Album2001".into()),
album_artist: Some("An Album Artist6001".into()),
track_number: Some("01".into()),
genre: Some("Electronica6001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
},
duration: Duration::from_secs(710),
features_version: 1,
cue_info: None,
};
let fourth_song = LibrarySong {
bliss_song: song,
extra_info: ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("/path/to/charlie6001"),
},
};
let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES)
.map(|x| x as f32 * 50.)
.collect::<Vec<f32>>()
.try_into()
.unwrap();
let song = Song {
path: "/path/to/song7001".into(),
artist: Some("Artist7001".into()),
title: Some("Title7001".into()),
album: Some("An Album7001".into()),
album_artist: Some("An Album Artist7001".into()),
track_number: Some("01".into()),
genre: Some("Electronica7001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
},
duration: Duration::from_secs(810),
features_version: 1,
cue_info: None,
};
let fifth_song = LibrarySong {
bliss_song: song,
extra_info: ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("/path/to/charlie7001"),
},
};
let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES)
.map(|x| x as f32 * 100.)
.collect::<Vec<f32>>()
.try_into()
.unwrap();
let song = Song {
path: "/path/to/cuetrack.cue/CUE_TRACK001".into(),
artist: Some("CUE Artist".into()),
title: Some("CUE Title 01".into()),
album: Some("CUE Album".into()),
album_artist: Some("CUE Album Artist".into()),
track_number: Some("01".into()),
genre: None,
analysis: Analysis {
internal_analysis: analysis_vector,
},
duration: Duration::from_secs(810),
features_version: 1,
cue_info: Some(CueInfo {
cue_path: PathBuf::from("/path/to/cuetrack.cue"),
audio_file_path: PathBuf::from("/path/to/cuetrack.flac"),
}),
};
let sixth_song = LibrarySong {
bliss_song: song,
extra_info: ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("/path/to/charlie7001"),
},
};
let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES)
.map(|x| x as f32 * 101.)
.collect::<Vec<f32>>()
.try_into()
.unwrap();
let song = Song {
path: "/path/to/cuetrack.cue/CUE_TRACK002".into(),
artist: Some("CUE Artist".into()),
title: Some("CUE Title 02".into()),
album: Some("CUE Album".into()),
album_artist: Some("CUE Album Artist".into()),
track_number: Some("02".into()),
genre: None,
analysis: Analysis {
internal_analysis: analysis_vector,
},
duration: Duration::from_secs(910),
features_version: 1,
cue_info: Some(CueInfo {
cue_path: PathBuf::from("/path/to/cuetrack.cue"),
audio_file_path: PathBuf::from("/path/to/cuetrack.flac"),
}),
};
let seventh_song = LibrarySong {
bliss_song: song,
extra_info: ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("/path/to/charlie7001"),
},
};
{
let connection = library.sqlite_conn.lock().unwrap();
connection
.execute(
"
insert into song (
id, path, artist, title, album, album_artist, track_number,
genre, duration, analyzed, version, extra_info,
cue_path, audio_file_path
) values (
1001, '/path/to/song1001', 'Artist1001', 'Title1001', 'An Album1001',
'An Album Artist1001', '03', 'Electronica1001', 310, true,
1, '{\"ignore\": true, \"metadata_bliss_does_not_have\":
\"/path/to/charlie1001\"}', null, null
),
(
2001, '/path/to/song2001', 'Artist2001', 'Title2001', 'An Album2001',
'An Album Artist2001', '02', 'Electronica2001', 410, true,
1, '{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie2001\"}', null, null
),
(
3001, '/path/to/song3001', null, null, null,
null, null, null, null, false, 1, '{}', null, null
),
(
4001, '/path/to/song4001', 'Artist4001', 'Title4001', 'An Album4001',
'An Album Artist4001', '01', 'Electronica4001', 510, true,
0, '{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie4001\"}', null, null
),
(
5001, '/path/to/song5001', 'Artist5001', 'Title5001', 'An Album1001',
'An Album Artist5001', '01', 'Electronica5001', 610, true,
1, '{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie5001\"}', null, null
),
(
6001, '/path/to/song6001', 'Artist6001', 'Title6001', 'An Album2001',
'An Album Artist6001', '01', 'Electronica6001', 710, true,
1, '{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie6001\"}', null, null
),
(
7001, '/path/to/song7001', 'Artist7001', 'Title7001', 'An Album7001',
'An Album Artist7001', '01', 'Electronica7001', 810, true,
1, '{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie7001\"}', null, null
),
(
7002, '/path/to/cuetrack.cue/CUE_TRACK001', 'CUE Artist',
'CUE Title 01', 'CUE Album',
'CUE Album Artist', '01', null, 810, true,
1, '{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie7001\"}', '/path/to/cuetrack.cue',
'/path/to/cuetrack.flac'
),
(
7003, '/path/to/cuetrack.cue/CUE_TRACK002', 'CUE Artist',
'CUE Title 02', 'CUE Album',
'CUE Album Artist', '02', null, 910, true,
1, '{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie7001\"}', '/path/to/cuetrack.cue',
'/path/to/cuetrack.flac'
),
(
8001, '/path/to/song8001', 'Artist8001', 'Title8001', 'An Album1001',
'An Album Artist8001', '03', 'Electronica8001', 910, true,
0, '{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie8001\"}', null, null
),
(
9001, './data/s16_stereo_22_5kHz.flac', 'Artist9001', 'Title9001',
'An Album9001', 'An Album Artist8001', '03', 'Electronica8001',
1010, true, 0, '{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie7001\"}', null, null
);
",
[],
)
.unwrap();
for index in 0..NUMBER_FEATURES {
connection
.execute(
"
insert into feature(song_id, feature, feature_index)
values
(1001, ?2, ?1),
(2001, ?3, ?1),
(3001, ?4, ?1),
(5001, ?5, ?1),
(6001, ?6, ?1),
(7001, ?7, ?1),
(7002, ?8, ?1),
(7003, ?9, ?1);
",
params![
index,
index as f32 / 10.,
index as f32 + 10.,
index as f32 / 10. + 1.,
index as f32 / 2.,
index as f32 * 0.9,
index as f32 * 50.,
index as f32 * 100.,
index as f32 * 101.,
],
)
.unwrap();
}
for index in 0..NUMBER_FEATURES - 5 {
connection
.execute(
"
insert into feature(song_id, feature, feature_index)
values
(8001, ?2, ?1),
(9001, ?3, ?1);
",
params![index, index as f32 / 20., index + 1],
)
.unwrap();
}
}
(
library,
config_dir,
(
first_song,
second_song,
third_song,
fourth_song,
fifth_song,
sixth_song,
seventh_song,
),
)
}
fn _library_song_from_database<T: DeserializeOwned + Serialize + Clone + Debug>(
connection: MutexGuard<Connection>,
song_path: &str,
) -> LibrarySong<T> {
let mut song = connection
.query_row(
"
select
path, artist, title, album, album_artist,
track_number, genre, duration, version, extra_info,
cue_path, audio_file_path
from song where path=?
",
params![song_path],
|row| {
let path: String = row.get(0)?;
let cue_path: Option<String> = row.get(10)?;
let audio_file_path: Option<String> = row.get(11)?;
let mut cue_info = None;
if let Some(cue_path) = cue_path {
cue_info = Some(CueInfo {
cue_path: PathBuf::from(cue_path),
audio_file_path: PathBuf::from(audio_file_path.unwrap()),
})
};
let song = Song {
path: PathBuf::from(path),
artist: row.get(1).unwrap(),
title: row.get(2).unwrap(),
album: row.get(3).unwrap(),
album_artist: row.get(4).unwrap(),
track_number: row.get(5).unwrap(),
genre: row.get(6).unwrap(),
analysis: Analysis {
internal_analysis: [0.; NUMBER_FEATURES],
},
duration: Duration::from_secs_f64(row.get(7).unwrap()),
features_version: row.get(8).unwrap(),
cue_info,
};
let serialized: String = row.get(9).unwrap();
let extra_info = serde_json::from_str(&serialized).unwrap();
Ok(LibrarySong {
bliss_song: song,
extra_info,
})
},
)
.expect("Song does not exist in the db.");
let mut stmt = connection
.prepare(
"
select
feature from feature join song on song.id = feature.song_id
where song.path = ? order by feature_index
",
)
.unwrap();
let analysis_vector = Analysis {
internal_analysis: stmt
.query_map(params![song_path], |row| row.get(0))
.unwrap()
.into_iter()
.map(|x| x.unwrap())
.collect::<Vec<f32>>()
.try_into()
.unwrap(),
};
song.bliss_song.analysis = analysis_vector;
song
}
fn _basic_song_from_database(connection: MutexGuard<Connection>, song_path: &str) -> Song {
let mut expected_song = connection
.query_row(
"
select
path, artist, title, album, album_artist,
track_number, genre, duration, version
from song where path=? and analyzed = true
",
params![song_path],
|row| {
let path: String = row.get(0)?;
Ok(Song {
path: PathBuf::from(path),
artist: row.get(1).unwrap(),
title: row.get(2).unwrap(),
album: row.get(3).unwrap(),
album_artist: row.get(4).unwrap(),
track_number: row.get(5).unwrap(),
genre: row.get(6).unwrap(),
analysis: Analysis {
internal_analysis: [0.; NUMBER_FEATURES],
},
duration: Duration::from_secs_f64(row.get(7).unwrap()),
features_version: row.get(8).unwrap(),
cue_info: None,
})
},
)
.expect("Song is probably not in the db");
let mut stmt = connection
.prepare(
"
select
feature from feature join song on song.id = feature.song_id
where song.path = ? order by feature_index
",
)
.unwrap();
let expected_analysis_vector = Analysis {
internal_analysis: stmt
.query_map(params![song_path], |row| row.get(0))
.unwrap()
.into_iter()
.map(|x| x.unwrap())
.collect::<Vec<f32>>()
.try_into()
.unwrap(),
};
expected_song.analysis = expected_analysis_vector;
expected_song
}
fn _generate_basic_song(path: Option<String>) -> Song {
let path = path.unwrap_or_else(|| "/path/to/song".into());
let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES)
.map(|x| x as f32 + 0.1)
.collect::<Vec<f32>>()
.try_into()
.unwrap();
Song {
path: path.into(),
artist: Some("An Artist".into()),
title: Some("Title".into()),
album: Some("An Album".into()),
album_artist: Some("An Album Artist".into()),
track_number: Some("03".into()),
genre: Some("Electronica".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
},
duration: Duration::from_secs(80),
features_version: 1,
cue_info: None,
}
}
fn _generate_library_song(path: Option<String>) -> LibrarySong<ExtraInfo> {
let song = _generate_basic_song(path);
let extra_info = ExtraInfo {
ignore: true,
metadata_bliss_does_not_have: "FoobarIze".into(),
};
LibrarySong {
bliss_song: song,
extra_info,
}
}
#[test]
fn test_library_playlist_song_not_existing() {
let (library, _temp_dir, _) = setup_test_library();
assert!(library
.playlist_from::<ExtraInfo>("not-existing", 2)
.is_err());
}
#[test]
fn test_library_playlist_crop() {
let (library, _temp_dir, _) = setup_test_library();
let songs: Vec<LibrarySong<ExtraInfo>> =
library.playlist_from("/path/to/song2001", 2).unwrap();
assert_eq!(2, songs.len());
}
#[test]
fn test_library_simple_playlist() {
let (library, _temp_dir, _) = setup_test_library();
let songs: Vec<LibrarySong<ExtraInfo>> =
library.playlist_from("/path/to/song2001", 20).unwrap();
assert_eq!(
vec![
"/path/to/song2001",
"/path/to/song6001",
"/path/to/song5001",
"/path/to/song1001",
"/path/to/song7001",
"/path/to/cuetrack.cue/CUE_TRACK001",
"/path/to/cuetrack.cue/CUE_TRACK002",
],
songs
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<String>>(),
)
}
#[test]
fn test_library_custom_playlist_distance() {
let (library, _temp_dir, _) = setup_test_library();
let distance =
|a: &Array1<f32>, b: &Array1<f32>| (a.get(1).unwrap() - b.get(1).unwrap()).abs();
let songs: Vec<LibrarySong<ExtraInfo>> = library
.playlist_from_custom(
"/path/to/song2001",
20,
distance,
closest_to_first_song_by_key,
true,
)
.unwrap();
assert_eq!(
vec![
"/path/to/song2001",
"/path/to/song6001",
"/path/to/song5001",
"/path/to/song1001",
"/path/to/song7001",
"/path/to/cuetrack.cue/CUE_TRACK001",
"/path/to/cuetrack.cue/CUE_TRACK002",
],
songs
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<String>>(),
)
}
fn custom_sort<F>(
_: &LibrarySong<ExtraInfo>,
songs: &mut Vec<LibrarySong<ExtraInfo>>,
_distance: impl DistanceMetric,
key_fn: F,
) where
F: Fn(&LibrarySong<ExtraInfo>) -> Song,
{
songs.sort_by_key(|song| key_fn(song).path);
}
#[test]
fn test_library_custom_playlist_sort() {
let (library, _temp_dir, _) = setup_test_library();
let songs: Vec<LibrarySong<ExtraInfo>> = library
.playlist_from_custom(
"/path/to/song2001",
20,
euclidean_distance,
custom_sort,
true,
)
.unwrap();
assert_eq!(
vec![
"/path/to/cuetrack.cue/CUE_TRACK001",
"/path/to/cuetrack.cue/CUE_TRACK002",
"/path/to/song1001",
"/path/to/song2001",
"/path/to/song5001",
"/path/to/song6001",
"/path/to/song7001",
],
songs
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<String>>(),
)
}
#[test]
fn test_library_custom_playlist_dedup() {
let (library, _temp_dir, _) = setup_test_library();
let distance = |a: &Array1<f32>, b: &Array1<f32>| {
((a.get(1).unwrap() - b.get(1).unwrap()).abs() / 30.).floor()
};
let songs: Vec<LibrarySong<ExtraInfo>> = library
.playlist_from_custom(
"/path/to/song2001",
20,
distance,
closest_to_first_song_by_key,
true,
)
.unwrap();
assert_eq!(
vec![
"/path/to/song1001",
"/path/to/song7001",
"/path/to/cuetrack.cue/CUE_TRACK001"
],
songs
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<String>>(),
);
let distance =
|a: &Array1<f32>, b: &Array1<f32>| ((a.get(1).unwrap() - b.get(1).unwrap()).abs());
let songs: Vec<LibrarySong<ExtraInfo>> = library
.playlist_from_custom(
"/path/to/song2001",
20,
distance,
closest_to_first_song_by_key,
false,
)
.unwrap();
assert_eq!(
vec![
"/path/to/song2001",
"/path/to/song6001",
"/path/to/song5001",
"/path/to/song1001",
"/path/to/song7001",
"/path/to/cuetrack.cue/CUE_TRACK001",
"/path/to/cuetrack.cue/CUE_TRACK002",
],
songs
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<String>>(),
)
}
#[test]
fn test_library_album_playlist() {
let (library, _temp_dir, _) = setup_test_library();
let album: Vec<LibrarySong<ExtraInfo>> = library
.album_playlist_from("An Album1001".to_string(), 20)
.unwrap();
assert_eq!(
vec![
"/path/to/song5001".to_string(),
"/path/to/song1001".to_string(),
"/path/to/song6001".to_string(),
"/path/to/song2001".to_string(),
"/path/to/song7001".to_string(),
"/path/to/cuetrack.cue/CUE_TRACK001".to_string(),
"/path/to/cuetrack.cue/CUE_TRACK002".to_string(),
],
album
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<_>>(),
)
}
#[test]
fn test_library_album_playlist_crop() {
let (library, _temp_dir, _) = setup_test_library();
let album: Vec<LibrarySong<ExtraInfo>> = library
.album_playlist_from("An Album1001".to_string(), 1)
.unwrap();
assert_eq!(
vec![
"/path/to/song5001".to_string(),
"/path/to/song1001".to_string(),
"/path/to/song6001".to_string(),
"/path/to/song2001".to_string(),
],
album
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<_>>(),
)
}
#[test]
fn test_library_songs_from_album() {
let (library, _temp_dir, _) = setup_test_library();
let album: Vec<LibrarySong<ExtraInfo>> = library.songs_from_album("An Album1001").unwrap();
assert_eq!(
vec![
"/path/to/song5001".to_string(),
"/path/to/song1001".to_string()
],
album
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<_>>(),
)
}
#[test]
fn test_library_songs_from_album_proper_features_version() {
let (library, _temp_dir, _) = setup_test_library();
let album: Vec<LibrarySong<ExtraInfo>> = library.songs_from_album("An Album1001").unwrap();
assert_eq!(
vec![
"/path/to/song5001".to_string(),
"/path/to/song1001".to_string()
],
album
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<_>>(),
)
}
#[test]
fn test_library_songs_from_album_not_existing() {
let (library, _temp_dir, _) = setup_test_library();
assert!(library
.songs_from_album::<ExtraInfo>("not-existing")
.is_err());
}
#[test]
fn test_library_delete_song_non_existing() {
let (mut library, _temp_dir, _) = setup_test_library();
{
let connection = library.sqlite_conn.lock().unwrap();
let count: u32 = connection
.query_row(
"select count(*) from feature join song on song.id = feature.song_id where song.path = ?",
["not-existing"],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 0);
let count: u32 = connection
.query_row(
"select count(*) from song where path = ?",
["not-existing"],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 0);
}
assert!(library.delete_song("not-existing").is_err());
}
#[test]
fn test_library_delete_song() {
let (mut library, _temp_dir, _) = setup_test_library();
{
let connection = library.sqlite_conn.lock().unwrap();
let count: u32 = connection
.query_row(
"select count(*) from feature join song on song.id = feature.song_id where song.path = ?",
["/path/to/song1001"],
|row| row.get(0),
)
.unwrap();
assert!(count >= 1);
let count: u32 = connection
.query_row(
"select count(*) from song where path = ?",
["/path/to/song1001"],
|row| row.get(0),
)
.unwrap();
assert!(count >= 1);
}
library.delete_song("/path/to/song1001").unwrap();
{
let connection = library.sqlite_conn.lock().unwrap();
let count: u32 = connection
.query_row(
"select count(*) from feature join song on song.id = feature.song_id where song.path = ?",
["/path/to/song1001"],
|row| row.get(0),
)
.unwrap();
assert_eq!(0, count);
let count: u32 = connection
.query_row(
"select count(*) from song where path = ?",
["/path/to/song1001"],
|row| row.get(0),
)
.unwrap();
assert_eq!(0, count);
}
}
#[test]
fn test_analyze_paths_cue() {
let (mut library, _temp_dir, _) = setup_test_library();
library.config.base_config_mut().features_version = 0;
{
let sqlite_conn =
Connection::open(&library.config.base_config().database_path).unwrap();
sqlite_conn.execute("delete from song", []).unwrap();
}
let paths = vec![
"./data/s16_mono_22_5kHz.flac",
"./data/testcue.cue",
"non-existing",
];
library.analyze_paths(paths.to_owned(), false).unwrap();
let expected_analyzed_paths = vec![
"./data/s16_mono_22_5kHz.flac",
"./data/testcue.cue/CUE_TRACK001",
"./data/testcue.cue/CUE_TRACK002",
"./data/testcue.cue/CUE_TRACK003",
];
{
let connection = library.sqlite_conn.lock().unwrap();
let mut stmt = connection
.prepare(
"
select
path from song where analyzed = true and path not like '%song%'
order by path
",
)
.unwrap();
let paths = stmt
.query_map(params![], |row| row.get(0))
.unwrap()
.map(|x| x.unwrap())
.collect::<Vec<String>>();
assert_eq!(paths, expected_analyzed_paths);
}
{
let connection = library.sqlite_conn.lock().unwrap();
let song: LibrarySong<()> =
_library_song_from_database(connection, "./data/testcue.cue/CUE_TRACK001");
assert!(song.bliss_song.cue_info.is_some());
}
}
#[test]
fn test_analyze_paths() {
let (mut library, _temp_dir, _) = setup_test_library();
library.config.base_config_mut().features_version = 0;
let paths = vec![
"./data/s16_mono_22_5kHz.flac",
"./data/s16_stereo_22_5kHz.flac",
"non-existing",
];
library.analyze_paths(paths.to_owned(), false).unwrap();
let songs = paths[..2]
.iter()
.map(|path| {
let connection = library.sqlite_conn.lock().unwrap();
_library_song_from_database(connection, path)
})
.collect::<Vec<LibrarySong<()>>>();
let expected_songs = paths[..2]
.iter()
.zip(vec![(), ()].into_iter())
.map(|(path, expected_extra_info)| LibrarySong {
bliss_song: Song::from_path(path).unwrap(),
extra_info: expected_extra_info,
})
.collect::<Vec<LibrarySong<()>>>();
assert_eq!(songs, expected_songs);
assert_eq!(
library.config.base_config_mut().features_version,
FEATURES_VERSION
);
}
#[test]
fn test_analyze_paths_convert_extra_info() {
let (mut library, _temp_dir, _) = setup_test_library();
library.config.base_config_mut().features_version = 0;
let paths = vec![
("./data/s16_mono_22_5kHz.flac", true),
("./data/s16_stereo_22_5kHz.flac", false),
("non-existing", false),
];
library
.analyze_paths_convert_extra_info(paths.to_owned(), true, |b, _, _| ExtraInfo {
ignore: b,
metadata_bliss_does_not_have: String::from("coucou"),
})
.unwrap();
library
.analyze_paths_convert_extra_info(paths.to_owned(), false, |b, _, _| ExtraInfo {
ignore: b,
metadata_bliss_does_not_have: String::from("coucou"),
})
.unwrap();
let songs = paths[..2]
.iter()
.map(|(path, _)| {
let connection = library.sqlite_conn.lock().unwrap();
_library_song_from_database(connection, path)
})
.collect::<Vec<LibrarySong<ExtraInfo>>>();
let expected_songs = paths[..2]
.iter()
.zip(
vec![
ExtraInfo {
ignore: true,
metadata_bliss_does_not_have: String::from("coucou"),
},
ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("coucou"),
},
]
.into_iter(),
)
.map(|((path, _extra_info), expected_extra_info)| LibrarySong {
bliss_song: Song::from_path(path).unwrap(),
extra_info: expected_extra_info,
})
.collect::<Vec<LibrarySong<ExtraInfo>>>();
assert_eq!(songs, expected_songs);
assert_eq!(
library.config.base_config_mut().features_version,
FEATURES_VERSION
);
}
#[test]
fn test_analyze_paths_extra_info() {
let (mut library, _temp_dir, _) = setup_test_library();
let paths = vec![
(
"./data/s16_mono_22_5kHz.flac",
ExtraInfo {
ignore: true,
metadata_bliss_does_not_have: String::from("hey"),
},
),
(
"./data/s16_stereo_22_5kHz.flac",
ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("hello"),
},
),
(
"non-existing",
ExtraInfo {
ignore: true,
metadata_bliss_does_not_have: String::from("coucou"),
},
),
];
library
.analyze_paths_extra_info(paths.to_owned(), false)
.unwrap();
let songs = paths[..2]
.iter()
.map(|(path, _)| {
let connection = library.sqlite_conn.lock().unwrap();
_library_song_from_database(connection, path)
})
.collect::<Vec<LibrarySong<ExtraInfo>>>();
let expected_songs = paths[..2]
.iter()
.zip(
vec![
ExtraInfo {
ignore: true,
metadata_bliss_does_not_have: String::from("hey"),
},
ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("hello"),
},
]
.into_iter(),
)
.map(|((path, _extra_info), expected_extra_info)| LibrarySong {
bliss_song: Song::from_path(path).unwrap(),
extra_info: expected_extra_info,
})
.collect::<Vec<LibrarySong<ExtraInfo>>>();
assert_eq!(songs, expected_songs);
}
#[test]
fn test_update_skip_analyzed() {
let (mut library, _temp_dir, _) = setup_test_library();
library.config.base_config_mut().features_version = 0;
for input in vec![
("./data/s16_mono_22_5kHz.flac", true),
("./data/s16_mono_22_5khz.flac", false),
]
.into_iter()
{
let paths = vec![input.to_owned()];
library
.update_library_convert_extra_info(paths.to_owned(), false, |b, _, _| ExtraInfo {
ignore: b,
metadata_bliss_does_not_have: String::from("coucou"),
})
.unwrap();
let song = {
let connection = library.sqlite_conn.lock().unwrap();
_library_song_from_database::<ExtraInfo>(connection, "./data/s16_mono_22_5kHz.flac")
};
let expected_song = {
LibrarySong {
bliss_song: Song::from_path("./data/s16_mono_22_5kHz.flac").unwrap(),
extra_info: ExtraInfo {
ignore: true,
metadata_bliss_does_not_have: String::from("coucou"),
},
}
};
assert_eq!(song, expected_song);
assert_eq!(
library.config.base_config_mut().features_version,
FEATURES_VERSION
);
}
}
fn _get_song_analyzed(connection: MutexGuard<Connection>, path: String) -> bool {
let mut stmt = connection
.prepare(
"
select
analyzed from song
where song.path = ?
",
)
.unwrap();
stmt.query_row([path], |row| row.get(0)).unwrap()
}
#[test]
fn test_update_library_override_old_features() {
let (mut library, _temp_dir, _) = setup_test_library();
let path: String = "./data/s16_stereo_22_5kHz.flac".into();
{
let connection = library.sqlite_conn.lock().unwrap();
let mut stmt = connection
.prepare(
"
select
feature from feature join song on song.id = feature.song_id
where song.path = ? order by feature_index
",
)
.unwrap();
let analysis_vector = stmt
.query_map(params![path], |row| row.get(0))
.unwrap()
.into_iter()
.map(|x| x.unwrap())
.collect::<Vec<f32>>();
assert_eq!(
analysis_vector,
vec![1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15.]
)
}
library
.update_library(vec![path.to_owned()], false)
.unwrap();
let connection = library.sqlite_conn.lock().unwrap();
let mut stmt = connection
.prepare(
"
select
feature from feature join song on song.id = feature.song_id
where song.path = ? order by feature_index
",
)
.unwrap();
let analysis_vector = Analysis {
internal_analysis: stmt
.query_map(params![path], |row| row.get(0))
.unwrap()
.into_iter()
.map(|x| x.unwrap())
.collect::<Vec<f32>>()
.try_into()
.unwrap(),
};
let expected_analysis_vector = Song::from_path(path).unwrap().analysis;
assert_eq!(analysis_vector, expected_analysis_vector);
}
#[test]
fn test_update_library() {
let (mut library, _temp_dir, _) = setup_test_library();
library.config.base_config_mut().features_version = 0;
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song4001".into()));
}
let paths = vec![
"./data/s16_mono_22_5kHz.flac",
"./data/s16_stereo_22_5kHz.flac",
"/path/to/song4001",
"non-existing",
];
library.update_library(paths.to_owned(), false).unwrap();
library.update_library(paths.to_owned(), true).unwrap();
let songs = paths[..2]
.iter()
.map(|path| {
let connection = library.sqlite_conn.lock().unwrap();
_library_song_from_database(connection, path)
})
.collect::<Vec<LibrarySong<()>>>();
let expected_songs = paths[..2]
.iter()
.zip(vec![(), ()].into_iter())
.map(|(path, expected_extra_info)| LibrarySong {
bliss_song: Song::from_path(path).unwrap(),
extra_info: expected_extra_info,
})
.collect::<Vec<LibrarySong<()>>>();
assert_eq!(songs, expected_songs);
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(!_get_song_analyzed(connection, "/path/to/song4001".into()));
}
assert_eq!(
library.config.base_config_mut().features_version,
FEATURES_VERSION
);
}
#[test]
fn test_update_extra_info() {
let (mut library, _temp_dir, _) = setup_test_library();
library.config.base_config_mut().features_version = 0;
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song4001".into()));
}
let paths = vec![
("./data/s16_mono_22_5kHz.flac", true),
("./data/s16_stereo_22_5kHz.flac", false),
("/path/to/song4001", false),
("non-existing", false),
];
library
.update_library_extra_info(paths.to_owned(), false)
.unwrap();
let songs = paths[..2]
.iter()
.map(|(path, _)| {
let connection = library.sqlite_conn.lock().unwrap();
_library_song_from_database(connection, path)
})
.collect::<Vec<LibrarySong<bool>>>();
let expected_songs = paths[..2]
.iter()
.zip(vec![true, false].into_iter())
.map(|((path, _extra_info), expected_extra_info)| LibrarySong {
bliss_song: Song::from_path(path).unwrap(),
extra_info: expected_extra_info,
})
.collect::<Vec<LibrarySong<bool>>>();
assert_eq!(songs, expected_songs);
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(!_get_song_analyzed(connection, "/path/to/song4001".into()));
}
assert_eq!(
library.config.base_config_mut().features_version,
FEATURES_VERSION
);
}
#[test]
fn test_update_convert_extra_info() {
let (mut library, _temp_dir, _) = setup_test_library();
library.config.base_config_mut().features_version = 0;
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song4001".into()));
}
let paths = vec![
("./data/s16_mono_22_5kHz.flac", true),
("./data/s16_stereo_22_5kHz.flac", false),
("/path/to/song4001", false),
("non-existing", false),
];
library
.update_library_convert_extra_info(paths.to_owned(), false, |b, _, _| ExtraInfo {
ignore: b,
metadata_bliss_does_not_have: String::from("coucou"),
})
.unwrap();
let songs = paths[..2]
.iter()
.map(|(path, _)| {
let connection = library.sqlite_conn.lock().unwrap();
_library_song_from_database(connection, path)
})
.collect::<Vec<LibrarySong<ExtraInfo>>>();
let expected_songs = paths[..2]
.iter()
.zip(
vec![
ExtraInfo {
ignore: true,
metadata_bliss_does_not_have: String::from("coucou"),
},
ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("coucou"),
},
]
.into_iter(),
)
.map(|((path, _extra_info), expected_extra_info)| LibrarySong {
bliss_song: Song::from_path(path).unwrap(),
extra_info: expected_extra_info,
})
.collect::<Vec<LibrarySong<ExtraInfo>>>();
assert_eq!(songs, expected_songs);
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(!_get_song_analyzed(connection, "/path/to/song4001".into()));
}
assert_eq!(
library.config.base_config_mut().features_version,
FEATURES_VERSION
);
}
#[test]
fn test_song_from_path() {
let (library, _temp_dir, _) = setup_test_library();
let analysis_vector: [f32; NUMBER_FEATURES] = (0..NUMBER_FEATURES)
.map(|x| x as f32 + 10.)
.collect::<Vec<f32>>()
.try_into()
.unwrap();
let song = Song {
path: "/path/to/song2001".into(),
artist: Some("Artist2001".into()),
title: Some("Title2001".into()),
album: Some("An Album2001".into()),
album_artist: Some("An Album Artist2001".into()),
track_number: Some("02".into()),
genre: Some("Electronica2001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
},
duration: Duration::from_secs(410),
features_version: 1,
cue_info: None,
};
let expected_song = LibrarySong {
bliss_song: song,
extra_info: ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("/path/to/charlie2001"),
},
};
let song = library
.song_from_path::<ExtraInfo>("/path/to/song2001")
.unwrap();
assert_eq!(song, expected_song)
}
#[test]
fn test_store_failed_song() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.store_failed_song(
"/some/failed/path",
BlissError::ProviderError("error with the analysis".into()),
)
.unwrap();
let connection = library.sqlite_conn.lock().unwrap();
let (error, analyzed): (String, bool) = connection
.query_row(
"
select
error, analyzed
from song where path=?
",
params!["/some/failed/path"],
|row| Ok((row.get_unwrap(0), row.get_unwrap(1))),
)
.unwrap();
assert_eq!(
error,
String::from(
"error happened with the music library provider - error with the analysis"
)
);
assert_eq!(analyzed, false);
let count_features: u32 = connection
.query_row(
"
select
count(*) from feature join song
on song.id = feature.song_id where path=?
",
params!["/some/failed/path"],
|row| Ok(row.get_unwrap(0)),
)
.unwrap();
assert_eq!(count_features, 0);
}
#[test]
fn test_songs_from_library() {
let (library, _temp_dir, expected_library_songs) = setup_test_library();
let library_songs = library.songs_from_library::<ExtraInfo>().unwrap();
assert_eq!(library_songs.len(), 7);
assert_eq!(
expected_library_songs,
(
library_songs[0].to_owned(),
library_songs[1].to_owned(),
library_songs[2].to_owned(),
library_songs[3].to_owned(),
library_songs[4].to_owned(),
library_songs[5].to_owned(),
library_songs[6].to_owned(),
)
);
}
#[test]
fn test_songs_from_library_screwed_db() {
let (library, _temp_dir, _) = setup_test_library();
{
let connection = library.sqlite_conn.lock().unwrap();
connection
.execute(
"insert into feature (song_id, feature, feature_index)
values (2001, 1.5, 21)
",
[],
)
.unwrap();
}
let error = library.songs_from_library::<ExtraInfo>().unwrap_err();
assert_eq!(
error.to_string(),
String::from(
"error happened with the music library provider - \
Song with ID 2001 and path /path/to/song2001Â has a \
different feature number than expected. Please rescan or \
update the song library.",
),
);
}
#[test]
fn test_song_from_path_not_analyzed() {
let (library, _temp_dir, _) = setup_test_library();
let error = library.song_from_path::<ExtraInfo>("/path/to/song4001");
assert!(error.is_err());
}
#[test]
fn test_song_from_path_not_found() {
let (library, _temp_dir, _) = setup_test_library();
let error = library.song_from_path::<ExtraInfo>("/path/to/song4001");
assert!(error.is_err());
}
#[test]
fn test_get_default_data_folder_no_default_path() {
env::set_var("XDG_DATA_HOME", "/home/foo/.local/share/");
assert_eq!(
PathBuf::from("/home/foo/.local/share/bliss-rs"),
BaseConfig::get_default_data_folder().unwrap()
);
env::remove_var("XDG_DATA_HOME");
assert_eq!(
PathBuf::from("/local/directory/bliss-rs"),
BaseConfig::get_default_data_folder().unwrap()
);
}
#[test]
fn test_library_new_default_write() {
let (library, _temp_dir, _) = setup_test_library();
let config_content = fs::read_to_string(&library.config.base_config().config_path)
.unwrap()
.replace(' ', "")
.replace('\n', "");
assert_eq!(
config_content,
format!(
"{{\"config_path\":\"{}\",\"database_path\":\"{}\",\"features_version\":{},\"number_cores\":{}}}",
library.config.base_config().config_path.display(),
library.config.base_config().database_path.display(),
FEATURES_VERSION,
num_cpus::get(),
)
);
}
#[test]
fn test_library_new_create_database() {
let (library, _temp_dir, _) = setup_test_library();
let sqlite_conn = Connection::open(&library.config.base_config().database_path).unwrap();
sqlite_conn
.execute(
"
insert into song (
id, path, artist, title, album, album_artist,
track_number, genre, stamp, version, duration, analyzed,
extra_info
)
values (
1, '/random/path', 'Some Artist', 'A Title', 'Some Album',
'Some Album Artist', '01', 'Electronica', '2022-01-01',
1, 250, true, '{\"key\": \"value\"}'
);
",
[],
)
.unwrap();
sqlite_conn
.execute(
"
insert into feature(id, song_id, feature, feature_index)
values (2000, 1, 1.1, 1)
on conflict(song_id, feature_index) do update set feature=excluded.feature;
",
[],
)
.unwrap();
}
#[test]
fn test_library_store_song() {
let (mut library, _temp_dir, _) = setup_test_library();
let song = _generate_basic_song(None);
let library_song = LibrarySong {
bliss_song: song.to_owned(),
extra_info: (),
};
library.store_song(&library_song).unwrap();
let connection = library.sqlite_conn.lock().unwrap();
let expected_song = _basic_song_from_database(connection, &song.path.to_string_lossy());
assert_eq!(expected_song, song);
}
#[test]
fn test_library_extra_info() {
let (mut library, _temp_dir, _) = setup_test_library();
let song = _generate_library_song(None);
library.store_song(&song).unwrap();
let connection = library.sqlite_conn.lock().unwrap();
let returned_song =
_library_song_from_database(connection, &song.bliss_song.path.to_string_lossy());
assert_eq!(returned_song, song);
}
#[test]
fn test_from_config_path_non_existing() {
assert!(
Library::<CustomConfig>::from_config_path(Some(PathBuf::from("non-existing"))).is_err()
);
}
#[test]
fn test_from_config_path() {
let config_dir = TempDir::new("coucou").unwrap();
let config_file = config_dir.path().join("config.json");
let database_file = config_dir.path().join("bliss.db");
let base_config = BaseConfig::new(
Some(config_file.to_owned()),
Some(database_file),
Some(nzus(1)),
)
.unwrap();
let config = CustomConfig {
base_config,
second_path_to_music_library: "/path/to/somewhere".into(),
ignore_wav_files: true,
};
let song = _generate_library_song(None);
{
let mut library = Library::new(config.to_owned()).unwrap();
library.store_song(&song).unwrap();
}
let library: Library<CustomConfig> = Library::from_config_path(Some(config_file)).unwrap();
let connection = library.sqlite_conn.lock().unwrap();
let returned_song =
_library_song_from_database(connection, &song.bliss_song.path.to_string_lossy());
assert_eq!(library.config, config);
assert_eq!(song, returned_song);
}
#[test]
fn test_config_serialize_deserialize() {
let config_dir = TempDir::new("coucou").unwrap();
let config_file = config_dir.path().join("config.json");
let database_file = config_dir.path().join("bliss.db");
let base_config = BaseConfig::new(
Some(config_file.to_owned()),
Some(database_file),
Some(nzus(1)),
)
.unwrap();
let config = CustomConfig {
base_config,
second_path_to_music_library: "/path/to/somewhere".into(),
ignore_wav_files: true,
};
config.write().unwrap();
assert_eq!(
config,
CustomConfig::from_path(&config_file.to_string_lossy()).unwrap(),
);
}
#[test]
fn test_library_sanity_check_fail() {
let (mut library, _temp_dir, _) = setup_test_library();
assert!(!library.version_sanity_check().unwrap());
}
#[test]
fn test_library_sanity_check_ok() {
let (mut library, _temp_dir, _) = setup_test_library();
{
let sqlite_conn =
Connection::open(&library.config.base_config().database_path).unwrap();
sqlite_conn
.execute("delete from song where version != 1", [])
.unwrap();
}
assert!(library.version_sanity_check().unwrap());
}
#[test]
fn test_config_number_cpus() {
let config_dir = TempDir::new("coucou").unwrap();
let config_file = config_dir.path().join("config.json");
let database_file = config_dir.path().join("bliss.db");
let base_config = BaseConfig::new(
Some(config_file.to_owned()),
Some(database_file.to_owned()),
None,
)
.unwrap();
let config = CustomConfig {
base_config,
second_path_to_music_library: "/path/to/somewhere".into(),
ignore_wav_files: true,
};
assert_eq!(config.get_number_cores().get(), num_cpus::get());
let base_config =
BaseConfig::new(Some(config_file), Some(database_file), Some(nzus(1))).unwrap();
let mut config = CustomConfig {
base_config,
second_path_to_music_library: "/path/to/somewhere".into(),
ignore_wav_files: true,
};
assert_eq!(config.get_number_cores().get(), 1);
config.set_number_cores(nzus(2)).unwrap();
assert_eq!(config.get_number_cores().get(), 2);
}
#[test]
fn test_library_create_all_dirs() {
let config_dir = TempDir::new("coucou")
.unwrap()
.path()
.join("path")
.join("to");
assert!(!config_dir.is_dir());
let config_file = config_dir.join("config.json");
let database_file = config_dir.join("bliss.db");
Library::<BaseConfig>::new_from_base(Some(config_file), Some(database_file), Some(nzus(1)))
.unwrap();
assert!(config_dir.is_dir());
}
}