Merge pull request #41 from Polochon-street/add-actually-useful-library-struct
Add a proper library / config example
This commit is contained in:
commit
bf428a62af
11 changed files with 4218 additions and 244 deletions
6
.github/workflows/rust.yml
vendored
6
.github/workflows/rust.yml
vendored
|
@ -30,14 +30,16 @@ jobs:
|
|||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
- name: Run library tests
|
||||
run: cargo test --verbose --features=library
|
||||
- name: Run example tests
|
||||
run: cargo test --verbose --examples
|
||||
- name: Build benches
|
||||
run: cargo +nightly-2022-02-16 bench --verbose --features=bench --no-run
|
||||
- name: Build examples
|
||||
run: cargo build --examples --verbose --features=serde
|
||||
run: cargo build --examples --verbose --features=serde,library
|
||||
- name: Lint
|
||||
run: cargo clippy --examples --features=serde -- -D warnings
|
||||
run: cargo clippy --examples --features=serde,library -- -D warnings
|
||||
|
||||
build-test-lint-windows:
|
||||
name: Windows - build, test and lint
|
||||
|
|
635
Cargo.lock
generated
635
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
34
Cargo.toml
34
Cargo.toml
|
@ -11,7 +11,7 @@ keywords = ["audio", "analysis", "MIR", "playlist", "similarity"]
|
|||
readme = "README.md"
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
features = ["bliss-audio-aubio-rs/rustdoc"]
|
||||
features = ["bliss-audio-aubio-rs/rustdoc", "library"]
|
||||
no-default-features = true
|
||||
|
||||
[features]
|
||||
|
@ -23,6 +23,12 @@ ffmpeg-static = ["ffmpeg-next/static"]
|
|||
python-bindings = ["bliss-audio-aubio-rs/fftw3"]
|
||||
# Enable the benchmarks with `cargo +nightly bench --features=bench`
|
||||
bench = []
|
||||
library = [
|
||||
"serde", "dep:rusqlite", "dep:dirs", "dep:tempdir",
|
||||
"dep:anyhow", "dep:serde_ini", "dep:serde_json",
|
||||
"dep:indicatif",
|
||||
]
|
||||
serde = ["dep:serde"]
|
||||
|
||||
[dependencies]
|
||||
ripemd160 = "0.9.0"
|
||||
|
@ -44,13 +50,35 @@ thiserror = "1.0.24"
|
|||
bliss-audio-aubio-rs = "0.2.0"
|
||||
strum = "0.21"
|
||||
strum_macros = "0.21"
|
||||
serde = { version = "1.0", optional = true, features = ["derive"] }
|
||||
rcue = "0.1.1"
|
||||
|
||||
# Deps for the library feature
|
||||
serde = { version = "1.0", optional = true, features = ["derive"] }
|
||||
serde_json = { version = "1.0.59", optional = true }
|
||||
serde_ini = { version = "0.2.0", optional = true }
|
||||
tempdir = { version = "0.3.7", optional = true }
|
||||
rusqlite = { version = "0.27.0", optional = true }
|
||||
dirs = { version = "4.0.0", optional = true }
|
||||
anyhow = { version = "1.0.58", optional = true }
|
||||
indicatif = { version = "0.17.0", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
mime_guess = "2.0.3"
|
||||
glob = "0.3.0"
|
||||
anyhow = "1.0.45"
|
||||
serde_json = "1.0.59"
|
||||
clap = "2.33.3"
|
||||
pretty_assertions = "1.2.1"
|
||||
serde_json = "1.0.59"
|
||||
|
||||
[[example]]
|
||||
name = "library"
|
||||
required-features = ["library"]
|
||||
|
||||
[[example]]
|
||||
name = "library_extra_info"
|
||||
required-features = ["library"]
|
||||
|
||||
|
||||
[[example]]
|
||||
name = "playlist"
|
||||
required-features = ["serde"]
|
||||
|
|
202
examples/library.rs
Normal file
202
examples/library.rs
Normal file
|
@ -0,0 +1,202 @@
|
|||
/// Basic example of how one would combine bliss with an "audio player",
|
||||
/// through [Library].
|
||||
///
|
||||
/// For simplicity's sake, this example recursively gets songs from a folder
|
||||
/// to emulate an audio player library, without handling CUE files.
|
||||
use anyhow::Result;
|
||||
use bliss_audio::library::{AppConfigTrait, BaseConfig, Library};
|
||||
use clap::{App, Arg, SubCommand};
|
||||
use glob::glob;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
// A config structure, that will be serialized as a
|
||||
// JSON file upon Library creation.
|
||||
pub struct Config {
|
||||
#[serde(flatten)]
|
||||
// The base configuration, containing both the config file
|
||||
// path, as well as the database path.
|
||||
pub base_config: BaseConfig,
|
||||
// An extra field, to store the music library path. Any number
|
||||
// of arbitrary fields (even Serializable structures) can
|
||||
// of course be added.
|
||||
pub music_library_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new(
|
||||
music_library_path: PathBuf,
|
||||
config_path: Option<PathBuf>,
|
||||
database_path: Option<PathBuf>,
|
||||
) -> Result<Self> {
|
||||
let base_config = BaseConfig::new(config_path, database_path)?;
|
||||
Ok(Self {
|
||||
base_config,
|
||||
music_library_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The AppConfigTrait must know how to access the base config.
|
||||
impl AppConfigTrait for Config {
|
||||
fn base_config(&self) -> &BaseConfig {
|
||||
&self.base_config
|
||||
}
|
||||
|
||||
fn base_config_mut(&mut self) -> &mut BaseConfig {
|
||||
&mut self.base_config
|
||||
}
|
||||
}
|
||||
|
||||
// A trait allowing to implement methods for the Library,
|
||||
// useful if you don't need to store extra information in fields.
|
||||
// Otherwise, doing
|
||||
// ```
|
||||
// struct CustomLibrary {
|
||||
// library: Library<Config>,
|
||||
// extra_field: ...,
|
||||
// }
|
||||
// ```
|
||||
// and implementing functions for that struct would be the way to go.
|
||||
// That's what the [reference](https://github.com/Polochon-street/blissify-rs)
|
||||
// implementation does.
|
||||
trait CustomLibrary {
|
||||
fn song_paths(&self) -> Result<Vec<String>>;
|
||||
}
|
||||
|
||||
impl CustomLibrary for Library<Config> {
|
||||
/// Get all songs in the player library
|
||||
fn song_paths(&self) -> Result<Vec<String>> {
|
||||
let music_path = &self.config.music_library_path;
|
||||
let pattern = Path::new(&music_path).join("**").join("*");
|
||||
|
||||
Ok(glob(&pattern.to_string_lossy())?
|
||||
.map(|e| fs::canonicalize(e.unwrap()).unwrap())
|
||||
.filter(|e| match mime_guess::from_path(e).first() {
|
||||
Some(m) => m.type_() == "audio",
|
||||
None => false,
|
||||
})
|
||||
.map(|x| x.to_string_lossy().to_string())
|
||||
.collect::<Vec<String>>())
|
||||
}
|
||||
}
|
||||
|
||||
// A simple example of what a CLI-app would look.
|
||||
//
|
||||
// Note that `Library::new` is used only on init, and subsequent
|
||||
// commands use `Library::from_path`.
|
||||
fn main() -> Result<()> {
|
||||
let matches = App::new("library-example")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.author("Polochon_street")
|
||||
.about("Example binary implementing bliss for an audio player.")
|
||||
.subcommand(
|
||||
SubCommand::with_name("init")
|
||||
.about(
|
||||
"Initialize a Library, both storing the config and analyzing folders
|
||||
containing songs.",
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("FOLDER")
|
||||
.help("A folder containing the music library to analyze.")
|
||||
.required(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("database-path")
|
||||
.short("d")
|
||||
.long("database-path")
|
||||
.help(
|
||||
"Optional path where to store the database file containing
|
||||
the songs' analysis. Defaults to XDG_DATA_HOME/bliss-rs/bliss.db.",
|
||||
)
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("config-path")
|
||||
.short("c")
|
||||
.long("config-path")
|
||||
.help(
|
||||
"Optional path where to store the config file containing
|
||||
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
|
||||
)
|
||||
.takes_value(true),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("update")
|
||||
.about(
|
||||
"Update a Library's songs, trying to analyze failed songs,
|
||||
as well as songs not in the library.",
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("config-path")
|
||||
.short("c")
|
||||
.long("config-path")
|
||||
.help(
|
||||
"Optional path where to load the config file containing
|
||||
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
|
||||
)
|
||||
.takes_value(true),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("playlist")
|
||||
.about(
|
||||
"Make a playlist, starting with the song at SONG_PATH, returning
|
||||
the songs' paths.",
|
||||
)
|
||||
.arg(Arg::with_name("SONG_PATH").takes_value(true))
|
||||
.arg(
|
||||
Arg::with_name("config-path")
|
||||
.short("c")
|
||||
.long("config-path")
|
||||
.help(
|
||||
"Optional path where to load the config file containing
|
||||
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
|
||||
)
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("playlist-length")
|
||||
.short("l")
|
||||
.long("playlist-length")
|
||||
.help("Optional playlist length. Defaults to 20.")
|
||||
.takes_value(true),
|
||||
),
|
||||
)
|
||||
.get_matches();
|
||||
if let Some(sub_m) = matches.subcommand_matches("init") {
|
||||
let folder = PathBuf::from(sub_m.value_of("FOLDER").unwrap());
|
||||
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
|
||||
let database_path = sub_m.value_of("database-path").map(PathBuf::from);
|
||||
|
||||
let config = Config::new(folder, config_path, database_path)?;
|
||||
let mut library = Library::new(config)?;
|
||||
|
||||
library.analyze_paths(library.song_paths()?, true)?;
|
||||
} else if let Some(sub_m) = matches.subcommand_matches("update") {
|
||||
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
|
||||
let mut library: Library<Config> = Library::from_config_path(config_path)?;
|
||||
library.update_library(library.song_paths()?, true)?;
|
||||
} else if let Some(sub_m) = matches.subcommand_matches("playlist") {
|
||||
let song_path = sub_m.value_of("SONG_PATH").unwrap();
|
||||
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
|
||||
let playlist_length = sub_m
|
||||
.value_of("playlist-length")
|
||||
.unwrap_or("20")
|
||||
.parse::<usize>()?;
|
||||
let library: Library<Config> = Library::from_config_path(config_path)?;
|
||||
let songs = library.playlist_from::<()>(song_path, playlist_length)?;
|
||||
let song_paths = songs
|
||||
.into_iter()
|
||||
.map(|s| s.bliss_song.path.to_string_lossy().to_string())
|
||||
.collect::<Vec<String>>();
|
||||
for song in song_paths {
|
||||
println!("{:?}", song);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
225
examples/library_extra_info.rs
Normal file
225
examples/library_extra_info.rs
Normal file
|
@ -0,0 +1,225 @@
|
|||
/// Basic example of how one would combine bliss with an "audio player",
|
||||
/// through [Library], showing how to put extra info in the database for
|
||||
/// each song.
|
||||
///
|
||||
/// For simplicity's sake, this example recursively gets songs from a folder
|
||||
/// to emulate an audio player library, without handling CUE files.
|
||||
use anyhow::Result;
|
||||
use bliss_audio::library::{AppConfigTrait, BaseConfig, Library};
|
||||
use clap::{App, Arg, SubCommand};
|
||||
use glob::glob;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
/// A config structure, that will be serialized as a
|
||||
/// JSON file upon Library creation.
|
||||
pub struct Config {
|
||||
#[serde(flatten)]
|
||||
/// The base configuration, containing both the config file
|
||||
/// path, as well as the database path.
|
||||
pub base_config: BaseConfig,
|
||||
/// An extra field, to store the music library path. Any number
|
||||
/// of arbitrary fields (even Serializable structures) can
|
||||
/// of course be added.
|
||||
pub music_library_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn new(
|
||||
music_library_path: PathBuf,
|
||||
config_path: Option<PathBuf>,
|
||||
database_path: Option<PathBuf>,
|
||||
) -> Result<Self> {
|
||||
let base_config = BaseConfig::new(config_path, database_path)?;
|
||||
Ok(Self {
|
||||
base_config,
|
||||
music_library_path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The AppConfigTrait must know how to access the base config.
|
||||
impl AppConfigTrait for Config {
|
||||
fn base_config(&self) -> &BaseConfig {
|
||||
&self.base_config
|
||||
}
|
||||
|
||||
fn base_config_mut(&mut self) -> &mut BaseConfig {
|
||||
&mut self.base_config
|
||||
}
|
||||
}
|
||||
|
||||
// A trait allowing to implement methods for the Library,
|
||||
// useful if you don't need to store extra information in fields.
|
||||
// Otherwise, doing
|
||||
// ```
|
||||
// struct CustomLibrary {
|
||||
// library: Library<Config>,
|
||||
// extra_field: ...,
|
||||
// }
|
||||
// ```
|
||||
// and implementing functions for that struct would be the way to go.
|
||||
// That's what the [reference](https://github.com/Polochon-street/blissify-rs)
|
||||
// implementation does.
|
||||
trait CustomLibrary {
|
||||
fn song_paths_info(&self) -> Result<Vec<(String, ExtraInfo)>>;
|
||||
}
|
||||
|
||||
impl CustomLibrary for Library<Config> {
|
||||
/// Get all songs in the player library, along with the extra info
|
||||
/// one would want to store along with each song.
|
||||
fn song_paths_info(&self) -> Result<Vec<(String, ExtraInfo)>> {
|
||||
let music_path = &self.config.music_library_path;
|
||||
let pattern = Path::new(&music_path).join("**").join("*");
|
||||
|
||||
Ok(glob(&pattern.to_string_lossy())?
|
||||
.map(|e| fs::canonicalize(e.unwrap()).unwrap())
|
||||
.filter_map(|e| {
|
||||
mime_guess::from_path(&e).first().map(|m| {
|
||||
(
|
||||
e.to_string_lossy().to_string(),
|
||||
ExtraInfo {
|
||||
extension: e.extension().map(|e| e.to_string_lossy().to_string()),
|
||||
file_name: e.file_name().map(|e| e.to_string_lossy().to_string()),
|
||||
mime_type: format!("{}/{}", m.type_(), m.subtype()),
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<(String, ExtraInfo)>>())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Default)]
|
||||
// An (somewhat simple) example of what extra metadata one would put, along
|
||||
// with song analysis data.
|
||||
struct ExtraInfo {
|
||||
extension: Option<String>,
|
||||
file_name: Option<String>,
|
||||
mime_type: String,
|
||||
}
|
||||
|
||||
// A simple example of what a CLI-app would look.
|
||||
//
|
||||
// Note that `Library::new` is used only on init, and subsequent
|
||||
// commands use `Library::from_path`.
|
||||
fn main() -> Result<()> {
|
||||
let matches = App::new("library-example")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
.author("Polochon_street")
|
||||
.about("Example binary implementing bliss for an audio player.")
|
||||
.subcommand(
|
||||
SubCommand::with_name("init")
|
||||
.about(
|
||||
"Initialize a Library, both storing the config and analyzing folders
|
||||
containing songs.",
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("FOLDER")
|
||||
.help("A folder containing the music library to analyze.")
|
||||
.required(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("database-path")
|
||||
.short("d")
|
||||
.long("database-path")
|
||||
.help(
|
||||
"Optional path where to store the database file containing
|
||||
the songs' analysis. Defaults to XDG_DATA_HOME/bliss-rs/bliss.db.",
|
||||
)
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("config-path")
|
||||
.short("c")
|
||||
.long("config-path")
|
||||
.help(
|
||||
"Optional path where to store the config file containing
|
||||
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
|
||||
)
|
||||
.takes_value(true),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("update")
|
||||
.about(
|
||||
"Update a Library's songs, trying to analyze failed songs,
|
||||
as well as songs not in the library.",
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("config-path")
|
||||
.short("c")
|
||||
.long("config-path")
|
||||
.help(
|
||||
"Optional path where to load the config file containing
|
||||
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
|
||||
)
|
||||
.takes_value(true),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
SubCommand::with_name("playlist")
|
||||
.about(
|
||||
"Make a playlist, starting with the song at SONG_PATH, returning
|
||||
the songs' paths.",
|
||||
)
|
||||
.arg(Arg::with_name("SONG_PATH").takes_value(true))
|
||||
.arg(
|
||||
Arg::with_name("config-path")
|
||||
.short("c")
|
||||
.long("config-path")
|
||||
.help(
|
||||
"Optional path where to load the config file containing
|
||||
the library setup. Defaults to XDG_DATA_HOME/bliss-rs/config.json.",
|
||||
)
|
||||
.takes_value(true),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name("playlist-length")
|
||||
.short("l")
|
||||
.long("playlist-length")
|
||||
.help("Optional playlist length. Defaults to 20.")
|
||||
.takes_value(true),
|
||||
),
|
||||
)
|
||||
.get_matches();
|
||||
if let Some(sub_m) = matches.subcommand_matches("init") {
|
||||
let folder = PathBuf::from(sub_m.value_of("FOLDER").unwrap());
|
||||
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
|
||||
let database_path = sub_m.value_of("database-path").map(PathBuf::from);
|
||||
|
||||
let config = Config::new(folder, config_path, database_path)?;
|
||||
let mut library = Library::new(config)?;
|
||||
|
||||
library.analyze_paths_extra_info(library.song_paths_info()?, true)?;
|
||||
} else if let Some(sub_m) = matches.subcommand_matches("update") {
|
||||
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
|
||||
let mut library: Library<Config> = Library::from_config_path(config_path)?;
|
||||
library.update_library_extra_info(library.song_paths_info()?, true)?;
|
||||
} else if let Some(sub_m) = matches.subcommand_matches("playlist") {
|
||||
let song_path = sub_m.value_of("SONG_PATH").unwrap();
|
||||
let config_path = sub_m.value_of("config-path").map(PathBuf::from);
|
||||
let playlist_length = sub_m
|
||||
.value_of("playlist-length")
|
||||
.unwrap_or("20")
|
||||
.parse::<usize>()?;
|
||||
let library: Library<Config> = Library::from_config_path(config_path)?;
|
||||
let songs = library.playlist_from::<ExtraInfo>(song_path, playlist_length)?;
|
||||
let playlist = songs
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
(
|
||||
s.bliss_song.path.to_string_lossy().to_string(),
|
||||
s.extra_info.mime_type,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<(String, String)>>();
|
||||
for (path, mime_type) in playlist {
|
||||
println!("{} <{}>", path, mime_type,);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -1,26 +1,16 @@
|
|||
#[cfg(feature = "serde")]
|
||||
use anyhow::Result;
|
||||
#[cfg(feature = "serde")]
|
||||
use bliss_audio::playlist::{closest_to_first_song, dedup_playlist, euclidean_distance};
|
||||
#[cfg(feature = "serde")]
|
||||
use bliss_audio::{analyze_paths, Song};
|
||||
#[cfg(feature = "serde")]
|
||||
use clap::{App, Arg};
|
||||
#[cfg(feature = "serde")]
|
||||
use glob::glob;
|
||||
#[cfg(feature = "serde")]
|
||||
use std::env;
|
||||
#[cfg(feature = "serde")]
|
||||
use std::fs;
|
||||
#[cfg(feature = "serde")]
|
||||
use std::io::BufReader;
|
||||
#[cfg(feature = "serde")]
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/* Analyzes a folder recursively, and make a playlist out of the file
|
||||
* provided by the user. */
|
||||
// How to use: ./playlist [-o file.m3u] [-a analysis.json] <folder> <file to start the playlist from>
|
||||
#[cfg(feature = "serde")]
|
||||
fn main() -> Result<()> {
|
||||
let matches = App::new("playlist")
|
||||
.version(env!("CARGO_PKG_VERSION"))
|
||||
|
@ -103,8 +93,3 @@ fn main() -> Result<()> {
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "serde"))]
|
||||
fn main() {
|
||||
println!("You need the serde feature enabled to run this file.");
|
||||
}
|
||||
|
|
|
@ -131,7 +131,7 @@ impl BlissCueFile {
|
|||
let song = Song {
|
||||
path: PathBuf::from(format!(
|
||||
"{}/CUE_TRACK{:03}",
|
||||
self.audio_file_path.to_string_lossy(),
|
||||
self.cue_path.to_string_lossy(),
|
||||
index,
|
||||
)),
|
||||
album: self.album.to_owned(),
|
||||
|
@ -210,7 +210,7 @@ mod tests {
|
|||
let songs = BlissCue::songs_from_path("data/testcue.cue").unwrap();
|
||||
let expected = vec![
|
||||
Ok(Song {
|
||||
path: Path::new("data/testcue.flac/CUE_TRACK001").to_path_buf(),
|
||||
path: Path::new("data/testcue.cue/CUE_TRACK001").to_path_buf(),
|
||||
analysis: Analysis {
|
||||
internal_analysis: [
|
||||
0.38463724,
|
||||
|
@ -250,7 +250,7 @@ mod tests {
|
|||
..Default::default()
|
||||
}),
|
||||
Ok(Song {
|
||||
path: Path::new("data/testcue.flac/CUE_TRACK002").to_path_buf(),
|
||||
path: Path::new("data/testcue.cue/CUE_TRACK002").to_path_buf(),
|
||||
analysis: Analysis {
|
||||
internal_analysis: [
|
||||
0.18622077,
|
||||
|
@ -290,7 +290,7 @@ mod tests {
|
|||
..Default::default()
|
||||
}),
|
||||
Ok(Song {
|
||||
path: Path::new("data/testcue.flac/CUE_TRACK003").to_path_buf(),
|
||||
path: Path::new("data/testcue.cue/CUE_TRACK003").to_path_buf(),
|
||||
analysis: Analysis {
|
||||
internal_analysis: [
|
||||
0.0024261475,
|
||||
|
|
38
src/lib.rs
38
src/lib.rs
|
@ -15,6 +15,14 @@
|
|||
//! is as easy as computing distances between that song and the rest, and ordering
|
||||
//! the songs by distance, ascending.
|
||||
//!
|
||||
//! If you want to implement a bliss plugin for an already existing audio
|
||||
//! player, the [Library] struct is a collection of goodies that should prove
|
||||
//! useful (it contains utilities to store analyzed songs in a self-contained
|
||||
//! database file, to make playlists directly from the database, etc).
|
||||
//! [blissify](https://github.com/Polochon-street/blissify-rs/) for both
|
||||
//! an example of how the [Library] struct works, and a real-life demo of bliss
|
||||
//! implemented for [MPD](https://www.musicpd.org/).
|
||||
//!
|
||||
//! # Examples
|
||||
//!
|
||||
//! ### Analyze & compute the distance between two songs
|
||||
|
@ -59,6 +67,8 @@
|
|||
#![warn(rustdoc::missing_doc_code_examples)]
|
||||
mod chroma;
|
||||
pub mod cue;
|
||||
#[cfg(feature = "library")]
|
||||
pub mod library;
|
||||
mod misc;
|
||||
pub mod playlist;
|
||||
mod song;
|
||||
|
@ -262,11 +272,11 @@ mod tests {
|
|||
#[test]
|
||||
fn test_analyze_paths() {
|
||||
let paths = vec![
|
||||
PathBuf::from("./data/s16_mono_22_5kHz.flac"),
|
||||
PathBuf::from("./data/testcue.cue"),
|
||||
PathBuf::from("./data/white_noise.flac"),
|
||||
PathBuf::from("definitely-not-existing.foo"),
|
||||
PathBuf::from("not-existing.foo"),
|
||||
"./data/s16_mono_22_5kHz.flac",
|
||||
"./data/testcue.cue",
|
||||
"./data/white_noise.flac",
|
||||
"definitely-not-existing.foo",
|
||||
"not-existing.foo",
|
||||
];
|
||||
let mut results = analyze_paths(&paths)
|
||||
.map(|x| match &x.1 {
|
||||
|
@ -304,21 +314,9 @@ mod tests {
|
|||
)),
|
||||
),
|
||||
(true, PathBuf::from("./data/s16_mono_22_5kHz.flac"), None),
|
||||
(
|
||||
true,
|
||||
PathBuf::from("./data/testcue.flac/CUE_TRACK001"),
|
||||
None,
|
||||
),
|
||||
(
|
||||
true,
|
||||
PathBuf::from("./data/testcue.flac/CUE_TRACK002"),
|
||||
None,
|
||||
),
|
||||
(
|
||||
true,
|
||||
PathBuf::from("./data/testcue.flac/CUE_TRACK003"),
|
||||
None,
|
||||
),
|
||||
(true, PathBuf::from("./data/testcue.cue/CUE_TRACK001"), None),
|
||||
(true, PathBuf::from("./data/testcue.cue/CUE_TRACK002"), None),
|
||||
(true, PathBuf::from("./data/testcue.cue/CUE_TRACK003"), None),
|
||||
(true, PathBuf::from("./data/white_noise.flac"), None),
|
||||
];
|
||||
|
||||
|
|
2850
src/library.rs
Normal file
2850
src/library.rs
Normal file
File diff suppressed because it is too large
Load diff
448
src/playlist.rs
448
src/playlist.rs
|
@ -7,6 +7,8 @@
|
|||
//! They will yield different styles of playlists, so don't hesitate to
|
||||
//! experiment with them if the default (euclidean distance for now) doesn't
|
||||
//! suit you.
|
||||
// TODO on the `by_key` functions: maybe Fn(&T) -> &Song is enough? Compared
|
||||
// to -> Song
|
||||
use crate::{BlissError, BlissResult, Song, NUMBER_FEATURES};
|
||||
use ndarray::{Array, Array1, Array2, Axis};
|
||||
use ndarray_stats::QuantileExt;
|
||||
|
@ -47,6 +49,23 @@ pub fn closest_to_first_song(
|
|||
songs.sort_by_cached_key(|song| n32(first_song.custom_distance(song, &distance)));
|
||||
}
|
||||
|
||||
/// Sort `songs` in place by putting songs close to `first_song` first
|
||||
/// using the `distance` metric.
|
||||
///
|
||||
/// Sort songs with a key extraction function, useful for when you have a
|
||||
/// structure like `CustomSong { bliss_song: Song, something_else: bool }`
|
||||
pub fn closest_to_first_song_by_key<F, T>(
|
||||
first_song: &T,
|
||||
#[allow(clippy::ptr_arg)] songs: &mut Vec<T>,
|
||||
distance: impl DistanceMetric,
|
||||
key_fn: F,
|
||||
) where
|
||||
F: Fn(&T) -> Song,
|
||||
{
|
||||
let first_song = key_fn(first_song);
|
||||
songs.sort_by_cached_key(|song| n32(first_song.custom_distance(&key_fn(song), &distance)));
|
||||
}
|
||||
|
||||
/// Sort `songs` in place using the `distance` metric and ordering by
|
||||
/// the smallest distance between each song.
|
||||
///
|
||||
|
@ -71,6 +90,43 @@ pub fn song_to_song(first_song: &Song, songs: &mut Vec<Song>, distance: impl Dis
|
|||
*songs = new_songs;
|
||||
}
|
||||
|
||||
/// Sort `songs` in place using the `distance` metric and ordering by
|
||||
/// the smallest distance between each song.
|
||||
///
|
||||
/// If the generated playlist is `[song1, song2, song3, song4]`, it means
|
||||
/// song2 is closest to song1, song3 is closest to song2, and song4 is closest
|
||||
/// to song3.
|
||||
///
|
||||
/// Note that this has a tendency to go from one style to the other very fast,
|
||||
/// and it can be slow on big libraries.
|
||||
///
|
||||
/// Sort songs with a key extraction function, useful for when you have a
|
||||
/// structure like `CustomSong { bliss_song: Song, something_else: bool }`
|
||||
// TODO: maybe Clone is not needed?
|
||||
pub fn song_to_song_by_key<F, T: std::cmp::PartialEq + Clone>(
|
||||
first_song: &T,
|
||||
songs: &mut Vec<T>,
|
||||
distance: impl DistanceMetric,
|
||||
key_fn: F,
|
||||
) where
|
||||
F: Fn(&T) -> Song,
|
||||
{
|
||||
let mut new_songs: Vec<T> = Vec::with_capacity(songs.len());
|
||||
let mut bliss_song = key_fn(&first_song.to_owned());
|
||||
|
||||
while !songs.is_empty() {
|
||||
let distances: Array1<f32> = Array::from_shape_fn(songs.len(), |i| {
|
||||
bliss_song.custom_distance(&key_fn(&songs[i]), &distance)
|
||||
});
|
||||
let idx = distances.argmin().unwrap();
|
||||
let song = songs[idx].to_owned();
|
||||
bliss_song = key_fn(&songs[idx]).to_owned();
|
||||
new_songs.push(song.to_owned());
|
||||
songs.retain(|s| s != &song);
|
||||
}
|
||||
*songs = new_songs;
|
||||
}
|
||||
|
||||
/// Remove duplicate songs from a playlist, in place.
|
||||
///
|
||||
/// Two songs are considered duplicates if they either have the same,
|
||||
|
@ -86,6 +142,29 @@ pub fn dedup_playlist(songs: &mut Vec<Song>, distance_threshold: Option<f32>) {
|
|||
dedup_playlist_custom_distance(songs, distance_threshold, euclidean_distance);
|
||||
}
|
||||
|
||||
/// Remove duplicate songs from a playlist, in place.
|
||||
///
|
||||
/// Two songs are considered duplicates if they either have the same,
|
||||
/// non-empty title and artist name, or if they are close enough in terms
|
||||
/// of distance.
|
||||
///
|
||||
/// Dedup songs with a key extraction function, useful for when you have a
|
||||
/// structure like `CustomSong { bliss_song: Song, something_else: bool }` you
|
||||
/// want to deduplicate.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `songs`: The playlist to remove duplicates from.
|
||||
/// * `distance_threshold`: The distance threshold under which two songs are
|
||||
/// considered identical. If `None`, a default value of 0.05 will be used.
|
||||
/// * `key_fn`: A function used to retrieve the bliss [Song] from `T`.
|
||||
pub fn dedup_playlist_by_key<T, F>(songs: &mut Vec<T>, distance_threshold: Option<f32>, key_fn: F)
|
||||
where
|
||||
F: Fn(&T) -> Song,
|
||||
{
|
||||
dedup_playlist_custom_distance_by_key(songs, distance_threshold, euclidean_distance, key_fn);
|
||||
}
|
||||
|
||||
/// Remove duplicate songs from a playlist, in place, using a custom distance
|
||||
/// metric.
|
||||
///
|
||||
|
@ -115,6 +194,45 @@ pub fn dedup_playlist_custom_distance(
|
|||
});
|
||||
}
|
||||
|
||||
/// Remove duplicate songs from a playlist, in place, using a custom distance
|
||||
/// metric.
|
||||
///
|
||||
/// Two songs are considered duplicates if they either have the same,
|
||||
/// non-empty title and artist name, or if they are close enough in terms
|
||||
/// of distance.
|
||||
///
|
||||
/// Dedup songs with a key extraction function, useful for when you have a
|
||||
/// structure like `CustomSong { bliss_song: Song, something_else: bool }`
|
||||
/// you want to deduplicate.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `songs`: The playlist to remove duplicates from.
|
||||
/// * `distance_threshold`: The distance threshold under which two songs are
|
||||
/// considered identical. If `None`, a default value of 0.05 will be used.
|
||||
/// * `distance`: A custom distance metric.
|
||||
/// * `key_fn`: A function used to retrieve the bliss [Song] from `T`.
|
||||
pub fn dedup_playlist_custom_distance_by_key<F, T>(
|
||||
songs: &mut Vec<T>,
|
||||
distance_threshold: Option<f32>,
|
||||
distance: impl DistanceMetric,
|
||||
key_fn: F,
|
||||
) where
|
||||
F: Fn(&T) -> Song,
|
||||
{
|
||||
songs.dedup_by(|s1, s2| {
|
||||
let s1 = key_fn(s1);
|
||||
let s2 = key_fn(s2);
|
||||
n32(s1.custom_distance(&s2, &distance)) < distance_threshold.unwrap_or(0.05)
|
||||
|| (s1.title.is_some()
|
||||
&& s2.title.is_some()
|
||||
&& s1.artist.is_some()
|
||||
&& s2.artist.is_some()
|
||||
&& s1.title == s2.title
|
||||
&& s1.artist == s2.artist)
|
||||
});
|
||||
}
|
||||
|
||||
/// Return a list of albums in a `pool` of songs that are similar to
|
||||
/// songs in `group`, discarding songs that don't belong to an album.
|
||||
/// It basically makes an "album" playlist from the `pool` of songs.
|
||||
|
@ -203,6 +321,114 @@ pub fn closest_album_to_group(group: Vec<Song>, pool: Vec<Song>) -> BlissResult<
|
|||
Ok(playlist)
|
||||
}
|
||||
|
||||
/// Return a list of albums in a `pool` of songs that are similar to
|
||||
/// songs in `group`, discarding songs that don't belong to an album.
|
||||
/// It basically makes an "album" playlist from the `pool` of songs.
|
||||
///
|
||||
/// `group` should be ordered by track number.
|
||||
///
|
||||
/// Songs from `group` would usually just be songs from an album, but not
|
||||
/// necessarily - they are discarded from `pool` no matter what.
|
||||
///
|
||||
/// Order songs with a key extraction function, useful for when you have a
|
||||
/// structure like `CustomSong { bliss_song: Song, something_else: bool }`
|
||||
/// you want to order.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `group` - A small group of songs, e.g. an album.
|
||||
/// * `pool` - A pool of songs to find similar songs in, e.g. a user's song
|
||||
/// library.
|
||||
/// * `key_fn`: A function used to retrieve the bliss [Song] from `T`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of T, including `group` at the beginning, that you
|
||||
/// most likely want to plug in your audio player by using something like
|
||||
/// `ret.map(|song| song.path.to_owned()).collect::<Vec<String>>()`.
|
||||
// TODO: maybe Clone is not needed?
|
||||
pub fn closest_album_to_group_by_key<T: PartialEq + Clone, F>(
|
||||
group: Vec<T>,
|
||||
pool: Vec<T>,
|
||||
key_fn: F,
|
||||
) -> BlissResult<Vec<T>>
|
||||
where
|
||||
F: Fn(&T) -> Song,
|
||||
{
|
||||
let mut albums_analysis: HashMap<String, Array2<f32>> = HashMap::new();
|
||||
let mut albums = Vec::new();
|
||||
|
||||
// Remove songs from the group from the pool.
|
||||
let pool = pool
|
||||
.into_iter()
|
||||
.filter(|s| !group.contains(s))
|
||||
.collect::<Vec<_>>();
|
||||
for song in &pool {
|
||||
let song = key_fn(song);
|
||||
if let Some(album) = song.album {
|
||||
if let Some(analysis) = albums_analysis.get_mut(&album as &str) {
|
||||
analysis
|
||||
.push_row(song.analysis.as_arr1().view())
|
||||
.map_err(|e| {
|
||||
BlissError::ProviderError(format!("while computing distances: {}", e))
|
||||
})?;
|
||||
} else {
|
||||
let mut array = Array::zeros((1, song.analysis.as_arr1().len()));
|
||||
array.assign(&song.analysis.as_arr1());
|
||||
albums_analysis.insert(album.to_owned(), array);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut group_analysis = Array::zeros((group.len(), NUMBER_FEATURES));
|
||||
for (song, mut column) in group.iter().zip(group_analysis.axis_iter_mut(Axis(0))) {
|
||||
let song = key_fn(song);
|
||||
column.assign(&song.analysis.as_arr1());
|
||||
}
|
||||
let first_analysis = group_analysis
|
||||
.mean_axis(Axis(0))
|
||||
.ok_or_else(|| BlissError::ProviderError(String::from("Mean of empty slice")))?;
|
||||
for (album, analysis) in albums_analysis.iter() {
|
||||
let mean_analysis = analysis
|
||||
.mean_axis(Axis(0))
|
||||
.ok_or_else(|| BlissError::ProviderError(String::from("Mean of empty slice")))?;
|
||||
let album = album.to_owned();
|
||||
albums.push((album, mean_analysis.to_owned()));
|
||||
}
|
||||
|
||||
albums.sort_by_key(|(_, analysis)| n32(euclidean_distance(&first_analysis, analysis)));
|
||||
let mut playlist = group;
|
||||
for (album, _) in albums {
|
||||
let mut al = pool
|
||||
.iter()
|
||||
.filter(|s| {
|
||||
let s = key_fn(s);
|
||||
s.album.is_some() && s.album.as_ref().unwrap() == &album.to_string()
|
||||
})
|
||||
.map(|s| s.to_owned())
|
||||
.collect::<Vec<T>>();
|
||||
al.sort_by(|s1, s2| {
|
||||
let s1 = key_fn(s1);
|
||||
let s2 = key_fn(s2);
|
||||
let track_number1 = s1
|
||||
.track_number
|
||||
.to_owned()
|
||||
.unwrap_or_else(|| String::from(""));
|
||||
let track_number2 = s2
|
||||
.track_number
|
||||
.to_owned()
|
||||
.unwrap_or_else(|| String::from(""));
|
||||
if let Ok(x) = track_number1.parse::<i32>() {
|
||||
if let Ok(y) = track_number2.parse::<i32>() {
|
||||
return x.cmp(&y);
|
||||
}
|
||||
}
|
||||
s1.track_number.cmp(&s2.track_number)
|
||||
});
|
||||
playlist.extend_from_slice(&al);
|
||||
}
|
||||
Ok(playlist)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
@ -210,6 +436,12 @@ mod test {
|
|||
use ndarray::arr1;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct CustomSong {
|
||||
something: bool,
|
||||
bliss_song: Song,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dedup_playlist_custom_distance() {
|
||||
let first_song = Song {
|
||||
|
@ -316,6 +548,91 @@ mod test {
|
|||
fourth_song.to_owned(),
|
||||
]
|
||||
);
|
||||
|
||||
let first_song = CustomSong {
|
||||
bliss_song: first_song,
|
||||
something: true,
|
||||
};
|
||||
let second_song = CustomSong {
|
||||
bliss_song: second_song,
|
||||
something: true,
|
||||
};
|
||||
let first_song_dupe = CustomSong {
|
||||
bliss_song: first_song_dupe,
|
||||
something: true,
|
||||
};
|
||||
let third_song = CustomSong {
|
||||
bliss_song: third_song,
|
||||
something: true,
|
||||
};
|
||||
let fourth_song = CustomSong {
|
||||
bliss_song: fourth_song,
|
||||
something: true,
|
||||
};
|
||||
|
||||
let fifth_song = CustomSong {
|
||||
bliss_song: fifth_song,
|
||||
something: true,
|
||||
};
|
||||
|
||||
let mut playlist = vec![
|
||||
first_song.to_owned(),
|
||||
first_song_dupe.to_owned(),
|
||||
second_song.to_owned(),
|
||||
third_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
fifth_song.to_owned(),
|
||||
];
|
||||
dedup_playlist_custom_distance_by_key(&mut playlist, None, euclidean_distance, |s| {
|
||||
s.bliss_song.to_owned()
|
||||
});
|
||||
assert_eq!(
|
||||
playlist,
|
||||
vec![
|
||||
first_song.to_owned(),
|
||||
second_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
],
|
||||
);
|
||||
let mut playlist = vec![
|
||||
first_song.to_owned(),
|
||||
first_song_dupe.to_owned(),
|
||||
second_song.to_owned(),
|
||||
third_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
fifth_song.to_owned(),
|
||||
];
|
||||
dedup_playlist_custom_distance_by_key(&mut playlist, Some(20.), cosine_distance, |s| {
|
||||
s.bliss_song.to_owned()
|
||||
});
|
||||
assert_eq!(playlist, vec![first_song.to_owned()]);
|
||||
let mut playlist = vec![
|
||||
first_song.to_owned(),
|
||||
first_song_dupe.to_owned(),
|
||||
second_song.to_owned(),
|
||||
third_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
fifth_song.to_owned(),
|
||||
];
|
||||
dedup_playlist_by_key(&mut playlist, Some(20.), |s| s.bliss_song.to_owned());
|
||||
assert_eq!(playlist, vec![first_song.to_owned()]);
|
||||
let mut playlist = vec![
|
||||
first_song.to_owned(),
|
||||
first_song_dupe.to_owned(),
|
||||
second_song.to_owned(),
|
||||
third_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
fifth_song.to_owned(),
|
||||
];
|
||||
dedup_playlist_by_key(&mut playlist, None, |s| s.bliss_song.to_owned());
|
||||
assert_eq!(
|
||||
playlist,
|
||||
vec![
|
||||
first_song.to_owned(),
|
||||
second_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
@ -358,20 +675,64 @@ mod test {
|
|||
};
|
||||
let mut songs = vec![
|
||||
first_song.to_owned(),
|
||||
third_song.to_owned(),
|
||||
first_song_dupe.to_owned(),
|
||||
second_song.to_owned(),
|
||||
third_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
];
|
||||
song_to_song(&first_song, &mut songs, euclidean_distance);
|
||||
assert_eq!(
|
||||
songs,
|
||||
vec![
|
||||
first_song,
|
||||
first_song.to_owned(),
|
||||
first_song_dupe.to_owned(),
|
||||
second_song.to_owned(),
|
||||
third_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
],
|
||||
);
|
||||
|
||||
let first_song = CustomSong {
|
||||
bliss_song: first_song,
|
||||
something: true,
|
||||
};
|
||||
let second_song = CustomSong {
|
||||
bliss_song: second_song,
|
||||
something: true,
|
||||
};
|
||||
let first_song_dupe = CustomSong {
|
||||
bliss_song: first_song_dupe,
|
||||
something: true,
|
||||
};
|
||||
let third_song = CustomSong {
|
||||
bliss_song: third_song,
|
||||
something: true,
|
||||
};
|
||||
let fourth_song = CustomSong {
|
||||
bliss_song: fourth_song,
|
||||
something: true,
|
||||
};
|
||||
|
||||
let mut songs: Vec<CustomSong> = vec![
|
||||
first_song.to_owned(),
|
||||
first_song_dupe.to_owned(),
|
||||
third_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
second_song.to_owned(),
|
||||
];
|
||||
|
||||
song_to_song_by_key(&first_song, &mut songs, euclidean_distance, |s| {
|
||||
s.bliss_song.to_owned()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
songs,
|
||||
vec![
|
||||
first_song,
|
||||
first_song_dupe,
|
||||
second_song,
|
||||
third_song,
|
||||
fourth_song
|
||||
fourth_song,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -431,6 +792,46 @@ mod test {
|
|||
fifth_song.to_owned(),
|
||||
];
|
||||
closest_to_first_song(&first_song, &mut songs, euclidean_distance);
|
||||
|
||||
let first_song = CustomSong {
|
||||
bliss_song: first_song,
|
||||
something: true,
|
||||
};
|
||||
let second_song = CustomSong {
|
||||
bliss_song: second_song,
|
||||
something: true,
|
||||
};
|
||||
let first_song_dupe = CustomSong {
|
||||
bliss_song: first_song_dupe,
|
||||
something: true,
|
||||
};
|
||||
let third_song = CustomSong {
|
||||
bliss_song: third_song,
|
||||
something: true,
|
||||
};
|
||||
let fourth_song = CustomSong {
|
||||
bliss_song: fourth_song,
|
||||
something: true,
|
||||
};
|
||||
|
||||
let fifth_song = CustomSong {
|
||||
bliss_song: fifth_song,
|
||||
something: true,
|
||||
};
|
||||
|
||||
let mut songs: Vec<CustomSong> = vec![
|
||||
first_song.to_owned(),
|
||||
first_song_dupe.to_owned(),
|
||||
second_song.to_owned(),
|
||||
third_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
fifth_song.to_owned(),
|
||||
];
|
||||
|
||||
closest_to_first_song_by_key(&first_song, &mut songs, euclidean_distance, |s| {
|
||||
s.bliss_song.to_owned()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
songs,
|
||||
vec![
|
||||
|
@ -538,5 +939,46 @@ mod test {
|
|||
],
|
||||
closest_album_to_group(group, pool.to_owned()).unwrap(),
|
||||
);
|
||||
|
||||
let first_song = CustomSong {
|
||||
bliss_song: first_song,
|
||||
something: true,
|
||||
};
|
||||
let second_song = CustomSong {
|
||||
bliss_song: second_song,
|
||||
something: true,
|
||||
};
|
||||
let third_song = CustomSong {
|
||||
bliss_song: third_song,
|
||||
something: true,
|
||||
};
|
||||
let fourth_song = CustomSong {
|
||||
bliss_song: fourth_song,
|
||||
something: true,
|
||||
};
|
||||
|
||||
let fifth_song = CustomSong {
|
||||
bliss_song: fifth_song,
|
||||
something: true,
|
||||
};
|
||||
|
||||
let pool = vec![
|
||||
first_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
third_song.to_owned(),
|
||||
second_song.to_owned(),
|
||||
fifth_song.to_owned(),
|
||||
];
|
||||
let group = vec![first_song.to_owned(), third_song.to_owned()];
|
||||
assert_eq!(
|
||||
vec![
|
||||
first_song.to_owned(),
|
||||
third_song.to_owned(),
|
||||
fourth_song.to_owned(),
|
||||
second_song.to_owned()
|
||||
],
|
||||
closest_album_to_group_by_key(group, pool.to_owned(), |s| s.bliss_song.to_owned())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -62,6 +62,7 @@ pub struct Song {
|
|||
/// Song's album's artist name, read from the metadata
|
||||
pub album_artist: Option<String>,
|
||||
/// Song's tracked number, read from the metadata
|
||||
/// TODO normalize this into an integer
|
||||
pub track_number: Option<String>,
|
||||
/// Song's genre, read from the metadata (`""` if empty)
|
||||
pub genre: Option<String>,
|
||||
|
|
Loading…
Reference in a new issue