#![cfg_attr(
feature = "ffmpeg",
doc = r##"
```no_run
use anyhow::{Error, Result};
use bliss_audio::library::{BaseConfig, Library};
use bliss_audio::decoder::ffmpeg::FFmpegDecoder;
use std::path::PathBuf;
let config_path = Some(PathBuf::from("path/to/config/config.json"));
let database_path = Some(PathBuf::from("path/to/config/bliss.db"));
let config = BaseConfig::new(config_path, database_path, None)?;
let library: Library<BaseConfig, FFmpegDecoder> = Library::new(config)?;
# Ok::<(), Error>(())
```"##
)]
use crate::cue::CueInfo;
use crate::playlist::closest_album_to_group;
use crate::playlist::closest_to_songs;
use crate::playlist::dedup_playlist_custom_distance;
use crate::playlist::euclidean_distance;
use crate::playlist::DistanceMetricBuilder;
use crate::song::AnalysisOptions;
use crate::FeaturesVersion;
use anyhow::{bail, Context, Result};
#[cfg(all(not(test), not(feature = "integration-tests")))]
use dirs::config_local_dir;
#[cfg(all(not(test), not(feature = "integration-tests")))]
use dirs::data_local_dir;
use indicatif::{ProgressBar, ProgressStyle};
use ndarray::Array2;
use rusqlite::params;
use rusqlite::params_from_iter;
use rusqlite::Connection;
use rusqlite::Params;
use rusqlite::Row;
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::fs::create_dir_all;
use std::marker::PhantomData;
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex;
use crate::decoder::Decoder as DecoderTrait;
use crate::Song;
use crate::{Analysis, BlissError, NUMBER_FEATURES};
use rusqlite::types::ToSqlOutput;
use rusqlite::Error as RusqliteError;
use rusqlite::{
types::{FromSql, FromSqlResult, ValueRef},
ToSql,
};
use std::convert::TryInto;
use std::time::Duration;
impl ToSql for FeaturesVersion {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(ToSqlOutput::from(*self as u16))
}
}
impl FromSql for FeaturesVersion {
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
let value = value.as_i64()?;
FeaturesVersion::try_from(u16::try_from(value).unwrap())
.map_err(|e| rusqlite::types::FromSqlError::Other(Box::new(e)))
}
}
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().analysis_options.number_cores = number_cores;
self.write()
}
fn set_features_version(&mut self, features_version: FeaturesVersion) -> Result<()> {
self.base_config_mut().analysis_options.features_version = features_version;
self.write()
}
fn get_features_version(&self) -> FeaturesVersion {
self.base_config().analysis_options.features_version
}
fn get_number_cores(&self) -> NonZeroUsize {
self.base_config().analysis_options.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, Debug, Clone)]
pub struct BaseConfig {
pub config_path: PathBuf,
pub database_path: PathBuf,
#[serde(flatten)]
pub analysis_options: AnalysisOptions,
#[serde(default = "default_m")]
pub m: Array2<f32>,
}
fn default_m() -> Array2<f32> {
Array2::eye(NUMBER_FEATURES)
}
impl BaseConfig {
pub(crate) fn get_default_data_folder() -> Result<PathBuf> {
let error_message = "No suitable path found to store bliss' song database. Consider specifying such a path.";
let default_folder = env::var("XDG_CONFIG_HOME")
.map(|path| Path::new(&path).join("bliss-rs"))
.or_else(|_| {
config_local_dir()
.map(|p| p.join("bliss-rs"))
.with_context(|| error_message)
});
if let Ok(folder) = &default_folder {
if folder.exists() {
return Ok(folder.clone());
}
}
if let Ok(legacy_folder) = BaseConfig::get_legacy_data_folder() {
if legacy_folder.exists() {
return Ok(legacy_folder);
}
}
default_folder
}
fn get_legacy_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>,
analysis_options: Option<AnalysisOptions>,
) -> Result<Self> {
let provided_database_path = database_path.is_some();
let provided_config_path = config_path.is_some();
let mut final_config_path = {
if let Some(path) = config_path {
path
} else {
Self::get_default_data_folder()?.join(Path::new("config.json"))
}
};
let mut final_database_path = {
if let Some(path) = database_path {
path
} else {
Self::get_default_data_folder()?.join(Path::new("songs.db"))
}
};
if provided_database_path && !provided_config_path {
final_config_path = final_database_path
.parent()
.ok_or(BlissError::ProviderError(String::from(
"provided database path was invalid.",
)))?
.join(Path::new("config.json"))
} else if !provided_database_path && provided_config_path {
final_database_path = final_config_path
.parent()
.ok_or(BlissError::ProviderError(String::from(
"provided config path was invalid.",
)))?
.join(Path::new("songs.db"))
}
Ok(Self {
config_path: final_config_path,
database_path: final_database_path,
analysis_options: analysis_options.unwrap_or_default(),
m: Array2::eye(NUMBER_FEATURES),
})
}
}
impl AppConfigTrait for BaseConfig {
fn base_config(&self) -> &BaseConfig {
self
}
fn base_config_mut(&mut self) -> &mut BaseConfig {
self
}
}
pub struct Library<Config, D: ?Sized> {
pub config: Config,
pub sqlite_conn: Arc<Mutex<Connection>>,
decoder: PhantomData<D>,
}
#[derive(Debug, Eq, PartialEq)]
pub struct ProcessingError {
pub song_path: PathBuf,
pub error: String,
pub features_version: FeaturesVersion,
}
#[derive(Debug, PartialEq, Clone)]
pub struct LibrarySong<T: Serialize + DeserializeOwned + Clone> {
pub bliss_song: Song,
pub extra_info: T,
}
impl<T: Serialize + DeserializeOwned + Clone> AsRef<Song> for LibrarySong<T> {
fn as_ref(&self) -> &Song {
&self.bliss_song
}
}
#[derive(Debug, PartialEq)]
pub enum SanityError {
MultipleVersionsInDB {
versions: Vec<FeaturesVersion>,
},
OldFeaturesVersionInDB {
version: FeaturesVersion,
},
}
impl<Config: AppConfigTrait, D: ?Sized + DecoderTrait> Library<Config, D> {
const SQLITE_SCHEMA: &'static str = "
create table song (
id integer primary key,
path text not null unique,
duration float,
album_artist text,
artist text,
title text,
album text,
track_number integer,
disc_number integer,
genre text,
cue_path text,
audio_file_path text,
stamp timestamp default current_timestamp,
version integer not null,
analyzed boolean default false,
extra_info json,
error text
);
pragma foreign_keys = on;
create table 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
)
";
const SQLITE_MIGRATIONS: &'static [&'static str] = &[
"",
"
alter table song add column track_number_1 integer;
update song set track_number_1 = s1.cast_track_number from (
select cast(track_number as int) as cast_track_number, id from song
) as s1 where s1.id = song.id and cast(track_number as int) != 0;
alter table song drop column track_number;
alter table song rename column track_number_1 to track_number;
",
"alter table song add column disc_number integer;",
"
-- Training triplets used to do metric learning, in conjunction with
-- a human-processed survey. In this table, songs pointed to
-- by song_1_id and song_2_id are closer together than they
-- are to the song pointed to by odd_one_out_id, i.e.
-- d(s1, s2) < d(s1, odd_one_out) and d(s1, s2) < d(s2, odd_one_out)
create table training_triplet (
id integer primary key,
song_1_id integer not null,
song_2_id integer not null,
odd_one_out_id integer not null,
stamp timestamp default current_timestamp,
foreign key(song_1_id) references song(id) on delete cascade,
foreign key(song_2_id) references song(id) on delete cascade,
foreign key(odd_one_out_id) references song(id) on delete cascade
)
",
"
create table song_bak (
id integer primary key,
path text not null unique,
duration float,
album_artist text,
artist text,
title text,
album text,
track_number integer,
disc_number integer,
genre text,
cue_path text,
audio_file_path text,
stamp timestamp default current_timestamp,
version integer not null,
analyzed boolean default false,
extra_info json,
error text
);
insert into song_bak (
id, path, duration, album_artist, artist, title, album, track_number,
disc_number,genre, cue_path, audio_file_path, stamp, version,
analyzed, extra_info, error
) select
id, path, duration, album_artist, artist, title, album, track_number,
disc_number,genre, cue_path, audio_file_path, stamp,
coalesce(version, 1), analyzed, extra_info, error
from song;
drop table song;
alter table song_bak rename to song;
",
];
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)?;
Library::<Config, D>::upgrade(&sqlite_conn).map_err(|e| {
BlissError::ProviderError(format!("Could not run database upgrade: {e}"))
})?;
config.write()?;
Ok(Self {
config,
sqlite_conn: Arc::new(Mutex::new(sqlite_conn)),
decoder: PhantomData,
})
}
fn upgrade(sqlite_conn: &Connection) -> Result<()> {
let version: u32 = sqlite_conn
.query_row("pragma user_version", [], |row| row.get(0))
.map_err(|e| {
BlissError::ProviderError(format!("Could not get database version: {e}."))
})?;
let migrations = Library::<Config, D>::SQLITE_MIGRATIONS;
match version.cmp(&(migrations.len() as u32)) {
std::cmp::Ordering::Equal => return Ok(()),
std::cmp::Ordering::Greater => bail!(format!(
"bliss-rs version {} is older than the schema version {}",
version,
migrations.len()
)),
_ => (),
};
let number_tables: u32 = sqlite_conn
.query_row("select count(*) from pragma_table_list", [], |row| {
row.get(0)
})
.map_err(|e| {
BlissError::ProviderError(format!(
"Could not query initial database information: {e}",
))
})?;
let is_database_new = number_tables <= 2;
if version == 0 && is_database_new {
sqlite_conn
.execute_batch(Library::<Config, D>::SQLITE_SCHEMA)
.map_err(|e| {
BlissError::ProviderError(format!("Could not initialize schema: {e}."))
})?;
} else {
for migration in migrations.iter().skip(version as usize) {
sqlite_conn.execute_batch(migration).map_err(|e| {
BlissError::ProviderError(format!("Could not execute migration: {e}."))
})?;
}
}
sqlite_conn
.execute(&format!("pragma user_version = {}", migrations.len()), [])
.map_err(|e| {
BlissError::ProviderError(format!("Could not update database version: {e}."))
})?;
Ok(())
}
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)?;
Library::<Config, D>::upgrade(&sqlite_conn)?;
let library = Self {
config,
sqlite_conn: Arc::new(Mutex::new(sqlite_conn)),
decoder: PhantomData,
};
Ok(library)
}
pub fn version_sanity_check(&mut self) -> Result<Vec<SanityError>> {
let mut errors = vec![];
let connection = self
.sqlite_conn
.lock()
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
let mut stmt = connection.prepare("select distinct version from song")?;
let mut features_version: Vec<FeaturesVersion> = stmt
.query_map([], |row| row.get::<_, FeaturesVersion>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
features_version.sort();
if features_version.len() > 1 {
errors.push(SanityError::MultipleVersionsInDB {
versions: features_version.to_owned(),
})
}
if features_version
.iter()
.any(|features_version_in_db| features_version_in_db != &FeaturesVersion::LATEST)
{
errors.push(SanityError::OldFeaturesVersionInDB {
version: features_version[0],
});
}
Ok(errors)
}
pub fn new_from_base(
config_path: Option<PathBuf>,
database_path: Option<PathBuf>,
analysis_options: Option<AnalysisOptions>,
) -> Result<Self>
where
BaseConfig: Into<Config>,
{
let base = BaseConfig::new(config_path, database_path, analysis_options)?;
let config = base.into();
Self::new(config)
}
pub fn playlist_from<'a, T: Serialize + DeserializeOwned + Clone + 'a>(
&self,
song_paths: &[&str],
) -> Result<impl Iterator<Item = LibrarySong<T>> + 'a> {
self.playlist_from_custom(song_paths, &euclidean_distance, closest_to_songs, true)
}
pub fn playlist_from_custom<'a, T, F, I>(
&self,
initial_song_paths: &[&str],
distance: &'a dyn DistanceMetricBuilder,
sort_by: F,
deduplicate: bool,
) -> Result<impl Iterator<Item = LibrarySong<T>> + 'a>
where
T: Serialize + DeserializeOwned + Clone + 'a,
F: Fn(&[LibrarySong<T>], &[LibrarySong<T>], &'a dyn DistanceMetricBuilder) -> I,
I: Iterator<Item = LibrarySong<T>> + 'a,
{
let initial_songs: Vec<LibrarySong<T>> = initial_song_paths
.iter()
.map(|s| {
self.song_from_path(s).map_err(|_| {
BlissError::ProviderError(format!("song '{s}' has not been analyzed"))
})
})
.collect::<Result<Vec<_>, BlissError>>()?;
let songs = self
.songs_from_library()?
.into_iter()
.filter(|s| {
!initial_song_paths.contains(&&*s.bliss_song.path.to_string_lossy().to_string())
})
.collect::<Vec<_>>();
let iterator = sort_by(&initial_songs, &songs, distance);
let mut iterator: Box<dyn Iterator<Item = LibrarySong<T>>> =
Box::new(initial_songs.into_iter().chain(iterator));
if deduplicate {
iterator = Box::new(dedup_playlist_custom_distance(iterator, None, distance));
}
Ok(iterator)
}
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(album, songs)?;
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;
}
song.bliss_song.album.clone_into(&mut current_album);
}
index += 1;
}
let playlist = &playlist[..index];
Ok(playlist.to_vec())
}
pub fn update_library<P: Into<PathBuf>>(
&mut self,
paths: Vec<P>,
delete_everything_else: bool,
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,
delete_everything_else,
show_progress_bar,
|x, _, _| x,
self.config.base_config().analysis_options,
)
}
pub fn update_library_with_options<P: Into<PathBuf>>(
&mut self,
paths: Vec<P>,
delete_everything_else: bool,
show_progress_bar: bool,
analysis_options: AnalysisOptions,
) -> Result<()> {
let paths_extra_info = paths.into_iter().map(|path| (path, ())).collect::<Vec<_>>();
self.update_library_convert_extra_info(
paths_extra_info,
delete_everything_else,
show_progress_bar,
|x, _, _| x,
analysis_options,
)
}
pub fn update_library_extra_info<T: Serialize + DeserializeOwned + Clone, P: Into<PathBuf>>(
&mut self,
paths_extra_info: Vec<(P, T)>,
delete_everything_else: bool,
show_progress_bar: bool,
) -> Result<()> {
self.update_library_convert_extra_info(
paths_extra_info,
delete_everything_else,
show_progress_bar,
|extra_info, _, _| extra_info,
self.config.base_config().analysis_options,
)
}
pub fn update_library_convert_extra_info<
T: Serialize + DeserializeOwned + Clone,
U,
P: Into<PathBuf>,
>(
&mut self,
paths_extra_info: Vec<(P, U)>,
delete_everything_else: bool,
show_progress_bar: bool,
convert_extra_info: fn(U, &Song, &Self) -> T,
analysis_options: AnalysisOptions,
) -> 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([analysis_options.features_version], |row| {
Ok(row.get_unwrap::<usize, String>(0))
})?
.map(|x| PathBuf::from(x.unwrap()))
.collect::<HashSet<PathBuf>>();
return_value
};
let paths_extra_info: Vec<_> = paths_extra_info
.into_iter()
.map(|(x, y)| (x.into(), y))
.collect();
let paths: HashSet<_> = paths_extra_info.iter().map(|(p, _)| p.to_owned()).collect();
if delete_everything_else {
let existing_paths_old_features_version = {
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 order by id
",
)?;
#[allow(clippy::let_and_return)]
let return_value = path_statement
.query_map([], |row| Ok(row.get_unwrap::<usize, String>(0)))?
.map(|x| PathBuf::from(x.unwrap()))
.collect::<HashSet<PathBuf>>();
return_value
};
let paths_to_delete = existing_paths_old_features_version.difference(&paths);
self.delete_paths(paths_to_delete)?;
}
let paths_to_analyze = paths_extra_info
.into_iter()
.filter(|(path, _)| !existing_paths.contains(path))
.collect::<Vec<(PathBuf, U)>>();
{
let connection = self
.sqlite_conn
.lock()
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
if !paths_to_analyze.is_empty() {
connection.execute(
"delete from song where version != ?",
params![analysis_options.features_version],
)?;
}
}
self.analyze_paths_convert_extra_info(
paths_to_analyze,
show_progress_bar,
convert_extra_info,
analysis_options,
)
}
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<_>>();
let analysis_options = self.config.base_config().analysis_options;
self.analyze_paths_convert_extra_info(
paths_extra_info,
show_progress_bar,
|x, _, _| x,
analysis_options,
)
}
pub fn analyze_paths_with_options<P: Into<PathBuf>>(
&mut self,
paths: Vec<P>,
show_progress_bar: bool,
analysis_options: AnalysisOptions,
) -> 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,
analysis_options,
)
}
pub fn analyze_paths_extra_info<
T: Serialize + DeserializeOwned + std::fmt::Debug + Clone,
P: Into<PathBuf>,
>(
&mut self,
paths_extra_info: Vec<(P, T)>,
show_progress_bar: bool,
analysis_options: AnalysisOptions,
) -> Result<()> {
self.analyze_paths_convert_extra_info(
paths_extra_info,
show_progress_bar,
|extra_info, _, _| extra_info,
analysis_options,
)
}
pub fn analyze_paths_convert_extra_info<
T: Serialize + DeserializeOwned + Clone,
U,
P: Into<PathBuf>,
>(
&mut self,
paths_extra_info: Vec<(P, U)>,
show_progress_bar: bool,
convert_extra_info: fn(U, &Song, &Self) -> T,
analysis_options: AnalysisOptions,
) -> Result<()> {
let number_songs = paths_extra_info.len();
if number_songs == 0 {
log::info!("No (new) songs found.");
return Ok(());
}
log::info!("Analyzing {number_songs} song(s), this might take some time…",);
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 = D::analyze_paths_with_options(paths_extra_info.keys(), analysis_options);
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, analysis_options.features_version)?;
failure_count += 1;
}
};
pb.inc(1);
}
pb.finish_with_message(format!(
"Analyzed {success_count} song(s) successfully. {failure_count} Failure(s).",
));
log::info!("Analyzed {success_count} song(s) successfully. {failure_count} Failure(s).",);
self.config.base_config_mut().analysis_options = analysis_options;
self.config.write()?;
Ok(())
}
fn _songs_from_statement<T: Serialize + DeserializeOwned + Clone, 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(13)?, 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::new(chunk, song.bliss_song.features_version)
.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 + Clone>(
&self,
) -> Result<Vec<LibrarySong<T>>> {
let songs_statement = "
select
path, artist, title, album, album_artist,
track_number, disc_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().analysis_options.features_version];
self._songs_from_statement(songs_statement, features_statement, params)
}
pub fn songs_from_album<T: Serialize + DeserializeOwned + Clone>(
&self,
album_title: &str,
) -> Result<Vec<LibrarySong<T>>> {
let params = params![
album_title,
self.config.base_config().analysis_options.features_version
];
let songs_statement = "
select
path, artist, title, album, album_artist,
track_number, disc_number, genre, duration, version, extra_info, cue_path,
audio_file_path, id
from song where album = ? and analyzed = true and version = ?
order
by disc_number, track_number;
";
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 disc_number, track_number;
";
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 + Clone>(
&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, disc_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 = Analysis::new(
stmt.query_map(params![song_path], |row| row.get(0))
.unwrap()
.map(|x| x.unwrap())
.collect::<Vec<f32>>(),
song.bliss_song.features_version,
)
.map_err(|_| {
BlissError::ProviderError(format!(
"song has more or less than {NUMBER_FEATURES} features",
))
})?;
song.bliss_song.analysis = analysis;
Ok(song)
}
fn _song_from_row_closure<T: Serialize + DeserializeOwned + Clone>(
row: &Row,
) -> Result<LibrarySong<T>, RusqliteError> {
let path: String = row.get(0)?;
let cue_path: Option<String> = row.get(11)?;
let audio_file_path: Option<String> = row.get(12)?;
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_ref(1)
.unwrap()
.as_bytes_or_null()
.unwrap()
.map(|v| String::from_utf8_lossy(v).to_string()),
title: row
.get_ref(2)
.unwrap()
.as_bytes_or_null()
.unwrap()
.map(|v| String::from_utf8_lossy(v).to_string()),
album: row
.get_ref(3)
.unwrap()
.as_bytes_or_null()
.unwrap()
.map(|v| String::from_utf8_lossy(v).to_string()),
album_artist: row
.get_ref(4)
.unwrap()
.as_bytes_or_null()
.unwrap()
.map(|v| String::from_utf8_lossy(v).to_string()),
track_number: row
.get_ref(5)
.unwrap()
.as_i64_or_null()
.unwrap()
.map(|v| v as i32),
disc_number: row
.get_ref(6)
.unwrap()
.as_i64_or_null()
.unwrap()
.map(|v| v as i32),
genre: row
.get_ref(7)
.unwrap()
.as_bytes_or_null()
.unwrap()
.map(|v| String::from_utf8_lossy(v).to_string()),
analysis: Analysis {
internal_analysis: vec![0.; NUMBER_FEATURES],
features_version: row.get(9).unwrap(),
},
duration: Duration::from_secs_f64(row.get(8).unwrap()),
features_version: row.get(9).unwrap(),
cue_info,
};
let serialized: Option<String> = row.get(10).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 + Clone>(
&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, disc_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, ?14
)
on conflict(path)
do update set
artist=excluded.artist,
title=excluded.title,
album=excluded.album,
track_number=excluded.track_number,
disc_number=excluded.disc_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.disc_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,
features_version: FeaturesVersion,
) -> Result<()> {
self.sqlite_conn
.lock()
.unwrap()
.execute(
"
insert or replace into song (path, error, version) values (?1, ?2, ?3)
",
params![
song_path.into().to_string_lossy().to_string(),
e.to_string(),
features_version,
],
)
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
Ok(())
}
pub fn get_failed_songs(&self) -> Result<Vec<ProcessingError>> {
let conn = self.sqlite_conn.lock().unwrap();
let mut stmt = conn.prepare(
"
select path, error, version
from song where error is not null order by id
",
)?;
let rows = stmt.query_map([], |row| {
Ok(ProcessingError {
song_path: row.get::<_, String>(0)?.into(),
error: row.get(1)?,
features_version: row.get(2)?,
})
})?;
Ok(rows
.into_iter()
.map(|r| r.unwrap())
.collect::<Vec<ProcessingError>>())
}
pub fn delete_path<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(())
}
pub fn delete_paths<P: Into<PathBuf>, I: IntoIterator<Item = P>>(
&mut self,
paths: I,
) -> Result<usize> {
let song_paths: Vec<String> = paths
.into_iter()
.map(|x| x.into().to_string_lossy().to_string())
.collect();
if song_paths.is_empty() {
return Ok(0);
};
let count = self
.sqlite_conn
.lock()
.unwrap()
.execute(
&format!(
"delete from song where path in ({})",
repeat_vars(song_paths.len()),
),
params_from_iter(song_paths),
)
.map_err(|e| BlissError::ProviderError(e.to_string()))?;
Ok(count)
}
}
fn repeat_vars(count: usize) -> String {
assert_ne!(count, 0);
let mut s = "?,".repeat(count);
s.pop();
s
}
#[cfg(any(test, feature = "integration-tests"))]
fn data_local_dir() -> Option<PathBuf> {
Some(PathBuf::from("/tmp/data"))
}
#[cfg(any(test, feature = "integration-tests"))]
fn config_local_dir() -> Option<PathBuf> {
Some(PathBuf::from("/tmp/"))
}
#[cfg(test)]
mod test {
use super::*;
use crate::{decoder::PreAnalyzedSong, Analysis, NUMBER_FEATURES};
use ndarray::Array1;
use pretty_assertions::assert_eq;
use serde::{de::DeserializeOwned, Deserialize};
use serde_json::Value;
use std::thread;
use std::{convert::TryInto, fmt::Debug, str::FromStr, sync::MutexGuard, time::Duration};
use tempdir::TempDir;
#[cfg(feature = "ffmpeg")]
use crate::song::decoder::ffmpeg::FFmpegDecoder as Decoder;
use crate::song::decoder::Decoder as DecoderTrait;
struct DummyDecoder;
impl DecoderTrait for DummyDecoder {
fn decode(_: &Path) -> crate::BlissResult<crate::decoder::PreAnalyzedSong> {
Ok(PreAnalyzedSong::default())
}
}
#[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Default)]
struct ExtraInfo {
ignore: bool,
metadata_bliss_does_not_have: String,
}
#[derive(Deserialize, Serialize, PartialEq, 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()
}
#[cfg(feature = "ffmpeg")]
fn setup_test_library() -> (
Library<BaseConfig, Decoder>,
TempDir,
(
LibrarySong<ExtraInfo>,
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, Decoder>::new_from_base(
Some(config_file),
Some(database_file),
None,
)
.unwrap();
let analysis_vector = (0..NUMBER_FEATURES)
.map(|x| x as f32 / 10.)
.collect::<Vec<f32>>();
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(3),
disc_number: Some(1),
genre: Some("Electronica1001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
features_version: FeaturesVersion::LATEST,
},
duration: Duration::from_secs(310),
features_version: FeaturesVersion::LATEST,
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 = (0..NUMBER_FEATURES)
.map(|x| x as f32 + 10.)
.collect::<Vec<f32>>();
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(2),
disc_number: Some(1),
genre: Some("Electronica2001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
features_version: FeaturesVersion::LATEST,
},
duration: Duration::from_secs(410),
features_version: FeaturesVersion::LATEST,
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 = (0..NUMBER_FEATURES)
.map(|x| x as f32 + 10.)
.collect::<Vec<f32>>();
let song = Song {
path: "/path/to/song2201".into(),
artist: Some("Artist2001".into()),
title: Some("Title2001".into()),
album: Some("An Album2001".into()),
album_artist: Some("An Album Artist2001".into()),
track_number: Some(1),
disc_number: Some(2),
genre: Some("Electronica2001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
features_version: FeaturesVersion::LATEST,
},
duration: Duration::from_secs(410),
features_version: FeaturesVersion::LATEST,
cue_info: None,
};
let second_song_dupe = LibrarySong {
bliss_song: song,
extra_info: ExtraInfo {
ignore: false,
metadata_bliss_does_not_have: String::from("/path/to/charlie2201"),
},
};
let analysis_vector = (0..NUMBER_FEATURES)
.map(|x| x as f32 / 2.)
.collect::<Vec<f32>>();
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(1),
disc_number: Some(1),
genre: Some("Electronica5001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
features_version: FeaturesVersion::LATEST,
},
duration: Duration::from_secs(610),
features_version: FeaturesVersion::LATEST,
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 = (0..NUMBER_FEATURES)
.map(|x| x as f32 * 0.9)
.collect::<Vec<f32>>();
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(1),
disc_number: Some(1),
genre: Some("Electronica6001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
features_version: FeaturesVersion::LATEST,
},
duration: Duration::from_secs(710),
features_version: FeaturesVersion::LATEST,
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 = (0..NUMBER_FEATURES)
.map(|x| x as f32 * 50.)
.collect::<Vec<f32>>();
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(1),
disc_number: Some(1),
genre: Some("Electronica7001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
features_version: FeaturesVersion::LATEST,
},
duration: Duration::from_secs(810),
features_version: FeaturesVersion::LATEST,
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 = (0..NUMBER_FEATURES)
.map(|x| x as f32 * 100.)
.collect::<Vec<f32>>();
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(1),
disc_number: Some(1),
genre: None,
analysis: Analysis {
internal_analysis: analysis_vector,
features_version: FeaturesVersion::LATEST,
},
duration: Duration::from_secs(810),
features_version: FeaturesVersion::LATEST,
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 = (0..NUMBER_FEATURES)
.map(|x| x as f32 * 101.)
.collect::<Vec<f32>>();
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(2),
disc_number: Some(1),
genre: None,
analysis: Analysis {
internal_analysis: analysis_vector,
features_version: FeaturesVersion::LATEST,
},
duration: Duration::from_secs(910),
features_version: FeaturesVersion::LATEST,
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(
&format!(
"
insert into song (
id, path, artist, title, album, album_artist, track_number,
disc_number, genre, duration, analyzed, version, extra_info,
cue_path, audio_file_path, error
) values (
1001, '/path/to/song1001', 'Artist1001', 'Title1001', 'An Album1001',
'An Album Artist1001', 3, 1, 'Electronica1001', 310, true,
{new_version}, '{{\"ignore\": true, \"metadata_bliss_does_not_have\":
\"/path/to/charlie1001\"}}', null, null, null
),
(
2001, '/path/to/song2001', 'Artist2001', 'Title2001', 'An Album2001',
'An Album Artist2001', 2, 1, 'Electronica2001', 410, true,
{new_version}, '{{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie2001\"}}', null, null, null
),
(
2201, '/path/to/song2201', 'Artist2001', 'Title2001', 'An Album2001',
'An Album Artist2001', 1, 2, 'Electronica2001', 410, true,
{new_version}, '{{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie2201\"}}', null, null, null
),
(
3001, '/path/to/song3001', null, null, null,
null, null, null, null, null, false, {new_version}, '{{}}', null, null, null
),
(
4001, '/path/to/song4001', 'Artist4001', 'Title4001', 'An Album4001',
'An Album Artist4001', 1, 1, 'Electronica4001', 510, true,
{old_version}, '{{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie4001\"}}', null, null, null
),
(
5001, '/path/to/song5001', 'Artist5001', 'Title5001', 'An Album1001',
'An Album Artist5001', 1, 1, 'Electronica5001', 610, true,
{new_version}, '{{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie5001\"}}', null, null, null
),
(
6001, '/path/to/song6001', 'Artist6001', 'Title6001', 'An Album2001',
'An Album Artist6001', 1, 1, 'Electronica6001', 710, true,
{new_version}, '{{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie6001\"}}', null, null, null
),
(
7001, '/path/to/song7001', 'Artist7001', 'Title7001', 'An Album7001',
'An Album Artist7001', 1, 1, 'Electronica7001', 810, true,
{new_version}, '{{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie7001\"}}', null, null, null
),
(
7002, '/path/to/cuetrack.cue/CUE_TRACK001', 'CUE Artist',
'CUE Title 01', 'CUE Album',
'CUE Album Artist', 1, 1, null, 810, true,
{new_version}, '{{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie7001\"}}', '/path/to/cuetrack.cue',
'/path/to/cuetrack.flac', null
),
(
7003, '/path/to/cuetrack.cue/CUE_TRACK002', 'CUE Artist',
'CUE Title 02', 'CUE Album',
'CUE Album Artist', 2, 1, null, 910, true,
{new_version}, '{{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie7001\"}}', '/path/to/cuetrack.cue',
'/path/to/cuetrack.flac', null
),
(
8001, '/path/to/song8001', 'Artist8001', 'Title8001', 'An Album1001',
'An Album Artist8001', 3, 1, 'Electronica8001', 910, true,
{old_version}, '{{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie8001\"}}', null, null, null
),
(
9001, './data/s16_stereo_22_5kHz.flac', 'Artist9001', 'Title9001',
'An Album9001', 'An Album Artist8001', 3, 1, 'Electronica8001',
1010, true, {old_version}, '{{\"ignore\": false, \"metadata_bliss_does_not_have\":
\"/path/to/charlie7001\"}}', null, null, null
),
(
404, './data/not-existing.m4a', null, null,
null, null, null, null, null,
null, false, {old_version}, null, null, null, 'error finding the file'
),
(
502, './data/invalid-file.m4a', null, null,
null, null, null, null, null,
null, false, {old_version}, null, null, null, 'error decoding the file'
);
",
new_version = FeaturesVersion::LATEST as u16,
old_version = FeaturesVersion::Version1 as u16,
),
[],
)
.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),
(2201, ?10, ?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.,
index as f32 + 10.,
],
)
.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,
second_song_dupe,
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, disc_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(11)?;
let audio_file_path: Option<String> = row.get(12)?;
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 features_version: FeaturesVersion = row.get(9).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(),
disc_number: row.get(6).unwrap(),
genre: row.get(7).unwrap(),
analysis: Analysis {
internal_analysis: vec![0.; features_version.feature_count()],
features_version: features_version,
},
duration: Duration::from_secs_f64(row.get(8).unwrap()),
features_version: features_version,
cue_info,
};
let serialized: String = row.get(10).unwrap();
let extra_info = serde_json::from_str(&serialized).unwrap();
Ok(LibrarySong {
bliss_song: song,
extra_info,
})
},
)
.expect("Song does not exist in the database");
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(),
features_version: song.bliss_song.analysis.features_version,
};
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, disc_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(),
disc_number: row.get(6).unwrap(),
genre: row.get(7).unwrap(),
analysis: Analysis {
internal_analysis: vec![0.; NUMBER_FEATURES],
features_version: FeaturesVersion::Version2,
},
duration: Duration::from_secs_f64(row.get(8).unwrap()),
features_version: row.get(9).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()
.map_err(|v| {
BlissError::ProviderError(format!("Could not retrieve analysis for song {} that was supposed to be analyzed: {:?}.", song_path, v))
})
.unwrap(),
features_version: FeaturesVersion::Version2,
};
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 = (0..NUMBER_FEATURES)
.map(|x| x as f32 + 0.1)
.collect::<Vec<f32>>();
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(3),
disc_number: Some(1),
genre: Some("Electronica".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
features_version: FeaturesVersion::Version2,
},
duration: Duration::from_secs(80),
features_version: FeaturesVersion::LATEST,
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,
}
}
fn first_factor_distance(a: &Array1<f32>, b: &Array1<f32>) -> f32 {
(a[1] - b[1]).abs()
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_library_playlist_song_not_existing() {
let (library, _temp_dir, _) = setup_test_library();
assert!(library
.playlist_from::<ExtraInfo>(&["not-existing"])
.is_err());
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_library_simple_playlist() {
let (library, _temp_dir, _) = setup_test_library();
let songs: Vec<LibrarySong<ExtraInfo>> = library
.playlist_from(&["/path/to/song2001"])
.unwrap()
.collect();
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]
#[cfg(feature = "ffmpeg")]
fn test_library_playlist_dupe_order_preserved() {
let (library, _temp_dir, _) = setup_test_library();
let songs: Vec<LibrarySong<ExtraInfo>> = library
.playlist_from_custom(
&["/path/to/song2201"],
&euclidean_distance,
closest_to_songs,
false,
)
.unwrap()
.collect();
assert_eq!(
vec![
"/path/to/song2201",
"/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 first_factor_divided_by_30_distance(a: &Array1<f32>, b: &Array1<f32>) -> f32 {
((a[1] - b[1]).abs() / 30.).floor()
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_library_playlist_deduplication() {
let (library, _temp_dir, _) = setup_test_library();
let songs: Vec<LibrarySong<ExtraInfo>> = library
.playlist_from_custom(
&["/path/to/song2001"],
&first_factor_divided_by_30_distance,
closest_to_songs,
true,
)
.unwrap()
.collect();
assert_eq!(
vec![
"/path/to/song2001",
"/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 songs: Vec<LibrarySong<ExtraInfo>> = library
.playlist_from_custom(
&["/path/to/song2001"],
&first_factor_distance,
&closest_to_songs,
true,
)
.unwrap()
.collect();
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]
#[cfg(feature = "ffmpeg")]
fn test_library_playlist_take() {
let (library, _temp_dir, _) = setup_test_library();
let songs: Vec<LibrarySong<ExtraInfo>> = library
.playlist_from(&["/path/to/song2001"])
.unwrap()
.take(4)
.collect();
assert_eq!(
vec![
"/path/to/song2001",
"/path/to/song6001",
"/path/to/song5001",
"/path/to/song1001",
],
songs
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<String>>(),
)
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_library_custom_playlist_distance() {
let (library, _temp_dir, _) = setup_test_library();
let songs: Vec<LibrarySong<ExtraInfo>> = library
.playlist_from_custom(
&["/path/to/song2001"],
&first_factor_distance,
closest_to_songs,
false,
)
.unwrap()
.collect();
assert_eq!(
vec![
"/path/to/song2001",
"/path/to/song2201",
"/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(
_: &[LibrarySong<ExtraInfo>],
songs: &[LibrarySong<ExtraInfo>],
_distance: &dyn DistanceMetricBuilder,
) -> impl Iterator<Item = LibrarySong<ExtraInfo>> {
let mut songs = songs.to_vec();
songs.sort_by(|s1, s2| s1.bliss_song.path.cmp(&s2.bliss_song.path));
songs.to_vec().into_iter()
}
#[test]
#[cfg(feature = "ffmpeg")]
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"],
&euclidean_distance,
custom_sort,
false,
)
.unwrap()
.collect();
assert_eq!(
vec![
"/path/to/song2001",
"/path/to/cuetrack.cue/CUE_TRACK001",
"/path/to/cuetrack.cue/CUE_TRACK002",
"/path/to/song1001",
"/path/to/song2201",
"/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]
#[cfg(feature = "ffmpeg")]
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/song2201".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]
#[cfg(feature = "ffmpeg")]
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(),
"/path/to/song2201".to_string(),
],
album
.into_iter()
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
.collect::<Vec<_>>(),
)
}
#[test]
#[cfg(feature = "ffmpeg")]
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]
#[cfg(feature = "ffmpeg")]
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]
#[cfg(feature = "ffmpeg")]
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]
#[cfg(feature = "ffmpeg")]
fn test_library_delete_path_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_path("not-existing").is_err());
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_library_delete_path() {
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_path("/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]
#[cfg(feature = "ffmpeg")]
fn test_library_delete_paths() {
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 in (?1, ?2)",
["/path/to/song1001", "/path/to/song2001"],
|row| row.get(0),
)
.unwrap();
assert!(count >= 1);
let count: u32 = connection
.query_row(
"select count(*) from song where path in (?1, ?2)",
["/path/to/song1001", "/path/to/song2001"],
|row| row.get(0),
)
.unwrap();
assert!(count >= 1);
}
library
.delete_paths(vec!["/path/to/song1001", "/path/to/song2001"])
.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 in (?1, ?2)",
["/path/to/song1001", "/path/to/song2001"],
|row| row.get(0),
)
.unwrap();
assert_eq!(0, count);
let count: u32 = connection
.query_row(
"select count(*) from song where path in (?1, ?2)",
["/path/to/song1001", "/path/to/song2001"],
|row| row.get(0),
)
.unwrap();
assert_eq!(0, count);
let count: u32 = connection
.query_row("select count(*) from feature", [], |row| row.get(0))
.unwrap();
assert!(count >= 1);
let count: u32 = connection
.query_row("select count(*) from song", [], |row| row.get(0))
.unwrap();
assert!(count >= 1);
}
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_library_delete_paths_empty() {
let (mut library, _temp_dir, _) = setup_test_library();
assert_eq!(library.delete_paths::<String, _>([]).unwrap(), 0);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_library_delete_paths_non_existing() {
let (mut library, _temp_dir, _) = setup_test_library();
assert_eq!(library.delete_paths(["not-existing"]).unwrap(), 0);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_analyze_paths_cue() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.config
.base_config_mut()
.analysis_options
.features_version = FeaturesVersion::Version1;
{
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_with_options(
paths.to_owned(),
false,
AnalysisOptions {
features_version: FeaturesVersion::Version2,
..Default::default()
},
)
.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]
#[cfg(feature = "ffmpeg")]
fn test_analyze_paths() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.config
.base_config_mut()
.analysis_options
.features_version = FeaturesVersion::LATEST;
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: Decoder::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()
.analysis_options
.features_version,
FeaturesVersion::LATEST
);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_analyze_paths_convert_extra_info() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.config
.base_config_mut()
.analysis_options
.features_version = FeaturesVersion::Version1;
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"),
},
AnalysisOptions::default(),
)
.unwrap();
library
.analyze_paths_convert_extra_info(
paths.to_owned(),
false,
|b, _, _| ExtraInfo {
ignore: b,
metadata_bliss_does_not_have: String::from("coucou"),
},
AnalysisOptions::default(),
)
.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: Decoder::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()
.analysis_options
.features_version,
FeaturesVersion::LATEST
);
}
#[test]
#[cfg(feature = "ffmpeg")]
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, AnalysisOptions::default())
.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: Decoder::song_from_path(path).unwrap(),
extra_info: expected_extra_info,
})
.collect::<Vec<LibrarySong<ExtraInfo>>>();
assert_eq!(songs, expected_songs);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_update_skip_analyzed() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.config
.base_config_mut()
.analysis_options
.features_version = FeaturesVersion::Version1;
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(),
true,
false,
|b, _, _| ExtraInfo {
ignore: b,
metadata_bliss_does_not_have: String::from("coucou"),
},
AnalysisOptions {
features_version: FeaturesVersion::Version1,
..Default::default()
},
)
.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: Decoder::song_from_path_with_options(
"./data/s16_mono_22_5kHz.flac",
AnalysisOptions {
features_version: FeaturesVersion::Version1,
..Default::default()
},
)
.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()
.analysis_options
.features_version,
FeaturesVersion::Version1
);
}
}
fn _get_song_analyzed(
connection: MutexGuard<Connection>,
path: String,
) -> Result<bool, RusqliteError> {
let mut stmt = connection.prepare(
"
select
analyzed from song
where song.path = ?
",
)?;
stmt.query_row([path], |row| (row.get(0)))
}
#[test]
#[cfg(feature = "ffmpeg")]
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 song: LibrarySong<ExtraInfo> = _library_song_from_database(connection, &path);
assert_eq!(
song.bliss_song.analysis,
Analysis {
internal_analysis: vec![
1., 2., 3., 4., 5., 6., 7., 8., 9., 10., 11., 12., 13., 14., 15., 16., 17.,
18.
],
features_version: FeaturesVersion::Version1,
}
)
}
{
let connection = library.sqlite_conn.lock().unwrap();
let count_old_features_version: u32 = connection
.query_row(
"select count(*) from song where version = ? and analyzed = true",
params![FeaturesVersion::Version1],
|row| row.get(0),
)
.unwrap();
assert!(count_old_features_version > 0);
}
library
.update_library(vec![path.to_owned()], true, false)
.unwrap();
{
let connection = library.sqlite_conn.lock().unwrap();
let count_old_features_version: u32 = connection
.query_row(
"select count(*) from song where version = ? and analyzed = true",
params![FeaturesVersion::Version1],
|row| row.get(0),
)
.unwrap();
assert_eq!(count_old_features_version, 0);
}
let connection = library.sqlite_conn.lock().unwrap();
let song: LibrarySong<()> = _library_song_from_database(connection, &path);
let expected_analysis_vector = Decoder::song_from_path(path).unwrap().analysis;
assert_eq!(song.bliss_song.analysis, expected_analysis_vector);
assert_eq!(
song.bliss_song.analysis.features_version,
FeaturesVersion::LATEST
);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_update_library() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.config
.base_config_mut()
.analysis_options
.features_version = FeaturesVersion::LATEST;
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song4001".into()).unwrap());
}
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(), true, false)
.unwrap();
library
.update_library(paths.to_owned(), true, 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: Decoder::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()).unwrap());
}
assert_eq!(
library
.config
.base_config_mut()
.analysis_options
.features_version,
FeaturesVersion::LATEST
);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_update_library_with_options() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.config
.base_config_mut()
.analysis_options
.features_version = FeaturesVersion::LATEST;
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song4001".into()).unwrap());
}
{
let connection = library.sqlite_conn.lock().unwrap();
connection
.execute("update song set extra_info = \"null\";", [])
.unwrap();
}
let paths = vec![
"./data/s16_mono_22_5kHz.flac",
"./data/s16_stereo_22_5kHz.flac",
"/path/to/song4001",
"non-existing",
];
library
.update_library_with_options(
paths.to_owned(),
true,
false,
AnalysisOptions {
features_version: FeaturesVersion::Version1,
..Default::default()
},
)
.unwrap();
library
.update_library_with_options(
paths.to_owned(),
true,
false,
AnalysisOptions {
features_version: FeaturesVersion::Version1,
..Default::default()
},
)
.unwrap();
let first_song = {
let connection = library.sqlite_conn.lock().unwrap();
_library_song_from_database(connection, paths[0])
};
let expected_song = LibrarySong {
bliss_song: Decoder::song_from_path_with_options(
paths[0],
AnalysisOptions {
features_version: FeaturesVersion::Version1,
..Default::default()
},
)
.unwrap(),
extra_info: (),
};
assert_eq!(first_song, expected_song);
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song4001".into()).unwrap());
}
assert_eq!(
library
.config
.base_config_mut()
.analysis_options
.features_version,
FeaturesVersion::Version1
);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_update_extra_info() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.config
.base_config_mut()
.analysis_options
.features_version = FeaturesVersion::LATEST;
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song4001".into()).unwrap());
}
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(), true, 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: Decoder::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()).unwrap());
}
assert_eq!(
library
.config
.base_config_mut()
.analysis_options
.features_version,
FeaturesVersion::LATEST
);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_update_convert_extra_info() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.config
.base_config_mut()
.analysis_options
.features_version = FeaturesVersion::Version1;
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song4001".into()).unwrap());
}
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song2001".into()).unwrap());
}
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(),
true,
false,
|b, _, _| ExtraInfo {
ignore: b,
metadata_bliss_does_not_have: String::from("coucou"),
},
AnalysisOptions::default(),
)
.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: Decoder::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()).unwrap());
}
{
let connection = library.sqlite_conn.lock().unwrap();
assert_eq!(
rusqlite::Error::QueryReturnedNoRows,
_get_song_analyzed(connection, "/path/to/song2001".into()).unwrap_err(),
);
}
assert_eq!(
library
.config
.base_config_mut()
.analysis_options
.features_version,
FeaturesVersion::LATEST
);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_update_convert_extra_info_do_not_delete() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.config
.base_config_mut()
.analysis_options
.features_version = FeaturesVersion::Version1;
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song4001".into()).unwrap());
}
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song2001".into()).unwrap());
}
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,
false,
|b, _, _| ExtraInfo {
ignore: b,
metadata_bliss_does_not_have: String::from("coucou"),
},
AnalysisOptions::default(),
)
.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: Decoder::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()).unwrap());
}
{
let connection = library.sqlite_conn.lock().unwrap();
assert!(_get_song_analyzed(connection, "/path/to/song2001".into()).unwrap());
}
assert_eq!(
library
.config
.base_config_mut()
.analysis_options
.features_version,
FeaturesVersion::LATEST
);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_song_from_path() {
let (library, _temp_dir, _) = setup_test_library();
let analysis_vector = (0..NUMBER_FEATURES)
.map(|x| x as f32 + 10.)
.collect::<Vec<f32>>();
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(2),
disc_number: Some(1),
genre: Some("Electronica2001".into()),
analysis: Analysis {
internal_analysis: analysis_vector,
features_version: FeaturesVersion::Version2,
},
duration: Duration::from_secs(410),
features_version: FeaturesVersion::Version2,
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]
#[cfg(feature = "ffmpeg")]
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()),
FeaturesVersion::Version1,
)
.unwrap();
let connection = library.sqlite_conn.lock().unwrap();
let (error, analyzed, features_version): (String, bool, FeaturesVersion) = connection
.query_row(
"
select
error, analyzed, version
from song where path=?
",
params!["/some/failed/path"],
|row| Ok((row.get_unwrap(0), row.get_unwrap(1), row.get_unwrap(2))),
)
.unwrap();
assert_eq!(
error,
String::from(
"error happened with the music library provider - error with the analysis"
)
);
assert_eq!(analyzed, false);
assert_eq!(features_version, FeaturesVersion::Version1);
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]
#[cfg(feature = "ffmpeg")]
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(), 8);
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(),
library_songs[7].to_owned(),
)
);
}
#[test]
#[cfg(feature = "ffmpeg")]
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, 29)
",
[],
)
.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]
#[cfg(feature = "ffmpeg")]
fn test_song_from_path_not_analyzed() {
let (library, _temp_dir, _) = setup_test_library();
let error = library.song_from_path::<ExtraInfo>("/path/to/song404");
assert!(error.is_err());
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_song_from_path_not_found() {
let (library, _temp_dir, _) = setup_test_library();
let error = library.song_from_path::<ExtraInfo>("/path/to/randomsong");
assert!(error.is_err());
}
#[test]
fn test_get_default_data_folder_no_default_path() {
env::set_var("XDG_CONFIG_HOME", "/home/foo/.config");
env::set_var("XDG_DATA_HOME", "/home/foo/.local/share");
assert_eq!(
PathBuf::from("/home/foo/.config/bliss-rs"),
BaseConfig::get_default_data_folder().unwrap()
);
env::remove_var("XDG_CONFIG_HOME");
env::remove_var("XDG_DATA_HOME");
let existing_legacy_folder = TempDir::new("tmp").unwrap();
create_dir_all(existing_legacy_folder.path().join("bliss-rs")).unwrap();
env::set_var("XDG_CONFIG_HOME", "/home/foo/.config");
env::set_var("XDG_DATA_HOME", existing_legacy_folder.path().as_os_str());
assert_eq!(
existing_legacy_folder.path().join("bliss-rs"),
BaseConfig::get_default_data_folder().unwrap()
);
let existing_folder = TempDir::new("tmp").unwrap();
create_dir_all(existing_folder.path().join("bliss-rs")).unwrap();
env::set_var("XDG_CONFIG_HOME", existing_folder.path().as_os_str());
assert_eq!(
existing_folder.path().join("bliss-rs"),
BaseConfig::get_default_data_folder().unwrap()
);
env::remove_var("XDG_DATA_HOME");
env::remove_var("XDG_CONFIG_HOME");
assert_eq!(
PathBuf::from("/tmp/bliss-rs/"),
BaseConfig::get_default_data_folder().unwrap()
);
}
#[test]
#[cfg(feature = "ffmpeg")]
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\":{},\
\"m\":{{\"v\":1,\"dim\":[{},{}],\"data\":{}}}}}",
library.config.base_config().config_path.display(),
library.config.base_config().database_path.display(),
FeaturesVersion::LATEST as u16,
thread::available_parallelism().unwrap_or(NonZeroUsize::new(1).unwrap()),
NUMBER_FEATURES,
NUMBER_FEATURES,
format!(
"{:?}",
Array2::<f32>::eye(NUMBER_FEATURES).as_slice().unwrap()
)
.replace(" ", ""),
)
);
}
#[test]
#[cfg(feature = "ffmpeg")]
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, disc_number, genre, stamp, version, duration, analyzed,
extra_info
)
values (
1, '/random/path', 'Some Artist', 'A Title', 'Some Album',
'Some Album Artist', 1, 1, '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]
#[cfg(feature = "ffmpeg")]
fn test_library_new_database_upgrade() {
let config_dir = TempDir::new("tmp").unwrap();
let sqlite_db_path = config_dir.path().join("test.db");
{
let sqlite_conn = Connection::open(sqlite_db_path.clone()).unwrap();
let sql_statements = fs::read_to_string("data/old_database.sql").unwrap();
sqlite_conn.execute_batch(&sql_statements).unwrap();
let track_number: String = sqlite_conn
.query_row("select track_number from song where id = 1", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(track_number, "01");
let version: u32 = sqlite_conn
.query_row("pragma user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 0);
}
let library = Library::<BaseConfig, DummyDecoder>::new_from_base(
Some(config_dir.path().join("config.txt")),
Some(sqlite_db_path.clone()),
Some(AnalysisOptions {
number_cores: nzus(1),
features_version: FeaturesVersion::Version1,
}),
)
.unwrap();
let sqlite_conn = library.sqlite_conn.lock().unwrap();
let mut query = sqlite_conn
.prepare("select track_number from song where id = ?1")
.unwrap();
let first_song_track_number: Option<u32> = query.query_row([1], |row| row.get(0)).unwrap();
assert_eq!(first_song_track_number, Some(1));
let second_song_track_number: Option<u32> = query.query_row([2], |row| row.get(0)).unwrap();
assert_eq!(None, second_song_track_number);
let third_song_track_number: Option<u32> = query.query_row([3], |row| row.get(0)).unwrap();
assert_eq!(None, third_song_track_number);
let fourth_song_track_number: Option<u32> = query.query_row([4], |row| row.get(0)).unwrap();
assert_eq!(None, fourth_song_track_number);
let version: u32 = sqlite_conn
.query_row("pragma user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 5);
Library::<BaseConfig, DummyDecoder>::new_from_base(
Some(config_dir.path().join("config.txt")),
Some(sqlite_db_path),
Some(AnalysisOptions {
number_cores: NonZeroUsize::new(1).unwrap(),
..Default::default()
}),
)
.unwrap();
let version: u32 = sqlite_conn
.query_row("pragma user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 5);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_library_new_database_already_last_version() {
let config_dir = TempDir::new("tmp").unwrap();
let sqlite_db_path = config_dir.path().join("test.db");
Library::<BaseConfig, DummyDecoder>::new_from_base(
Some(config_dir.path().join("config.txt")),
Some(sqlite_db_path.clone()),
Some(AnalysisOptions {
number_cores: NonZeroUsize::new(1).unwrap(),
..Default::default()
}),
)
.unwrap();
let library = Library::<BaseConfig, DummyDecoder>::new_from_base(
Some(config_dir.path().join("config.txt")),
Some(sqlite_db_path.clone()),
Some(AnalysisOptions {
number_cores: NonZeroUsize::new(1).unwrap(),
..Default::default()
}),
)
.unwrap();
let sqlite_conn = library.sqlite_conn.lock().unwrap();
let version: u32 = sqlite_conn
.query_row("pragma user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 5);
}
#[test]
#[cfg(feature = "ffmpeg")]
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_base_config_new() {
let random_config_home = TempDir::new("config").unwrap();
let config_path = random_config_home.path().join("test.json");
let database_path = random_config_home.path().join("database.db");
let base_config = BaseConfig::new(
Some(config_path.to_owned()),
Some(database_path.to_owned()),
Some(AnalysisOptions {
number_cores: NonZeroUsize::new(4).unwrap(),
features_version: FeaturesVersion::Version1,
}),
)
.unwrap();
base_config.write().unwrap();
let data = fs::read_to_string(&config_path).unwrap();
let config = BaseConfig::deserialize_config(&data).unwrap();
assert_eq!(
config,
BaseConfig {
config_path: config_path,
database_path: database_path,
analysis_options: AnalysisOptions {
number_cores: NonZeroUsize::new(4).unwrap(),
features_version: FeaturesVersion::Version1
},
m: default_m(),
}
);
let v: Value = serde_json::from_str(&data).unwrap();
let obj = v.as_object().expect("top-level JSON must be an object");
assert!(obj.contains_key("config_path"));
assert!(obj.contains_key("database_path"));
assert!(obj.contains_key("m"));
assert!(obj.contains_key("features_version"));
assert!(obj.contains_key("number_cores"));
}
#[test]
fn test_base_config_new_default() {
let random_config_home = TempDir::new("config").unwrap();
let config_path = random_config_home.path().join("test.json");
let base_config = BaseConfig::new(Some(config_path.to_owned()), None, None).unwrap();
base_config.write().unwrap();
let data = fs::read_to_string(&config_path).unwrap();
let config = BaseConfig::deserialize_config(&data).unwrap();
let cores = thread::available_parallelism().unwrap_or(NonZeroUsize::new(1).unwrap());
assert_eq!(
config,
BaseConfig {
config_path: config_path,
database_path: random_config_home.path().join("songs.db"),
analysis_options: AnalysisOptions {
number_cores: cores,
features_version: FeaturesVersion::LATEST,
},
m: default_m(),
}
);
let v: Value = serde_json::from_str(&data).unwrap();
let obj = v.as_object().expect("top-level JSON must be an object");
assert!(obj.contains_key("config_path"));
assert!(obj.contains_key("database_path"));
assert!(obj.contains_key("m"));
assert!(obj.contains_key("features_version"));
assert!(obj.contains_key("number_cores"));
}
#[test]
fn test_path_base_config_new() {
{
let xdg_config_home = TempDir::new("test-bliss").unwrap();
fs::create_dir_all(xdg_config_home.path().join("bliss-rs")).unwrap();
env::set_var("XDG_CONFIG_HOME", xdg_config_home.path());
let base_config = BaseConfig::new(None, None, None).unwrap();
assert_eq!(
base_config.config_path,
xdg_config_home.path().join("bliss-rs/config.json"),
);
assert_eq!(
base_config.database_path,
xdg_config_home.path().join("bliss-rs/songs.db"),
);
base_config.write().unwrap();
assert!(xdg_config_home.path().join("bliss-rs/config.json").exists());
}
{
let random_config_home = TempDir::new("config").unwrap();
let base_config = BaseConfig::new(
Some(random_config_home.path().join("test.json")),
None,
None,
)
.unwrap();
base_config.write().unwrap();
assert_eq!(
base_config.config_path,
random_config_home.path().join("test.json"),
);
assert_eq!(
base_config.database_path,
random_config_home.path().join("songs.db")
);
assert!(random_config_home.path().join("test.json").exists());
}
{
let random_config_home = TempDir::new("database").unwrap();
let base_config =
BaseConfig::new(None, Some(random_config_home.path().join("test.db")), None)
.unwrap();
base_config.write().unwrap();
assert_eq!(
base_config.config_path,
random_config_home.path().join("config.json"),
);
assert_eq!(
base_config.database_path,
random_config_home.path().join("test.db"),
);
}
{
let random_config_home = TempDir::new("config").unwrap();
let random_database_home = TempDir::new("database").unwrap();
fs::create_dir_all(random_config_home.path().join("bliss-rs")).unwrap();
let base_config = BaseConfig::new(
Some(random_config_home.path().join("config_test.json")),
Some(random_database_home.path().join("test-database.db")),
None,
)
.unwrap();
base_config.write().unwrap();
assert_eq!(
base_config.config_path,
random_config_home.path().join("config_test.json"),
);
assert_eq!(
base_config.database_path,
random_database_home.path().join("test-database.db"),
);
assert!(random_config_home.path().join("config_test.json").exists());
}
}
#[test]
#[cfg(feature = "ffmpeg")]
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, DummyDecoder>::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(AnalysisOptions {
number_cores: nzus(1),
..Default::default()
}),
)
.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::<_, DummyDecoder>::new(config.to_owned()).unwrap();
library.store_song(&song).unwrap();
}
let library: Library<CustomConfig, DummyDecoder> =
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_from_file() {
let config = BaseConfig::from_path("./data/sample-config.json").unwrap();
let mut m: Array2<f32> = Array2::eye(FeaturesVersion::Version1.feature_count());
m[[0, 1]] = 1.;
assert_eq!(
config,
BaseConfig {
config_path: PathBuf::from_str("/tmp/bliss-rs/config.json").unwrap(),
database_path: PathBuf::from_str("/tmp/bliss-rs/songs.db").unwrap(),
analysis_options: AnalysisOptions {
features_version: FeaturesVersion::Version1,
number_cores: NonZeroUsize::new(8).unwrap()
},
m,
}
);
}
#[test]
fn test_config_old_existing() {
let config = BaseConfig::from_path("./data/old_config.json").unwrap();
assert_eq!(
config,
BaseConfig {
config_path: PathBuf::from_str("/tmp/bliss-rs/config.json").unwrap(),
database_path: PathBuf::from_str("/tmp/bliss-rs/songs.db").unwrap(),
analysis_options: AnalysisOptions {
features_version: FeaturesVersion::Version1,
number_cores: NonZeroUsize::new(8).unwrap()
},
m: Array2::eye(NUMBER_FEATURES),
}
);
}
#[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(AnalysisOptions {
number_cores: nzus(1),
features_version: FeaturesVersion::Version1,
}),
)
.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]
#[cfg(feature = "ffmpeg")]
fn test_library_sanity_check_fail() {
let (mut library, _temp_dir, _) = setup_test_library();
assert_eq!(
library.version_sanity_check().unwrap(),
vec![
SanityError::MultipleVersionsInDB {
versions: vec![FeaturesVersion::Version1, FeaturesVersion::Version2]
},
SanityError::OldFeaturesVersionInDB {
version: FeaturesVersion::Version1
}
],
);
}
#[test]
#[cfg(feature = "ffmpeg")]
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",
[FeaturesVersion::LATEST],
)
.unwrap();
}
assert!(library.version_sanity_check().unwrap().is_empty());
}
#[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(),
usize::from(thread::available_parallelism().unwrap_or(NonZeroUsize::new(1).unwrap())),
);
let base_config = BaseConfig::new(
Some(config_file),
Some(database_file),
Some(AnalysisOptions {
number_cores: nzus(1),
..Default::default()
}),
)
.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_config_features_version() {
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_features_version(), FeaturesVersion::LATEST,);
let base_config = BaseConfig::new(
Some(config_file),
Some(database_file),
Some(AnalysisOptions {
features_version: FeaturesVersion::Version1,
..Default::default()
}),
)
.unwrap();
let mut config = CustomConfig {
base_config,
second_path_to_music_library: "/path/to/somewhere".into(),
ignore_wav_files: true,
};
assert_eq!(config.get_features_version(), FeaturesVersion::Version1);
config
.set_features_version(FeaturesVersion::Version2)
.unwrap();
assert_eq!(config.get_features_version(), FeaturesVersion::Version2);
}
#[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, DummyDecoder>::new_from_base(
Some(config_file),
Some(database_file),
Some(AnalysisOptions {
number_cores: nzus(1),
..Default::default()
}),
)
.unwrap();
assert!(config_dir.is_dir());
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_library_get_failed_songs() {
let (library, _temp_dir, _) = setup_test_library();
let failed_songs = library.get_failed_songs().unwrap();
assert_eq!(
failed_songs,
vec![
ProcessingError {
song_path: PathBuf::from("./data/not-existing.m4a"),
error: String::from("error finding the file"),
features_version: FeaturesVersion::Version1,
},
ProcessingError {
song_path: PathBuf::from("./data/invalid-file.m4a"),
error: String::from("error decoding the file"),
features_version: FeaturesVersion::Version1,
}
]
);
}
#[test]
#[cfg(feature = "ffmpeg")]
fn test_analyze_store_failed_songs() {
let (mut library, _temp_dir, _) = setup_test_library();
library
.config
.base_config_mut()
.analysis_options
.features_version = FeaturesVersion::Version1;
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 failed_songs = library.get_failed_songs().unwrap();
assert!(failed_songs.contains(&ProcessingError {
song_path: PathBuf::from("non-existing"),
error: String::from("error happened while decoding file - while opening format for file 'non-existing': ffmpeg::Error(2: No such file or directory)."),
features_version: FeaturesVersion::Version1,
}));
}
}