Compare commits
41 Commits
Author | SHA1 | Date | |
---|---|---|---|
5d510ff787 | |||
4db09667fd | |||
e5a367aa90 | |||
0d7e6bb555 | |||
e22068e461 | |||
0b0599318e | |||
dbaef0422f | |||
90db5faae7 | |||
d6f4b2b6b7 | |||
38517caf4e | |||
8b008292cb | |||
9d1caffd9c | |||
8e48412282 | |||
fd9d3677ec | |||
ebd63cc80b | |||
6333b7a131 | |||
cda1487734 | |||
398963b9fd | |||
c38961c3c1 | |||
0fefc52603 | |||
f82a6376e0 | |||
43961b3ea1 | |||
b70499d8de | |||
cf7e23c38c | |||
d8fd952456 | |||
871aeb8436 | |||
8ff09e66ba | |||
f395433343 | |||
d9d5945422 | |||
a062817ae7 | |||
3ed13ca0e9 | |||
a75dd46a40 | |||
c53ba8f35f | |||
8550f7d6da | |||
bd7e9ceb4d | |||
b70711d886 | |||
c015f4c112 | |||
4dc56f66c6 | |||
42d1edb69c | |||
fd19ea3eb3 | |||
4d2ea77da9 |
@ -1,4 +1,4 @@
|
|||||||
FROM docker.io/library/rust:1.75
|
FROM docker.io/library/rust:1.80
|
||||||
|
|
||||||
RUN rustup component add \
|
RUN rustup component add \
|
||||||
clippy \
|
clippy \
|
||||||
@ -9,5 +9,10 @@ RUN cargo install \
|
|||||||
grcov
|
grcov
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
beets \
|
nodejs \
|
||||||
nodejs
|
pipx
|
||||||
|
|
||||||
|
# Once pipx>=1.5.0 is available use --global instead of env
|
||||||
|
RUN env PIPX_HOME=/usr/local/pipx \
|
||||||
|
PIPX_BIN_DIR=/usr/local/bin \
|
||||||
|
pipx install --include-deps --system-site-packages beets==2.0.0
|
||||||
|
@ -13,7 +13,7 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build_and_test:
|
build_and_test:
|
||||||
name: Build and Test
|
name: Build and Test
|
||||||
container: docker.io/drrobot/musichoard-ci:rust-1.75
|
container: docker.io/drrobot/musichoard-ci:20240824-1
|
||||||
env:
|
env:
|
||||||
BEETSDIR: ./
|
BEETSDIR: ./
|
||||||
LLVM_PROFILE_FILE: target/debug/profraw/musichoard-%p-%m.profraw
|
LLVM_PROFILE_FILE: target/debug/profraw/musichoard-%p-%m.profraw
|
||||||
@ -33,9 +33,11 @@ jobs:
|
|||||||
--source-dir .
|
--source-dir .
|
||||||
--ignore-not-existing
|
--ignore-not-existing
|
||||||
--ignore "build.rs"
|
--ignore "build.rs"
|
||||||
|
--ignore "examples/*"
|
||||||
--ignore "tests/*"
|
--ignore "tests/*"
|
||||||
--ignore "src/main.rs"
|
--ignore "src/main.rs"
|
||||||
--ignore "src/bin/musichoard-edit.rs"
|
--ignore "src/bin/musichoard-edit.rs"
|
||||||
|
--excl-line "^#\[derive|unimplemented\!\(\)"
|
||||||
--excl-start "GRCOV_EXCL_START|mod tests \{"
|
--excl-start "GRCOV_EXCL_START|mod tests \{"
|
||||||
--excl-stop "GRCOV_EXCL_STOP"
|
--excl-stop "GRCOV_EXCL_STOP"
|
||||||
--output-path ./target/debug/coverage/
|
--output-path ./target/debug/coverage/
|
||||||
@ -46,7 +48,7 @@ jobs:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
container: docker.io/drrobot/musichoard-ci:rust-1.75
|
container: docker.io/drrobot/musichoard-ci:20240824-1
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- run: cargo clippy --no-default-features --all-targets -- -D warnings
|
- run: cargo clippy --no-default-features --all-targets -- -D warnings
|
||||||
|
730
Cargo.lock
generated
730
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
30
Cargo.toml
30
Cargo.toml
@ -7,14 +7,18 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
aho-corasick = { version = "1.1.2", optional = true }
|
aho-corasick = { version = "1.1.2", optional = true }
|
||||||
crossterm = { version = "0.27.0", optional = true}
|
crossterm = { version = "0.28.1", optional = true}
|
||||||
once_cell = { version = "1.19.0", optional = true}
|
once_cell = { version = "1.19.0", optional = true}
|
||||||
openssh = { version = "0.10.3", features = ["native-mux"], default-features = false, optional = true}
|
openssh = { version = "0.10.3", features = ["native-mux"], default-features = false, optional = true}
|
||||||
ratatui = { version = "0.26.0", optional = true}
|
paste = { version = "1.0.15", optional = true }
|
||||||
|
ratatui = { version = "0.28.1", optional = true}
|
||||||
|
reqwest = { version = "0.11.25", features = ["blocking", "json"], optional = true }
|
||||||
serde = { version = "1.0.196", features = ["derive"], optional = true }
|
serde = { version = "1.0.196", features = ["derive"], optional = true }
|
||||||
serde_json = { version = "1.0.113", optional = true}
|
serde_json = { version = "1.0.113", optional = true}
|
||||||
structopt = { version = "0.3.26", optional = true}
|
structopt = { version = "0.3.26", optional = true}
|
||||||
tokio = { version = "1.36.0", features = ["rt"], optional = true}
|
tokio = { version = "1.36.0", features = ["rt"], optional = true}
|
||||||
|
# ratatui/crossterm dependency version must match with musichhoard's ratatui/crossterm
|
||||||
|
tui-input = { version = "0.10.1", optional = true }
|
||||||
url = { version = "2.5.0" }
|
url = { version = "2.5.0" }
|
||||||
uuid = { version = "1.7.0" }
|
uuid = { version = "1.7.0" }
|
||||||
|
|
||||||
@ -31,16 +35,32 @@ default = ["database-json", "library-beets"]
|
|||||||
bin = ["structopt"]
|
bin = ["structopt"]
|
||||||
database-json = ["serde", "serde_json"]
|
database-json = ["serde", "serde_json"]
|
||||||
library-beets = []
|
library-beets = []
|
||||||
ssh-library = ["openssh", "tokio"]
|
library-beets-ssh = ["openssh", "tokio"]
|
||||||
tui = ["aho-corasick", "crossterm", "once_cell", "ratatui"]
|
musicbrainz = ["paste", "reqwest", "serde", "serde_json"]
|
||||||
|
tui = ["aho-corasick", "crossterm", "once_cell", "ratatui", "tui-input"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "musichoard"
|
name = "musichoard"
|
||||||
required-features = ["bin", "database-json", "library-beets", "ssh-library", "tui"]
|
required-features = ["bin", "database-json", "library-beets", "library-beets-ssh", "musicbrainz", "tui"]
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "musichoard-edit"
|
name = "musichoard-edit"
|
||||||
required-features = ["bin", "database-json"]
|
required-features = ["bin", "database-json"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "musicbrainz-api---browse"
|
||||||
|
path = "examples/musicbrainz_api/browse.rs"
|
||||||
|
required-features = ["bin", "musicbrainz"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "musicbrainz-api---lookup"
|
||||||
|
path = "examples/musicbrainz_api/lookup.rs"
|
||||||
|
required-features = ["bin", "musicbrainz"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "musicbrainz-api---search"
|
||||||
|
path = "examples/musicbrainz_api/search.rs"
|
||||||
|
required-features = ["bin", "musicbrainz"]
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
[package.metadata.docs.rs]
|
||||||
all-features = true
|
all-features = true
|
||||||
|
15
README.md
15
README.md
@ -1,5 +1,18 @@
|
|||||||
# Music Hoard
|
# Music Hoard
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
### Pre-requisites
|
||||||
|
|
||||||
|
#### musicbrainz-api
|
||||||
|
|
||||||
|
This feature requires the `openssl` system library.
|
||||||
|
|
||||||
|
On Fedora:
|
||||||
|
``` sh
|
||||||
|
sudo dnf install openssl-devel
|
||||||
|
```
|
||||||
|
|
||||||
## Usage notes
|
## Usage notes
|
||||||
|
|
||||||
### Text selection
|
### Text selection
|
||||||
@ -33,9 +46,11 @@ grcov codecov/debug/profraw \
|
|||||||
--source-dir . \
|
--source-dir . \
|
||||||
--ignore-not-existing \
|
--ignore-not-existing \
|
||||||
--ignore "build.rs" \
|
--ignore "build.rs" \
|
||||||
|
--ignore "examples/*" \
|
||||||
--ignore "tests/*" \
|
--ignore "tests/*" \
|
||||||
--ignore "src/main.rs" \
|
--ignore "src/main.rs" \
|
||||||
--ignore "src/bin/musichoard-edit.rs" \
|
--ignore "src/bin/musichoard-edit.rs" \
|
||||||
|
--excl-line "^#\[derive|unimplemented\!\(\)" \
|
||||||
--excl-start "GRCOV_EXCL_START|mod tests \{" \
|
--excl-start "GRCOV_EXCL_START|mod tests \{" \
|
||||||
--excl-stop "GRCOV_EXCL_STOP" \
|
--excl-stop "GRCOV_EXCL_STOP" \
|
||||||
--output-path ./codecov/debug/coverage/
|
--output-path ./codecov/debug/coverage/
|
||||||
|
3
build.rs
3
build.rs
@ -1,5 +1,6 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
|
println!("cargo::rustc-check-cfg=cfg(nightly)");
|
||||||
if let Some(true) = version_check::is_feature_flaggable() {
|
if let Some(true) = version_check::is_feature_flaggable() {
|
||||||
println!("cargo:rustc-cfg=nightly");
|
println!("cargo::rustc-cfg=nightly");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
94
examples/musicbrainz_api/browse.rs
Normal file
94
examples/musicbrainz_api/browse.rs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
use std::{thread, time};
|
||||||
|
|
||||||
|
use musichoard::{
|
||||||
|
collection::musicbrainz::Mbid,
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{browse::BrowseReleaseGroupRequest, MusicBrainzClient, PageSettings},
|
||||||
|
http::MusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use structopt::StructOpt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const USER_AGENT: &str = concat!(
|
||||||
|
"MusicHoard---examples---musicbrainz-api---browse/",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" ( musichoard@thenineworlds.net )"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct Opt {
|
||||||
|
#[structopt(subcommand)]
|
||||||
|
entity: OptEntity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
enum OptEntity {
|
||||||
|
#[structopt(about = "Browse release groups")]
|
||||||
|
ReleaseGroup(OptReleaseGroup),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
enum OptReleaseGroup {
|
||||||
|
#[structopt(about = "Browse release groups of an artist")]
|
||||||
|
Artist(OptMbid),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct OptMbid {
|
||||||
|
#[structopt(help = "MBID of the entity")]
|
||||||
|
mbid: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let opt = Opt::from_args();
|
||||||
|
|
||||||
|
println!("USER_AGENT: {USER_AGENT}");
|
||||||
|
|
||||||
|
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
match opt.entity {
|
||||||
|
OptEntity::ReleaseGroup(opt_release_group) => match opt_release_group {
|
||||||
|
OptReleaseGroup::Artist(opt_mbid) => {
|
||||||
|
let mbid: Mbid = opt_mbid.mbid.into();
|
||||||
|
let request = BrowseReleaseGroupRequest::artist(&mbid);
|
||||||
|
let mut paging = PageSettings::with_max_limit();
|
||||||
|
|
||||||
|
let mut response_counts: Vec<usize> = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let response = client
|
||||||
|
.browse_release_group(&request, &paging)
|
||||||
|
.expect("failed to make API call");
|
||||||
|
|
||||||
|
for rg in response.release_groups.iter() {
|
||||||
|
println!("{rg:?}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = response.release_groups.len();
|
||||||
|
response_counts.push(count);
|
||||||
|
|
||||||
|
println!("Release groups in this response: {count}");
|
||||||
|
|
||||||
|
paging = match response.page.next_page(paging, count) {
|
||||||
|
Some(paging) => paging,
|
||||||
|
None => break,
|
||||||
|
};
|
||||||
|
|
||||||
|
thread::sleep(time::Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Total: {}={} release groups",
|
||||||
|
response_counts
|
||||||
|
.iter()
|
||||||
|
.map(|i| i.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("+"),
|
||||||
|
response_counts.iter().sum::<usize>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
81
examples/musicbrainz_api/lookup.rs
Normal file
81
examples/musicbrainz_api/lookup.rs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
use musichoard::{
|
||||||
|
collection::musicbrainz::Mbid,
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
lookup::{LookupArtistRequest, LookupReleaseGroupRequest},
|
||||||
|
MusicBrainzClient,
|
||||||
|
},
|
||||||
|
http::MusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use structopt::StructOpt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const USER_AGENT: &str = concat!(
|
||||||
|
"MusicHoard---examples---musicbrainz-api---lookup/",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" ( musichoard@thenineworlds.net )"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct Opt {
|
||||||
|
#[structopt(subcommand)]
|
||||||
|
entity: OptEntity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
enum OptEntity {
|
||||||
|
#[structopt(about = "Lookup artist")]
|
||||||
|
Artist(OptArtist),
|
||||||
|
#[structopt(about = "Lookup release group")]
|
||||||
|
ReleaseGroup(OptReleaseGroup),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct OptArtist {
|
||||||
|
#[structopt(help = "Artist MBID to lookup")]
|
||||||
|
mbid: Uuid,
|
||||||
|
#[structopt(long, help = "Include release groups in lookup")]
|
||||||
|
release_groups: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct OptReleaseGroup {
|
||||||
|
#[structopt(help = "Release group MBID to lookup")]
|
||||||
|
mbid: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let opt = Opt::from_args();
|
||||||
|
|
||||||
|
println!("USER_AGENT: {USER_AGENT}");
|
||||||
|
|
||||||
|
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
match opt.entity {
|
||||||
|
OptEntity::Artist(opt_artist) => {
|
||||||
|
let mbid: Mbid = opt_artist.mbid.into();
|
||||||
|
let mut request = LookupArtistRequest::new(&mbid);
|
||||||
|
if opt_artist.release_groups {
|
||||||
|
request.include_release_groups();
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.lookup_artist(&request)
|
||||||
|
.expect("failed to make API call");
|
||||||
|
|
||||||
|
println!("{response:#?}");
|
||||||
|
}
|
||||||
|
OptEntity::ReleaseGroup(opt_release_group) => {
|
||||||
|
let mbid: Mbid = opt_release_group.mbid.into();
|
||||||
|
let request = LookupReleaseGroupRequest::new(&mbid);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.lookup_release_group(&request)
|
||||||
|
.expect("failed to make API call");
|
||||||
|
|
||||||
|
println!("{response:#?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
150
examples/musicbrainz_api/search.rs
Normal file
150
examples/musicbrainz_api/search.rs
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
use std::{num::ParseIntError, str::FromStr};
|
||||||
|
|
||||||
|
use musichoard::{
|
||||||
|
collection::{album::AlbumDate, musicbrainz::Mbid},
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
search::{SearchArtistRequest, SearchReleaseGroupRequest},
|
||||||
|
MusicBrainzClient, PageSettings,
|
||||||
|
},
|
||||||
|
http::MusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use structopt::StructOpt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const USER_AGENT: &str = concat!(
|
||||||
|
"MusicHoard---examples---musicbrainz-api---search/",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" ( musichoard@thenineworlds.net )"
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct Opt {
|
||||||
|
#[structopt(subcommand)]
|
||||||
|
entity: OptEntity,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
enum OptEntity {
|
||||||
|
#[structopt(about = "Search artist")]
|
||||||
|
Artist(OptArtist),
|
||||||
|
#[structopt(about = "Search release group")]
|
||||||
|
ReleaseGroup(OptReleaseGroup),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct OptArtist {
|
||||||
|
#[structopt(help = "Artist search string")]
|
||||||
|
string: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
enum OptReleaseGroup {
|
||||||
|
#[structopt(about = "Search by artist MBID, title(, and date)")]
|
||||||
|
Title(OptReleaseGroupTitle),
|
||||||
|
#[structopt(about = "Search by release group MBID")]
|
||||||
|
Rgid(OptReleaseGroupRgid),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct OptReleaseGroupTitle {
|
||||||
|
#[structopt(help = "Release group's artist MBID")]
|
||||||
|
arid: Uuid,
|
||||||
|
|
||||||
|
#[structopt(help = "Release group title")]
|
||||||
|
title: String,
|
||||||
|
|
||||||
|
#[structopt(help = "Release group release date")]
|
||||||
|
date: Option<Date>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt)]
|
||||||
|
struct OptReleaseGroupRgid {
|
||||||
|
#[structopt(help = "Release group MBID")]
|
||||||
|
rgid: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Date(AlbumDate);
|
||||||
|
|
||||||
|
impl FromStr for Date {
|
||||||
|
type Err = ParseIntError;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let mut elems = s.split('-');
|
||||||
|
|
||||||
|
let elem = elems.next();
|
||||||
|
let year = elem.map(|s| s.parse()).transpose()?;
|
||||||
|
|
||||||
|
let elem = elems.next();
|
||||||
|
let month = elem.map(|s| s.parse()).transpose()?;
|
||||||
|
|
||||||
|
let elem = elems.next();
|
||||||
|
let day = elem.map(|s| s.parse()).transpose()?;
|
||||||
|
|
||||||
|
Ok(Date(AlbumDate::new(year, month, day)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Date> for AlbumDate {
|
||||||
|
fn from(value: Date) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let opt = Opt::from_args();
|
||||||
|
|
||||||
|
println!("USER_AGENT: {USER_AGENT}");
|
||||||
|
|
||||||
|
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
match opt.entity {
|
||||||
|
OptEntity::Artist(opt_artist) => {
|
||||||
|
let query = SearchArtistRequest::new().string(&opt_artist.string);
|
||||||
|
|
||||||
|
println!("Query: {query}");
|
||||||
|
|
||||||
|
let paging = PageSettings::default();
|
||||||
|
let matches = client
|
||||||
|
.search_artist(&query, &paging)
|
||||||
|
.expect("failed to make API call");
|
||||||
|
|
||||||
|
println!("{matches:#?}");
|
||||||
|
}
|
||||||
|
OptEntity::ReleaseGroup(opt_release_group) => {
|
||||||
|
let arid: Mbid;
|
||||||
|
let date: AlbumDate;
|
||||||
|
let title: String;
|
||||||
|
let rgid: Mbid;
|
||||||
|
|
||||||
|
let query = match opt_release_group {
|
||||||
|
OptReleaseGroup::Title(opt_title) => {
|
||||||
|
arid = opt_title.arid.into();
|
||||||
|
date = opt_title.date.map(Into::into).unwrap_or_default();
|
||||||
|
title = opt_title.title;
|
||||||
|
SearchReleaseGroupRequest::new()
|
||||||
|
.arid(&arid)
|
||||||
|
.and()
|
||||||
|
.release_group(&title)
|
||||||
|
.and()
|
||||||
|
.first_release_date(&date)
|
||||||
|
}
|
||||||
|
OptReleaseGroup::Rgid(opt_rgid) => {
|
||||||
|
rgid = opt_rgid.rgid.into();
|
||||||
|
SearchReleaseGroupRequest::new().rgid(&rgid)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Query: {query}");
|
||||||
|
|
||||||
|
let paging = PageSettings::default();
|
||||||
|
let matches = client
|
||||||
|
.search_release_group(&query, &paging)
|
||||||
|
.expect("failed to make API call");
|
||||||
|
|
||||||
|
println!("{matches:#?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -3,12 +3,12 @@ use std::path::PathBuf;
|
|||||||
use structopt::{clap::AppSettings, StructOpt};
|
use structopt::{clap::AppSettings, StructOpt};
|
||||||
|
|
||||||
use musichoard::{
|
use musichoard::{
|
||||||
collection::artist::ArtistId,
|
collection::{album::AlbumId, artist::ArtistId},
|
||||||
database::json::{backend::JsonDatabaseFileBackend, JsonDatabase},
|
external::database::json::{backend::JsonDatabaseFileBackend, JsonDatabase},
|
||||||
MusicHoard, MusicHoardBuilder, NoLibrary,
|
IMusicHoardDatabase, MusicHoard, MusicHoardBuilder, NoLibrary,
|
||||||
};
|
};
|
||||||
|
|
||||||
type MH = MusicHoard<NoLibrary, JsonDatabase<JsonDatabaseFileBackend>>;
|
type MH = MusicHoard<JsonDatabase<JsonDatabaseFileBackend>, NoLibrary>;
|
||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
#[derive(StructOpt, Debug)]
|
||||||
#[structopt(about = "musichoard-edit: edit the MusicHoard database",
|
#[structopt(about = "musichoard-edit: edit the MusicHoard database",
|
||||||
@ -22,79 +22,55 @@ struct Opt {
|
|||||||
database_file_path: PathBuf,
|
database_file_path: PathBuf,
|
||||||
|
|
||||||
#[structopt(subcommand)]
|
#[structopt(subcommand)]
|
||||||
category: Category,
|
command: Command,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
#[derive(StructOpt, Debug)]
|
||||||
enum Category {
|
enum Command {
|
||||||
#[structopt(about = "Edit artist information")]
|
#[structopt(about = "Modify artist information")]
|
||||||
Artist(ArtistCommand),
|
Artist(ArtistOpt),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Category {
|
#[derive(StructOpt, Debug)]
|
||||||
fn handle(self, music_hoard: &mut MH) {
|
struct ArtistOpt {
|
||||||
match self {
|
// For some reason, not specyfing the artist name with the `long` name makes StructOpt failed
|
||||||
Category::Artist(artist_command) => artist_command.handle(music_hoard),
|
// for inexplicable reason. For example, it won't recognise `Abadde` or `Abadden` as a name and
|
||||||
}
|
// will insteady try to process it as a command.
|
||||||
}
|
#[structopt(long, help = "The name of the artist")]
|
||||||
|
name: String,
|
||||||
|
|
||||||
|
#[structopt(subcommand)]
|
||||||
|
command: ArtistCommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
#[derive(StructOpt, Debug)]
|
||||||
enum ArtistCommand {
|
enum ArtistCommand {
|
||||||
#[structopt(about = "Add a new artist to the collection")]
|
#[structopt(about = "Add a new artist to the collection")]
|
||||||
Add(ArtistValue),
|
Add,
|
||||||
#[structopt(about = "Remove an artist from the collection")]
|
#[structopt(about = "Remove an artist from the collection")]
|
||||||
Remove(ArtistValue),
|
Remove,
|
||||||
#[structopt(about = "Edit the artist's sort name")]
|
#[structopt(about = "Edit the artist's sort name")]
|
||||||
Sort(SortCommand),
|
Sort(SortCommand),
|
||||||
#[structopt(name = "musicbrainz", about = "Edit the MusicBrainz URL of an artist")]
|
#[structopt(about = "Edit a property of an artist")]
|
||||||
MusicBrainz(MusicBrainzCommand),
|
|
||||||
#[structopt(name = "property", about = "Edit a property of an artist")]
|
|
||||||
Property(PropertyCommand),
|
Property(PropertyCommand),
|
||||||
|
#[structopt(about = "Modify the artist's album information")]
|
||||||
|
Album(AlbumOpt),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
#[derive(StructOpt, Debug)]
|
||||||
enum SortCommand {
|
enum SortCommand {
|
||||||
#[structopt(about = "Set the provided name as the artist's sort name")]
|
#[structopt(about = "Set the provided name as the artist's sort name")]
|
||||||
Set(ArtistSortValue),
|
Set(SortValue),
|
||||||
#[structopt(about = "Clear the artist's sort name")]
|
#[structopt(about = "Clear the artist's sort name")]
|
||||||
Clear(ArtistValue),
|
Clear,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
#[derive(StructOpt, Debug)]
|
||||||
struct ArtistValue {
|
struct SortValue {
|
||||||
#[structopt(help = "The name of the artist")]
|
#[structopt(help = "The sort name")]
|
||||||
artist: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
|
||||||
struct ArtistSortValue {
|
|
||||||
#[structopt(help = "The name of the artist")]
|
|
||||||
artist: String,
|
|
||||||
#[structopt(help = "The sort name of the artist")]
|
|
||||||
sort: String,
|
sort: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
|
||||||
enum MusicBrainzCommand {
|
|
||||||
#[structopt(about = "Add a MusicBrainz URL without overwriting the existing value")]
|
|
||||||
Add(MusicBrainzValue),
|
|
||||||
#[structopt(about = "Remove the MusicBrainz URL")]
|
|
||||||
Remove(MusicBrainzValue),
|
|
||||||
#[structopt(about = "Set the MusicBrainz URL overwriting any existing value")]
|
|
||||||
Set(MusicBrainzValue),
|
|
||||||
#[structopt(about = "Clear the MusicBrainz URL)")]
|
|
||||||
Clear(ArtistValue),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
|
||||||
struct MusicBrainzValue {
|
|
||||||
#[structopt(help = "The name of the artist")]
|
|
||||||
artist: String,
|
|
||||||
#[structopt(help = "The MusicBrainz URL")]
|
|
||||||
url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
#[derive(StructOpt, Debug)]
|
||||||
enum PropertyCommand {
|
enum PropertyCommand {
|
||||||
#[structopt(about = "Add values to the property without overwriting existing values")]
|
#[structopt(about = "Add values to the property without overwriting existing values")]
|
||||||
@ -109,8 +85,6 @@ enum PropertyCommand {
|
|||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
#[derive(StructOpt, Debug)]
|
||||||
struct PropertyValue {
|
struct PropertyValue {
|
||||||
#[structopt(help = "The name of the artist")]
|
|
||||||
artist: String,
|
|
||||||
#[structopt(help = "The name of the property")]
|
#[structopt(help = "The name of the property")]
|
||||||
property: String,
|
property: String,
|
||||||
#[structopt(help = "The list of values")]
|
#[structopt(help = "The list of values")]
|
||||||
@ -119,122 +93,165 @@ struct PropertyValue {
|
|||||||
|
|
||||||
#[derive(StructOpt, Debug)]
|
#[derive(StructOpt, Debug)]
|
||||||
struct PropertyName {
|
struct PropertyName {
|
||||||
#[structopt(help = "The name of the artist")]
|
|
||||||
artist: String,
|
|
||||||
#[structopt(help = "The name of the property")]
|
#[structopt(help = "The name of the property")]
|
||||||
property: String,
|
property: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ArtistCommand {
|
#[derive(StructOpt, Debug)]
|
||||||
|
struct AlbumOpt {
|
||||||
|
// Using `long` for consistency with `ArtistOpt`.
|
||||||
|
#[structopt(long, help = "The title of the album")]
|
||||||
|
title: String,
|
||||||
|
|
||||||
|
#[structopt(subcommand)]
|
||||||
|
command: AlbumCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt, Debug)]
|
||||||
|
enum AlbumCommand {
|
||||||
|
#[structopt(about = "Edit the album's sequence value")]
|
||||||
|
Seq(AlbumSeqCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt, Debug)]
|
||||||
|
enum AlbumSeqCommand {
|
||||||
|
#[structopt(about = "Set the sequence value overwriting any existing value")]
|
||||||
|
Set(AlbumSeqValue),
|
||||||
|
#[structopt(about = "Clear the sequence value")]
|
||||||
|
Clear,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(StructOpt, Debug)]
|
||||||
|
struct AlbumSeqValue {
|
||||||
|
#[structopt(help = "The new sequence value")]
|
||||||
|
value: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Command {
|
||||||
fn handle(self, music_hoard: &mut MH) {
|
fn handle(self, music_hoard: &mut MH) {
|
||||||
match self {
|
match self {
|
||||||
ArtistCommand::Add(artist_value) => {
|
Command::Artist(artist_opt) => artist_opt.handle(music_hoard),
|
||||||
music_hoard.add_artist(ArtistId::new(artist_value.artist));
|
|
||||||
}
|
}
|
||||||
ArtistCommand::Remove(artist_value) => {
|
}
|
||||||
music_hoard.remove_artist(ArtistId::new(artist_value.artist));
|
}
|
||||||
|
|
||||||
|
impl ArtistOpt {
|
||||||
|
fn handle(self, music_hoard: &mut MH) {
|
||||||
|
self.command.handle(music_hoard, &self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArtistCommand {
|
||||||
|
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
|
||||||
|
match self {
|
||||||
|
ArtistCommand::Add => {
|
||||||
|
music_hoard
|
||||||
|
.add_artist(ArtistId::new(artist_name))
|
||||||
|
.expect("failed to add artist");
|
||||||
|
}
|
||||||
|
ArtistCommand::Remove => {
|
||||||
|
music_hoard
|
||||||
|
.remove_artist(ArtistId::new(artist_name))
|
||||||
|
.expect("failed to remove artist");
|
||||||
}
|
}
|
||||||
ArtistCommand::Sort(sort_command) => {
|
ArtistCommand::Sort(sort_command) => {
|
||||||
sort_command.handle(music_hoard);
|
sort_command.handle(music_hoard, artist_name);
|
||||||
}
|
|
||||||
ArtistCommand::MusicBrainz(musicbrainz_command) => {
|
|
||||||
musicbrainz_command.handle(music_hoard)
|
|
||||||
}
|
}
|
||||||
ArtistCommand::Property(property_command) => {
|
ArtistCommand::Property(property_command) => {
|
||||||
property_command.handle(music_hoard);
|
property_command.handle(music_hoard, artist_name);
|
||||||
|
}
|
||||||
|
ArtistCommand::Album(album_opt) => {
|
||||||
|
album_opt.handle(music_hoard, artist_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SortCommand {
|
impl SortCommand {
|
||||||
fn handle(self, music_hoard: &mut MH) {
|
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
|
||||||
match self {
|
match self {
|
||||||
SortCommand::Set(artist_sort_value) => music_hoard
|
SortCommand::Set(artist_sort_value) => music_hoard
|
||||||
.set_artist_sort(
|
.set_artist_sort(ArtistId::new(artist_name), artist_sort_value.sort)
|
||||||
ArtistId::new(artist_sort_value.artist),
|
|
||||||
ArtistId::new(artist_sort_value.sort),
|
|
||||||
)
|
|
||||||
.expect("faild to set artist sort name"),
|
.expect("faild to set artist sort name"),
|
||||||
SortCommand::Clear(artist_value) => music_hoard
|
SortCommand::Clear => music_hoard
|
||||||
.clear_artist_sort(ArtistId::new(artist_value.artist))
|
.clear_artist_sort(ArtistId::new(artist_name))
|
||||||
.expect("failed to clear artist sort name"),
|
.expect("failed to clear artist sort name"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MusicBrainzCommand {
|
|
||||||
fn handle(self, music_hoard: &mut MH) {
|
|
||||||
match self {
|
|
||||||
MusicBrainzCommand::Add(musicbrainz_value) => music_hoard
|
|
||||||
.add_musicbrainz_url(
|
|
||||||
ArtistId::new(musicbrainz_value.artist),
|
|
||||||
musicbrainz_value.url,
|
|
||||||
)
|
|
||||||
.expect("failed to add MusicBrainz URL"),
|
|
||||||
MusicBrainzCommand::Remove(musicbrainz_value) => music_hoard
|
|
||||||
.remove_musicbrainz_url(
|
|
||||||
ArtistId::new(musicbrainz_value.artist),
|
|
||||||
musicbrainz_value.url,
|
|
||||||
)
|
|
||||||
.expect("failed to remove MusicBrainz URL"),
|
|
||||||
MusicBrainzCommand::Set(musicbrainz_value) => music_hoard
|
|
||||||
.set_musicbrainz_url(
|
|
||||||
ArtistId::new(musicbrainz_value.artist),
|
|
||||||
musicbrainz_value.url,
|
|
||||||
)
|
|
||||||
.expect("failed to set MusicBrainz URL"),
|
|
||||||
MusicBrainzCommand::Clear(artist_value) => music_hoard
|
|
||||||
.clear_musicbrainz_url(ArtistId::new(artist_value.artist))
|
|
||||||
.expect("failed to clear MusicBrainz URL"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PropertyCommand {
|
impl PropertyCommand {
|
||||||
fn handle(self, music_hoard: &mut MH) {
|
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
|
||||||
match self {
|
match self {
|
||||||
PropertyCommand::Add(property_value) => music_hoard
|
PropertyCommand::Add(property_value) => music_hoard
|
||||||
.add_to_property(
|
.add_to_artist_property(
|
||||||
ArtistId::new(property_value.artist),
|
ArtistId::new(artist_name),
|
||||||
property_value.property,
|
property_value.property,
|
||||||
property_value.values,
|
property_value.values,
|
||||||
)
|
)
|
||||||
.expect("failed to add values to property"),
|
.expect("failed to add values to property"),
|
||||||
PropertyCommand::Remove(property_value) => music_hoard
|
PropertyCommand::Remove(property_value) => music_hoard
|
||||||
.remove_from_property(
|
.remove_from_artist_property(
|
||||||
ArtistId::new(property_value.artist),
|
ArtistId::new(artist_name),
|
||||||
property_value.property,
|
property_value.property,
|
||||||
property_value.values,
|
property_value.values,
|
||||||
)
|
)
|
||||||
.expect("failed to remove values from property"),
|
.expect("failed to remove values from property"),
|
||||||
PropertyCommand::Set(property_value) => music_hoard
|
PropertyCommand::Set(property_value) => music_hoard
|
||||||
.set_property(
|
.set_artist_property(
|
||||||
ArtistId::new(property_value.artist),
|
ArtistId::new(artist_name),
|
||||||
property_value.property,
|
property_value.property,
|
||||||
property_value.values,
|
property_value.values,
|
||||||
)
|
)
|
||||||
.expect("failed to set property"),
|
.expect("failed to set property"),
|
||||||
PropertyCommand::Clear(property_name) => music_hoard
|
PropertyCommand::Clear(property_name) => music_hoard
|
||||||
.clear_property(ArtistId::new(property_name.artist), property_name.property)
|
.clear_artist_property(ArtistId::new(artist_name), property_name.property)
|
||||||
.expect("failed to clear property"),
|
.expect("failed to clear property"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AlbumOpt {
|
||||||
|
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
|
||||||
|
self.command.handle(music_hoard, artist_name, &self.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumCommand {
|
||||||
|
fn handle(self, music_hoard: &mut MH, artist_name: &str, album_name: &str) {
|
||||||
|
match self {
|
||||||
|
AlbumCommand::Seq(seq_command) => {
|
||||||
|
seq_command.handle(music_hoard, artist_name, album_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumSeqCommand {
|
||||||
|
fn handle(self, music_hoard: &mut MH, artist_name: &str, album_name: &str) {
|
||||||
|
match self {
|
||||||
|
AlbumSeqCommand::Set(seq_value) => music_hoard
|
||||||
|
.set_album_seq(
|
||||||
|
ArtistId::new(artist_name),
|
||||||
|
AlbumId::new(album_name),
|
||||||
|
seq_value.value,
|
||||||
|
)
|
||||||
|
.expect("failed to set sequence value"),
|
||||||
|
AlbumSeqCommand::Clear => music_hoard
|
||||||
|
.clear_album_seq(ArtistId::new(artist_name), AlbumId::new(album_name))
|
||||||
|
.expect("failed to clear sequence value"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let opt = Opt::from_args();
|
let opt = Opt::from_args();
|
||||||
|
|
||||||
let db = JsonDatabase::new(JsonDatabaseFileBackend::new(&opt.database_file_path));
|
let db = JsonDatabase::new(JsonDatabaseFileBackend::new(&opt.database_file_path));
|
||||||
|
|
||||||
let mut music_hoard = MusicHoardBuilder::default().set_database(db).build();
|
let mut music_hoard = MusicHoardBuilder::default()
|
||||||
music_hoard
|
.set_database(db)
|
||||||
.load_from_database()
|
.build()
|
||||||
.expect("failed to load database");
|
.expect("failed to initialise MusicHoard");
|
||||||
|
opt.command.handle(&mut music_hoard);
|
||||||
opt.category.handle(&mut music_hoard);
|
|
||||||
|
|
||||||
music_hoard
|
|
||||||
.save_to_database()
|
|
||||||
.expect("failed to save database");
|
|
||||||
}
|
}
|
||||||
|
@ -1,27 +1,164 @@
|
|||||||
use std::mem;
|
use std::{
|
||||||
|
fmt::{self, Display},
|
||||||
|
mem,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::core::collection::{
|
use crate::core::collection::{
|
||||||
merge::{Merge, MergeSorted},
|
merge::{Merge, MergeSorted, WithId},
|
||||||
track::Track,
|
musicbrainz::{MbAlbumRef, MbRefOption},
|
||||||
|
track::{Track, TrackFormat},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// An album is a collection of tracks that were released together.
|
/// An album is a collection of tracks that were released together.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct Album {
|
pub struct Album {
|
||||||
pub id: AlbumId,
|
pub meta: AlbumMeta,
|
||||||
pub tracks: Vec<Track>,
|
pub tracks: Vec<Track>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Album metadata.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AlbumMeta {
|
||||||
|
pub id: AlbumId,
|
||||||
|
pub date: AlbumDate,
|
||||||
|
pub seq: AlbumSeq,
|
||||||
|
pub info: AlbumInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Album non-identifier metadata.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AlbumInfo {
|
||||||
|
pub musicbrainz: MbRefOption<MbAlbumRef>,
|
||||||
|
pub primary_type: Option<AlbumPrimaryType>,
|
||||||
|
pub secondary_types: Vec<AlbumSecondaryType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WithId for Album {
|
||||||
|
type Id = AlbumId;
|
||||||
|
|
||||||
|
fn id(&self) -> &Self::Id {
|
||||||
|
&self.meta.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The album identifier.
|
/// The album identifier.
|
||||||
#[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq, Hash)]
|
#[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq, Hash)]
|
||||||
pub struct AlbumId {
|
pub struct AlbumId {
|
||||||
pub year: u32,
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// There are crates for handling dates, but we don't need much complexity beyond year-month-day.
|
||||||
|
/// The album's release date.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct AlbumDate {
|
||||||
|
pub year: Option<u32>,
|
||||||
|
pub month: Option<u8>,
|
||||||
|
pub day: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumDate {
|
||||||
|
pub fn new(year: Option<u32>, month: Option<u8>, day: Option<u8>) -> Self {
|
||||||
|
AlbumDate { year, month, day }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<u32> for AlbumDate {
|
||||||
|
fn from(value: u32) -> Self {
|
||||||
|
AlbumDate::new(Some(value), None, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(u32, u8)> for AlbumDate {
|
||||||
|
fn from(value: (u32, u8)) -> Self {
|
||||||
|
AlbumDate::new(Some(value.0), Some(value.1), None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(u32, u8, u8)> for AlbumDate {
|
||||||
|
fn from(value: (u32, u8, u8)) -> Self {
|
||||||
|
AlbumDate::new(Some(value.0), Some(value.1), Some(value.2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The album's sequence to determine order when two or more albums have the same release date.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd, Ord, Eq, Hash)]
|
||||||
|
pub struct AlbumSeq(pub u8);
|
||||||
|
|
||||||
|
/// Based on [MusicBrainz types](https://musicbrainz.org/doc/Release_Group/Type).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AlbumPrimaryType {
|
||||||
|
/// Album
|
||||||
|
Album,
|
||||||
|
/// Single
|
||||||
|
Single,
|
||||||
|
/// EP
|
||||||
|
Ep,
|
||||||
|
/// Broadcast
|
||||||
|
Broadcast,
|
||||||
|
/// Other
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Based on [MusicBrainz types](https://musicbrainz.org/doc/Release_Group/Type).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum AlbumSecondaryType {
|
||||||
|
/// Compilation
|
||||||
|
Compilation,
|
||||||
|
/// Soundtrack
|
||||||
|
Soundtrack,
|
||||||
|
/// Spokenword
|
||||||
|
Spokenword,
|
||||||
|
/// Interview
|
||||||
|
Interview,
|
||||||
|
/// Audiobook
|
||||||
|
Audiobook,
|
||||||
|
/// Audio drama
|
||||||
|
AudioDrama,
|
||||||
|
/// Live
|
||||||
|
Live,
|
||||||
|
/// Remix
|
||||||
|
Remix,
|
||||||
|
/// DJ-mix
|
||||||
|
DjMix,
|
||||||
|
/// Mixtape/Street
|
||||||
|
MixtapeStreet,
|
||||||
|
/// Demo
|
||||||
|
Demo,
|
||||||
|
/// Field recording
|
||||||
|
FieldRecording,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The album's ownership status.
|
||||||
|
pub enum AlbumStatus {
|
||||||
|
None,
|
||||||
|
Owned(TrackFormat),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumStatus {
|
||||||
|
pub fn from_tracks(tracks: &[Track]) -> AlbumStatus {
|
||||||
|
match tracks.iter().map(|t| t.quality.format).min() {
|
||||||
|
Some(format) => AlbumStatus::Owned(format),
|
||||||
|
None => AlbumStatus::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Album {
|
impl Album {
|
||||||
pub fn get_sort_key(&self) -> &AlbumId {
|
pub fn new<Id: Into<AlbumId>, Date: Into<AlbumDate>>(
|
||||||
&self.id
|
id: Id,
|
||||||
|
date: Date,
|
||||||
|
primary_type: Option<AlbumPrimaryType>,
|
||||||
|
secondary_types: Vec<AlbumSecondaryType>,
|
||||||
|
) -> Self {
|
||||||
|
let info = AlbumInfo::new(MbRefOption::None, primary_type, secondary_types);
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta::new(id, date, info),
|
||||||
|
tracks: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_status(&self) -> AlbumStatus {
|
||||||
|
AlbumStatus::from_tracks(&self.tracks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,29 +170,187 @@ impl PartialOrd for Album {
|
|||||||
|
|
||||||
impl Ord for Album {
|
impl Ord for Album {
|
||||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
self.id.cmp(&other.id)
|
self.meta.cmp(&other.meta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Merge for Album {
|
impl Merge for Album {
|
||||||
fn merge_in_place(&mut self, other: Self) {
|
fn merge_in_place(&mut self, other: Self) {
|
||||||
assert_eq!(self.id, other.id);
|
self.meta.merge_in_place(other.meta);
|
||||||
let tracks = mem::take(&mut self.tracks);
|
let tracks = mem::take(&mut self.tracks);
|
||||||
self.tracks = MergeSorted::new(tracks.into_iter(), other.tracks.into_iter()).collect();
|
self.tracks = MergeSorted::new(tracks.into_iter(), other.tracks.into_iter()).collect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AlbumMeta {
|
||||||
|
pub fn new<Id: Into<AlbumId>, Date: Into<AlbumDate>>(
|
||||||
|
id: Id,
|
||||||
|
date: Date,
|
||||||
|
info: AlbumInfo,
|
||||||
|
) -> Self {
|
||||||
|
AlbumMeta {
|
||||||
|
id: id.into(),
|
||||||
|
date: date.into(),
|
||||||
|
seq: AlbumSeq::default(),
|
||||||
|
info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_sort_key(&self) -> (&AlbumDate, &AlbumSeq, &AlbumId) {
|
||||||
|
(&self.date, &self.seq, &self.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_seq(&mut self, seq: AlbumSeq) {
|
||||||
|
self.seq = seq;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_seq(&mut self) {
|
||||||
|
self.seq = AlbumSeq::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AlbumInfo {
|
||||||
|
fn default() -> Self {
|
||||||
|
AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
|
primary_type: None,
|
||||||
|
secondary_types: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumInfo {
|
||||||
|
pub fn new(
|
||||||
|
musicbrainz: MbRefOption<MbAlbumRef>,
|
||||||
|
primary_type: Option<AlbumPrimaryType>,
|
||||||
|
secondary_types: Vec<AlbumSecondaryType>,
|
||||||
|
) -> Self {
|
||||||
|
AlbumInfo {
|
||||||
|
musicbrainz,
|
||||||
|
primary_type,
|
||||||
|
secondary_types,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialOrd for AlbumMeta {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for AlbumMeta {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
self.get_sort_key().cmp(&other.get_sort_key())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Merge for AlbumMeta {
|
||||||
|
fn merge_in_place(&mut self, other: Self) {
|
||||||
|
assert_eq!(self.id, other.id);
|
||||||
|
self.seq = std::cmp::max(self.seq, other.seq);
|
||||||
|
|
||||||
|
self.info.merge_in_place(other.info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Merge for AlbumInfo {
|
||||||
|
fn merge_in_place(&mut self, other: Self) {
|
||||||
|
self.musicbrainz = self.musicbrainz.take().or(other.musicbrainz);
|
||||||
|
self.primary_type = self.primary_type.take().or(other.primary_type);
|
||||||
|
self.secondary_types.merge_in_place(other.secondary_types);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Into<String>> From<S> for AlbumId {
|
||||||
|
fn from(value: S) -> Self {
|
||||||
|
AlbumId::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<AlbumId> for AlbumId {
|
||||||
|
fn as_ref(&self) -> &AlbumId {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumId {
|
||||||
|
pub fn new<S: Into<String>>(name: S) -> AlbumId {
|
||||||
|
AlbumId { title: name.into() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for AlbumId {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::core::testmod::FULL_COLLECTION;
|
use crate::core::testmod::FULL_COLLECTION;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_date_from() {
|
||||||
|
let date: AlbumDate = 1986.into();
|
||||||
|
assert_eq!(date, AlbumDate::new(Some(1986), None, None));
|
||||||
|
|
||||||
|
let date: AlbumDate = (1986, 5).into();
|
||||||
|
assert_eq!(date, AlbumDate::new(Some(1986), Some(5), None));
|
||||||
|
|
||||||
|
let date: AlbumDate = (1986, 6, 8).into();
|
||||||
|
assert_eq!(date, AlbumDate::new(Some(1986), Some(6), Some(8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_date_seq_cmp() {
|
||||||
|
let date: AlbumDate = (2024, 3, 2).into();
|
||||||
|
|
||||||
|
let album_id_1 = AlbumId {
|
||||||
|
title: String::from("album z"),
|
||||||
|
};
|
||||||
|
let mut album_1 = Album::new(album_id_1, date.clone(), None, vec![]);
|
||||||
|
album_1.meta.set_seq(AlbumSeq(1));
|
||||||
|
|
||||||
|
let album_id_2 = AlbumId {
|
||||||
|
title: String::from("album a"),
|
||||||
|
};
|
||||||
|
let mut album_2 = Album::new(album_id_2, date.clone(), None, vec![]);
|
||||||
|
album_2.meta.set_seq(AlbumSeq(2));
|
||||||
|
|
||||||
|
assert_ne!(album_1, album_2);
|
||||||
|
assert!(album_1 < album_2);
|
||||||
|
assert!(album_1.meta < album_2.meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_clear_seq() {
|
||||||
|
let mut album = Album::new("An album", AlbumDate::default(), None, vec![]);
|
||||||
|
|
||||||
|
assert_eq!(album.meta.seq, AlbumSeq(0));
|
||||||
|
|
||||||
|
// Setting a seq on an album.
|
||||||
|
album.meta.set_seq(AlbumSeq(6));
|
||||||
|
assert_eq!(album.meta.seq, AlbumSeq(6));
|
||||||
|
|
||||||
|
album.meta.set_seq(AlbumSeq(6));
|
||||||
|
assert_eq!(album.meta.seq, AlbumSeq(6));
|
||||||
|
|
||||||
|
album.meta.set_seq(AlbumSeq(8));
|
||||||
|
assert_eq!(album.meta.seq, AlbumSeq(8));
|
||||||
|
|
||||||
|
// Clearing seq.
|
||||||
|
album.meta.clear_seq();
|
||||||
|
assert_eq!(album.meta.seq, AlbumSeq(0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_album_no_overlap() {
|
fn merge_album_no_overlap() {
|
||||||
let left = FULL_COLLECTION[0].albums[0].to_owned();
|
let left = FULL_COLLECTION[0].albums[0].to_owned();
|
||||||
let mut right = FULL_COLLECTION[0].albums[1].to_owned();
|
let mut right = FULL_COLLECTION[0].albums[1].to_owned();
|
||||||
right.id = left.id.clone();
|
right.meta.id = left.meta.id.clone();
|
||||||
|
|
||||||
let mut expected = left.clone();
|
let mut expected = left.clone();
|
||||||
expected.tracks.append(&mut right.tracks.clone());
|
expected.tracks.append(&mut right.tracks.clone());
|
||||||
@ -64,16 +359,16 @@ mod tests {
|
|||||||
let merged = left.clone().merge(right.clone());
|
let merged = left.clone().merge(right.clone());
|
||||||
assert_eq!(expected, merged);
|
assert_eq!(expected, merged);
|
||||||
|
|
||||||
// Non-overlapping merge should be commutative.
|
// Non-overlapping merge should be commutative in the tracks.
|
||||||
let merged = right.clone().merge(left.clone());
|
let merged = right.clone().merge(left.clone());
|
||||||
assert_eq!(expected, merged);
|
assert_eq!(expected.tracks, merged.tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_album_overlap() {
|
fn merge_album_overlap() {
|
||||||
let mut left = FULL_COLLECTION[0].albums[0].to_owned();
|
let mut left = FULL_COLLECTION[0].albums[0].to_owned();
|
||||||
let mut right = FULL_COLLECTION[0].albums[1].to_owned();
|
let mut right = FULL_COLLECTION[0].albums[1].to_owned();
|
||||||
right.id = left.id.clone();
|
right.meta.id = left.meta.id.clone();
|
||||||
left.tracks.push(right.tracks[0].clone());
|
left.tracks.push(right.tracks[0].clone());
|
||||||
left.tracks.sort_unstable();
|
left.tracks.sort_unstable();
|
||||||
|
|
||||||
|
@ -4,25 +4,42 @@ use std::{
|
|||||||
mem,
|
mem,
|
||||||
};
|
};
|
||||||
|
|
||||||
use url::Url;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::core::collection::{
|
use crate::core::collection::{
|
||||||
album::Album,
|
album::Album,
|
||||||
merge::{Merge, MergeSorted},
|
merge::{Merge, MergeCollections, WithId},
|
||||||
Error,
|
musicbrainz::{MbArtistRef, MbRefOption},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// An artist.
|
/// An artist.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct Artist {
|
pub struct Artist {
|
||||||
pub id: ArtistId,
|
pub meta: ArtistMeta,
|
||||||
pub sort: Option<ArtistId>,
|
|
||||||
pub musicbrainz: Option<MusicBrainz>,
|
|
||||||
pub properties: HashMap<String, Vec<String>>,
|
|
||||||
pub albums: Vec<Album>,
|
pub albums: Vec<Album>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Artist metadata.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ArtistMeta {
|
||||||
|
pub id: ArtistId,
|
||||||
|
pub sort: Option<String>,
|
||||||
|
pub info: ArtistInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artist non-identifier metadata.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ArtistInfo {
|
||||||
|
pub musicbrainz: MbRefOption<MbArtistRef>,
|
||||||
|
pub properties: HashMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WithId for Artist {
|
||||||
|
type Id = ArtistId;
|
||||||
|
|
||||||
|
fn id(&self) -> &Self::Id {
|
||||||
|
&self.meta.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The artist identifier.
|
/// The artist identifier.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct ArtistId {
|
pub struct ArtistId {
|
||||||
@ -31,64 +48,76 @@ pub struct ArtistId {
|
|||||||
|
|
||||||
impl Artist {
|
impl Artist {
|
||||||
/// Create new [`Artist`] with the given [`ArtistId`].
|
/// Create new [`Artist`] with the given [`ArtistId`].
|
||||||
pub fn new<ID: Into<ArtistId>>(id: ID) -> Self {
|
pub fn new<Id: Into<ArtistId>>(id: Id) -> Self {
|
||||||
Artist {
|
Artist {
|
||||||
id: id.into(),
|
meta: ArtistMeta::new(id),
|
||||||
sort: None,
|
|
||||||
musicbrainz: None,
|
|
||||||
properties: HashMap::new(),
|
|
||||||
albums: vec![],
|
albums: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_sort_key(&self) -> &ArtistId {
|
|
||||||
self.sort.as_ref().unwrap_or(&self.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_sort_key<SORT: Into<ArtistId>>(&mut self, sort: SORT) {
|
impl PartialOrd for Artist {
|
||||||
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
|
Some(self.cmp(other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ord for Artist {
|
||||||
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
|
self.meta.cmp(&other.meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Merge for Artist {
|
||||||
|
fn merge_in_place(&mut self, other: Self) {
|
||||||
|
self.meta.merge_in_place(other.meta);
|
||||||
|
let albums = mem::take(&mut self.albums);
|
||||||
|
self.albums = MergeCollections::merge_iter(albums, other.albums);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArtistMeta {
|
||||||
|
pub fn new<Id: Into<ArtistId>>(id: Id) -> Self {
|
||||||
|
ArtistMeta {
|
||||||
|
id: id.into(),
|
||||||
|
sort: None,
|
||||||
|
info: ArtistInfo::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_sort_key(&self) -> (&str,) {
|
||||||
|
(self.sort.as_ref().unwrap_or(&self.id.name),)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_sort_key<S: Into<String>>(&mut self, sort: S) {
|
||||||
self.sort = Some(sort.into());
|
self.sort = Some(sort.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_sort_key(&mut self) {
|
pub fn clear_sort_key(&mut self) {
|
||||||
_ = self.sort.take();
|
self.sort.take();
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_musicbrainz_url<S: AsRef<str>>(&mut self, url: S) -> Result<(), Error> {
|
|
||||||
let url: MusicBrainz = url.as_ref().try_into()?;
|
|
||||||
|
|
||||||
match &self.musicbrainz {
|
|
||||||
Some(current) => {
|
|
||||||
if current != &url {
|
|
||||||
return Err(Error::UrlError(format!(
|
|
||||||
"artist already has a different URL: {current}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
_ = self.musicbrainz.insert(url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
impl Default for ArtistInfo {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(MbRefOption::None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_musicbrainz_url<S: AsRef<str>>(&mut self, url: S) -> Result<(), Error> {
|
impl ArtistInfo {
|
||||||
let url = url.as_ref().try_into()?;
|
pub fn new(musicbrainz: MbRefOption<MbArtistRef>) -> Self {
|
||||||
|
ArtistInfo {
|
||||||
if self.musicbrainz == Some(url) {
|
musicbrainz,
|
||||||
_ = self.musicbrainz.take();
|
properties: HashMap::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
pub fn set_musicbrainz_ref(&mut self, mbref: MbRefOption<MbArtistRef>) {
|
||||||
|
self.musicbrainz = mbref
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_musicbrainz_url<S: AsRef<str>>(&mut self, url: S) -> Result<(), Error> {
|
pub fn clear_musicbrainz_ref(&mut self) {
|
||||||
_ = self.musicbrainz.insert(url.as_ref().try_into()?);
|
self.musicbrainz.take();
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_musicbrainz_url(&mut self) {
|
|
||||||
_ = self.musicbrainz.take();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// In the functions below, it would be better to use `contains` instead of `iter().any`, but for
|
// In the functions below, it would be better to use `contains` instead of `iter().any`, but for
|
||||||
@ -136,26 +165,37 @@ impl Artist {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialOrd for Artist {
|
impl PartialOrd for ArtistMeta {
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||||
Some(self.cmp(other))
|
Some(self.cmp(other))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ord for Artist {
|
impl Ord for ArtistMeta {
|
||||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
self.get_sort_key().cmp(other.get_sort_key())
|
self.get_sort_key().cmp(&other.get_sort_key())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Merge for Artist {
|
impl Merge for ArtistMeta {
|
||||||
fn merge_in_place(&mut self, other: Self) {
|
fn merge_in_place(&mut self, other: Self) {
|
||||||
assert_eq!(self.id, other.id);
|
assert_eq!(self.id, other.id);
|
||||||
|
|
||||||
self.sort = self.sort.take().or(other.sort);
|
self.sort = self.sort.take().or(other.sort);
|
||||||
|
self.info.merge_in_place(other.info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Merge for ArtistInfo {
|
||||||
|
fn merge_in_place(&mut self, other: Self) {
|
||||||
self.musicbrainz = self.musicbrainz.take().or(other.musicbrainz);
|
self.musicbrainz = self.musicbrainz.take().or(other.musicbrainz);
|
||||||
self.properties.merge_in_place(other.properties);
|
self.properties.merge_in_place(other.properties);
|
||||||
let albums = mem::take(&mut self.albums);
|
}
|
||||||
self.albums = MergeSorted::new(albums.into_iter(), other.albums.into_iter()).collect();
|
}
|
||||||
|
|
||||||
|
impl<S: Into<String>> From<S> for ArtistId {
|
||||||
|
fn from(value: S) -> Self {
|
||||||
|
ArtistId::new(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,69 +217,6 @@ impl Display for ArtistId {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An object with the [`IMbid`] trait contains a [MusicBrainz
|
|
||||||
/// Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID).
|
|
||||||
pub trait IMbid {
|
|
||||||
fn mbid(&self) -> &str;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// MusicBrainz reference.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct MusicBrainz(Url);
|
|
||||||
|
|
||||||
impl MusicBrainz {
|
|
||||||
/// Validate and wrap a MusicBrainz URL.
|
|
||||||
pub fn new<S: AsRef<str>>(url: S) -> Result<Self, Error> {
|
|
||||||
let url = Url::parse(url.as_ref())?;
|
|
||||||
|
|
||||||
if !url
|
|
||||||
.domain()
|
|
||||||
.map(|u| u.ends_with("musicbrainz.org"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
return Err(Self::invalid_url_error(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
match url.path_segments().and_then(|mut ps| ps.nth(1)) {
|
|
||||||
Some(segment) => Uuid::try_parse(segment)?,
|
|
||||||
None => return Err(Self::invalid_url_error(url)),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(MusicBrainz(url))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn invalid_url_error<U: Display>(url: U) -> Error {
|
|
||||||
Error::UrlError(format!("invalid MusicBrainz URL: {url}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<str> for MusicBrainz {
|
|
||||||
fn as_ref(&self) -> &str {
|
|
||||||
self.0.as_ref()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<&str> for MusicBrainz {
|
|
||||||
type Error = Error;
|
|
||||||
|
|
||||||
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
|
||||||
MusicBrainz::new(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for MusicBrainz {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
||||||
write!(f, "{}", self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IMbid for MusicBrainz {
|
|
||||||
fn mbid(&self) -> &str {
|
|
||||||
// The URL is assumed to have been validated.
|
|
||||||
self.0.path_segments().and_then(|mut ps| ps.nth(1)).unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::core::testmod::FULL_COLLECTION;
|
use crate::core::testmod::FULL_COLLECTION;
|
||||||
@ -253,254 +230,200 @@ mod tests {
|
|||||||
static MUSICBUTLER: &str = "https://www.musicbutler.io/artist-page/483340948";
|
static MUSICBUTLER: &str = "https://www.musicbutler.io/artist-page/483340948";
|
||||||
static MUSICBUTLER_2: &str = "https://www.musicbutler.io/artist-page/658903042/";
|
static MUSICBUTLER_2: &str = "https://www.musicbutler.io/artist-page/658903042/";
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn musicbrainz() {
|
|
||||||
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
|
|
||||||
let url = format!("https://musicbrainz.org/artist/{uuid}");
|
|
||||||
let mb = MusicBrainz::new(&url).unwrap();
|
|
||||||
assert_eq!(url, mb.as_ref());
|
|
||||||
assert_eq!(uuid, mb.mbid());
|
|
||||||
|
|
||||||
let url = "not a url at all".to_string();
|
|
||||||
let expected_error: Error = url::ParseError::RelativeUrlWithoutBase.into();
|
|
||||||
let actual_error = MusicBrainz::new(url).unwrap_err();
|
|
||||||
assert_eq!(actual_error, expected_error);
|
|
||||||
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
|
||||||
|
|
||||||
let url = "https://musicbrainz.org/artist/i-am-not-a-uuid".to_string();
|
|
||||||
let expected_error: Error = Uuid::try_parse("i-am-not-a-uuid").unwrap_err().into();
|
|
||||||
let actual_error = MusicBrainz::new(url).unwrap_err();
|
|
||||||
assert_eq!(actual_error, expected_error);
|
|
||||||
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
|
||||||
|
|
||||||
let url = "https://musicbrainz.org/artist".to_string();
|
|
||||||
let expected_error = Error::UrlError(format!("invalid MusicBrainz URL: {url}"));
|
|
||||||
let actual_error = MusicBrainz::new(&url).unwrap_err();
|
|
||||||
assert_eq!(actual_error, expected_error);
|
|
||||||
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn urls() {
|
|
||||||
assert!(MusicBrainz::new(MUSICBRAINZ).is_ok());
|
|
||||||
assert!(MusicBrainz::new(MUSICBUTLER).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn artist_sort_set_clear() {
|
fn artist_sort_set_clear() {
|
||||||
let artist_id = ArtistId::new("an artist");
|
let artist_id = ArtistId::new("an artist");
|
||||||
let sort_id_1 = ArtistId::new("sort id 1");
|
let sort_id_1 = String::from("sort id 1");
|
||||||
let sort_id_2 = ArtistId::new("sort id 2");
|
let sort_id_2 = String::from("sort id 2");
|
||||||
|
|
||||||
let mut artist = Artist::new(artist_id.clone());
|
let mut artist = Artist::new(&artist_id.name);
|
||||||
|
|
||||||
assert_eq!(artist.id, artist_id);
|
assert_eq!(artist.meta.id, artist_id);
|
||||||
assert_eq!(artist.sort, None);
|
assert_eq!(artist.meta.sort, None);
|
||||||
assert_eq!(artist.get_sort_key(), &artist_id);
|
assert_eq!(artist.meta.get_sort_key(), (artist_id.name.as_str(),));
|
||||||
|
assert!(artist.meta < ArtistMeta::new(sort_id_1.clone()));
|
||||||
|
assert!(artist.meta < ArtistMeta::new(sort_id_2.clone()));
|
||||||
assert!(artist < Artist::new(sort_id_1.clone()));
|
assert!(artist < Artist::new(sort_id_1.clone()));
|
||||||
assert!(artist < Artist::new(sort_id_2.clone()));
|
assert!(artist < Artist::new(sort_id_2.clone()));
|
||||||
|
|
||||||
artist.set_sort_key(sort_id_1.clone());
|
artist.meta.set_sort_key(sort_id_1.clone());
|
||||||
|
|
||||||
assert_eq!(artist.id, artist_id);
|
assert_eq!(artist.meta.id, artist_id);
|
||||||
assert_eq!(artist.sort.as_ref(), Some(&sort_id_1));
|
assert_eq!(artist.meta.sort.as_ref(), Some(&sort_id_1));
|
||||||
assert_eq!(artist.get_sort_key(), &sort_id_1);
|
assert_eq!(artist.meta.get_sort_key(), (sort_id_1.as_str(),));
|
||||||
|
assert!(artist.meta > ArtistMeta::new(artist_id.clone()));
|
||||||
|
assert!(artist.meta < ArtistMeta::new(sort_id_2.clone()));
|
||||||
assert!(artist > Artist::new(artist_id.clone()));
|
assert!(artist > Artist::new(artist_id.clone()));
|
||||||
assert!(artist < Artist::new(sort_id_2.clone()));
|
assert!(artist < Artist::new(sort_id_2.clone()));
|
||||||
|
|
||||||
artist.set_sort_key(sort_id_2.clone());
|
artist.meta.set_sort_key(sort_id_2.clone());
|
||||||
|
|
||||||
assert_eq!(artist.id, artist_id);
|
assert_eq!(artist.meta.id, artist_id);
|
||||||
assert_eq!(artist.sort.as_ref(), Some(&sort_id_2));
|
assert_eq!(artist.meta.sort.as_ref(), Some(&sort_id_2));
|
||||||
assert_eq!(artist.get_sort_key(), &sort_id_2);
|
assert_eq!(artist.meta.get_sort_key(), (sort_id_2.as_str(),));
|
||||||
|
assert!(artist.meta > ArtistMeta::new(artist_id.clone()));
|
||||||
|
assert!(artist.meta > ArtistMeta::new(sort_id_1.clone()));
|
||||||
assert!(artist > Artist::new(artist_id.clone()));
|
assert!(artist > Artist::new(artist_id.clone()));
|
||||||
assert!(artist > Artist::new(sort_id_1.clone()));
|
assert!(artist > Artist::new(sort_id_1.clone()));
|
||||||
|
|
||||||
artist.clear_sort_key();
|
artist.meta.clear_sort_key();
|
||||||
|
|
||||||
assert_eq!(artist.id, artist_id);
|
assert_eq!(artist.meta.id, artist_id);
|
||||||
assert_eq!(artist.sort, None);
|
assert_eq!(artist.meta.sort, None);
|
||||||
assert_eq!(artist.get_sort_key(), &artist_id);
|
assert_eq!(artist.meta.get_sort_key(), (artist_id.name.as_str(),));
|
||||||
|
assert!(artist.meta < ArtistMeta::new(sort_id_1.clone()));
|
||||||
|
assert!(artist.meta < ArtistMeta::new(sort_id_2.clone()));
|
||||||
assert!(artist < Artist::new(sort_id_1.clone()));
|
assert!(artist < Artist::new(sort_id_1.clone()));
|
||||||
assert!(artist < Artist::new(sort_id_2.clone()));
|
assert!(artist < Artist::new(sort_id_2.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn add_remove_musicbrainz_url() {
|
|
||||||
let mut artist = Artist::new(ArtistId::new("an artist"));
|
|
||||||
|
|
||||||
let mut expected: Option<MusicBrainz> = None;
|
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
|
||||||
|
|
||||||
// Adding incorect URL is an error.
|
|
||||||
assert!(artist.add_musicbrainz_url(MUSICBUTLER).is_err());
|
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
|
||||||
|
|
||||||
// Adding URL to artist.
|
|
||||||
assert!(artist.add_musicbrainz_url(MUSICBRAINZ).is_ok());
|
|
||||||
_ = expected.insert(MusicBrainz::new(MUSICBRAINZ).unwrap());
|
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
|
||||||
|
|
||||||
// Adding the same URL again is ok, but does not do anything.
|
|
||||||
assert!(artist.add_musicbrainz_url(MUSICBRAINZ).is_ok());
|
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
|
||||||
|
|
||||||
// Adding further URLs is an error.
|
|
||||||
assert!(artist.add_musicbrainz_url(MUSICBRAINZ_2).is_err());
|
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
|
||||||
|
|
||||||
// Removing a URL not in the collection is okay, but does not do anything.
|
|
||||||
assert!(artist.remove_musicbrainz_url(MUSICBRAINZ_2).is_ok());
|
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
|
||||||
|
|
||||||
// Removing a URL in the collection removes it.
|
|
||||||
assert!(artist.remove_musicbrainz_url(MUSICBRAINZ).is_ok());
|
|
||||||
_ = expected.take();
|
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
|
||||||
|
|
||||||
assert!(artist.remove_musicbrainz_url(MUSICBRAINZ).is_ok());
|
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_clear_musicbrainz_url() {
|
fn set_clear_musicbrainz_url() {
|
||||||
let mut artist = Artist::new(ArtistId::new("an artist"));
|
let mut artist = Artist::new(ArtistId::new("an artist"));
|
||||||
|
|
||||||
let mut expected: Option<MusicBrainz> = None;
|
let mut expected: MbRefOption<MbArtistRef> = MbRefOption::None;
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
assert_eq!(artist.meta.info.musicbrainz, expected);
|
||||||
|
|
||||||
// Setting an incorrect URL is an error.
|
|
||||||
assert!(artist.set_musicbrainz_url(MUSICBUTLER).is_err());
|
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
|
||||||
|
|
||||||
// Setting a URL on an artist.
|
// Setting a URL on an artist.
|
||||||
assert!(artist.set_musicbrainz_url(MUSICBRAINZ).is_ok());
|
artist.meta.info.set_musicbrainz_ref(MbRefOption::Some(
|
||||||
_ = expected.insert(MusicBrainz::new(MUSICBRAINZ).unwrap());
|
MbArtistRef::from_url_str(MUSICBRAINZ).unwrap(),
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
));
|
||||||
|
expected.replace(MbArtistRef::from_url_str(MUSICBRAINZ).unwrap());
|
||||||
|
assert_eq!(artist.meta.info.musicbrainz, expected);
|
||||||
|
|
||||||
assert!(artist.set_musicbrainz_url(MUSICBRAINZ).is_ok());
|
artist.meta.info.set_musicbrainz_ref(MbRefOption::Some(
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
MbArtistRef::from_url_str(MUSICBRAINZ).unwrap(),
|
||||||
|
));
|
||||||
|
assert_eq!(artist.meta.info.musicbrainz, expected);
|
||||||
|
|
||||||
assert!(artist.set_musicbrainz_url(MUSICBRAINZ_2).is_ok());
|
artist.meta.info.set_musicbrainz_ref(MbRefOption::Some(
|
||||||
_ = expected.insert(MusicBrainz::new(MUSICBRAINZ_2).unwrap());
|
MbArtistRef::from_url_str(MUSICBRAINZ_2).unwrap(),
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
));
|
||||||
|
expected.replace(MbArtistRef::from_url_str(MUSICBRAINZ_2).unwrap());
|
||||||
|
assert_eq!(artist.meta.info.musicbrainz, expected);
|
||||||
|
|
||||||
// Clearing URLs.
|
// Clearing URLs.
|
||||||
artist.clear_musicbrainz_url();
|
artist.meta.info.clear_musicbrainz_ref();
|
||||||
_ = expected.take();
|
expected.take();
|
||||||
assert_eq!(artist.musicbrainz, expected);
|
assert_eq!(artist.meta.info.musicbrainz, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_to_remove_from_property() {
|
fn add_to_remove_from_property() {
|
||||||
let mut artist = Artist::new(ArtistId::new("an artist"));
|
let mut artist = Artist::new(ArtistId::new("an artist"));
|
||||||
|
|
||||||
|
let info = &mut artist.meta.info;
|
||||||
let mut expected: Vec<String> = vec![];
|
let mut expected: Vec<String> = vec![];
|
||||||
assert!(artist.properties.is_empty());
|
assert!(info.properties.is_empty());
|
||||||
|
|
||||||
// Adding a single URL.
|
// Adding a single URL.
|
||||||
artist.add_to_property("MusicButler", vec![MUSICBUTLER]);
|
info.add_to_property("MusicButler", vec![MUSICBUTLER]);
|
||||||
expected.push(MUSICBUTLER.to_owned());
|
expected.push(MUSICBUTLER.to_owned());
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
// Adding a URL that already exists is ok, but does not do anything.
|
// Adding a URL that already exists is ok, but does not do anything.
|
||||||
artist.add_to_property("MusicButler", vec![MUSICBUTLER]);
|
info.add_to_property("MusicButler", vec![MUSICBUTLER]);
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
// Adding another single URL.
|
// Adding another single URL.
|
||||||
artist.add_to_property("MusicButler", vec![MUSICBUTLER_2]);
|
info.add_to_property("MusicButler", vec![MUSICBUTLER_2]);
|
||||||
expected.push(MUSICBUTLER_2.to_owned());
|
expected.push(MUSICBUTLER_2.to_owned());
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
artist.add_to_property("MusicButler", vec![MUSICBUTLER_2]);
|
info.add_to_property("MusicButler", vec![MUSICBUTLER_2]);
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
// Removing a URL.
|
// Removing a URL.
|
||||||
artist.remove_from_property("MusicButler", vec![MUSICBUTLER]);
|
info.remove_from_property("MusicButler", vec![MUSICBUTLER]);
|
||||||
expected.retain(|url| url != MUSICBUTLER);
|
expected.retain(|url| url != MUSICBUTLER);
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
// Removing URls that do not exist is okay, they will be ignored.
|
// Removing URls that do not exist is okay, they will be ignored.
|
||||||
artist.remove_from_property("MusicButler", vec![MUSICBUTLER]);
|
info.remove_from_property("MusicButler", vec![MUSICBUTLER]);
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
// Removing a URL.
|
// Removing a URL.
|
||||||
artist.remove_from_property("MusicButler", vec![MUSICBUTLER_2]);
|
info.remove_from_property("MusicButler", vec![MUSICBUTLER_2]);
|
||||||
expected.retain(|url| url.as_str() != MUSICBUTLER_2);
|
expected.retain(|url| url.as_str() != MUSICBUTLER_2);
|
||||||
assert!(artist.properties.is_empty());
|
assert!(info.properties.is_empty());
|
||||||
|
|
||||||
artist.remove_from_property("MusicButler", vec![MUSICBUTLER_2]);
|
info.remove_from_property("MusicButler", vec![MUSICBUTLER_2]);
|
||||||
assert!(artist.properties.is_empty());
|
assert!(info.properties.is_empty());
|
||||||
|
|
||||||
// Adding URLs if some exist is okay, they will be ignored.
|
// Adding URLs if some exist is okay, they will be ignored.
|
||||||
artist.add_to_property("MusicButler", vec![MUSICBUTLER]);
|
info.add_to_property("MusicButler", vec![MUSICBUTLER]);
|
||||||
expected.push(MUSICBUTLER.to_owned());
|
expected.push(MUSICBUTLER.to_owned());
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
artist.add_to_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
info.add_to_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
||||||
expected.push(MUSICBUTLER_2.to_owned());
|
expected.push(MUSICBUTLER_2.to_owned());
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
// Removing URLs if some do not exist is okay, they will be ignored.
|
// Removing URLs if some do not exist is okay, they will be ignored.
|
||||||
artist.remove_from_property("MusicButler", vec![MUSICBUTLER]);
|
info.remove_from_property("MusicButler", vec![MUSICBUTLER]);
|
||||||
expected.retain(|url| url.as_str() != MUSICBUTLER);
|
expected.retain(|url| url.as_str() != MUSICBUTLER);
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
artist.remove_from_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
info.remove_from_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
||||||
expected.retain(|url| url.as_str() != MUSICBUTLER_2);
|
expected.retain(|url| url.as_str() != MUSICBUTLER_2);
|
||||||
assert!(artist.properties.is_empty());
|
assert!(info.properties.is_empty());
|
||||||
|
|
||||||
// Adding mutliple URLs without clashes.
|
// Adding mutliple URLs without clashes.
|
||||||
artist.add_to_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
info.add_to_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
||||||
expected.push(MUSICBUTLER.to_owned());
|
expected.push(MUSICBUTLER.to_owned());
|
||||||
expected.push(MUSICBUTLER_2.to_owned());
|
expected.push(MUSICBUTLER_2.to_owned());
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
// Removing multiple URLs without clashes.
|
// Removing multiple URLs without clashes.
|
||||||
artist.remove_from_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
info.remove_from_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
||||||
expected.clear();
|
expected.clear();
|
||||||
assert!(artist.properties.is_empty());
|
assert!(info.properties.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_clear_musicbutler_urls() {
|
fn set_clear_musicbutler_urls() {
|
||||||
let mut artist = Artist::new(ArtistId::new("an artist"));
|
let mut artist = Artist::new(ArtistId::new("an artist"));
|
||||||
|
|
||||||
|
let info = &mut artist.meta.info;
|
||||||
let mut expected: Vec<String> = vec![];
|
let mut expected: Vec<String> = vec![];
|
||||||
assert!(artist.properties.is_empty());
|
assert!(info.properties.is_empty());
|
||||||
|
|
||||||
// Set URLs.
|
// Set URLs.
|
||||||
artist.set_property("MusicButler", vec![MUSICBUTLER]);
|
info.set_property("MusicButler", vec![MUSICBUTLER]);
|
||||||
expected.push(MUSICBUTLER.to_owned());
|
expected.push(MUSICBUTLER.to_owned());
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
artist.set_property("MusicButler", vec![MUSICBUTLER_2]);
|
info.set_property("MusicButler", vec![MUSICBUTLER_2]);
|
||||||
expected.clear();
|
expected.clear();
|
||||||
expected.push(MUSICBUTLER_2.to_owned());
|
expected.push(MUSICBUTLER_2.to_owned());
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
artist.set_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
info.set_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
||||||
expected.clear();
|
expected.clear();
|
||||||
expected.push(MUSICBUTLER.to_owned());
|
expected.push(MUSICBUTLER.to_owned());
|
||||||
expected.push(MUSICBUTLER_2.to_owned());
|
expected.push(MUSICBUTLER_2.to_owned());
|
||||||
assert_eq!(artist.properties.get("MusicButler"), Some(&expected));
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
// Clear URLs.
|
// Clear URLs.
|
||||||
artist.clear_property("MusicButler");
|
info.clear_property("MusicButler");
|
||||||
expected.clear();
|
expected.clear();
|
||||||
assert!(artist.properties.is_empty());
|
assert!(info.properties.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_artist_no_overlap() {
|
fn merge_artist_no_overlap() {
|
||||||
let left = FULL_COLLECTION[0].to_owned();
|
let left = FULL_COLLECTION[0].to_owned();
|
||||||
let mut right = FULL_COLLECTION[1].to_owned();
|
let mut right = FULL_COLLECTION[1].to_owned();
|
||||||
right.id = left.id.clone();
|
right.meta.id = left.meta.id.clone();
|
||||||
right.musicbrainz = None;
|
right.meta.info.musicbrainz = MbRefOption::None;
|
||||||
right.properties = HashMap::new();
|
right.meta.info.properties = HashMap::new();
|
||||||
|
|
||||||
let mut expected = left.clone();
|
let mut expected = left.clone();
|
||||||
expected.properties = expected.properties.merge(right.clone().properties);
|
expected.meta.info.properties = expected
|
||||||
|
.meta
|
||||||
|
.info
|
||||||
|
.properties
|
||||||
|
.merge(right.clone().meta.info.properties);
|
||||||
expected.albums.append(&mut right.albums.clone());
|
expected.albums.append(&mut right.albums.clone());
|
||||||
expected.albums.sort_unstable();
|
expected.albums.sort_unstable();
|
||||||
|
|
||||||
@ -516,12 +439,16 @@ mod tests {
|
|||||||
fn merge_artist_overlap() {
|
fn merge_artist_overlap() {
|
||||||
let mut left = FULL_COLLECTION[0].to_owned();
|
let mut left = FULL_COLLECTION[0].to_owned();
|
||||||
let mut right = FULL_COLLECTION[1].to_owned();
|
let mut right = FULL_COLLECTION[1].to_owned();
|
||||||
right.id = left.id.clone();
|
right.meta.id = left.meta.id.clone();
|
||||||
left.albums.push(right.albums[0].clone());
|
left.albums.push(right.albums[0].clone());
|
||||||
left.albums.sort_unstable();
|
left.albums.sort_unstable();
|
||||||
|
|
||||||
let mut expected = left.clone();
|
let mut expected = left.clone();
|
||||||
expected.properties = expected.properties.merge(right.clone().properties);
|
expected.meta.info.properties = expected
|
||||||
|
.meta
|
||||||
|
.info
|
||||||
|
.properties
|
||||||
|
.merge(right.clone().meta.info.properties);
|
||||||
expected.albums.append(&mut right.albums.clone());
|
expected.albums.append(&mut right.albums.clone());
|
||||||
expected.albums.sort_unstable();
|
expected.albums.sort_unstable();
|
||||||
expected.albums.dedup();
|
expected.albums.dedup();
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use std::{cmp::Ordering, collections::HashMap, hash::Hash, iter::Peekable};
|
use std::{cmp::Ordering, collections::HashMap, hash::Hash, iter::Peekable, marker::PhantomData};
|
||||||
|
|
||||||
/// A trait for merging two objects. The merge is asymmetric with the left argument considered to be
|
/// A trait for merging two objects. The merge is asymmetric with the left argument considered to be
|
||||||
/// the primary whose properties are to be kept in case of collisions.
|
/// the primary whose properties are to be kept in case of collisions.
|
||||||
@ -79,3 +79,45 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait WithId {
|
||||||
|
type Id;
|
||||||
|
|
||||||
|
fn id(&self) -> &Self::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MergeCollections<ID, T, IT> {
|
||||||
|
_id: PhantomData<ID>,
|
||||||
|
_t: PhantomData<T>,
|
||||||
|
_it: PhantomData<IT>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<ID, T, IT> MergeCollections<ID, T, IT>
|
||||||
|
where
|
||||||
|
ID: Eq + Hash + Clone,
|
||||||
|
T: WithId<Id = ID> + Merge + Ord,
|
||||||
|
IT: IntoIterator<Item = T>,
|
||||||
|
{
|
||||||
|
pub fn merge_iter(primary: IT, secondary: IT) -> Vec<T> {
|
||||||
|
let primary = primary
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| (item.id().clone(), item))
|
||||||
|
.collect();
|
||||||
|
Self::merge(primary, secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge(mut primary: HashMap<ID, T>, secondary: IT) -> Vec<T> {
|
||||||
|
for secondary_item in secondary {
|
||||||
|
if let Some(ref mut primary_item) = primary.get_mut(secondary_item.id()) {
|
||||||
|
primary_item.merge_in_place(secondary_item);
|
||||||
|
} else {
|
||||||
|
primary.insert(secondary_item.id().clone(), secondary_item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut collection: Vec<T> = primary.into_values().collect();
|
||||||
|
collection.sort_unstable();
|
||||||
|
|
||||||
|
collection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
pub mod album;
|
pub mod album;
|
||||||
pub mod artist;
|
pub mod artist;
|
||||||
|
pub mod merge;
|
||||||
|
pub mod musicbrainz;
|
||||||
pub mod track;
|
pub mod track;
|
||||||
|
|
||||||
mod merge;
|
|
||||||
pub use merge::Merge;
|
|
||||||
|
|
||||||
use std::fmt::{self, Display};
|
use std::fmt::{self, Display};
|
||||||
|
|
||||||
/// The [`Collection`] alias type for convenience.
|
/// The [`Collection`] alias type for convenience.
|
||||||
@ -15,6 +14,8 @@ pub type Collection = Vec<artist::Artist>;
|
|||||||
/// Error type for the [`collection`] module.
|
/// Error type for the [`collection`] module.
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
|
/// An error occurred when processing an MBID.
|
||||||
|
MbidError(String),
|
||||||
/// An error occurred when processing a URL.
|
/// An error occurred when processing a URL.
|
||||||
UrlError(String),
|
UrlError(String),
|
||||||
}
|
}
|
||||||
@ -22,6 +23,7 @@ pub enum Error {
|
|||||||
impl Display for Error {
|
impl Display for Error {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
match *self {
|
match *self {
|
||||||
|
Self::MbidError(ref s) => write!(f, "an error occurred when processing an MBID: {s}"),
|
||||||
Self::UrlError(ref s) => write!(f, "an error occurred when processing a URL: {s}"),
|
Self::UrlError(ref s) => write!(f, "an error occurred when processing a URL: {s}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -35,6 +37,6 @@ impl From<url::ParseError> for Error {
|
|||||||
|
|
||||||
impl From<uuid::Error> for Error {
|
impl From<uuid::Error> for Error {
|
||||||
fn from(err: uuid::Error) -> Error {
|
fn from(err: uuid::Error) -> Error {
|
||||||
Error::UrlError(err.to_string())
|
Error::MbidError(err.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
289
src/core/collection/musicbrainz.rs
Normal file
289
src/core/collection/musicbrainz.rs
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
use std::{fmt, mem};
|
||||||
|
|
||||||
|
use url::Url;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::core::collection::Error;
|
||||||
|
|
||||||
|
const MB_DOMAIN: &str = "musicbrainz.org";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Mbid(Uuid);
|
||||||
|
|
||||||
|
impl Mbid {
|
||||||
|
pub fn uuid(&self) -> &Uuid {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Uuid> for Mbid {
|
||||||
|
fn from(value: Uuid) -> Self {
|
||||||
|
Mbid(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! try_from_impl_for_mbid {
|
||||||
|
($from:ty) => {
|
||||||
|
impl TryFrom<$from> for Mbid {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn try_from(value: $from) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Uuid::parse_str(value.as_ref())?.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try_from_impl_for_mbid!(&str);
|
||||||
|
try_from_impl_for_mbid!(&String);
|
||||||
|
try_from_impl_for_mbid!(String);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum MbRefOption<T> {
|
||||||
|
Some(T),
|
||||||
|
CannotHaveMbid,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> MbRefOption<T> {
|
||||||
|
pub fn or(self, optb: MbRefOption<T>) -> MbRefOption<T> {
|
||||||
|
match (&self, &optb) {
|
||||||
|
(MbRefOption::Some(_), _) | (MbRefOption::CannotHaveMbid, MbRefOption::None) => self,
|
||||||
|
_ => optb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace(&mut self, value: T) -> MbRefOption<T> {
|
||||||
|
mem::replace(self, MbRefOption::Some(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take(&mut self) -> MbRefOption<T> {
|
||||||
|
mem::replace(self, MbRefOption::None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct MusicBrainzRef {
|
||||||
|
mbid: Mbid,
|
||||||
|
url: Url,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IMusicBrainzRef {
|
||||||
|
fn mbid(&self) -> &Mbid;
|
||||||
|
fn url(&self) -> &Url;
|
||||||
|
fn entity() -> &'static str;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct MbArtistRef(MusicBrainzRef);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct MbAlbumRef(MusicBrainzRef);
|
||||||
|
|
||||||
|
macro_rules! impl_imusicbrainzref {
|
||||||
|
($mbref:ident, $entity:literal) => {
|
||||||
|
impl IMusicBrainzRef for $mbref {
|
||||||
|
fn mbid(&self) -> &Mbid {
|
||||||
|
&self.0.mbid
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self) -> &Url {
|
||||||
|
&self.0.url
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entity() -> &'static str {
|
||||||
|
$entity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<Url> for $mbref {
|
||||||
|
type Error = Error;
|
||||||
|
|
||||||
|
fn try_from(url: Url) -> Result<Self, Self::Error> {
|
||||||
|
Ok($mbref(MusicBrainzRef::from_url(url, $mbref::entity())?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Uuid> for $mbref {
|
||||||
|
fn from(uuid: Uuid) -> Self {
|
||||||
|
$mbref(MusicBrainzRef::from_mbid(uuid, $mbref::entity()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Mbid> for $mbref {
|
||||||
|
fn from(mbid: Mbid) -> Self {
|
||||||
|
$mbref(MusicBrainzRef::from_mbid(mbid, $mbref::entity()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl $mbref {
|
||||||
|
pub fn from_url_str<S: AsRef<str>>(url: S) -> Result<Self, Error> {
|
||||||
|
let url: Url = url.as_ref().try_into()?;
|
||||||
|
url.try_into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl $mbref {
|
||||||
|
pub fn from_uuid_str<S: AsRef<str>>(uuid: S) -> Result<Self, Error> {
|
||||||
|
let uuid: Uuid = uuid.as_ref().try_into()?;
|
||||||
|
Ok(uuid.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_imusicbrainzref!(MbArtistRef, "artist");
|
||||||
|
impl_imusicbrainzref!(MbAlbumRef, "release-group");
|
||||||
|
|
||||||
|
impl MusicBrainzRef {
|
||||||
|
fn from_url(url: Url, entity: &'static str) -> Result<Self, Error> {
|
||||||
|
if !url
|
||||||
|
.domain()
|
||||||
|
.map(|u| u.ends_with(MB_DOMAIN))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Err(Self::invalid_url_error(url, entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
// path_segments only returns an empty iterator if the URL cannot-be-a-base. However, if the
|
||||||
|
// URL cannot-be-a-base then it will fail the check above already as it won't have a domain.
|
||||||
|
if url.path_segments().and_then(|mut ps| ps.nth(0)).unwrap() != entity {
|
||||||
|
return Err(Self::invalid_url_error(url, entity));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mbid = match url.path_segments().and_then(|mut ps| ps.nth(1)) {
|
||||||
|
Some(segment) => Uuid::try_parse(segment)?.into(),
|
||||||
|
None => return Err(Self::invalid_url_error(url, entity)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(MusicBrainzRef { mbid, url })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_mbid<ID: Into<Mbid>>(id: ID, entity: &'static str) -> Self {
|
||||||
|
let mbid = id.into();
|
||||||
|
let uuid_str = mbid.uuid().to_string();
|
||||||
|
let url = Url::parse(&format!("https://{MB_DOMAIN}/{entity}/{uuid_str}")).unwrap();
|
||||||
|
MusicBrainzRef { mbid, url }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_url_error<U: fmt::Display>(url: U, entity: &'static str) -> Error {
|
||||||
|
Error::UrlError(format!("invalid {entity} MusicBrainz URL: {url}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist() {
|
||||||
|
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
|
||||||
|
let url_str = format!("https://musicbrainz.org/artist/{uuid}");
|
||||||
|
|
||||||
|
let mb = MbArtistRef::from_url_str(&url_str).unwrap();
|
||||||
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
|
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||||
|
|
||||||
|
let mb = MbArtistRef::from_uuid_str(uuid).unwrap();
|
||||||
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
|
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||||
|
|
||||||
|
let mbid: Mbid = TryInto::<Uuid>::try_into(uuid).unwrap().into();
|
||||||
|
let mb: MbArtistRef = mbid.into();
|
||||||
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
|
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||||
|
|
||||||
|
let url: Url = url_str.as_str().try_into().unwrap();
|
||||||
|
let mb: MbArtistRef = url.try_into().unwrap();
|
||||||
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
|
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album() {
|
||||||
|
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
|
||||||
|
let url_str = format!("https://musicbrainz.org/release-group/{uuid}");
|
||||||
|
|
||||||
|
let mb = MbAlbumRef::from_url_str(&url_str).unwrap();
|
||||||
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
|
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||||
|
|
||||||
|
let mb = MbAlbumRef::from_uuid_str(uuid).unwrap();
|
||||||
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
|
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||||
|
|
||||||
|
let mbid: Mbid = TryInto::<Uuid>::try_into(uuid).unwrap().into();
|
||||||
|
let mb: MbAlbumRef = mbid.into();
|
||||||
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
|
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||||
|
|
||||||
|
let url: Url = url_str.as_str().try_into().unwrap();
|
||||||
|
let mb: MbAlbumRef = url.try_into().unwrap();
|
||||||
|
assert_eq!(url_str, mb.url().as_ref());
|
||||||
|
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_a_url() {
|
||||||
|
let url = "not a url at all";
|
||||||
|
let expected_error: Error = url::ParseError::RelativeUrlWithoutBase.into();
|
||||||
|
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||||
|
assert_eq!(actual_error, expected_error);
|
||||||
|
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_url() {
|
||||||
|
let url = "https://www.musicbutler.io/artist-page/483340948";
|
||||||
|
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}"));
|
||||||
|
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||||
|
assert_eq!(actual_error, expected_error);
|
||||||
|
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_invalid_type() {
|
||||||
|
let url = "https://musicbrainz.org/release-group/i-am-not-a-uuid";
|
||||||
|
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}"));
|
||||||
|
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||||
|
assert_eq!(actual_error, expected_error);
|
||||||
|
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_invalid_type() {
|
||||||
|
let url = "https://musicbrainz.org/artist/i-am-not-a-uuid";
|
||||||
|
let expected_error =
|
||||||
|
Error::UrlError(format!("invalid release-group MusicBrainz URL: {url}"));
|
||||||
|
let actual_error = MbAlbumRef::from_url_str(url).unwrap_err();
|
||||||
|
assert_eq!(actual_error, expected_error);
|
||||||
|
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_uuid() {
|
||||||
|
let url = "https://musicbrainz.org/artist/i-am-not-a-uuid";
|
||||||
|
let expected_error: Error = Uuid::try_parse("i-am-not-a-uuid").unwrap_err().into();
|
||||||
|
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||||
|
assert_eq!(actual_error, expected_error);
|
||||||
|
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_type() {
|
||||||
|
let url = "https://musicbrainz.org";
|
||||||
|
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}/"));
|
||||||
|
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||||
|
assert_eq!(actual_error, expected_error);
|
||||||
|
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_uuid() {
|
||||||
|
let url = "https://musicbrainz.org/artist";
|
||||||
|
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}"));
|
||||||
|
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||||
|
assert_eq!(actual_error, expected_error);
|
||||||
|
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||||
|
}
|
||||||
|
}
|
@ -4,35 +4,39 @@ use crate::core::collection::merge::Merge;
|
|||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct Track {
|
pub struct Track {
|
||||||
pub id: TrackId,
|
pub id: TrackId,
|
||||||
|
pub number: TrackNum,
|
||||||
pub artist: Vec<String>,
|
pub artist: Vec<String>,
|
||||||
pub quality: Quality,
|
pub quality: TrackQuality,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The track identifier.
|
/// The track identifier.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub struct TrackId {
|
pub struct TrackId {
|
||||||
pub number: u32,
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The track number.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct TrackNum(pub u32);
|
||||||
|
|
||||||
/// The track quality. Combines format and bitrate information.
|
/// The track quality. Combines format and bitrate information.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct Quality {
|
pub struct TrackQuality {
|
||||||
pub format: Format,
|
pub format: TrackFormat,
|
||||||
pub bitrate: u32,
|
pub bitrate: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Track {
|
impl Track {
|
||||||
pub fn get_sort_key(&self) -> &TrackId {
|
pub fn get_sort_key(&self) -> (&TrackNum, &TrackId) {
|
||||||
&self.id
|
(&self.number, &self.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The track file format.
|
/// The track file format.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
pub enum Format {
|
pub enum TrackFormat {
|
||||||
Flac,
|
|
||||||
Mp3,
|
Mp3,
|
||||||
|
Flac,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialOrd for Track {
|
impl PartialOrd for Track {
|
||||||
@ -43,7 +47,7 @@ impl PartialOrd for Track {
|
|||||||
|
|
||||||
impl Ord for Track {
|
impl Ord for Track {
|
||||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||||
self.id.cmp(&other.id)
|
self.get_sort_key().cmp(&other.get_sort_key())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,24 +61,31 @@ impl Merge for Track {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_ord() {
|
||||||
|
assert!(TrackFormat::Mp3 < TrackFormat::Flac);
|
||||||
|
assert!(TrackFormat::Flac > TrackFormat::Mp3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn merge_track() {
|
fn merge_track() {
|
||||||
let left = Track {
|
let left = Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 4,
|
|
||||||
title: String::from("a title"),
|
title: String::from("a title"),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(4),
|
||||||
artist: vec![String::from("left artist")],
|
artist: vec![String::from("left artist")],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 1411,
|
bitrate: 1411,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let right = Track {
|
let right = Track {
|
||||||
id: left.id.clone(),
|
id: left.id.clone(),
|
||||||
|
number: left.number,
|
||||||
artist: vec![String::from("right artist")],
|
artist: vec![String::from("right artist")],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 320,
|
bitrate: 320,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,39 +0,0 @@
|
|||||||
pub static DATABASE_JSON: &str = "{\
|
|
||||||
\"V20240210\":\
|
|
||||||
[\
|
|
||||||
{\
|
|
||||||
\"name\":\"Album_Artist ‘A’\",\
|
|
||||||
\"sort\":null,\
|
|
||||||
\"musicbrainz\":\"https://musicbrainz.org/artist/00000000-0000-0000-0000-000000000000\",\
|
|
||||||
\"properties\":{\
|
|
||||||
\"MusicButler\":[\"https://www.musicbutler.io/artist-page/000000000\"],\
|
|
||||||
\"Qobuz\":[\"https://www.qobuz.com/nl-nl/interpreter/artist-a/download-streaming-albums\"]\
|
|
||||||
}\
|
|
||||||
},\
|
|
||||||
{\
|
|
||||||
\"name\":\"Album_Artist ‘B’\",\
|
|
||||||
\"sort\":null,\
|
|
||||||
\"musicbrainz\":\"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111\",\
|
|
||||||
\"properties\":{\
|
|
||||||
\"Bandcamp\":[\"https://artist-b.bandcamp.com/\"],\
|
|
||||||
\"MusicButler\":[\
|
|
||||||
\"https://www.musicbutler.io/artist-page/111111111\",\
|
|
||||||
\"https://www.musicbutler.io/artist-page/111111112\"\
|
|
||||||
],\
|
|
||||||
\"Qobuz\":[\"https://www.qobuz.com/nl-nl/interpreter/artist-b/download-streaming-albums\"]\
|
|
||||||
}\
|
|
||||||
},\
|
|
||||||
{\
|
|
||||||
\"name\":\"The Album_Artist ‘C’\",\
|
|
||||||
\"sort\":\"Album_Artist ‘C’, The\",\
|
|
||||||
\"musicbrainz\":\"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111\",\
|
|
||||||
\"properties\":{}\
|
|
||||||
},\
|
|
||||||
{\
|
|
||||||
\"name\":\"Album_Artist ‘D’\",\
|
|
||||||
\"sort\":null,\
|
|
||||||
\"musicbrainz\":null,\
|
|
||||||
\"properties\":{}\
|
|
||||||
}\
|
|
||||||
]\
|
|
||||||
}";
|
|
@ -1,48 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
collection::artist::{ArtistId, MusicBrainz},
|
|
||||||
core::{
|
|
||||||
collection::{artist::Artist, Collection},
|
|
||||||
database::{serde::Database, LoadError},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub type DeserializeDatabase = Database<DeserializeArtist>;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct DeserializeArtist {
|
|
||||||
name: String,
|
|
||||||
sort: Option<String>,
|
|
||||||
musicbrainz: Option<String>,
|
|
||||||
properties: HashMap<String, Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<DeserializeDatabase> for Collection {
|
|
||||||
type Error = LoadError;
|
|
||||||
|
|
||||||
fn try_from(database: DeserializeDatabase) -> Result<Self, Self::Error> {
|
|
||||||
match database {
|
|
||||||
Database::V20240210(collection) => collection
|
|
||||||
.into_iter()
|
|
||||||
.map(|artist| artist.try_into())
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<DeserializeArtist> for Artist {
|
|
||||||
type Error = LoadError;
|
|
||||||
|
|
||||||
fn try_from(artist: DeserializeArtist) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Artist {
|
|
||||||
id: ArtistId::new(artist.name),
|
|
||||||
sort: artist.sort.map(ArtistId::new),
|
|
||||||
musicbrainz: artist.musicbrainz.map(MusicBrainz::new).transpose()?,
|
|
||||||
properties: artist.properties,
|
|
||||||
albums: vec![],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
//! Helper module for backends that can use serde for (de)serialisation.
|
|
||||||
|
|
||||||
pub mod deserialize;
|
|
||||||
pub mod serialize;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub enum Database<ARTIST> {
|
|
||||||
V20240210(Vec<ARTIST>),
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use crate::core::{
|
|
||||||
collection::{artist::Artist, Collection},
|
|
||||||
database::serde::Database,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub type SerializeDatabase<'a> = Database<SerializeArtist<'a>>;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
pub struct SerializeArtist<'a> {
|
|
||||||
name: &'a str,
|
|
||||||
sort: Option<&'a str>,
|
|
||||||
musicbrainz: Option<&'a str>,
|
|
||||||
properties: BTreeMap<&'a str, &'a Vec<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> From<&'a Collection> for SerializeDatabase<'a> {
|
|
||||||
fn from(collection: &'a Collection) -> Self {
|
|
||||||
Database::V20240210(collection.iter().map(|artist| artist.into()).collect())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> From<&'a Artist> for SerializeArtist<'a> {
|
|
||||||
fn from(artist: &'a Artist) -> Self {
|
|
||||||
SerializeArtist {
|
|
||||||
name: &artist.id.name,
|
|
||||||
sort: artist.sort.as_ref().map(|id| id.name.as_ref()),
|
|
||||||
musicbrainz: artist.musicbrainz.as_ref().map(|mb| mb.as_ref()),
|
|
||||||
properties: artist
|
|
||||||
.properties
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.as_ref(), v))
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,10 +1,5 @@
|
|||||||
//! Module for storing MusicHoard data in a database.
|
//! Module for storing MusicHoard data in a database.
|
||||||
|
|
||||||
#[cfg(feature = "database-json")]
|
|
||||||
pub mod json;
|
|
||||||
#[cfg(feature = "database-json")]
|
|
||||||
mod serde;
|
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -64,7 +59,9 @@ impl From<std::io::Error> for LoadError {
|
|||||||
impl From<collection::Error> for LoadError {
|
impl From<collection::Error> for LoadError {
|
||||||
fn from(err: collection::Error) -> Self {
|
fn from(err: collection::Error) -> Self {
|
||||||
match err {
|
match err {
|
||||||
collection::Error::UrlError(e) => LoadError::SerDeError(e),
|
collection::Error::UrlError(e) | collection::Error::MbidError(e) => {
|
||||||
|
LoadError::SerDeError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -102,13 +99,13 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_database_load() {
|
fn null_database_load() {
|
||||||
let database = NullDatabase;
|
let database = NullDatabase;
|
||||||
assert!(database.load().unwrap().is_empty());
|
assert!(database.load().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_database_save() {
|
fn null_database_save() {
|
||||||
let mut database = NullDatabase;
|
let mut database = NullDatabase;
|
||||||
assert!(database.save(&vec![]).is_ok());
|
assert!(database.save(&vec![]).is_ok());
|
||||||
}
|
}
|
@ -1,14 +1,11 @@
|
|||||||
//! Module for interacting with the music library.
|
//! Module for interacting with the music library.
|
||||||
|
|
||||||
#[cfg(feature = "library-beets")]
|
|
||||||
pub mod beets;
|
|
||||||
|
|
||||||
use std::{collections::HashSet, fmt, num::ParseIntError, str::Utf8Error};
|
use std::{collections::HashSet, fmt, num::ParseIntError, str::Utf8Error};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use mockall::automock;
|
use mockall::automock;
|
||||||
|
|
||||||
use crate::core::collection::track::Format;
|
use crate::core::collection::track::TrackFormat;
|
||||||
|
|
||||||
/// Trait for interacting with the music library.
|
/// Trait for interacting with the music library.
|
||||||
#[cfg_attr(test, automock)]
|
#[cfg_attr(test, automock)]
|
||||||
@ -32,11 +29,13 @@ pub struct Item {
|
|||||||
pub album_artist: String,
|
pub album_artist: String,
|
||||||
pub album_artist_sort: Option<String>,
|
pub album_artist_sort: Option<String>,
|
||||||
pub album_year: u32,
|
pub album_year: u32,
|
||||||
|
pub album_month: u8,
|
||||||
|
pub album_day: u8,
|
||||||
pub album_title: String,
|
pub album_title: String,
|
||||||
pub track_number: u32,
|
pub track_number: u32,
|
||||||
pub track_title: String,
|
pub track_title: String,
|
||||||
pub track_artist: Vec<String>,
|
pub track_artist: Vec<String>,
|
||||||
pub track_format: Format,
|
pub track_format: TrackFormat,
|
||||||
pub track_bitrate: u32,
|
pub track_bitrate: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,34 +45,27 @@ pub enum Field {
|
|||||||
AlbumArtist(String),
|
AlbumArtist(String),
|
||||||
AlbumArtistSort(String),
|
AlbumArtistSort(String),
|
||||||
AlbumYear(u32),
|
AlbumYear(u32),
|
||||||
|
AlbumMonth(u8),
|
||||||
|
AlbumDay(u8),
|
||||||
AlbumTitle(String),
|
AlbumTitle(String),
|
||||||
TrackNumber(u32),
|
TrackNumber(u32),
|
||||||
TrackTitle(String),
|
TrackTitle(String),
|
||||||
TrackArtist(Vec<String>),
|
TrackArtist(Vec<String>),
|
||||||
|
TrackFormat(TrackFormat),
|
||||||
All(String),
|
All(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A library query. Can include or exclude particular fields.
|
/// A library query. Can include or exclude particular fields.
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
pub struct Query {
|
pub struct Query {
|
||||||
include: HashSet<Field>,
|
pub include: HashSet<Field>,
|
||||||
exclude: HashSet<Field>,
|
pub exclude: HashSet<Field>,
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Query {
|
|
||||||
/// Create an empty query.
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Query {
|
impl Query {
|
||||||
/// Create an empty query.
|
/// Create an empty query.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Query {
|
Query::default()
|
||||||
include: HashSet::new(),
|
|
||||||
exclude: HashSet::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refine the query to include a particular search term.
|
/// Refine the query to include a particular search term.
|
||||||
@ -144,7 +136,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_library_list() {
|
fn null_library_list() {
|
||||||
let mut library = NullLibrary;
|
let mut library = NullLibrary;
|
||||||
assert!(library.list(&Query::default()).unwrap().is_empty());
|
assert!(library.list(&Query::default()).unwrap().is_empty());
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
use crate::core::{collection::track::Format, library::Item};
|
use crate::core::{collection::track::TrackFormat, interface::library::Item};
|
||||||
|
|
||||||
pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
||||||
vec![
|
vec![
|
||||||
@ -8,17 +8,21 @@ pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
|||||||
album_artist: String::from("Album_Artist ‘A’"),
|
album_artist: String::from("Album_Artist ‘A’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 1998,
|
album_year: 1998,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title a.a"),
|
album_title: String::from("album_title a.a"),
|
||||||
track_number: 1,
|
track_number: 1,
|
||||||
track_title: String::from("track a.a.1"),
|
track_title: String::from("track a.a.1"),
|
||||||
track_artist: vec![String::from("artist a.a.1")],
|
track_artist: vec![String::from("artist a.a.1")],
|
||||||
track_format: Format::Flac,
|
track_format: TrackFormat::Flac,
|
||||||
track_bitrate: 992,
|
track_bitrate: 992,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘A’"),
|
album_artist: String::from("Album_Artist ‘A’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 1998,
|
album_year: 1998,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title a.a"),
|
album_title: String::from("album_title a.a"),
|
||||||
track_number: 2,
|
track_number: 2,
|
||||||
track_title: String::from("track a.a.2"),
|
track_title: String::from("track a.a.2"),
|
||||||
@ -26,68 +30,80 @@ pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
|||||||
String::from("artist a.a.2.1"),
|
String::from("artist a.a.2.1"),
|
||||||
String::from("artist a.a.2.2"),
|
String::from("artist a.a.2.2"),
|
||||||
],
|
],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 320,
|
track_bitrate: 320,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘A’"),
|
album_artist: String::from("Album_Artist ‘A’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 1998,
|
album_year: 1998,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title a.a"),
|
album_title: String::from("album_title a.a"),
|
||||||
track_number: 3,
|
track_number: 3,
|
||||||
track_title: String::from("track a.a.3"),
|
track_title: String::from("track a.a.3"),
|
||||||
track_artist: vec![String::from("artist a.a.3")],
|
track_artist: vec![String::from("artist a.a.3")],
|
||||||
track_format: Format::Flac,
|
track_format: TrackFormat::Flac,
|
||||||
track_bitrate: 1061,
|
track_bitrate: 1061,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘A’"),
|
album_artist: String::from("Album_Artist ‘A’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 1998,
|
album_year: 1998,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title a.a"),
|
album_title: String::from("album_title a.a"),
|
||||||
track_number: 4,
|
track_number: 4,
|
||||||
track_title: String::from("track a.a.4"),
|
track_title: String::from("track a.a.4"),
|
||||||
track_artist: vec![String::from("artist a.a.4")],
|
track_artist: vec![String::from("artist a.a.4")],
|
||||||
track_format: Format::Flac,
|
track_format: TrackFormat::Flac,
|
||||||
track_bitrate: 1042,
|
track_bitrate: 1042,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘A’"),
|
album_artist: String::from("Album_Artist ‘A’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2015,
|
album_year: 2015,
|
||||||
|
album_month: 4,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title a.b"),
|
album_title: String::from("album_title a.b"),
|
||||||
track_number: 1,
|
track_number: 1,
|
||||||
track_title: String::from("track a.b.1"),
|
track_title: String::from("track a.b.1"),
|
||||||
track_artist: vec![String::from("artist a.b.1")],
|
track_artist: vec![String::from("artist a.b.1")],
|
||||||
track_format: Format::Flac,
|
track_format: TrackFormat::Flac,
|
||||||
track_bitrate: 1004,
|
track_bitrate: 1004,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘A’"),
|
album_artist: String::from("Album_Artist ‘A’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2015,
|
album_year: 2015,
|
||||||
|
album_month: 4,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title a.b"),
|
album_title: String::from("album_title a.b"),
|
||||||
track_number: 2,
|
track_number: 2,
|
||||||
track_title: String::from("track a.b.2"),
|
track_title: String::from("track a.b.2"),
|
||||||
track_artist: vec![String::from("artist a.b.2")],
|
track_artist: vec![String::from("artist a.b.2")],
|
||||||
track_format: Format::Flac,
|
track_format: TrackFormat::Flac,
|
||||||
track_bitrate: 1077,
|
track_bitrate: 1077,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘B’"),
|
album_artist: String::from("Album_Artist ‘B’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2003,
|
album_year: 2003,
|
||||||
|
album_month: 6,
|
||||||
|
album_day: 6,
|
||||||
album_title: String::from("album_title b.a"),
|
album_title: String::from("album_title b.a"),
|
||||||
track_number: 1,
|
track_number: 1,
|
||||||
track_title: String::from("track b.a.1"),
|
track_title: String::from("track b.a.1"),
|
||||||
track_artist: vec![String::from("artist b.a.1")],
|
track_artist: vec![String::from("artist b.a.1")],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 190,
|
track_bitrate: 190,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘B’"),
|
album_artist: String::from("Album_Artist ‘B’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2003,
|
album_year: 2003,
|
||||||
|
album_month: 6,
|
||||||
|
album_day: 6,
|
||||||
album_title: String::from("album_title b.a"),
|
album_title: String::from("album_title b.a"),
|
||||||
track_number: 2,
|
track_number: 2,
|
||||||
track_title: String::from("track b.a.2"),
|
track_title: String::from("track b.a.2"),
|
||||||
@ -95,24 +111,28 @@ pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
|||||||
String::from("artist b.a.2.1"),
|
String::from("artist b.a.2.1"),
|
||||||
String::from("artist b.a.2.2"),
|
String::from("artist b.a.2.2"),
|
||||||
],
|
],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 120,
|
track_bitrate: 120,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘B’"),
|
album_artist: String::from("Album_Artist ‘B’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2008,
|
album_year: 2008,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title b.b"),
|
album_title: String::from("album_title b.b"),
|
||||||
track_number: 1,
|
track_number: 1,
|
||||||
track_title: String::from("track b.b.1"),
|
track_title: String::from("track b.b.1"),
|
||||||
track_artist: vec![String::from("artist b.b.1")],
|
track_artist: vec![String::from("artist b.b.1")],
|
||||||
track_format: Format::Flac,
|
track_format: TrackFormat::Flac,
|
||||||
track_bitrate: 1077,
|
track_bitrate: 1077,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘B’"),
|
album_artist: String::from("Album_Artist ‘B’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2008,
|
album_year: 2008,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title b.b"),
|
album_title: String::from("album_title b.b"),
|
||||||
track_number: 2,
|
track_number: 2,
|
||||||
track_title: String::from("track b.b.2"),
|
track_title: String::from("track b.b.2"),
|
||||||
@ -120,24 +140,28 @@ pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
|||||||
String::from("artist b.b.2.1"),
|
String::from("artist b.b.2.1"),
|
||||||
String::from("artist b.b.2.2"),
|
String::from("artist b.b.2.2"),
|
||||||
],
|
],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 320,
|
track_bitrate: 320,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘B’"),
|
album_artist: String::from("Album_Artist ‘B’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2009,
|
album_year: 2009,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title b.c"),
|
album_title: String::from("album_title b.c"),
|
||||||
track_number: 1,
|
track_number: 1,
|
||||||
track_title: String::from("track b.c.1"),
|
track_title: String::from("track b.c.1"),
|
||||||
track_artist: vec![String::from("artist b.c.1")],
|
track_artist: vec![String::from("artist b.c.1")],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 190,
|
track_bitrate: 190,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘B’"),
|
album_artist: String::from("Album_Artist ‘B’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2009,
|
album_year: 2009,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title b.c"),
|
album_title: String::from("album_title b.c"),
|
||||||
track_number: 2,
|
track_number: 2,
|
||||||
track_title: String::from("track b.c.2"),
|
track_title: String::from("track b.c.2"),
|
||||||
@ -145,24 +169,28 @@ pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
|||||||
String::from("artist b.c.2.1"),
|
String::from("artist b.c.2.1"),
|
||||||
String::from("artist b.c.2.2"),
|
String::from("artist b.c.2.2"),
|
||||||
],
|
],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 120,
|
track_bitrate: 120,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘B’"),
|
album_artist: String::from("Album_Artist ‘B’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2015,
|
album_year: 2015,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title b.d"),
|
album_title: String::from("album_title b.d"),
|
||||||
track_number: 1,
|
track_number: 1,
|
||||||
track_title: String::from("track b.d.1"),
|
track_title: String::from("track b.d.1"),
|
||||||
track_artist: vec![String::from("artist b.d.1")],
|
track_artist: vec![String::from("artist b.d.1")],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 190,
|
track_bitrate: 190,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘B’"),
|
album_artist: String::from("Album_Artist ‘B’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2015,
|
album_year: 2015,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title b.d"),
|
album_title: String::from("album_title b.d"),
|
||||||
track_number: 2,
|
track_number: 2,
|
||||||
track_title: String::from("track b.d.2"),
|
track_title: String::from("track b.d.2"),
|
||||||
@ -170,24 +198,28 @@ pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
|||||||
String::from("artist b.d.2.1"),
|
String::from("artist b.d.2.1"),
|
||||||
String::from("artist b.d.2.2"),
|
String::from("artist b.d.2.2"),
|
||||||
],
|
],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 120,
|
track_bitrate: 120,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("The Album_Artist ‘C’"),
|
album_artist: String::from("The Album_Artist ‘C’"),
|
||||||
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
||||||
album_year: 1985,
|
album_year: 1985,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title c.a"),
|
album_title: String::from("album_title c.a"),
|
||||||
track_number: 1,
|
track_number: 1,
|
||||||
track_title: String::from("track c.a.1"),
|
track_title: String::from("track c.a.1"),
|
||||||
track_artist: vec![String::from("artist c.a.1")],
|
track_artist: vec![String::from("artist c.a.1")],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 320,
|
track_bitrate: 320,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("The Album_Artist ‘C’"),
|
album_artist: String::from("The Album_Artist ‘C’"),
|
||||||
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
||||||
album_year: 1985,
|
album_year: 1985,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title c.a"),
|
album_title: String::from("album_title c.a"),
|
||||||
track_number: 2,
|
track_number: 2,
|
||||||
track_title: String::from("track c.a.2"),
|
track_title: String::from("track c.a.2"),
|
||||||
@ -195,24 +227,28 @@ pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
|||||||
String::from("artist c.a.2.1"),
|
String::from("artist c.a.2.1"),
|
||||||
String::from("artist c.a.2.2"),
|
String::from("artist c.a.2.2"),
|
||||||
],
|
],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 120,
|
track_bitrate: 120,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("The Album_Artist ‘C’"),
|
album_artist: String::from("The Album_Artist ‘C’"),
|
||||||
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
||||||
album_year: 2018,
|
album_year: 2018,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title c.b"),
|
album_title: String::from("album_title c.b"),
|
||||||
track_number: 1,
|
track_number: 1,
|
||||||
track_title: String::from("track c.b.1"),
|
track_title: String::from("track c.b.1"),
|
||||||
track_artist: vec![String::from("artist c.b.1")],
|
track_artist: vec![String::from("artist c.b.1")],
|
||||||
track_format: Format::Flac,
|
track_format: TrackFormat::Flac,
|
||||||
track_bitrate: 1041,
|
track_bitrate: 1041,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("The Album_Artist ‘C’"),
|
album_artist: String::from("The Album_Artist ‘C’"),
|
||||||
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
||||||
album_year: 2018,
|
album_year: 2018,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title c.b"),
|
album_title: String::from("album_title c.b"),
|
||||||
track_number: 2,
|
track_number: 2,
|
||||||
track_title: String::from("track c.b.2"),
|
track_title: String::from("track c.b.2"),
|
||||||
@ -220,24 +256,28 @@ pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
|||||||
String::from("artist c.b.2.1"),
|
String::from("artist c.b.2.1"),
|
||||||
String::from("artist c.b.2.2"),
|
String::from("artist c.b.2.2"),
|
||||||
],
|
],
|
||||||
track_format: Format::Flac,
|
track_format: TrackFormat::Flac,
|
||||||
track_bitrate: 756,
|
track_bitrate: 756,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘D’"),
|
album_artist: String::from("Album_Artist ‘D’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 1995,
|
album_year: 1995,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title d.a"),
|
album_title: String::from("album_title d.a"),
|
||||||
track_number: 1,
|
track_number: 1,
|
||||||
track_title: String::from("track d.a.1"),
|
track_title: String::from("track d.a.1"),
|
||||||
track_artist: vec![String::from("artist d.a.1")],
|
track_artist: vec![String::from("artist d.a.1")],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 120,
|
track_bitrate: 120,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘D’"),
|
album_artist: String::from("Album_Artist ‘D’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 1995,
|
album_year: 1995,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title d.a"),
|
album_title: String::from("album_title d.a"),
|
||||||
track_number: 2,
|
track_number: 2,
|
||||||
track_title: String::from("track d.a.2"),
|
track_title: String::from("track d.a.2"),
|
||||||
@ -245,24 +285,28 @@ pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
|||||||
String::from("artist d.a.2.1"),
|
String::from("artist d.a.2.1"),
|
||||||
String::from("artist d.a.2.2"),
|
String::from("artist d.a.2.2"),
|
||||||
],
|
],
|
||||||
track_format: Format::Mp3,
|
track_format: TrackFormat::Mp3,
|
||||||
track_bitrate: 120,
|
track_bitrate: 120,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘D’"),
|
album_artist: String::from("Album_Artist ‘D’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2028,
|
album_year: 2028,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title d.b"),
|
album_title: String::from("album_title d.b"),
|
||||||
track_number: 1,
|
track_number: 1,
|
||||||
track_title: String::from("track d.b.1"),
|
track_title: String::from("track d.b.1"),
|
||||||
track_artist: vec![String::from("artist d.b.1")],
|
track_artist: vec![String::from("artist d.b.1")],
|
||||||
track_format: Format::Flac,
|
track_format: TrackFormat::Flac,
|
||||||
track_bitrate: 841,
|
track_bitrate: 841,
|
||||||
},
|
},
|
||||||
Item {
|
Item {
|
||||||
album_artist: String::from("Album_Artist ‘D’"),
|
album_artist: String::from("Album_Artist ‘D’"),
|
||||||
album_artist_sort: None,
|
album_artist_sort: None,
|
||||||
album_year: 2028,
|
album_year: 2028,
|
||||||
|
album_month: 0,
|
||||||
|
album_day: 0,
|
||||||
album_title: String::from("album_title d.b"),
|
album_title: String::from("album_title d.b"),
|
||||||
track_number: 2,
|
track_number: 2,
|
||||||
track_title: String::from("track d.b.2"),
|
track_title: String::from("track d.b.2"),
|
||||||
@ -270,7 +314,7 @@ pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
|||||||
String::from("artist d.b.2.1"),
|
String::from("artist d.b.2.1"),
|
||||||
String::from("artist d.b.2.2"),
|
String::from("artist d.b.2.2"),
|
||||||
],
|
],
|
||||||
track_format: Format::Flac,
|
track_format: TrackFormat::Flac,
|
||||||
track_bitrate: 756,
|
track_bitrate: 756,
|
||||||
},
|
},
|
||||||
]
|
]
|
2
src/core/interface/mod.rs
Normal file
2
src/core/interface/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod database;
|
||||||
|
pub mod library;
|
@ -1,28 +0,0 @@
|
|||||||
use once_cell::sync::Lazy;
|
|
||||||
|
|
||||||
pub static LIBRARY_BEETS: Lazy<Vec<String>> = Lazy::new(|| -> Vec<String> {
|
|
||||||
vec![
|
|
||||||
String::from("Album_Artist ‘A’ -*^- -*^- 1998 -*^- album_title a.a -*^- 1 -*^- track a.a.1 -*^- artist a.a.1 -*^- FLAC -*^- 992"),
|
|
||||||
String::from("Album_Artist ‘A’ -*^- -*^- 1998 -*^- album_title a.a -*^- 2 -*^- track a.a.2 -*^- artist a.a.2.1; artist a.a.2.2 -*^- MP3 -*^- 320"),
|
|
||||||
String::from("Album_Artist ‘A’ -*^- -*^- 1998 -*^- album_title a.a -*^- 3 -*^- track a.a.3 -*^- artist a.a.3 -*^- FLAC -*^- 1061"),
|
|
||||||
String::from("Album_Artist ‘A’ -*^- -*^- 1998 -*^- album_title a.a -*^- 4 -*^- track a.a.4 -*^- artist a.a.4 -*^- FLAC -*^- 1042"),
|
|
||||||
String::from("Album_Artist ‘A’ -*^- -*^- 2015 -*^- album_title a.b -*^- 1 -*^- track a.b.1 -*^- artist a.b.1 -*^- FLAC -*^- 1004"),
|
|
||||||
String::from("Album_Artist ‘A’ -*^- -*^- 2015 -*^- album_title a.b -*^- 2 -*^- track a.b.2 -*^- artist a.b.2 -*^- FLAC -*^- 1077"),
|
|
||||||
String::from("Album_Artist ‘B’ -*^- -*^- 2003 -*^- album_title b.a -*^- 1 -*^- track b.a.1 -*^- artist b.a.1 -*^- MP3 -*^- 190"),
|
|
||||||
String::from("Album_Artist ‘B’ -*^- -*^- 2003 -*^- album_title b.a -*^- 2 -*^- track b.a.2 -*^- artist b.a.2.1; artist b.a.2.2 -*^- MP3 -*^- 120"),
|
|
||||||
String::from("Album_Artist ‘B’ -*^- -*^- 2008 -*^- album_title b.b -*^- 1 -*^- track b.b.1 -*^- artist b.b.1 -*^- FLAC -*^- 1077"),
|
|
||||||
String::from("Album_Artist ‘B’ -*^- -*^- 2008 -*^- album_title b.b -*^- 2 -*^- track b.b.2 -*^- artist b.b.2.1; artist b.b.2.2 -*^- MP3 -*^- 320"),
|
|
||||||
String::from("Album_Artist ‘B’ -*^- -*^- 2009 -*^- album_title b.c -*^- 1 -*^- track b.c.1 -*^- artist b.c.1 -*^- MP3 -*^- 190"),
|
|
||||||
String::from("Album_Artist ‘B’ -*^- -*^- 2009 -*^- album_title b.c -*^- 2 -*^- track b.c.2 -*^- artist b.c.2.1; artist b.c.2.2 -*^- MP3 -*^- 120"),
|
|
||||||
String::from("Album_Artist ‘B’ -*^- -*^- 2015 -*^- album_title b.d -*^- 1 -*^- track b.d.1 -*^- artist b.d.1 -*^- MP3 -*^- 190"),
|
|
||||||
String::from("Album_Artist ‘B’ -*^- -*^- 2015 -*^- album_title b.d -*^- 2 -*^- track b.d.2 -*^- artist b.d.2.1; artist b.d.2.2 -*^- MP3 -*^- 120"),
|
|
||||||
String::from("The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- 1985 -*^- album_title c.a -*^- 1 -*^- track c.a.1 -*^- artist c.a.1 -*^- MP3 -*^- 320"),
|
|
||||||
String::from("The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- 1985 -*^- album_title c.a -*^- 2 -*^- track c.a.2 -*^- artist c.a.2.1; artist c.a.2.2 -*^- MP3 -*^- 120"),
|
|
||||||
String::from("The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- 2018 -*^- album_title c.b -*^- 1 -*^- track c.b.1 -*^- artist c.b.1 -*^- FLAC -*^- 1041"),
|
|
||||||
String::from("The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- 2018 -*^- album_title c.b -*^- 2 -*^- track c.b.2 -*^- artist c.b.2.1; artist c.b.2.2 -*^- FLAC -*^- 756"),
|
|
||||||
String::from("Album_Artist ‘D’ -*^- -*^- 1995 -*^- album_title d.a -*^- 1 -*^- track d.a.1 -*^- artist d.a.1 -*^- MP3 -*^- 120"),
|
|
||||||
String::from("Album_Artist ‘D’ -*^- -*^- 1995 -*^- album_title d.a -*^- 2 -*^- track d.a.2 -*^- artist d.a.2.1; artist d.a.2.2 -*^- MP3 -*^- 120"),
|
|
||||||
String::from("Album_Artist ‘D’ -*^- -*^- 2028 -*^- album_title d.b -*^- 1 -*^- track d.b.1 -*^- artist d.b.1 -*^- FLAC -*^- 841"),
|
|
||||||
String::from("Album_Artist ‘D’ -*^- -*^- 2028 -*^- album_title d.b -*^- 2 -*^- track d.b.2 -*^- artist d.b.2.1; artist d.b.2.2 -*^- FLAC -*^- 756")
|
|
||||||
]
|
|
||||||
});
|
|
@ -1,6 +1,5 @@
|
|||||||
pub mod collection;
|
pub mod collection;
|
||||||
pub mod database;
|
pub mod interface;
|
||||||
pub mod library;
|
|
||||||
pub mod musichoard;
|
pub mod musichoard;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
231
src/core/musichoard/base.rs
Normal file
231
src/core/musichoard/base.rs
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
use crate::core::{
|
||||||
|
collection::{
|
||||||
|
album::{Album, AlbumId},
|
||||||
|
artist::{Artist, ArtistId},
|
||||||
|
merge::MergeCollections,
|
||||||
|
Collection,
|
||||||
|
},
|
||||||
|
musichoard::{Error, MusicHoard},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait IMusicHoardBase {
|
||||||
|
fn get_collection(&self) -> &Collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database, Library> IMusicHoardBase for MusicHoard<Database, Library> {
|
||||||
|
fn get_collection(&self) -> &Collection {
|
||||||
|
&self.collection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IMusicHoardBasePrivate {
|
||||||
|
fn sort_artists(collection: &mut [Artist]);
|
||||||
|
fn sort_albums_and_tracks<'a, C: Iterator<Item = &'a mut Artist>>(collection: C);
|
||||||
|
|
||||||
|
fn merge_collections(&self) -> Collection;
|
||||||
|
|
||||||
|
fn get_artist<'a>(collection: &'a Collection, artist_id: &ArtistId) -> Option<&'a Artist>;
|
||||||
|
fn get_artist_mut<'a>(
|
||||||
|
collection: &'a mut Collection,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
) -> Option<&'a mut Artist>;
|
||||||
|
fn get_artist_mut_or_err<'a>(
|
||||||
|
collection: &'a mut Collection,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
) -> Result<&'a mut Artist, Error>;
|
||||||
|
|
||||||
|
fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album>;
|
||||||
|
fn get_album_mut_or_err<'a>(
|
||||||
|
artist: &'a mut Artist,
|
||||||
|
album_id: &AlbumId,
|
||||||
|
) -> Result<&'a mut Album, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database, Library> IMusicHoardBasePrivate for MusicHoard<Database, Library> {
|
||||||
|
fn sort_artists(collection: &mut [Artist]) {
|
||||||
|
collection.sort_unstable();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_albums_and_tracks<'a, COL: Iterator<Item = &'a mut Artist>>(collection: COL) {
|
||||||
|
for artist in collection {
|
||||||
|
artist.albums.sort_unstable();
|
||||||
|
for album in artist.albums.iter_mut() {
|
||||||
|
album.tracks.sort_unstable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_collections(&self) -> Collection {
|
||||||
|
MergeCollections::merge(self.library_cache.clone(), self.database_cache.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_artist<'a>(collection: &'a Collection, artist_id: &ArtistId) -> Option<&'a Artist> {
|
||||||
|
collection.iter().find(|a| &a.meta.id == artist_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_artist_mut<'a>(
|
||||||
|
collection: &'a mut Collection,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
) -> Option<&'a mut Artist> {
|
||||||
|
collection.iter_mut().find(|a| &a.meta.id == artist_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_artist_mut_or_err<'a>(
|
||||||
|
collection: &'a mut Collection,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
) -> Result<&'a mut Artist, Error> {
|
||||||
|
Self::get_artist_mut(collection, artist_id).ok_or_else(|| {
|
||||||
|
Error::CollectionError(format!("artist '{}' is not in the collection", artist_id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album> {
|
||||||
|
artist.albums.iter_mut().find(|a| &a.meta.id == album_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_album_mut_or_err<'a>(
|
||||||
|
artist: &'a mut Artist,
|
||||||
|
album_id: &AlbumId,
|
||||||
|
) -> Result<&'a mut Album, Error> {
|
||||||
|
Self::get_album_mut(artist, album_id).ok_or_else(|| {
|
||||||
|
Error::CollectionError(format!(
|
||||||
|
"album '{}' does not belong to the artist",
|
||||||
|
album_id
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::core::testmod::FULL_COLLECTION;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_collection_no_overlap() {
|
||||||
|
let half: usize = FULL_COLLECTION.len() / 2;
|
||||||
|
|
||||||
|
let left = FULL_COLLECTION[..half].to_owned();
|
||||||
|
let right = FULL_COLLECTION[half..].to_owned();
|
||||||
|
|
||||||
|
let mut expected = FULL_COLLECTION.to_owned();
|
||||||
|
expected.sort_unstable();
|
||||||
|
|
||||||
|
let mut mh = MusicHoard {
|
||||||
|
library_cache: left
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| (a.meta.id.clone(), a))
|
||||||
|
.collect(),
|
||||||
|
database_cache: right.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
mh.collection = mh.merge_collections();
|
||||||
|
assert_eq!(expected, mh.collection);
|
||||||
|
|
||||||
|
// The merge is completely non-overlapping so it should be commutative.
|
||||||
|
let mut mh = MusicHoard {
|
||||||
|
library_cache: right
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| (a.meta.id.clone(), a))
|
||||||
|
.collect(),
|
||||||
|
database_cache: left.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
mh.collection = mh.merge_collections();
|
||||||
|
assert_eq!(expected, mh.collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_collection_overlap() {
|
||||||
|
let half: usize = FULL_COLLECTION.len() / 2;
|
||||||
|
|
||||||
|
let left = FULL_COLLECTION[..(half + 1)].to_owned();
|
||||||
|
let right = FULL_COLLECTION[half..].to_owned();
|
||||||
|
|
||||||
|
let mut expected = FULL_COLLECTION.to_owned();
|
||||||
|
expected.sort_unstable();
|
||||||
|
|
||||||
|
let mut mh = MusicHoard {
|
||||||
|
library_cache: left
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| (a.meta.id.clone(), a))
|
||||||
|
.collect(),
|
||||||
|
database_cache: right.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
mh.collection = mh.merge_collections();
|
||||||
|
assert_eq!(expected, mh.collection);
|
||||||
|
|
||||||
|
// The merge does not overwrite any data so it should be commutative.
|
||||||
|
let mut mh = MusicHoard {
|
||||||
|
library_cache: right
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| (a.meta.id.clone(), a))
|
||||||
|
.collect(),
|
||||||
|
database_cache: left.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
mh.collection = mh.merge_collections();
|
||||||
|
assert_eq!(expected, mh.collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_collection_incompatible_sorting() {
|
||||||
|
// It may be that the same artist in one collection has a "sort" field defined while the
|
||||||
|
// same artist in the other collection does not. This means that the two collections are not
|
||||||
|
// sorted consistently. If the merge assumes they are sorted consistently this will lead to
|
||||||
|
// the same artist appearing twice in the final list. This should not be the case.
|
||||||
|
|
||||||
|
// We will mimic this situation by taking the last artist from FULL_COLLECTION and giving it
|
||||||
|
// a sorting name that would place it in the beginning.
|
||||||
|
let left = FULL_COLLECTION.to_owned();
|
||||||
|
let mut right: Vec<Artist> = vec![left.last().unwrap().clone()];
|
||||||
|
|
||||||
|
assert!(right.first().unwrap() > left.first().unwrap());
|
||||||
|
let artist_sort = Some(String::from("Album_Artist 0"));
|
||||||
|
right[0].meta.sort = artist_sort.clone();
|
||||||
|
assert!(right.first().unwrap() < left.first().unwrap());
|
||||||
|
|
||||||
|
// The result of the merge should be the same list of artists, but with the last artist now
|
||||||
|
// in first place.
|
||||||
|
let mut expected = left.to_owned();
|
||||||
|
expected.last_mut().as_mut().unwrap().meta.sort = artist_sort.clone();
|
||||||
|
expected.rotate_right(1);
|
||||||
|
|
||||||
|
let mut mh = MusicHoard {
|
||||||
|
library_cache: left
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| (a.meta.id.clone(), a))
|
||||||
|
.collect(),
|
||||||
|
database_cache: right.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
mh.collection = mh.merge_collections();
|
||||||
|
assert_eq!(expected, mh.collection);
|
||||||
|
|
||||||
|
// The merge overwrites the sort data, but no data is erased so it should be commutative.
|
||||||
|
let mut mh = MusicHoard {
|
||||||
|
library_cache: right
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| (a.meta.id.clone(), a))
|
||||||
|
.collect(),
|
||||||
|
database_cache: left.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
mh.collection = mh.merge_collections();
|
||||||
|
assert_eq!(expected, mh.collection);
|
||||||
|
}
|
||||||
|
}
|
191
src/core/musichoard/builder.rs
Normal file
191
src/core/musichoard/builder.rs
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
interface::{database::IDatabase, library::ILibrary},
|
||||||
|
musichoard::{database::IMusicHoardDatabase, Error, MusicHoard, NoDatabase, NoLibrary},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Builder for [`MusicHoard`]. Its purpose is to make it easier to set various combinations of
|
||||||
|
/// library/database or their absence.
|
||||||
|
pub struct MusicHoardBuilder<Database, Library> {
|
||||||
|
database: Database,
|
||||||
|
library: Library,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MusicHoardBuilder<NoDatabase, NoLibrary> {
|
||||||
|
/// Create a [`MusicHoardBuilder`].
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicHoardBuilder<NoDatabase, NoLibrary> {
|
||||||
|
/// Create a [`MusicHoardBuilder`].
|
||||||
|
pub fn new() -> Self {
|
||||||
|
MusicHoardBuilder {
|
||||||
|
database: NoDatabase,
|
||||||
|
library: NoLibrary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database, Library> MusicHoardBuilder<Database, Library> {
|
||||||
|
/// Set a library for [`MusicHoard`].
|
||||||
|
pub fn set_library<NewLibrary: ILibrary>(
|
||||||
|
self,
|
||||||
|
library: NewLibrary,
|
||||||
|
) -> MusicHoardBuilder<Database, NewLibrary> {
|
||||||
|
MusicHoardBuilder {
|
||||||
|
database: self.database,
|
||||||
|
library,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a database for [`MusicHoard`].
|
||||||
|
pub fn set_database<NewDatabase: IDatabase>(
|
||||||
|
self,
|
||||||
|
database: NewDatabase,
|
||||||
|
) -> MusicHoardBuilder<NewDatabase, Library> {
|
||||||
|
MusicHoardBuilder {
|
||||||
|
database,
|
||||||
|
library: self.library,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicHoardBuilder<NoDatabase, NoLibrary> {
|
||||||
|
/// Build [`MusicHoard`] with the currently set library and database.
|
||||||
|
pub fn build(self) -> MusicHoard<NoDatabase, NoLibrary> {
|
||||||
|
MusicHoard::empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicHoard<NoDatabase, NoLibrary> {
|
||||||
|
/// Create a new [`MusicHoard`] without any library or database.
|
||||||
|
pub fn empty() -> Self {
|
||||||
|
MusicHoard {
|
||||||
|
collection: vec![],
|
||||||
|
pre_commit: vec![],
|
||||||
|
database: NoDatabase,
|
||||||
|
database_cache: vec![],
|
||||||
|
library: NoLibrary,
|
||||||
|
library_cache: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Library: ILibrary> MusicHoardBuilder<NoDatabase, Library> {
|
||||||
|
/// Build [`MusicHoard`] with the currently set library and database.
|
||||||
|
pub fn build(self) -> MusicHoard<NoDatabase, Library> {
|
||||||
|
MusicHoard::library(self.library)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Library: ILibrary> MusicHoard<NoDatabase, Library> {
|
||||||
|
/// Create a new [`MusicHoard`] with the provided [`ILibrary`] and no database.
|
||||||
|
pub fn library(library: Library) -> Self {
|
||||||
|
MusicHoard {
|
||||||
|
collection: vec![],
|
||||||
|
pre_commit: vec![],
|
||||||
|
database: NoDatabase,
|
||||||
|
database_cache: vec![],
|
||||||
|
library,
|
||||||
|
library_cache: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database: IDatabase> MusicHoardBuilder<Database, NoLibrary> {
|
||||||
|
/// Build [`MusicHoard`] with the currently set library and database.
|
||||||
|
pub fn build(self) -> Result<MusicHoard<Database, NoLibrary>, Error> {
|
||||||
|
MusicHoard::database(self.database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database: IDatabase> MusicHoard<Database, NoLibrary> {
|
||||||
|
/// Create a new [`MusicHoard`] with the provided [`IDatabase`] and no library.
|
||||||
|
pub fn database(database: Database) -> Result<Self, Error> {
|
||||||
|
let mut mh = MusicHoard {
|
||||||
|
collection: vec![],
|
||||||
|
pre_commit: vec![],
|
||||||
|
database,
|
||||||
|
database_cache: vec![],
|
||||||
|
library: NoLibrary,
|
||||||
|
library_cache: HashMap::new(),
|
||||||
|
};
|
||||||
|
mh.reload_database()?;
|
||||||
|
Ok(mh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database: IDatabase, Library: ILibrary> MusicHoardBuilder<Database, Library> {
|
||||||
|
/// Build [`MusicHoard`] with the currently set library and database.
|
||||||
|
pub fn build(self) -> Result<MusicHoard<Database, Library>, Error> {
|
||||||
|
MusicHoard::new(self.database, self.library)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database: IDatabase, Library: ILibrary> MusicHoard<Database, Library> {
|
||||||
|
/// Create a new [`MusicHoard`] with the provided [`ILibrary`] and [`IDatabase`].
|
||||||
|
pub fn new(database: Database, library: Library) -> Result<Self, Error> {
|
||||||
|
let mut mh = MusicHoard {
|
||||||
|
collection: vec![],
|
||||||
|
pre_commit: vec![],
|
||||||
|
database,
|
||||||
|
database_cache: vec![],
|
||||||
|
library,
|
||||||
|
library_cache: HashMap::new(),
|
||||||
|
};
|
||||||
|
mh.reload_database()?;
|
||||||
|
Ok(mh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::core::{
|
||||||
|
interface::{database::NullDatabase, library::NullLibrary},
|
||||||
|
musichoard::library::IMusicHoardLibrary,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_library_no_database() {
|
||||||
|
MusicHoardBuilder::default().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty() {
|
||||||
|
let music_hoard = MusicHoard::empty();
|
||||||
|
assert!(!format!("{music_hoard:?}").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_library_no_database() {
|
||||||
|
let mut mh = MusicHoardBuilder::default()
|
||||||
|
.set_library(NullLibrary)
|
||||||
|
.build();
|
||||||
|
assert!(mh.rescan_library().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_library_with_database() {
|
||||||
|
let mut mh = MusicHoardBuilder::default()
|
||||||
|
.set_database(NullDatabase)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(mh.reload_database().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_library_with_database() {
|
||||||
|
let mut mh = MusicHoardBuilder::default()
|
||||||
|
.set_library(NullLibrary)
|
||||||
|
.set_database(NullDatabase)
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
assert!(mh.rescan_library().is_ok());
|
||||||
|
assert!(mh.reload_database().is_ok());
|
||||||
|
}
|
||||||
|
}
|
769
src/core/musichoard/database.rs
Normal file
769
src/core/musichoard/database.rs
Normal file
@ -0,0 +1,769 @@
|
|||||||
|
use std::mem;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::{album::AlbumInfo, artist::ArtistInfo, merge::Merge},
|
||||||
|
core::{
|
||||||
|
collection::{
|
||||||
|
album::{Album, AlbumId, AlbumSeq},
|
||||||
|
artist::{Artist, ArtistId},
|
||||||
|
Collection,
|
||||||
|
},
|
||||||
|
interface::database::IDatabase,
|
||||||
|
musichoard::{base::IMusicHoardBasePrivate, Error, MusicHoard, NoDatabase},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait IMusicHoardDatabase {
|
||||||
|
fn reload_database(&mut self) -> Result<(), Error>;
|
||||||
|
|
||||||
|
fn add_artist<IntoId: Into<ArtistId>>(&mut self, artist_id: IntoId) -> Result<(), Error>;
|
||||||
|
fn remove_artist<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
|
||||||
|
|
||||||
|
fn set_artist_sort<Id: AsRef<ArtistId>, S: Into<String>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
artist_sort: S,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
fn clear_artist_sort<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
|
||||||
|
|
||||||
|
fn merge_artist_info<Id: AsRef<ArtistId>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
info: ArtistInfo,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
fn clear_artist_info<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
|
||||||
|
|
||||||
|
fn add_to_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
property: S,
|
||||||
|
values: Vec<S>,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
fn remove_from_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
property: S,
|
||||||
|
values: Vec<S>,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
fn set_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
property: S,
|
||||||
|
values: Vec<S>,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
fn clear_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
property: S,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
|
||||||
|
fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: ArtistIdRef,
|
||||||
|
album_id: AlbumIdRef,
|
||||||
|
seq: u8,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
fn clear_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: ArtistIdRef,
|
||||||
|
album_id: AlbumIdRef,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
fn merge_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
album_id: AlbumIdRef,
|
||||||
|
info: AlbumInfo,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
fn clear_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
album_id: AlbumIdRef,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database: IDatabase, Library> IMusicHoardDatabase for MusicHoard<Database, Library> {
|
||||||
|
fn reload_database(&mut self) -> Result<(), Error> {
|
||||||
|
self.database_cache = self.database.load()?;
|
||||||
|
Self::sort_albums_and_tracks(self.database_cache.iter_mut());
|
||||||
|
|
||||||
|
self.collection = self.merge_collections();
|
||||||
|
self.pre_commit = self.collection.clone();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_artist<IntoId: Into<ArtistId>>(&mut self, artist_id: IntoId) -> Result<(), Error> {
|
||||||
|
let artist_id: ArtistId = artist_id.into();
|
||||||
|
|
||||||
|
self.update_collection(|collection| {
|
||||||
|
if Self::get_artist(collection, &artist_id).is_none() {
|
||||||
|
collection.push(Artist::new(artist_id));
|
||||||
|
Self::sort_artists(collection);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_artist<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
|
||||||
|
self.update_collection(|collection| {
|
||||||
|
let index_opt = collection
|
||||||
|
.iter()
|
||||||
|
.position(|a| &a.meta.id == artist_id.as_ref());
|
||||||
|
if let Some(index) = index_opt {
|
||||||
|
collection.remove(index);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_artist_sort<Id: AsRef<ArtistId>, S: Into<String>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
artist_sort: S,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.update_artist_and(
|
||||||
|
artist_id.as_ref(),
|
||||||
|
|artist| artist.meta.set_sort_key(artist_sort),
|
||||||
|
|collection| Self::sort_artists(collection),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_artist_sort<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
|
||||||
|
self.update_artist_and(
|
||||||
|
artist_id.as_ref(),
|
||||||
|
|artist| artist.meta.clear_sort_key(),
|
||||||
|
|collection| Self::sort_artists(collection),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_artist_info<Id: AsRef<ArtistId>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
mut info: ArtistInfo,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.update_artist(artist_id.as_ref(), |artist| {
|
||||||
|
mem::swap(&mut artist.meta.info, &mut info);
|
||||||
|
artist.meta.info.merge_in_place(info);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_artist_info<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
|
||||||
|
self.update_artist(artist_id.as_ref(), |artist| {
|
||||||
|
artist.meta.info = ArtistInfo::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_to_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
property: S,
|
||||||
|
values: Vec<S>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.update_artist(artist_id.as_ref(), |artist| {
|
||||||
|
artist.meta.info.add_to_property(property, values)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_from_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
property: S,
|
||||||
|
values: Vec<S>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.update_artist(artist_id.as_ref(), |artist| {
|
||||||
|
artist.meta.info.remove_from_property(property, values)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
property: S,
|
||||||
|
values: Vec<S>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.update_artist(artist_id.as_ref(), |artist| {
|
||||||
|
artist.meta.info.set_property(property, values)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
property: S,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.update_artist(artist_id.as_ref(), |artist| {
|
||||||
|
artist.meta.info.clear_property(property)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: ArtistIdRef,
|
||||||
|
album_id: AlbumIdRef,
|
||||||
|
seq: u8,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.update_album_and(
|
||||||
|
artist_id.as_ref(),
|
||||||
|
album_id.as_ref(),
|
||||||
|
|album| album.meta.set_seq(AlbumSeq(seq)),
|
||||||
|
|artist| artist.albums.sort_unstable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: ArtistIdRef,
|
||||||
|
album_id: AlbumIdRef,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.update_album_and(
|
||||||
|
artist_id.as_ref(),
|
||||||
|
album_id.as_ref(),
|
||||||
|
|album| album.meta.clear_seq(),
|
||||||
|
|artist| artist.albums.sort_unstable(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
album_id: AlbumIdRef,
|
||||||
|
mut info: AlbumInfo,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.update_album(artist_id.as_ref(), album_id.as_ref(), |album| {
|
||||||
|
mem::swap(&mut album.meta.info, &mut info);
|
||||||
|
album.meta.info.merge_in_place(info);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: Id,
|
||||||
|
album_id: AlbumIdRef,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.update_album(artist_id.as_ref(), album_id.as_ref(), |album| {
|
||||||
|
album.meta.info = AlbumInfo::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IMusicHoardDatabasePrivate {
|
||||||
|
fn commit(&mut self) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Library> IMusicHoardDatabasePrivate for MusicHoard<NoDatabase, Library> {
|
||||||
|
fn commit(&mut self) -> Result<(), Error> {
|
||||||
|
self.collection = self.pre_commit.clone();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database: IDatabase, Library> IMusicHoardDatabasePrivate for MusicHoard<Database, Library> {
|
||||||
|
fn commit(&mut self) -> Result<(), Error> {
|
||||||
|
if self.collection != self.pre_commit {
|
||||||
|
if let Err(err) = self.database.save(&self.pre_commit) {
|
||||||
|
self.pre_commit = self.collection.clone();
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
self.collection = self.pre_commit.clone();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database: IDatabase, Library> MusicHoard<Database, Library> {
|
||||||
|
fn update_collection<FnColl>(&mut self, fn_coll: FnColl) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
FnColl: FnOnce(&mut Collection),
|
||||||
|
{
|
||||||
|
fn_coll(&mut self.pre_commit);
|
||||||
|
self.commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_artist_and<FnArtist, FnColl>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
fn_artist: FnArtist,
|
||||||
|
fn_coll: FnColl,
|
||||||
|
) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
FnArtist: FnOnce(&mut Artist),
|
||||||
|
FnColl: FnOnce(&mut Collection),
|
||||||
|
{
|
||||||
|
let artist = Self::get_artist_mut_or_err(&mut self.pre_commit, artist_id)?;
|
||||||
|
fn_artist(artist);
|
||||||
|
self.update_collection(fn_coll)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_artist<FnArtist>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
fn_artist: FnArtist,
|
||||||
|
) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
FnArtist: FnOnce(&mut Artist),
|
||||||
|
{
|
||||||
|
self.update_artist_and(artist_id, fn_artist, |_| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_album_and<FnAlbum, FnArtist>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
album_id: &AlbumId,
|
||||||
|
fn_album: FnAlbum,
|
||||||
|
fn_artist: FnArtist,
|
||||||
|
) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
FnAlbum: FnOnce(&mut Album),
|
||||||
|
FnArtist: FnOnce(&mut Artist),
|
||||||
|
{
|
||||||
|
let artist = Self::get_artist_mut_or_err(&mut self.pre_commit, artist_id)?;
|
||||||
|
let album = Self::get_album_mut_or_err(artist, album_id)?;
|
||||||
|
fn_album(album);
|
||||||
|
fn_artist(artist);
|
||||||
|
self.update_collection(|_| {})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_album<FnAlbum>(
|
||||||
|
&mut self,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
album_id: &AlbumId,
|
||||||
|
fn_album: FnAlbum,
|
||||||
|
) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
FnAlbum: FnOnce(&mut Album),
|
||||||
|
{
|
||||||
|
self.update_album_and(artist_id, album_id, fn_album, |_| {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockall::{predicate, Sequence};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::{
|
||||||
|
album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
musicbrainz::{MbArtistRef, MbRefOption},
|
||||||
|
},
|
||||||
|
core::{
|
||||||
|
collection::{album::AlbumDate, artist::ArtistId},
|
||||||
|
interface::database::{self, MockIDatabase},
|
||||||
|
musichoard::{base::IMusicHoardBase, NoLibrary},
|
||||||
|
testmod::FULL_COLLECTION,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
static MBID: &str = "d368baa8-21ca-4759-9731-0b2753071ad8";
|
||||||
|
static MUSICBUTLER: &str = "https://www.musicbutler.io/artist-page/483340948";
|
||||||
|
static MUSICBUTLER_2: &str = "https://www.musicbutler.io/artist-page/658903042/";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_new_delete() {
|
||||||
|
let artist_id = ArtistId::new("an artist");
|
||||||
|
let artist_id_2 = ArtistId::new("another artist");
|
||||||
|
|
||||||
|
let collection = FULL_COLLECTION.to_owned();
|
||||||
|
let mut with_artist = collection.clone();
|
||||||
|
with_artist.push(Artist::new(artist_id.clone()));
|
||||||
|
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
let mut seq = Sequence::new();
|
||||||
|
database
|
||||||
|
.expect_load()
|
||||||
|
.times(1)
|
||||||
|
.times(1)
|
||||||
|
.in_sequence(&mut seq)
|
||||||
|
.returning(|| Ok(FULL_COLLECTION.to_owned()));
|
||||||
|
database
|
||||||
|
.expect_save()
|
||||||
|
.times(1)
|
||||||
|
.in_sequence(&mut seq)
|
||||||
|
.with(predicate::eq(with_artist.clone()))
|
||||||
|
.returning(|_| Ok(()));
|
||||||
|
database
|
||||||
|
.expect_save()
|
||||||
|
.times(1)
|
||||||
|
.in_sequence(&mut seq)
|
||||||
|
.with(predicate::eq(collection.clone()))
|
||||||
|
.returning(|_| Ok(()));
|
||||||
|
|
||||||
|
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||||
|
assert_eq!(music_hoard.collection, collection);
|
||||||
|
|
||||||
|
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
|
||||||
|
assert_eq!(music_hoard.collection, with_artist);
|
||||||
|
|
||||||
|
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
|
||||||
|
assert_eq!(music_hoard.collection, with_artist);
|
||||||
|
|
||||||
|
assert!(music_hoard.remove_artist(&artist_id_2).is_ok());
|
||||||
|
assert_eq!(music_hoard.collection, with_artist);
|
||||||
|
|
||||||
|
assert!(music_hoard.remove_artist(&artist_id).is_ok());
|
||||||
|
assert_eq!(music_hoard.collection, collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_sort_set_clear() {
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||||
|
database.expect_save().times(4).returning(|_| Ok(()));
|
||||||
|
|
||||||
|
type MH = MusicHoard<MockIDatabase, NoLibrary>;
|
||||||
|
let mut music_hoard: MH = MusicHoard::database(database).unwrap();
|
||||||
|
|
||||||
|
let artist_1_id = ArtistId::new("the artist");
|
||||||
|
let artist_1_sort = String::from("artist, the");
|
||||||
|
|
||||||
|
// Must be after "artist, the", but before "the artist"
|
||||||
|
let artist_2_id = ArtistId::new("b-artist");
|
||||||
|
|
||||||
|
assert!(artist_1_sort < artist_2_id.name);
|
||||||
|
assert!(artist_2_id < artist_1_id);
|
||||||
|
|
||||||
|
assert!(music_hoard.add_artist(artist_1_id.clone()).is_ok());
|
||||||
|
assert!(music_hoard.add_artist(artist_2_id.clone()).is_ok());
|
||||||
|
|
||||||
|
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
|
||||||
|
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
|
||||||
|
|
||||||
|
assert!(artist_2 < artist_1);
|
||||||
|
|
||||||
|
assert_eq!(artist_1, &music_hoard.collection[1]);
|
||||||
|
assert_eq!(artist_2, &music_hoard.collection[0]);
|
||||||
|
|
||||||
|
music_hoard
|
||||||
|
.set_artist_sort(artist_1_id.as_ref(), artist_1_sort.clone())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
|
||||||
|
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
|
||||||
|
|
||||||
|
assert!(artist_1 < artist_2);
|
||||||
|
|
||||||
|
assert_eq!(artist_1, &music_hoard.collection[0]);
|
||||||
|
assert_eq!(artist_2, &music_hoard.collection[1]);
|
||||||
|
|
||||||
|
music_hoard.clear_artist_sort(artist_1_id.as_ref()).unwrap();
|
||||||
|
|
||||||
|
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
|
||||||
|
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
|
||||||
|
|
||||||
|
assert!(artist_2 < artist_1);
|
||||||
|
|
||||||
|
assert_eq!(artist_1, &music_hoard.collection[1]);
|
||||||
|
assert_eq!(artist_2, &music_hoard.collection[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collection_error() {
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||||
|
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||||
|
|
||||||
|
let artist_id = ArtistId::new("an artist");
|
||||||
|
let actual_err = music_hoard
|
||||||
|
.merge_artist_info(&artist_id, ArtistInfo::default())
|
||||||
|
.unwrap_err();
|
||||||
|
let expected_err =
|
||||||
|
Error::CollectionError(format!("artist '{artist_id}' is not in the collection"));
|
||||||
|
assert_eq!(actual_err, expected_err);
|
||||||
|
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_clear_artist_info() {
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||||
|
database.expect_save().times(3).returning(|_| Ok(()));
|
||||||
|
|
||||||
|
let artist_id = ArtistId::new("an artist");
|
||||||
|
let artist_id_2 = ArtistId::new("another artist");
|
||||||
|
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||||
|
|
||||||
|
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
|
||||||
|
|
||||||
|
let mut expected: MbRefOption<MbArtistRef> = MbRefOption::None;
|
||||||
|
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
|
||||||
|
|
||||||
|
let info = ArtistInfo::new(MbRefOption::Some(MbArtistRef::from_uuid_str(MBID).unwrap()));
|
||||||
|
|
||||||
|
// Setting a URL on an artist not in the collection is an error.
|
||||||
|
assert!(music_hoard
|
||||||
|
.merge_artist_info(&artist_id_2, info.clone())
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
|
||||||
|
|
||||||
|
// Setting a URL on an artist.
|
||||||
|
assert!(music_hoard
|
||||||
|
.merge_artist_info(&artist_id, info.clone())
|
||||||
|
.is_ok());
|
||||||
|
expected.replace(MbArtistRef::from_uuid_str(MBID).unwrap());
|
||||||
|
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
|
||||||
|
|
||||||
|
// Clearing URLs on an artist that does not exist is an error.
|
||||||
|
assert!(music_hoard.clear_artist_info(&artist_id_2).is_err());
|
||||||
|
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
|
||||||
|
|
||||||
|
// Clearing URLs.
|
||||||
|
assert!(music_hoard.clear_artist_info(&artist_id).is_ok());
|
||||||
|
expected.take();
|
||||||
|
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_to_remove_from_property() {
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||||
|
database.expect_save().times(3).returning(|_| Ok(()));
|
||||||
|
|
||||||
|
let artist_id = ArtistId::new("an artist");
|
||||||
|
let artist_id_2 = ArtistId::new("another artist");
|
||||||
|
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||||
|
|
||||||
|
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
|
||||||
|
|
||||||
|
let mut expected: Vec<String> = vec![];
|
||||||
|
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||||
|
|
||||||
|
// Adding URLs to an artist not in the collection is an error.
|
||||||
|
assert!(music_hoard
|
||||||
|
.add_to_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
|
||||||
|
.is_err());
|
||||||
|
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||||
|
|
||||||
|
// Adding mutliple URLs without clashes.
|
||||||
|
assert!(music_hoard
|
||||||
|
.add_to_artist_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
|
||||||
|
.is_ok());
|
||||||
|
expected.push(MUSICBUTLER.to_owned());
|
||||||
|
expected.push(MUSICBUTLER_2.to_owned());
|
||||||
|
let info = &music_hoard.collection[0].meta.info;
|
||||||
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
|
// Removing URLs from an artist not in the collection is an error.
|
||||||
|
assert!(music_hoard
|
||||||
|
.remove_from_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
|
||||||
|
.is_err());
|
||||||
|
let info = &music_hoard.collection[0].meta.info;
|
||||||
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
|
// Removing multiple URLs without clashes.
|
||||||
|
assert!(music_hoard
|
||||||
|
.remove_from_artist_property(
|
||||||
|
&artist_id,
|
||||||
|
"MusicButler",
|
||||||
|
vec![MUSICBUTLER, MUSICBUTLER_2]
|
||||||
|
)
|
||||||
|
.is_ok());
|
||||||
|
expected.clear();
|
||||||
|
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_clear_property() {
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||||
|
database.expect_save().times(3).returning(|_| Ok(()));
|
||||||
|
|
||||||
|
let artist_id = ArtistId::new("an artist");
|
||||||
|
let artist_id_2 = ArtistId::new("another artist");
|
||||||
|
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||||
|
|
||||||
|
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
|
||||||
|
|
||||||
|
let mut expected: Vec<String> = vec![];
|
||||||
|
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||||
|
|
||||||
|
// Seting URL on an artist not in the collection is an error.
|
||||||
|
assert!(music_hoard
|
||||||
|
.set_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
|
||||||
|
.is_err());
|
||||||
|
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||||
|
|
||||||
|
// Set URLs.
|
||||||
|
assert!(music_hoard
|
||||||
|
.set_artist_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
|
||||||
|
.is_ok());
|
||||||
|
expected.clear();
|
||||||
|
expected.push(MUSICBUTLER.to_owned());
|
||||||
|
expected.push(MUSICBUTLER_2.to_owned());
|
||||||
|
let info = &music_hoard.collection[0].meta.info;
|
||||||
|
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||||
|
|
||||||
|
// Clearing URLs on an artist that does not exist is an error.
|
||||||
|
assert!(music_hoard
|
||||||
|
.clear_artist_property(&artist_id_2, "MusicButler")
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
// Clear URLs.
|
||||||
|
assert!(music_hoard
|
||||||
|
.clear_artist_property(&artist_id, "MusicButler")
|
||||||
|
.is_ok());
|
||||||
|
expected.clear();
|
||||||
|
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_clear_album_seq() {
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
|
||||||
|
let artist_id = ArtistId::new("an artist");
|
||||||
|
let album_id = AlbumId::new("an album");
|
||||||
|
let album_id_2 = AlbumId::new("another album");
|
||||||
|
|
||||||
|
let mut database_result = vec![Artist::new(artist_id.clone())];
|
||||||
|
database_result[0].albums.push(Album::new(
|
||||||
|
album_id.clone(),
|
||||||
|
AlbumDate::default(),
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
));
|
||||||
|
|
||||||
|
database
|
||||||
|
.expect_load()
|
||||||
|
.times(1)
|
||||||
|
.return_once(|| Ok(database_result));
|
||||||
|
database.expect_save().times(2).returning(|_| Ok(()));
|
||||||
|
|
||||||
|
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||||
|
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(0));
|
||||||
|
|
||||||
|
// Seting seq on an album not belonging to the artist is an error.
|
||||||
|
assert!(music_hoard
|
||||||
|
.set_album_seq(&artist_id, &album_id_2, 6)
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(0));
|
||||||
|
|
||||||
|
// Set seq.
|
||||||
|
assert!(music_hoard.set_album_seq(&artist_id, &album_id, 6).is_ok());
|
||||||
|
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(6));
|
||||||
|
|
||||||
|
// Clearing seq on an album that does not exist is an error.
|
||||||
|
assert!(music_hoard
|
||||||
|
.clear_album_seq(&artist_id, &album_id_2)
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
// Clear seq.
|
||||||
|
assert!(music_hoard.clear_album_seq(&artist_id, &album_id).is_ok());
|
||||||
|
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_clear_album_info() {
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
|
||||||
|
let artist_id = ArtistId::new("an artist");
|
||||||
|
let album_id = AlbumId::new("an album");
|
||||||
|
let album_id_2 = AlbumId::new("another album");
|
||||||
|
|
||||||
|
let mut database_result = vec![Artist::new(artist_id.clone())];
|
||||||
|
database_result[0].albums.push(Album::new(
|
||||||
|
album_id.clone(),
|
||||||
|
AlbumDate::default(),
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
));
|
||||||
|
|
||||||
|
database
|
||||||
|
.expect_load()
|
||||||
|
.times(1)
|
||||||
|
.return_once(|| Ok(database_result));
|
||||||
|
database.expect_save().times(2).returning(|_| Ok(()));
|
||||||
|
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||||
|
|
||||||
|
let meta = &music_hoard.collection[0].albums[0].meta;
|
||||||
|
assert_eq!(meta.info.musicbrainz, MbRefOption::None);
|
||||||
|
assert_eq!(meta.info.primary_type, None);
|
||||||
|
assert_eq!(meta.info.secondary_types, Vec::new());
|
||||||
|
|
||||||
|
let info = AlbumInfo::new(
|
||||||
|
MbRefOption::CannotHaveMbid,
|
||||||
|
Some(AlbumPrimaryType::Album),
|
||||||
|
vec![AlbumSecondaryType::Live],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seting info on an album not belonging to the artist is an error.
|
||||||
|
assert!(music_hoard
|
||||||
|
.merge_album_info(&artist_id, &album_id_2, info.clone())
|
||||||
|
.is_err());
|
||||||
|
let meta = &music_hoard.collection[0].albums[0].meta;
|
||||||
|
assert_eq!(meta.info, AlbumInfo::default());
|
||||||
|
|
||||||
|
// Set info.
|
||||||
|
assert!(music_hoard
|
||||||
|
.merge_album_info(&artist_id, &album_id, info.clone())
|
||||||
|
.is_ok());
|
||||||
|
let meta = &music_hoard.collection[0].albums[0].meta;
|
||||||
|
assert_eq!(meta.info, info);
|
||||||
|
|
||||||
|
// Clearing info on an album that does not exist is an error.
|
||||||
|
assert!(music_hoard
|
||||||
|
.clear_album_info(&artist_id, &album_id_2)
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
// Clear info.
|
||||||
|
assert!(music_hoard.clear_album_info(&artist_id, &album_id).is_ok());
|
||||||
|
let meta = &music_hoard.collection[0].albums[0].meta;
|
||||||
|
assert_eq!(meta.info, AlbumInfo::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_database() {
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
|
||||||
|
database
|
||||||
|
.expect_load()
|
||||||
|
.times(1)
|
||||||
|
.return_once(|| Ok(FULL_COLLECTION.to_owned()));
|
||||||
|
|
||||||
|
let music_hoard = MusicHoard::database(database).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(music_hoard.get_collection(), &*FULL_COLLECTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_load_error() {
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
|
||||||
|
let database_result = Err(database::LoadError::IoError(String::from("I/O error")));
|
||||||
|
|
||||||
|
database
|
||||||
|
.expect_load()
|
||||||
|
.times(1)
|
||||||
|
.return_once(|| database_result);
|
||||||
|
|
||||||
|
let actual_err = MusicHoard::database(database).unwrap_err();
|
||||||
|
let expected_err = Error::DatabaseError(
|
||||||
|
database::LoadError::IoError(String::from("I/O error")).to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(actual_err, expected_err);
|
||||||
|
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn database_save_error() {
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
|
||||||
|
let database_result = Err(database::SaveError::IoError(String::from("I/O error")));
|
||||||
|
|
||||||
|
database.expect_load().return_once(|| Ok(vec![]));
|
||||||
|
database
|
||||||
|
.expect_save()
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_: &Collection| database_result);
|
||||||
|
|
||||||
|
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||||
|
|
||||||
|
let actual_err = music_hoard
|
||||||
|
.add_artist(ArtistId::new("an artist"))
|
||||||
|
.unwrap_err();
|
||||||
|
let expected_err = Error::DatabaseError(
|
||||||
|
database::SaveError::IoError(String::from("I/O error")).to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(actual_err, expected_err);
|
||||||
|
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
||||||
|
}
|
||||||
|
}
|
317
src/core/musichoard/library.rs
Normal file
317
src/core/musichoard/library.rs
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
collection::{
|
||||||
|
album::{Album, AlbumDate, AlbumId},
|
||||||
|
artist::{Artist, ArtistId},
|
||||||
|
track::{Track, TrackId, TrackNum, TrackQuality},
|
||||||
|
Collection,
|
||||||
|
},
|
||||||
|
interface::{
|
||||||
|
database::IDatabase,
|
||||||
|
library::{ILibrary, Item, Query},
|
||||||
|
},
|
||||||
|
musichoard::{
|
||||||
|
base::IMusicHoardBasePrivate, database::IMusicHoardDatabasePrivate, Error, MusicHoard,
|
||||||
|
NoDatabase,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait IMusicHoardLibrary {
|
||||||
|
fn rescan_library(&mut self) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Library: ILibrary> IMusicHoardLibrary for MusicHoard<NoDatabase, Library> {
|
||||||
|
fn rescan_library(&mut self) -> Result<(), Error> {
|
||||||
|
self.pre_commit = self.rescan_library_inner()?;
|
||||||
|
self.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database: IDatabase, Library: ILibrary> IMusicHoardLibrary for MusicHoard<Database, Library> {
|
||||||
|
fn rescan_library(&mut self) -> Result<(), Error> {
|
||||||
|
self.pre_commit = self.rescan_library_inner()?;
|
||||||
|
self.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Database, Library: ILibrary> MusicHoard<Database, Library> {
|
||||||
|
fn rescan_library_inner(&mut self) -> Result<Collection, Error> {
|
||||||
|
let items = self.library.list(&Query::new())?;
|
||||||
|
self.library_cache = Self::items_to_artists(items)?;
|
||||||
|
Self::sort_albums_and_tracks(self.library_cache.values_mut());
|
||||||
|
|
||||||
|
Ok(self.merge_collections())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn items_to_artists(items: Vec<Item>) -> Result<HashMap<ArtistId, Artist>, Error> {
|
||||||
|
let mut collection = HashMap::<ArtistId, Artist>::new();
|
||||||
|
|
||||||
|
for item in items.into_iter() {
|
||||||
|
let artist_id = ArtistId {
|
||||||
|
name: item.album_artist,
|
||||||
|
};
|
||||||
|
|
||||||
|
let artist_sort = item.album_artist_sort;
|
||||||
|
|
||||||
|
let album_id = AlbumId {
|
||||||
|
title: item.album_title,
|
||||||
|
};
|
||||||
|
|
||||||
|
let album_date = AlbumDate {
|
||||||
|
year: Some(item.album_year).filter(|y| y > &0),
|
||||||
|
month: Some(item.album_month).filter(|m| m > &0),
|
||||||
|
day: Some(item.album_day).filter(|d| d > &0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let track = Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: item.track_title,
|
||||||
|
},
|
||||||
|
number: TrackNum(item.track_number),
|
||||||
|
artist: item.track_artist,
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: item.track_format,
|
||||||
|
bitrate: item.track_bitrate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// There are usually many entries per artist. Therefore, we avoid simply calling
|
||||||
|
// .entry(artist_id.clone()).or_insert_with(..), because of the clone. The flipside is
|
||||||
|
// that insertions will thus do an additional lookup.
|
||||||
|
let artist = match collection.get_mut(&artist_id) {
|
||||||
|
Some(artist) => artist,
|
||||||
|
None => collection
|
||||||
|
.entry(artist_id.clone())
|
||||||
|
.or_insert_with(|| Artist::new(artist_id)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if artist.meta.sort.is_some() {
|
||||||
|
if artist_sort.is_some() && (artist.meta.sort != artist_sort) {
|
||||||
|
return Err(Error::CollectionError(format!(
|
||||||
|
"multiple album_artist_sort found for artist '{}': '{}' != '{}'",
|
||||||
|
artist.meta.id,
|
||||||
|
artist.meta.sort.as_ref().unwrap(),
|
||||||
|
artist_sort.as_ref().unwrap()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
} else if artist_sort.is_some() {
|
||||||
|
artist.meta.sort = artist_sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do a linear search as few artists have more than a handful of albums. Search from the
|
||||||
|
// back as the original items vector is usually already sorted.
|
||||||
|
match artist
|
||||||
|
.albums
|
||||||
|
.iter_mut()
|
||||||
|
.rev()
|
||||||
|
.find(|album| album.meta.id == album_id)
|
||||||
|
{
|
||||||
|
Some(album) => album.tracks.push(track),
|
||||||
|
None => {
|
||||||
|
let mut album = Album::new(album_id, album_date, None, vec![]);
|
||||||
|
album.tracks.push(track);
|
||||||
|
artist.albums.push(album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockall::{predicate, Sequence};
|
||||||
|
|
||||||
|
use crate::core::{
|
||||||
|
interface::{
|
||||||
|
database::MockIDatabase,
|
||||||
|
library::{self, testmod::LIBRARY_ITEMS, MockILibrary},
|
||||||
|
},
|
||||||
|
musichoard::base::IMusicHoardBase,
|
||||||
|
testmod::LIBRARY_COLLECTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rescan_library_ordered() {
|
||||||
|
let mut library = MockILibrary::new();
|
||||||
|
let mut database = MockIDatabase::new();
|
||||||
|
|
||||||
|
let library_input = Query::new();
|
||||||
|
let library_result = Ok(LIBRARY_ITEMS.to_owned());
|
||||||
|
|
||||||
|
library
|
||||||
|
.expect_list()
|
||||||
|
.with(predicate::eq(library_input))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_| library_result);
|
||||||
|
|
||||||
|
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||||
|
database
|
||||||
|
.expect_save()
|
||||||
|
.with(predicate::eq(&*LIBRARY_COLLECTION))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_| Ok(()));
|
||||||
|
|
||||||
|
let mut music_hoard = MusicHoard::new(database, library).unwrap();
|
||||||
|
|
||||||
|
music_hoard.rescan_library().unwrap();
|
||||||
|
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rescan_library_changed() {
|
||||||
|
let mut library = MockILibrary::new();
|
||||||
|
let mut seq = Sequence::new();
|
||||||
|
|
||||||
|
let library_input = Query::new();
|
||||||
|
let library_result = Ok(LIBRARY_ITEMS.to_owned());
|
||||||
|
|
||||||
|
library
|
||||||
|
.expect_list()
|
||||||
|
.with(predicate::eq(library_input))
|
||||||
|
.times(1)
|
||||||
|
.in_sequence(&mut seq)
|
||||||
|
.return_once(|_| library_result);
|
||||||
|
|
||||||
|
let library_input = Query::new();
|
||||||
|
let library_result = Ok(LIBRARY_ITEMS
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.album_title != "album_title a.a")
|
||||||
|
.cloned()
|
||||||
|
.collect());
|
||||||
|
|
||||||
|
library
|
||||||
|
.expect_list()
|
||||||
|
.with(predicate::eq(library_input))
|
||||||
|
.times(1)
|
||||||
|
.in_sequence(&mut seq)
|
||||||
|
.return_once(|_| library_result);
|
||||||
|
|
||||||
|
let mut music_hoard = MusicHoard::library(library);
|
||||||
|
|
||||||
|
music_hoard.rescan_library().unwrap();
|
||||||
|
assert!(music_hoard.get_collection()[0]
|
||||||
|
.albums
|
||||||
|
.iter()
|
||||||
|
.any(|album| album.meta.id.title == "album_title a.a"));
|
||||||
|
|
||||||
|
music_hoard.rescan_library().unwrap();
|
||||||
|
assert!(!music_hoard.get_collection()[0]
|
||||||
|
.albums
|
||||||
|
.iter()
|
||||||
|
.any(|album| album.meta.id.title == "album_title a.a"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rescan_library_unordered() {
|
||||||
|
let mut library = MockILibrary::new();
|
||||||
|
|
||||||
|
let library_input = Query::new();
|
||||||
|
let mut library_result = Ok(LIBRARY_ITEMS.to_owned());
|
||||||
|
|
||||||
|
// Swap the last item with the first.
|
||||||
|
let last = library_result.as_ref().unwrap().len() - 1;
|
||||||
|
library_result.as_mut().unwrap().swap(0, last);
|
||||||
|
|
||||||
|
library
|
||||||
|
.expect_list()
|
||||||
|
.with(predicate::eq(library_input))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_| library_result);
|
||||||
|
|
||||||
|
let mut music_hoard = MusicHoard::library(library);
|
||||||
|
|
||||||
|
music_hoard.rescan_library().unwrap();
|
||||||
|
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rescan_library_album_id_clash() {
|
||||||
|
let mut library = MockILibrary::new();
|
||||||
|
|
||||||
|
let mut expected = LIBRARY_COLLECTION.to_owned();
|
||||||
|
let removed_album_id = expected[0].albums[0].meta.id.clone();
|
||||||
|
let clashed_album_id = &expected[1].albums[0].meta.id;
|
||||||
|
|
||||||
|
let mut items = LIBRARY_ITEMS.to_owned();
|
||||||
|
for item in items
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|it| it.album_title == removed_album_id.title)
|
||||||
|
{
|
||||||
|
item.album_title = clashed_album_id.title.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
expected[0].albums[0].meta.id = clashed_album_id.clone();
|
||||||
|
|
||||||
|
let library_input = Query::new();
|
||||||
|
let library_result = Ok(items);
|
||||||
|
|
||||||
|
library
|
||||||
|
.expect_list()
|
||||||
|
.with(predicate::eq(library_input))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_| library_result);
|
||||||
|
|
||||||
|
let mut music_hoard = MusicHoard::library(library);
|
||||||
|
|
||||||
|
music_hoard.rescan_library().unwrap();
|
||||||
|
assert_eq!(music_hoard.get_collection()[0], expected[0]);
|
||||||
|
assert_eq!(music_hoard.get_collection(), &expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rescan_library_album_artist_sort_clash() {
|
||||||
|
let mut library = MockILibrary::new();
|
||||||
|
|
||||||
|
let library_input = Query::new();
|
||||||
|
let mut library_items = LIBRARY_ITEMS.to_owned();
|
||||||
|
|
||||||
|
assert_eq!(library_items[0].album_artist, library_items[1].album_artist);
|
||||||
|
library_items[0].album_artist_sort = Some(library_items[0].album_artist.clone());
|
||||||
|
library_items[1].album_artist_sort = Some(
|
||||||
|
library_items[1]
|
||||||
|
.album_artist
|
||||||
|
.clone()
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let library_result = Ok(library_items);
|
||||||
|
|
||||||
|
library
|
||||||
|
.expect_list()
|
||||||
|
.with(predicate::eq(library_input))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_| library_result);
|
||||||
|
|
||||||
|
let mut music_hoard = MusicHoard::library(library);
|
||||||
|
|
||||||
|
assert!(music_hoard.rescan_library().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_error() {
|
||||||
|
let mut library = MockILibrary::new();
|
||||||
|
|
||||||
|
let library_result = Err(library::Error::Invalid(String::from("invalid data")));
|
||||||
|
|
||||||
|
library
|
||||||
|
.expect_list()
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_| library_result);
|
||||||
|
|
||||||
|
let mut music_hoard = MusicHoard::library(library);
|
||||||
|
|
||||||
|
let actual_err = music_hoard.rescan_library().unwrap_err();
|
||||||
|
let expected_err =
|
||||||
|
Error::LibraryError(library::Error::Invalid(String::from("invalid data")).to_string());
|
||||||
|
|
||||||
|
assert_eq!(actual_err, expected_err);
|
||||||
|
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,57 @@
|
|||||||
//! The core MusicHoard module. Serves as the main entry-point into the library.
|
//! The core MusicHoard module. Serves as the main entry-point into the library.
|
||||||
|
|
||||||
#![allow(clippy::module_inception)]
|
mod base;
|
||||||
pub mod musichoard;
|
mod database;
|
||||||
pub mod musichoard_builder;
|
mod library;
|
||||||
|
|
||||||
use std::fmt::{self, Display};
|
pub mod builder;
|
||||||
|
|
||||||
use crate::core::{collection, database, library};
|
pub use base::IMusicHoardBase;
|
||||||
|
pub use database::IMusicHoardDatabase;
|
||||||
|
pub use library::IMusicHoardLibrary;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fmt::{self, Display},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::core::collection::{
|
||||||
|
artist::{Artist, ArtistId},
|
||||||
|
Collection,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::core::interface::{
|
||||||
|
database::{LoadError as DatabaseLoadError, SaveError as DatabaseSaveError},
|
||||||
|
library::Error as LibraryError,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The Music Hoard. It is responsible for pulling information from both the library and the
|
||||||
|
/// database, ensuring its consistent and writing back any changes.
|
||||||
|
// TODO: Split into inner and external/interfaces to facilitate building.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MusicHoard<Database, Library> {
|
||||||
|
collection: Collection,
|
||||||
|
pre_commit: Collection,
|
||||||
|
database: Database,
|
||||||
|
database_cache: Collection,
|
||||||
|
library: Library,
|
||||||
|
library_cache: HashMap<ArtistId, Artist>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phantom type for when a library implementation is not needed.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NoLibrary;
|
||||||
|
|
||||||
|
/// Phantom type for when a database implementation is not needed.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct NoDatabase;
|
||||||
|
|
||||||
|
impl Default for MusicHoard<NoDatabase, NoLibrary> {
|
||||||
|
/// Create a new [`MusicHoard`] without any library or database.
|
||||||
|
fn default() -> Self {
|
||||||
|
MusicHoard::empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Error type for `musichoard`.
|
/// Error type for `musichoard`.
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
@ -31,26 +76,20 @@ impl Display for Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<collection::Error> for Error {
|
impl From<LibraryError> for Error {
|
||||||
fn from(err: collection::Error) -> Self {
|
fn from(err: LibraryError) -> Error {
|
||||||
Error::CollectionError(err.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<library::Error> for Error {
|
|
||||||
fn from(err: library::Error) -> Error {
|
|
||||||
Error::LibraryError(err.to_string())
|
Error::LibraryError(err.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<database::LoadError> for Error {
|
impl From<DatabaseLoadError> for Error {
|
||||||
fn from(err: database::LoadError) -> Error {
|
fn from(err: DatabaseLoadError) -> Error {
|
||||||
Error::DatabaseError(err.to_string())
|
Error::DatabaseError(err.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<database::SaveError> for Error {
|
impl From<DatabaseSaveError> for Error {
|
||||||
fn from(err: database::SaveError) -> Error {
|
fn from(err: DatabaseSaveError) -> Error {
|
||||||
Error::DatabaseError(err.to_string())
|
Error::DatabaseError(err.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,932 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use crate::core::{
|
|
||||||
collection::{
|
|
||||||
album::{Album, AlbumId},
|
|
||||||
artist::{Artist, ArtistId},
|
|
||||||
track::{Quality, Track, TrackId},
|
|
||||||
Collection, Merge,
|
|
||||||
},
|
|
||||||
database::IDatabase,
|
|
||||||
library::{ILibrary, Item, Query},
|
|
||||||
musichoard::Error,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// The Music Hoard. It is responsible for pulling information from both the library and the
|
|
||||||
/// database, ensuring its consistent and writing back any changes.
|
|
||||||
pub struct MusicHoard<LIB, DB> {
|
|
||||||
collection: Collection,
|
|
||||||
library: LIB,
|
|
||||||
database: DB,
|
|
||||||
// There is no database cache since the database contains the entirety of the `collection`
|
|
||||||
// itself. Therefore, [`collection`] also represents the last state of the database.
|
|
||||||
library_cache: HashMap<ArtistId, Artist>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Phantom type for when a library implementation is not needed.
|
|
||||||
pub struct NoLibrary;
|
|
||||||
|
|
||||||
/// Phantom type for when a database implementation is not needed.
|
|
||||||
pub struct NoDatabase;
|
|
||||||
|
|
||||||
impl Default for MusicHoard<NoLibrary, NoDatabase> {
|
|
||||||
/// Create a new [`MusicHoard`] without any library or database.
|
|
||||||
fn default() -> Self {
|
|
||||||
MusicHoard::new(NoLibrary, NoDatabase)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<LIB, DB> MusicHoard<LIB, DB> {
|
|
||||||
/// Create a new [`MusicHoard`] with the provided [`ILibrary`] and [`IDatabase`].
|
|
||||||
pub fn new(library: LIB, database: DB) -> Self {
|
|
||||||
MusicHoard {
|
|
||||||
collection: vec![],
|
|
||||||
library,
|
|
||||||
database,
|
|
||||||
library_cache: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retrieve the [`Collection`].
|
|
||||||
pub fn get_collection(&self) -> &Collection {
|
|
||||||
&self.collection
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_artist<ID: Into<ArtistId>>(&mut self, artist_id: ID) {
|
|
||||||
let artist_id: ArtistId = artist_id.into();
|
|
||||||
|
|
||||||
if self.get_artist(&artist_id).is_none() {
|
|
||||||
self.collection.push(Artist::new(artist_id));
|
|
||||||
Self::sort_artists(&mut self.collection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_artist<ID: AsRef<ArtistId>>(&mut self, artist_id: ID) {
|
|
||||||
let index_opt = self
|
|
||||||
.collection
|
|
||||||
.iter()
|
|
||||||
.position(|a| &a.id == artist_id.as_ref());
|
|
||||||
|
|
||||||
if let Some(index) = index_opt {
|
|
||||||
self.collection.remove(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_artist_sort<ID: AsRef<ArtistId>, SORT: Into<ArtistId>>(
|
|
||||||
&mut self,
|
|
||||||
artist_id: ID,
|
|
||||||
artist_sort: SORT,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
self.get_artist_mut_or_err(artist_id.as_ref())?
|
|
||||||
.set_sort_key(artist_sort);
|
|
||||||
Self::sort(&mut self.collection);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_artist_sort<ID: AsRef<ArtistId>>(&mut self, artist_id: ID) -> Result<(), Error> {
|
|
||||||
self.get_artist_mut_or_err(artist_id.as_ref())?
|
|
||||||
.clear_sort_key();
|
|
||||||
Self::sort(&mut self.collection);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_musicbrainz_url<ID: AsRef<ArtistId>, S: AsRef<str>>(
|
|
||||||
&mut self,
|
|
||||||
artist_id: ID,
|
|
||||||
url: S,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
Ok(self
|
|
||||||
.get_artist_mut_or_err(artist_id.as_ref())?
|
|
||||||
.add_musicbrainz_url(url)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_musicbrainz_url<ID: AsRef<ArtistId>, S: AsRef<str>>(
|
|
||||||
&mut self,
|
|
||||||
artist_id: ID,
|
|
||||||
url: S,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
Ok(self
|
|
||||||
.get_artist_mut_or_err(artist_id.as_ref())?
|
|
||||||
.remove_musicbrainz_url(url)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_musicbrainz_url<ID: AsRef<ArtistId>, S: AsRef<str>>(
|
|
||||||
&mut self,
|
|
||||||
artist_id: ID,
|
|
||||||
url: S,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
Ok(self
|
|
||||||
.get_artist_mut_or_err(artist_id.as_ref())?
|
|
||||||
.set_musicbrainz_url(url)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_musicbrainz_url<ID: AsRef<ArtistId>>(
|
|
||||||
&mut self,
|
|
||||||
artist_id: ID,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
self.get_artist_mut_or_err(artist_id.as_ref())?
|
|
||||||
.clear_musicbrainz_url();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_to_property<ID: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
|
|
||||||
&mut self,
|
|
||||||
artist_id: ID,
|
|
||||||
property: S,
|
|
||||||
values: Vec<S>,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
self.get_artist_mut_or_err(artist_id.as_ref())?
|
|
||||||
.add_to_property(property, values);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_from_property<ID: AsRef<ArtistId>, S: AsRef<str>>(
|
|
||||||
&mut self,
|
|
||||||
artist_id: ID,
|
|
||||||
property: S,
|
|
||||||
values: Vec<S>,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
self.get_artist_mut_or_err(artist_id.as_ref())?
|
|
||||||
.remove_from_property(property, values);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_property<ID: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
|
|
||||||
&mut self,
|
|
||||||
artist_id: ID,
|
|
||||||
property: S,
|
|
||||||
values: Vec<S>,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
self.get_artist_mut_or_err(artist_id.as_ref())?
|
|
||||||
.set_property(property, values);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_property<ID: AsRef<ArtistId>, S: AsRef<str>>(
|
|
||||||
&mut self,
|
|
||||||
artist_id: ID,
|
|
||||||
property: S,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
self.get_artist_mut_or_err(artist_id.as_ref())?
|
|
||||||
.clear_property(property);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sort(collection: &mut [Artist]) {
|
|
||||||
Self::sort_artists(collection);
|
|
||||||
Self::sort_albums_and_tracks(collection.iter_mut());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sort_artists(collection: &mut [Artist]) {
|
|
||||||
collection.sort_unstable();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sort_albums_and_tracks<'a, COL: Iterator<Item = &'a mut Artist>>(collection: COL) {
|
|
||||||
for artist in collection {
|
|
||||||
artist.albums.sort_unstable();
|
|
||||||
for album in artist.albums.iter_mut() {
|
|
||||||
album.tracks.sort_unstable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge_collections(&mut self) {
|
|
||||||
let mut primary = self.library_cache.clone();
|
|
||||||
for secondary_artist in self.collection.drain(..) {
|
|
||||||
if let Some(ref mut primary_artist) = primary.get_mut(&secondary_artist.id) {
|
|
||||||
primary_artist.merge_in_place(secondary_artist);
|
|
||||||
} else {
|
|
||||||
primary.insert(secondary_artist.id.clone(), secondary_artist);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.collection.extend(primary.into_values());
|
|
||||||
Self::sort_artists(&mut self.collection);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn items_to_artists(items: Vec<Item>) -> Result<HashMap<ArtistId, Artist>, Error> {
|
|
||||||
let mut collection = HashMap::<ArtistId, Artist>::new();
|
|
||||||
|
|
||||||
for item in items.into_iter() {
|
|
||||||
let artist_id = ArtistId {
|
|
||||||
name: item.album_artist,
|
|
||||||
};
|
|
||||||
|
|
||||||
let artist_sort = item.album_artist_sort.map(|s| ArtistId { name: s });
|
|
||||||
|
|
||||||
let album_id = AlbumId {
|
|
||||||
year: item.album_year,
|
|
||||||
title: item.album_title,
|
|
||||||
};
|
|
||||||
|
|
||||||
let track = Track {
|
|
||||||
id: TrackId {
|
|
||||||
number: item.track_number,
|
|
||||||
title: item.track_title,
|
|
||||||
},
|
|
||||||
artist: item.track_artist,
|
|
||||||
quality: Quality {
|
|
||||||
format: item.track_format,
|
|
||||||
bitrate: item.track_bitrate,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// There are usually many entries per artist. Therefore, we avoid simply calling
|
|
||||||
// .entry(artist_id.clone()).or_insert_with(..), because of the clone. The flipside is
|
|
||||||
// that insertions will thus do an additional lookup.
|
|
||||||
let artist = match collection.get_mut(&artist_id) {
|
|
||||||
Some(artist) => artist,
|
|
||||||
None => collection
|
|
||||||
.entry(artist_id.clone())
|
|
||||||
.or_insert_with(|| Artist::new(artist_id)),
|
|
||||||
};
|
|
||||||
|
|
||||||
if artist.sort.is_some() {
|
|
||||||
if artist_sort.is_some() && (artist.sort != artist_sort) {
|
|
||||||
return Err(Error::CollectionError(format!(
|
|
||||||
"multiple album_artist_sort found for artist '{}': '{}' != '{}'",
|
|
||||||
artist.id,
|
|
||||||
artist.sort.as_ref().unwrap(),
|
|
||||||
artist_sort.as_ref().unwrap()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
} else if artist_sort.is_some() {
|
|
||||||
artist.sort = artist_sort;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do a linear search as few artists have more than a handful of albums. Search from the
|
|
||||||
// back as the original items vector is usually already sorted.
|
|
||||||
match artist
|
|
||||||
.albums
|
|
||||||
.iter_mut()
|
|
||||||
.rev()
|
|
||||||
.find(|album| album.id == album_id)
|
|
||||||
{
|
|
||||||
Some(album) => album.tracks.push(track),
|
|
||||||
None => artist.albums.push(Album {
|
|
||||||
id: album_id,
|
|
||||||
tracks: vec![track],
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(collection)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_artist(&self, artist_id: &ArtistId) -> Option<&Artist> {
|
|
||||||
self.collection.iter().find(|a| &a.id == artist_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_artist_mut(&mut self, artist_id: &ArtistId) -> Option<&mut Artist> {
|
|
||||||
self.collection.iter_mut().find(|a| &a.id == artist_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_artist_mut_or_err(&mut self, artist_id: &ArtistId) -> Result<&mut Artist, Error> {
|
|
||||||
self.get_artist_mut(artist_id).ok_or_else(|| {
|
|
||||||
Error::CollectionError(format!("artist '{}' is not in the collection", artist_id))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<LIB: ILibrary, DB> MusicHoard<LIB, DB> {
|
|
||||||
/// Rescan the library and merge with the in-memory collection.
|
|
||||||
pub fn rescan_library(&mut self) -> Result<(), Error> {
|
|
||||||
let items = self.library.list(&Query::new())?;
|
|
||||||
self.library_cache = Self::items_to_artists(items)?;
|
|
||||||
Self::sort_albums_and_tracks(self.library_cache.values_mut());
|
|
||||||
|
|
||||||
self.merge_collections();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<LIB, DB: IDatabase> MusicHoard<LIB, DB> {
|
|
||||||
/// Load the database and merge with the in-memory collection.
|
|
||||||
pub fn load_from_database(&mut self) -> Result<(), Error> {
|
|
||||||
self.collection = self.database.load()?;
|
|
||||||
Self::sort_albums_and_tracks(self.collection.iter_mut());
|
|
||||||
|
|
||||||
self.merge_collections();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save the in-memory collection to the database.
|
|
||||||
pub fn save_to_database(&mut self) -> Result<(), Error> {
|
|
||||||
self.database.save(&self.collection)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use mockall::predicate;
|
|
||||||
|
|
||||||
use crate::core::{
|
|
||||||
collection::artist::{ArtistId, MusicBrainz},
|
|
||||||
database::{self, MockIDatabase},
|
|
||||||
library::{self, testmod::LIBRARY_ITEMS, MockILibrary},
|
|
||||||
testmod::{FULL_COLLECTION, LIBRARY_COLLECTION},
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
static MUSICBRAINZ: &str =
|
|
||||||
"https://musicbrainz.org/artist/d368baa8-21ca-4759-9731-0b2753071ad8";
|
|
||||||
static MUSICBUTLER: &str = "https://www.musicbutler.io/artist-page/483340948";
|
|
||||||
static MUSICBUTLER_2: &str = "https://www.musicbutler.io/artist-page/658903042/";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artist_new_delete() {
|
|
||||||
let artist_id = ArtistId::new("an artist");
|
|
||||||
let artist_id_2 = ArtistId::new("another artist");
|
|
||||||
let mut music_hoard = MusicHoard::default();
|
|
||||||
|
|
||||||
let mut expected: Vec<Artist> = vec![];
|
|
||||||
|
|
||||||
music_hoard.add_artist(artist_id.clone());
|
|
||||||
expected.push(Artist::new(artist_id.clone()));
|
|
||||||
assert_eq!(music_hoard.collection, expected);
|
|
||||||
|
|
||||||
music_hoard.add_artist(artist_id.clone());
|
|
||||||
assert_eq!(music_hoard.collection, expected);
|
|
||||||
|
|
||||||
music_hoard.remove_artist(&artist_id_2);
|
|
||||||
assert_eq!(music_hoard.collection, expected);
|
|
||||||
|
|
||||||
music_hoard.remove_artist(&artist_id);
|
|
||||||
_ = expected.pop();
|
|
||||||
assert_eq!(music_hoard.collection, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artist_sort_set_clear() {
|
|
||||||
let mut music_hoard = MusicHoard::default();
|
|
||||||
|
|
||||||
let artist_1_id = ArtistId::new("the artist");
|
|
||||||
let artist_1_sort = ArtistId::new("artist, the");
|
|
||||||
|
|
||||||
// Must be after "artist, the", but before "the artist"
|
|
||||||
let artist_2_id = ArtistId::new("b-artist");
|
|
||||||
|
|
||||||
assert!(artist_1_sort < artist_2_id);
|
|
||||||
assert!(artist_2_id < artist_1_id);
|
|
||||||
|
|
||||||
music_hoard.add_artist(artist_1_id.clone());
|
|
||||||
music_hoard.add_artist(artist_2_id.clone());
|
|
||||||
|
|
||||||
let artist_1: &Artist = music_hoard.get_artist(&artist_1_id).unwrap();
|
|
||||||
let artist_2: &Artist = music_hoard.get_artist(&artist_2_id).unwrap();
|
|
||||||
|
|
||||||
assert!(artist_2 < artist_1);
|
|
||||||
|
|
||||||
assert_eq!(artist_1, &music_hoard.collection[1]);
|
|
||||||
assert_eq!(artist_2, &music_hoard.collection[0]);
|
|
||||||
|
|
||||||
music_hoard
|
|
||||||
.set_artist_sort(artist_1_id.as_ref(), artist_1_sort.clone())
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let artist_1: &Artist = music_hoard.get_artist(&artist_1_id).unwrap();
|
|
||||||
let artist_2: &Artist = music_hoard.get_artist(&artist_2_id).unwrap();
|
|
||||||
|
|
||||||
assert!(artist_1 < artist_2);
|
|
||||||
|
|
||||||
assert_eq!(artist_1, &music_hoard.collection[0]);
|
|
||||||
assert_eq!(artist_2, &music_hoard.collection[1]);
|
|
||||||
|
|
||||||
music_hoard.clear_artist_sort(artist_1_id.as_ref()).unwrap();
|
|
||||||
|
|
||||||
let artist_1: &Artist = music_hoard.get_artist(&artist_1_id).unwrap();
|
|
||||||
let artist_2: &Artist = music_hoard.get_artist(&artist_2_id).unwrap();
|
|
||||||
|
|
||||||
assert!(artist_2 < artist_1);
|
|
||||||
|
|
||||||
assert_eq!(artist_1, &music_hoard.collection[1]);
|
|
||||||
assert_eq!(artist_2, &music_hoard.collection[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn collection_error() {
|
|
||||||
let artist_id = ArtistId::new("an artist");
|
|
||||||
let mut music_hoard = MusicHoard::default();
|
|
||||||
music_hoard.add_artist(artist_id.clone());
|
|
||||||
|
|
||||||
let actual_err = music_hoard
|
|
||||||
.add_musicbrainz_url(&artist_id, MUSICBUTLER)
|
|
||||||
.unwrap_err();
|
|
||||||
let expected_err = Error::CollectionError(format!(
|
|
||||||
"an error occurred when processing a URL: invalid MusicBrainz URL: {MUSICBUTLER}"
|
|
||||||
));
|
|
||||||
assert_eq!(actual_err, expected_err);
|
|
||||||
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn add_remove_musicbrainz_url() {
|
|
||||||
let artist_id = ArtistId::new("an artist");
|
|
||||||
let artist_id_2 = ArtistId::new("another artist");
|
|
||||||
let mut music_hoard = MusicHoard::default();
|
|
||||||
|
|
||||||
music_hoard.add_artist(artist_id.clone());
|
|
||||||
|
|
||||||
let mut expected: Option<MusicBrainz> = None;
|
|
||||||
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
|
|
||||||
|
|
||||||
// Adding URL to an artist not in the collection is an error.
|
|
||||||
assert!(music_hoard
|
|
||||||
.add_musicbrainz_url(&artist_id_2, MUSICBRAINZ)
|
|
||||||
.is_err());
|
|
||||||
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
|
|
||||||
|
|
||||||
// Adding URL to artist.
|
|
||||||
assert!(music_hoard
|
|
||||||
.add_musicbrainz_url(&artist_id, MUSICBRAINZ)
|
|
||||||
.is_ok());
|
|
||||||
_ = expected.insert(MusicBrainz::new(MUSICBRAINZ).unwrap());
|
|
||||||
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
|
|
||||||
|
|
||||||
// Removing a URL from an artist not in the collection is an error.
|
|
||||||
assert!(music_hoard
|
|
||||||
.remove_musicbrainz_url(&artist_id_2, MUSICBRAINZ)
|
|
||||||
.is_err());
|
|
||||||
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
|
|
||||||
|
|
||||||
// Removing a URL in the collection removes it.
|
|
||||||
assert!(music_hoard
|
|
||||||
.remove_musicbrainz_url(&artist_id, MUSICBRAINZ)
|
|
||||||
.is_ok());
|
|
||||||
_ = expected.take();
|
|
||||||
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_clear_musicbrainz_url() {
|
|
||||||
let artist_id = ArtistId::new("an artist");
|
|
||||||
let artist_id_2 = ArtistId::new("another artist");
|
|
||||||
let mut music_hoard = MusicHoard::default();
|
|
||||||
|
|
||||||
music_hoard.add_artist(artist_id.clone());
|
|
||||||
|
|
||||||
let mut expected: Option<MusicBrainz> = None;
|
|
||||||
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
|
|
||||||
|
|
||||||
// Setting a URL on an artist not in the collection is an error.
|
|
||||||
assert!(music_hoard
|
|
||||||
.set_musicbrainz_url(&artist_id_2, MUSICBRAINZ)
|
|
||||||
.is_err());
|
|
||||||
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
|
|
||||||
|
|
||||||
// Setting a URL on an artist.
|
|
||||||
assert!(music_hoard
|
|
||||||
.set_musicbrainz_url(&artist_id, MUSICBRAINZ)
|
|
||||||
.is_ok());
|
|
||||||
_ = expected.insert(MusicBrainz::new(MUSICBRAINZ).unwrap());
|
|
||||||
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
|
|
||||||
|
|
||||||
// Clearing URLs on an artist that does not exist is an error.
|
|
||||||
assert!(music_hoard.clear_musicbrainz_url(&artist_id_2).is_err());
|
|
||||||
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
|
|
||||||
|
|
||||||
// Clearing URLs.
|
|
||||||
assert!(music_hoard.clear_musicbrainz_url(&artist_id).is_ok());
|
|
||||||
_ = expected.take();
|
|
||||||
assert_eq!(music_hoard.collection[0].musicbrainz, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn add_to_remove_from_property() {
|
|
||||||
let artist_id = ArtistId::new("an artist");
|
|
||||||
let artist_id_2 = ArtistId::new("another artist");
|
|
||||||
let mut music_hoard = MusicHoard::default();
|
|
||||||
|
|
||||||
music_hoard.add_artist(artist_id.clone());
|
|
||||||
|
|
||||||
let mut expected: Vec<String> = vec![];
|
|
||||||
assert!(music_hoard.collection[0].properties.is_empty());
|
|
||||||
|
|
||||||
// Adding URLs to an artist not in the collection is an error.
|
|
||||||
assert!(music_hoard
|
|
||||||
.add_to_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
|
|
||||||
.is_err());
|
|
||||||
assert!(music_hoard.collection[0].properties.is_empty());
|
|
||||||
|
|
||||||
// Adding mutliple URLs without clashes.
|
|
||||||
assert!(music_hoard
|
|
||||||
.add_to_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
|
|
||||||
.is_ok());
|
|
||||||
expected.push(MUSICBUTLER.to_owned());
|
|
||||||
expected.push(MUSICBUTLER_2.to_owned());
|
|
||||||
assert_eq!(
|
|
||||||
music_hoard.collection[0].properties.get("MusicButler"),
|
|
||||||
Some(&expected)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Removing URLs from an artist not in the collection is an error.
|
|
||||||
assert!(music_hoard
|
|
||||||
.remove_from_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
|
|
||||||
.is_err());
|
|
||||||
assert_eq!(
|
|
||||||
music_hoard.collection[0].properties.get("MusicButler"),
|
|
||||||
Some(&expected)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Removing multiple URLs without clashes.
|
|
||||||
assert!(music_hoard
|
|
||||||
.remove_from_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
|
|
||||||
.is_ok());
|
|
||||||
expected.clear();
|
|
||||||
assert!(music_hoard.collection[0].properties.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_clear_property() {
|
|
||||||
let artist_id = ArtistId::new("an artist");
|
|
||||||
let artist_id_2 = ArtistId::new("another artist");
|
|
||||||
let mut music_hoard = MusicHoard::default();
|
|
||||||
|
|
||||||
music_hoard.add_artist(artist_id.clone());
|
|
||||||
|
|
||||||
let mut expected: Vec<String> = vec![];
|
|
||||||
assert!(music_hoard.collection[0].properties.is_empty());
|
|
||||||
|
|
||||||
// Seting URL on an artist not in the collection is an error.
|
|
||||||
assert!(music_hoard
|
|
||||||
.set_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
|
|
||||||
.is_err());
|
|
||||||
assert!(music_hoard.collection[0].properties.is_empty());
|
|
||||||
|
|
||||||
// Set URLs.
|
|
||||||
assert!(music_hoard
|
|
||||||
.set_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
|
|
||||||
.is_ok());
|
|
||||||
expected.clear();
|
|
||||||
expected.push(MUSICBUTLER.to_owned());
|
|
||||||
expected.push(MUSICBUTLER_2.to_owned());
|
|
||||||
assert_eq!(
|
|
||||||
music_hoard.collection[0].properties.get("MusicButler"),
|
|
||||||
Some(&expected)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clearing URLs on an artist that does not exist is an error.
|
|
||||||
assert!(music_hoard
|
|
||||||
.clear_property(&artist_id_2, "MusicButler")
|
|
||||||
.is_err());
|
|
||||||
|
|
||||||
// Clear URLs.
|
|
||||||
assert!(music_hoard
|
|
||||||
.clear_property(&artist_id, "MusicButler")
|
|
||||||
.is_ok());
|
|
||||||
expected.clear();
|
|
||||||
assert!(music_hoard.collection[0].properties.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_collection_no_overlap() {
|
|
||||||
let half: usize = FULL_COLLECTION.len() / 2;
|
|
||||||
|
|
||||||
let left = FULL_COLLECTION[..half].to_owned();
|
|
||||||
let right = FULL_COLLECTION[half..].to_owned();
|
|
||||||
|
|
||||||
let mut expected = FULL_COLLECTION.to_owned();
|
|
||||||
expected.sort_unstable();
|
|
||||||
|
|
||||||
let mut mh = MusicHoard {
|
|
||||||
collection: right.clone(),
|
|
||||||
library_cache: left
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| (a.id.clone(), a))
|
|
||||||
.collect(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
mh.merge_collections();
|
|
||||||
assert_eq!(expected, mh.collection);
|
|
||||||
|
|
||||||
// The merge is completely non-overlapping so it should be commutative.
|
|
||||||
let mut mh = MusicHoard {
|
|
||||||
collection: left.clone(),
|
|
||||||
library_cache: right
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| (a.id.clone(), a))
|
|
||||||
.collect(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
mh.merge_collections();
|
|
||||||
assert_eq!(expected, mh.collection);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_collection_overlap() {
|
|
||||||
let half: usize = FULL_COLLECTION.len() / 2;
|
|
||||||
|
|
||||||
let left = FULL_COLLECTION[..(half + 1)].to_owned();
|
|
||||||
let right = FULL_COLLECTION[half..].to_owned();
|
|
||||||
|
|
||||||
let mut expected = FULL_COLLECTION.to_owned();
|
|
||||||
expected.sort_unstable();
|
|
||||||
|
|
||||||
let mut mh = MusicHoard {
|
|
||||||
collection: right.clone(),
|
|
||||||
library_cache: left
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| (a.id.clone(), a))
|
|
||||||
.collect(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
mh.merge_collections();
|
|
||||||
assert_eq!(expected, mh.collection);
|
|
||||||
|
|
||||||
// The merge does not overwrite any data so it should be commutative.
|
|
||||||
let mut mh = MusicHoard {
|
|
||||||
collection: left.clone(),
|
|
||||||
library_cache: right
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| (a.id.clone(), a))
|
|
||||||
.collect(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
mh.merge_collections();
|
|
||||||
assert_eq!(expected, mh.collection);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn merge_collection_incompatible_sorting() {
|
|
||||||
// It may be that the same artist in one collection has a "sort" field defined while the
|
|
||||||
// same artist in the other collection does not. This means that the two collections are not
|
|
||||||
// sorted consistently. If the merge assumes they are sorted consistently this will lead to
|
|
||||||
// the same artist appearing twice in the final list. This should not be the case.
|
|
||||||
|
|
||||||
// We will mimic this situation by taking the last artist from FULL_COLLECTION and giving it
|
|
||||||
// a sorting name that would place it in the beginning.
|
|
||||||
let left = FULL_COLLECTION.to_owned();
|
|
||||||
let mut right: Vec<Artist> = vec![left.last().unwrap().clone()];
|
|
||||||
|
|
||||||
assert!(right.first().unwrap() > left.first().unwrap());
|
|
||||||
let artist_sort = Some(ArtistId::new("Album_Artist 0"));
|
|
||||||
right[0].sort = artist_sort.clone();
|
|
||||||
assert!(right.first().unwrap() < left.first().unwrap());
|
|
||||||
|
|
||||||
// The result of the merge should be the same list of artists, but with the last artist now
|
|
||||||
// in first place.
|
|
||||||
let mut expected = left.to_owned();
|
|
||||||
expected.last_mut().as_mut().unwrap().sort = artist_sort.clone();
|
|
||||||
expected.rotate_right(1);
|
|
||||||
|
|
||||||
let mut mh = MusicHoard {
|
|
||||||
collection: right.clone(),
|
|
||||||
library_cache: left
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| (a.id.clone(), a))
|
|
||||||
.collect(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
mh.merge_collections();
|
|
||||||
assert_eq!(expected, mh.collection);
|
|
||||||
|
|
||||||
// The merge overwrites the sort data, but no data is erased so it should be commutative.
|
|
||||||
let mut mh = MusicHoard {
|
|
||||||
collection: left.clone(),
|
|
||||||
library_cache: right
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| (a.id.clone(), a))
|
|
||||||
.collect(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
mh.merge_collections();
|
|
||||||
assert_eq!(expected, mh.collection);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rescan_library_ordered() {
|
|
||||||
let mut library = MockILibrary::new();
|
|
||||||
let database = MockIDatabase::new();
|
|
||||||
|
|
||||||
let library_input = Query::new();
|
|
||||||
let library_result = Ok(LIBRARY_ITEMS.to_owned());
|
|
||||||
|
|
||||||
library
|
|
||||||
.expect_list()
|
|
||||||
.with(predicate::eq(library_input))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| library_result);
|
|
||||||
|
|
||||||
let mut music_hoard = MusicHoard::new(library, database);
|
|
||||||
|
|
||||||
music_hoard.rescan_library().unwrap();
|
|
||||||
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rescan_library_unordered() {
|
|
||||||
let mut library = MockILibrary::new();
|
|
||||||
let database = MockIDatabase::new();
|
|
||||||
|
|
||||||
let library_input = Query::new();
|
|
||||||
let mut library_result = Ok(LIBRARY_ITEMS.to_owned());
|
|
||||||
|
|
||||||
// Swap the last item with the first.
|
|
||||||
let last = library_result.as_ref().unwrap().len() - 1;
|
|
||||||
library_result.as_mut().unwrap().swap(0, last);
|
|
||||||
|
|
||||||
library
|
|
||||||
.expect_list()
|
|
||||||
.with(predicate::eq(library_input))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| library_result);
|
|
||||||
|
|
||||||
let mut music_hoard = MusicHoard::new(library, database);
|
|
||||||
|
|
||||||
music_hoard.rescan_library().unwrap();
|
|
||||||
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rescan_library_album_title_year_clash() {
|
|
||||||
let mut library = MockILibrary::new();
|
|
||||||
let database = MockIDatabase::new();
|
|
||||||
|
|
||||||
let mut expected = LIBRARY_COLLECTION.to_owned();
|
|
||||||
let removed_album_id = expected[0].albums[0].id.clone();
|
|
||||||
let clashed_album_id = &expected[1].albums[0].id;
|
|
||||||
|
|
||||||
let mut items = LIBRARY_ITEMS.to_owned();
|
|
||||||
for item in items.iter_mut().filter(|it| {
|
|
||||||
(it.album_year == removed_album_id.year) && (it.album_title == removed_album_id.title)
|
|
||||||
}) {
|
|
||||||
item.album_year = clashed_album_id.year;
|
|
||||||
item.album_title = clashed_album_id.title.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
expected[0].albums[0].id = clashed_album_id.clone();
|
|
||||||
|
|
||||||
let library_input = Query::new();
|
|
||||||
let library_result = Ok(items);
|
|
||||||
|
|
||||||
library
|
|
||||||
.expect_list()
|
|
||||||
.with(predicate::eq(library_input))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| library_result);
|
|
||||||
|
|
||||||
let mut music_hoard = MusicHoard::new(library, database);
|
|
||||||
|
|
||||||
music_hoard.rescan_library().unwrap();
|
|
||||||
assert_eq!(music_hoard.get_collection(), &expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rescan_library_album_artist_sort_clash() {
|
|
||||||
let mut library = MockILibrary::new();
|
|
||||||
let database = MockIDatabase::new();
|
|
||||||
|
|
||||||
let library_input = Query::new();
|
|
||||||
let mut library_items = LIBRARY_ITEMS.to_owned();
|
|
||||||
|
|
||||||
assert_eq!(library_items[0].album_artist, library_items[1].album_artist);
|
|
||||||
library_items[0].album_artist_sort = Some(library_items[0].album_artist.clone());
|
|
||||||
library_items[1].album_artist_sort = Some(
|
|
||||||
library_items[1]
|
|
||||||
.album_artist
|
|
||||||
.clone()
|
|
||||||
.chars()
|
|
||||||
.rev()
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let library_result = Ok(library_items);
|
|
||||||
|
|
||||||
library
|
|
||||||
.expect_list()
|
|
||||||
.with(predicate::eq(library_input))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| library_result);
|
|
||||||
|
|
||||||
let mut music_hoard = MusicHoard::new(library, database);
|
|
||||||
|
|
||||||
assert!(music_hoard.rescan_library().is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_database() {
|
|
||||||
let library = MockILibrary::new();
|
|
||||||
let mut database = MockIDatabase::new();
|
|
||||||
|
|
||||||
database
|
|
||||||
.expect_load()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|| Ok(FULL_COLLECTION.to_owned()));
|
|
||||||
|
|
||||||
let mut music_hoard = MusicHoard::new(library, database);
|
|
||||||
|
|
||||||
music_hoard.load_from_database().unwrap();
|
|
||||||
assert_eq!(music_hoard.get_collection(), &*FULL_COLLECTION);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rescan_get_save() {
|
|
||||||
let mut library = MockILibrary::new();
|
|
||||||
let mut database = MockIDatabase::new();
|
|
||||||
|
|
||||||
let library_input = Query::new();
|
|
||||||
let library_result = Ok(LIBRARY_ITEMS.to_owned());
|
|
||||||
|
|
||||||
let database_input = LIBRARY_COLLECTION.to_owned();
|
|
||||||
let database_result = Ok(());
|
|
||||||
|
|
||||||
library
|
|
||||||
.expect_list()
|
|
||||||
.with(predicate::eq(library_input))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| library_result);
|
|
||||||
|
|
||||||
database
|
|
||||||
.expect_save()
|
|
||||||
.with(predicate::eq(database_input))
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_: &Collection| database_result);
|
|
||||||
|
|
||||||
let mut music_hoard = MusicHoard::new(library, database);
|
|
||||||
|
|
||||||
music_hoard.rescan_library().unwrap();
|
|
||||||
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
|
|
||||||
music_hoard.save_to_database().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn library_error() {
|
|
||||||
let mut library = MockILibrary::new();
|
|
||||||
let database = MockIDatabase::new();
|
|
||||||
|
|
||||||
let library_result = Err(library::Error::Invalid(String::from("invalid data")));
|
|
||||||
|
|
||||||
library
|
|
||||||
.expect_list()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_| library_result);
|
|
||||||
|
|
||||||
let mut music_hoard = MusicHoard::new(library, database);
|
|
||||||
|
|
||||||
let actual_err = music_hoard.rescan_library().unwrap_err();
|
|
||||||
let expected_err =
|
|
||||||
Error::LibraryError(library::Error::Invalid(String::from("invalid data")).to_string());
|
|
||||||
|
|
||||||
assert_eq!(actual_err, expected_err);
|
|
||||||
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn database_load_error() {
|
|
||||||
let library = MockILibrary::new();
|
|
||||||
let mut database = MockIDatabase::new();
|
|
||||||
|
|
||||||
let database_result = Err(database::LoadError::IoError(String::from("I/O error")));
|
|
||||||
|
|
||||||
database
|
|
||||||
.expect_load()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|| database_result);
|
|
||||||
|
|
||||||
let mut music_hoard = MusicHoard::new(library, database);
|
|
||||||
|
|
||||||
let actual_err = music_hoard.load_from_database().unwrap_err();
|
|
||||||
let expected_err = Error::DatabaseError(
|
|
||||||
database::LoadError::IoError(String::from("I/O error")).to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(actual_err, expected_err);
|
|
||||||
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn database_save_error() {
|
|
||||||
let library = MockILibrary::new();
|
|
||||||
let mut database = MockIDatabase::new();
|
|
||||||
|
|
||||||
let database_result = Err(database::SaveError::IoError(String::from("I/O error")));
|
|
||||||
|
|
||||||
database
|
|
||||||
.expect_save()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|_: &Collection| database_result);
|
|
||||||
|
|
||||||
let mut music_hoard = MusicHoard::new(library, database);
|
|
||||||
|
|
||||||
let actual_err = music_hoard.save_to_database().unwrap_err();
|
|
||||||
let expected_err = Error::DatabaseError(
|
|
||||||
database::SaveError::IoError(String::from("I/O error")).to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(actual_err, expected_err);
|
|
||||||
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,92 +0,0 @@
|
|||||||
use crate::core::{
|
|
||||||
database::IDatabase,
|
|
||||||
library::ILibrary,
|
|
||||||
musichoard::musichoard::{MusicHoard, NoDatabase, NoLibrary},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Builder for [`MusicHoard`]. Its purpose is to make it easier to set various combinations of
|
|
||||||
/// library/database or their absence.
|
|
||||||
pub struct MusicHoardBuilder<LIB, DB> {
|
|
||||||
library: LIB,
|
|
||||||
database: DB,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MusicHoardBuilder<NoLibrary, NoDatabase> {
|
|
||||||
/// Create a [`MusicHoardBuilder`].
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MusicHoardBuilder<NoLibrary, NoDatabase> {
|
|
||||||
/// Create a [`MusicHoardBuilder`].
|
|
||||||
pub fn new() -> Self {
|
|
||||||
MusicHoardBuilder {
|
|
||||||
library: NoLibrary,
|
|
||||||
database: NoDatabase,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<LIB, DB> MusicHoardBuilder<LIB, DB> {
|
|
||||||
/// Set a library for [`MusicHoard`].
|
|
||||||
pub fn set_library<NEWLIB: ILibrary>(self, library: NEWLIB) -> MusicHoardBuilder<NEWLIB, DB> {
|
|
||||||
MusicHoardBuilder {
|
|
||||||
library,
|
|
||||||
database: self.database,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set a database for [`MusicHoard`].
|
|
||||||
pub fn set_database<NEWDB: IDatabase>(self, database: NEWDB) -> MusicHoardBuilder<LIB, NEWDB> {
|
|
||||||
MusicHoardBuilder {
|
|
||||||
library: self.library,
|
|
||||||
database,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build [`MusicHoard`] with the currently set library and database.
|
|
||||||
pub fn build(self) -> MusicHoard<LIB, DB> {
|
|
||||||
MusicHoard::new(self.library, self.database)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::core::{database::NullDatabase, library::NullLibrary};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_library_no_database() {
|
|
||||||
MusicHoardBuilder::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn with_library_no_database() {
|
|
||||||
let mut mh = MusicHoardBuilder::default()
|
|
||||||
.set_library(NullLibrary)
|
|
||||||
.build();
|
|
||||||
assert!(mh.rescan_library().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_library_with_database() {
|
|
||||||
let mut mh = MusicHoardBuilder::default()
|
|
||||||
.set_database(NullDatabase)
|
|
||||||
.build();
|
|
||||||
assert!(mh.load_from_database().is_ok());
|
|
||||||
assert!(mh.save_to_database().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn with_library_with_database() {
|
|
||||||
let mut mh = MusicHoardBuilder::default()
|
|
||||||
.set_library(NullLibrary)
|
|
||||||
.set_database(NullDatabase)
|
|
||||||
.build();
|
|
||||||
assert!(mh.rescan_library().is_ok());
|
|
||||||
assert!(mh.load_from_database().is_ok());
|
|
||||||
assert!(mh.save_to_database().is_ok());
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,11 +2,12 @@ use once_cell::sync::Lazy;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::core::collection::{
|
use crate::core::collection::{
|
||||||
album::{Album, AlbumId},
|
album::{Album, AlbumId, AlbumInfo, AlbumMeta, AlbumPrimaryType, AlbumSeq},
|
||||||
artist::{Artist, ArtistId, MusicBrainz},
|
artist::{Artist, ArtistId, ArtistInfo, ArtistMeta},
|
||||||
track::{Format, Quality, Track, TrackId},
|
musicbrainz::{MbAlbumRef, MbArtistRef, MbRefOption},
|
||||||
|
track::{Track, TrackFormat, TrackId, TrackNum, TrackQuality},
|
||||||
};
|
};
|
||||||
use crate::tests::*;
|
use crate::testmod::*;
|
||||||
|
|
||||||
pub static LIBRARY_COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| library_collection!());
|
pub static LIBRARY_COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| library::library_collection!());
|
||||||
pub static FULL_COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| full_collection!());
|
pub static FULL_COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| full::full_collection!());
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::core::database::json::IJsonDatabaseBackend;
|
use crate::external::database::json::IJsonDatabaseBackend;
|
||||||
|
|
||||||
/// JSON database backend that uses a local file for persistent storage.
|
/// JSON database backend that uses a local file for persistent storage.
|
||||||
pub struct JsonDatabaseFileBackend {
|
pub struct JsonDatabaseFileBackend {
|
@ -5,13 +5,14 @@ pub mod backend;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use mockall::automock;
|
use mockall::automock;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::{
|
||||||
|
core::{
|
||||||
collection::Collection,
|
collection::Collection,
|
||||||
database::{IDatabase, LoadError, SaveError},
|
interface::database::{IDatabase, LoadError, SaveError},
|
||||||
|
},
|
||||||
|
external::database::serde::{deserialize::DeserializeDatabase, serialize::SerializeDatabase},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::serde::{deserialize::DeserializeDatabase, serialize::SerializeDatabase};
|
|
||||||
|
|
||||||
impl From<serde_json::Error> for LoadError {
|
impl From<serde_json::Error> for LoadError {
|
||||||
fn from(err: serde_json::Error) -> LoadError {
|
fn from(err: serde_json::Error) -> LoadError {
|
||||||
LoadError::SerDeError(err.to_string())
|
LoadError::SerDeError(err.to_string())
|
||||||
@ -51,7 +52,7 @@ impl<JDB: IJsonDatabaseBackend> IDatabase for JsonDatabase<JDB> {
|
|||||||
fn load(&self) -> Result<Collection, LoadError> {
|
fn load(&self) -> Result<Collection, LoadError> {
|
||||||
let serialized = self.backend.read()?;
|
let serialized = self.backend.read()?;
|
||||||
let database: DeserializeDatabase = serde_json::from_str(&serialized)?;
|
let database: DeserializeDatabase = serde_json::from_str(&serialized)?;
|
||||||
database.try_into()
|
Ok(database.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save(&mut self, collection: &Collection) -> Result<(), SaveError> {
|
fn save(&mut self, collection: &Collection) -> Result<(), SaveError> {
|
||||||
@ -72,7 +73,7 @@ mod tests {
|
|||||||
use mockall::predicate;
|
use mockall::predicate;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
collection::{artist::Artist, Collection},
|
collection::{album::AlbumDate, artist::Artist, Collection},
|
||||||
testmod::FULL_COLLECTION,
|
testmod::FULL_COLLECTION,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -82,7 +83,10 @@ mod tests {
|
|||||||
fn expected() -> Collection {
|
fn expected() -> Collection {
|
||||||
let mut expected = FULL_COLLECTION.to_owned();
|
let mut expected = FULL_COLLECTION.to_owned();
|
||||||
for artist in expected.iter_mut() {
|
for artist in expected.iter_mut() {
|
||||||
artist.albums.clear();
|
for album in artist.albums.iter_mut() {
|
||||||
|
album.meta.date = AlbumDate::default();
|
||||||
|
album.tracks.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
expected
|
expected
|
||||||
}
|
}
|
||||||
@ -106,6 +110,7 @@ mod tests {
|
|||||||
fn load() {
|
fn load() {
|
||||||
let expected = expected();
|
let expected = expected();
|
||||||
let result = Ok(DATABASE_JSON.to_owned());
|
let result = Ok(DATABASE_JSON.to_owned());
|
||||||
|
eprintln!("{DATABASE_JSON}");
|
||||||
|
|
||||||
let mut backend = MockIJsonDatabaseBackend::new();
|
let mut backend = MockIJsonDatabaseBackend::new();
|
||||||
backend.expect_read().times(1).return_once(|| result);
|
backend.expect_read().times(1).return_once(|| result);
|
90
src/external/database/json/testmod.rs
vendored
Normal file
90
src/external/database/json/testmod.rs
vendored
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
pub static DATABASE_JSON: &str = "{\
|
||||||
|
\"V20240924\":\
|
||||||
|
[\
|
||||||
|
{\
|
||||||
|
\"name\":\"Album_Artist ‘A’\",\
|
||||||
|
\"sort\":null,\
|
||||||
|
\"musicbrainz\":{\"Some\":\"00000000-0000-0000-0000-000000000000\"},\
|
||||||
|
\"properties\":{\
|
||||||
|
\"MusicButler\":[\"https://www.musicbutler.io/artist-page/000000000\"],\
|
||||||
|
\"Qobuz\":[\"https://www.qobuz.com/nl-nl/interpreter/artist-a/download-streaming-albums\"]\
|
||||||
|
},\
|
||||||
|
\"albums\":[\
|
||||||
|
{\
|
||||||
|
\"title\":\"album_title a.a\",\"seq\":1,\
|
||||||
|
\"musicbrainz\":{\"Some\":\"00000000-0000-0000-0000-000000000000\"},\
|
||||||
|
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||||
|
},\
|
||||||
|
{\
|
||||||
|
\"title\":\"album_title a.b\",\"seq\":1,\"musicbrainz\":\"None\",\
|
||||||
|
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||||
|
}\
|
||||||
|
]\
|
||||||
|
},\
|
||||||
|
{\
|
||||||
|
\"name\":\"Album_Artist ‘B’\",\
|
||||||
|
\"sort\":null,\
|
||||||
|
\"musicbrainz\":{\"Some\":\"11111111-1111-1111-1111-111111111111\"},\
|
||||||
|
\"properties\":{\
|
||||||
|
\"Bandcamp\":[\"https://artist-b.bandcamp.com/\"],\
|
||||||
|
\"MusicButler\":[\
|
||||||
|
\"https://www.musicbutler.io/artist-page/111111111\",\
|
||||||
|
\"https://www.musicbutler.io/artist-page/111111112\"\
|
||||||
|
],\
|
||||||
|
\"Qobuz\":[\"https://www.qobuz.com/nl-nl/interpreter/artist-b/download-streaming-albums\"]\
|
||||||
|
},\
|
||||||
|
\"albums\":[\
|
||||||
|
{\
|
||||||
|
\"title\":\"album_title b.a\",\"seq\":1,\"musicbrainz\":\"None\",\
|
||||||
|
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||||
|
},\
|
||||||
|
{\
|
||||||
|
\"title\":\"album_title b.b\",\"seq\":3,\
|
||||||
|
\"musicbrainz\":{\"Some\":\"11111111-1111-1111-1111-111111111111\"},\
|
||||||
|
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||||
|
},\
|
||||||
|
{\
|
||||||
|
\"title\":\"album_title b.c\",\"seq\":2,\
|
||||||
|
\"musicbrainz\":{\"Some\":\"11111111-1111-1111-1111-111111111112\"},\
|
||||||
|
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||||
|
},\
|
||||||
|
{\
|
||||||
|
\"title\":\"album_title b.d\",\"seq\":4,\"musicbrainz\":\"None\",\
|
||||||
|
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||||
|
}\
|
||||||
|
]\
|
||||||
|
},\
|
||||||
|
{\
|
||||||
|
\"name\":\"The Album_Artist ‘C’\",\
|
||||||
|
\"sort\":\"Album_Artist ‘C’, The\",\
|
||||||
|
\"musicbrainz\":\"CannotHaveMbid\",\
|
||||||
|
\"properties\":{},\
|
||||||
|
\"albums\":[\
|
||||||
|
{\
|
||||||
|
\"title\":\"album_title c.a\",\"seq\":0,\"musicbrainz\":\"None\",\
|
||||||
|
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||||
|
},\
|
||||||
|
{\
|
||||||
|
\"title\":\"album_title c.b\",\"seq\":0,\"musicbrainz\":\"None\",\
|
||||||
|
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||||
|
}\
|
||||||
|
]\
|
||||||
|
},\
|
||||||
|
{\
|
||||||
|
\"name\":\"Album_Artist ‘D’\",\
|
||||||
|
\"sort\":null,\
|
||||||
|
\"musicbrainz\":\"None\",\
|
||||||
|
\"properties\":{},\
|
||||||
|
\"albums\":[\
|
||||||
|
{\
|
||||||
|
\"title\":\"album_title d.a\",\"seq\":0,\"musicbrainz\":\"None\",\
|
||||||
|
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||||
|
},\
|
||||||
|
{\
|
||||||
|
\"title\":\"album_title d.b\",\"seq\":0,\"musicbrainz\":\"None\",\
|
||||||
|
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||||
|
}\
|
||||||
|
]\
|
||||||
|
}\
|
||||||
|
]\
|
||||||
|
}";
|
4
src/external/database/mod.rs
vendored
Normal file
4
src/external/database/mod.rs
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
#[cfg(feature = "database-json")]
|
||||||
|
pub mod json;
|
||||||
|
#[cfg(feature = "database-json")]
|
||||||
|
mod serde;
|
71
src/external/database/serde/common.rs
vendored
Normal file
71
src/external/database/serde/common.rs
vendored
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::musicbrainz::MbRefOption,
|
||||||
|
core::collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(remote = "MbRefOption")]
|
||||||
|
pub enum MbRefOptionDef<T> {
|
||||||
|
Some(T),
|
||||||
|
CannotHaveMbid,
|
||||||
|
None,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(remote = "AlbumPrimaryType")]
|
||||||
|
pub enum AlbumPrimaryTypeDef {
|
||||||
|
Album,
|
||||||
|
Single,
|
||||||
|
Ep,
|
||||||
|
Broadcast,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
|
||||||
|
|
||||||
|
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
||||||
|
fn from(value: SerdeAlbumPrimaryType) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AlbumPrimaryType> for SerdeAlbumPrimaryType {
|
||||||
|
fn from(value: AlbumPrimaryType) -> Self {
|
||||||
|
SerdeAlbumPrimaryType(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(remote = "AlbumSecondaryType")]
|
||||||
|
pub enum AlbumSecondaryTypeDef {
|
||||||
|
Compilation,
|
||||||
|
Soundtrack,
|
||||||
|
Spokenword,
|
||||||
|
Interview,
|
||||||
|
Audiobook,
|
||||||
|
AudioDrama,
|
||||||
|
Live,
|
||||||
|
Remix,
|
||||||
|
DjMix,
|
||||||
|
MixtapeStreet,
|
||||||
|
Demo,
|
||||||
|
FieldRecording,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
|
||||||
|
|
||||||
|
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
||||||
|
fn from(value: SerdeAlbumSecondaryType) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AlbumSecondaryType> for SerdeAlbumSecondaryType {
|
||||||
|
fn from(value: AlbumSecondaryType) -> Self {
|
||||||
|
SerdeAlbumSecondaryType(value)
|
||||||
|
}
|
||||||
|
}
|
167
src/external/database/serde/deserialize.rs
vendored
Normal file
167
src/external/database/serde/deserialize.rs
vendored
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
use std::{collections::HashMap, fmt};
|
||||||
|
|
||||||
|
use serde::{de::Visitor, Deserialize, Deserializer};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::{
|
||||||
|
album::{AlbumInfo, AlbumMeta},
|
||||||
|
artist::{ArtistInfo, ArtistMeta},
|
||||||
|
musicbrainz::{MbAlbumRef, MbArtistRef, MbRefOption, Mbid},
|
||||||
|
},
|
||||||
|
core::collection::{
|
||||||
|
album::{Album, AlbumDate, AlbumId, AlbumSeq},
|
||||||
|
artist::{Artist, ArtistId},
|
||||||
|
Collection, Error as CollectionError,
|
||||||
|
},
|
||||||
|
external::database::serde::common::{SerdeAlbumPrimaryType, SerdeAlbumSecondaryType},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::common::MbRefOptionDef;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub enum DeserializeDatabase {
|
||||||
|
V20240924(Vec<DeserializeArtist>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeDatabase> for Collection {
|
||||||
|
fn from(database: DeserializeDatabase) -> Self {
|
||||||
|
match database {
|
||||||
|
DeserializeDatabase::V20240924(collection) => {
|
||||||
|
collection.into_iter().map(Into::into).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeserializeArtist {
|
||||||
|
name: String,
|
||||||
|
sort: Option<String>,
|
||||||
|
musicbrainz: DeserializeMbRefOption,
|
||||||
|
properties: HashMap<String, Vec<String>>,
|
||||||
|
albums: Vec<DeserializeAlbum>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeserializeAlbum {
|
||||||
|
title: String,
|
||||||
|
seq: u8,
|
||||||
|
musicbrainz: DeserializeMbRefOption,
|
||||||
|
primary_type: Option<SerdeAlbumPrimaryType>,
|
||||||
|
secondary_types: Vec<SerdeAlbumSecondaryType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DeserializeMbRefOption(#[serde(with = "MbRefOptionDef")] MbRefOption<DeserializeMbid>);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DeserializeMbid(Mbid);
|
||||||
|
|
||||||
|
macro_rules! impl_from_for_mb_ref_option {
|
||||||
|
($ref:ty) => {
|
||||||
|
impl From<DeserializeMbRefOption> for MbRefOption<$ref> {
|
||||||
|
fn from(value: DeserializeMbRefOption) -> Self {
|
||||||
|
match value.0 {
|
||||||
|
MbRefOption::Some(val) => MbRefOption::Some(val.0.into()),
|
||||||
|
MbRefOption::CannotHaveMbid => MbRefOption::CannotHaveMbid,
|
||||||
|
MbRefOption::None => MbRefOption::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_from_for_mb_ref_option!(MbArtistRef);
|
||||||
|
impl_from_for_mb_ref_option!(MbAlbumRef);
|
||||||
|
|
||||||
|
impl From<DeserializeMbid> for Mbid {
|
||||||
|
fn from(value: DeserializeMbid) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DeserializeMbidVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for DeserializeMbidVisitor {
|
||||||
|
type Value = DeserializeMbid;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a valid MusicBrainz identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(DeserializeMbid(
|
||||||
|
v.try_into()
|
||||||
|
.map_err(|e: CollectionError| E::custom(e.to_string()))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for DeserializeMbid {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
deserializer.deserialize_str(DeserializeMbidVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeArtist> for Artist {
|
||||||
|
fn from(artist: DeserializeArtist) -> Self {
|
||||||
|
Artist {
|
||||||
|
meta: ArtistMeta {
|
||||||
|
id: ArtistId::new(artist.name),
|
||||||
|
sort: artist.sort,
|
||||||
|
info: ArtistInfo {
|
||||||
|
musicbrainz: artist.musicbrainz.into(),
|
||||||
|
properties: artist.properties,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
albums: artist.albums.into_iter().map(Into::into).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeAlbum> for Album {
|
||||||
|
fn from(album: DeserializeAlbum) -> Self {
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId { title: album.title },
|
||||||
|
date: AlbumDate::default(),
|
||||||
|
seq: AlbumSeq(album.seq),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: album.musicbrainz.into(),
|
||||||
|
primary_type: album.primary_type.map(Into::into),
|
||||||
|
secondary_types: album.secondary_types.into_iter().map(Into::into).collect(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deserialize_mbid() {
|
||||||
|
let mbid = "\"d368baa8-21ca-4759-9731-0b2753071ad8\"";
|
||||||
|
let mbid: DeserializeMbid = serde_json::from_str(mbid).unwrap();
|
||||||
|
let mbid: Mbid = mbid.into();
|
||||||
|
assert_eq!(
|
||||||
|
mbid,
|
||||||
|
"d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mbid = "null";
|
||||||
|
let result: Result<DeserializeMbid, _> = serde_json::from_str(mbid);
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("a valid MusicBrainz identifier"));
|
||||||
|
}
|
||||||
|
}
|
5
src/external/database/serde/mod.rs
vendored
Normal file
5
src/external/database/serde/mod.rs
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
//! Helper module for backends that can use serde for (de)serialisation.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
pub mod deserialize;
|
||||||
|
pub mod serialize;
|
106
src/external/database/serde/serialize.rs
vendored
Normal file
106
src/external/database/serde/serialize.rs
vendored
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::musicbrainz::{MbRefOption, Mbid},
|
||||||
|
core::collection::{album::Album, artist::Artist, musicbrainz::IMusicBrainzRef, Collection},
|
||||||
|
external::database::serde::common::{
|
||||||
|
MbRefOptionDef, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub enum SerializeDatabase<'a> {
|
||||||
|
V20240924(Vec<SerializeArtist<'a>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a Collection> for SerializeDatabase<'a> {
|
||||||
|
fn from(collection: &'a Collection) -> Self {
|
||||||
|
SerializeDatabase::V20240924(collection.iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SerializeArtist<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
sort: Option<&'a str>,
|
||||||
|
musicbrainz: SerializeMbRefOption<'a>,
|
||||||
|
properties: BTreeMap<&'a str, &'a Vec<String>>,
|
||||||
|
albums: Vec<SerializeAlbum<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SerializeAlbum<'a> {
|
||||||
|
title: &'a str,
|
||||||
|
seq: u8,
|
||||||
|
musicbrainz: SerializeMbRefOption<'a>,
|
||||||
|
primary_type: Option<SerdeAlbumPrimaryType>,
|
||||||
|
secondary_types: Vec<SerdeAlbumSecondaryType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SerializeMbRefOption<'a>(
|
||||||
|
#[serde(with = "MbRefOptionDef")] MbRefOption<SerializeMbid<'a>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SerializeMbid<'a>(&'a Mbid);
|
||||||
|
|
||||||
|
impl<'a, T: IMusicBrainzRef> From<&'a MbRefOption<T>> for SerializeMbRefOption<'a> {
|
||||||
|
fn from(value: &'a MbRefOption<T>) -> Self {
|
||||||
|
match value {
|
||||||
|
MbRefOption::Some(val) => {
|
||||||
|
SerializeMbRefOption(MbRefOption::Some(SerializeMbid(val.mbid())))
|
||||||
|
}
|
||||||
|
MbRefOption::CannotHaveMbid => SerializeMbRefOption(MbRefOption::CannotHaveMbid),
|
||||||
|
MbRefOption::None => SerializeMbRefOption(MbRefOption::None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Serialize for SerializeMbid<'a> {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: serde::Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.0.uuid().as_hyphenated().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a Artist> for SerializeArtist<'a> {
|
||||||
|
fn from(artist: &'a Artist) -> Self {
|
||||||
|
SerializeArtist {
|
||||||
|
name: &artist.meta.id.name,
|
||||||
|
sort: artist.meta.sort.as_deref(),
|
||||||
|
musicbrainz: (&artist.meta.info.musicbrainz).into(),
|
||||||
|
properties: artist
|
||||||
|
.meta
|
||||||
|
.info
|
||||||
|
.properties
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.as_ref(), v))
|
||||||
|
.collect(),
|
||||||
|
albums: artist.albums.iter().map(Into::into).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a Album> for SerializeAlbum<'a> {
|
||||||
|
fn from(album: &'a Album) -> Self {
|
||||||
|
SerializeAlbum {
|
||||||
|
title: &album.meta.id.title,
|
||||||
|
seq: album.meta.seq.0,
|
||||||
|
musicbrainz: (&album.meta.info.musicbrainz).into(),
|
||||||
|
primary_type: album.meta.info.primary_type.map(Into::into),
|
||||||
|
secondary_types: album
|
||||||
|
.meta
|
||||||
|
.info
|
||||||
|
.secondary_types
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -8,7 +8,7 @@ use std::{
|
|||||||
str,
|
str,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::core::library::{beets::IBeetsLibraryExecutor, Error};
|
use crate::{core::interface::library::Error, external::library::beets::IBeetsLibraryExecutor};
|
||||||
|
|
||||||
const BEET_DEFAULT: &str = "beet";
|
const BEET_DEFAULT: &str = "beet";
|
||||||
|
|
||||||
@ -74,7 +74,7 @@ impl IBeetsLibraryExecutor for BeetsLibraryProcessExecutor {
|
|||||||
impl IBeetsLibraryExecutorPrivate for BeetsLibraryProcessExecutor {}
|
impl IBeetsLibraryExecutorPrivate for BeetsLibraryProcessExecutor {}
|
||||||
|
|
||||||
// GRCOV_EXCL_START
|
// GRCOV_EXCL_START
|
||||||
#[cfg(feature = "ssh-library")]
|
#[cfg(feature = "library-beets-ssh")]
|
||||||
pub mod ssh {
|
pub mod ssh {
|
||||||
//! Module for interacting with the music library via
|
//! Module for interacting with the music library via
|
||||||
//! [beets](https://beets.readthedocs.io/en/stable/) over SSH.
|
//! [beets](https://beets.readthedocs.io/en/stable/) over SSH.
|
@ -7,8 +7,8 @@ pub mod executor;
|
|||||||
use mockall::automock;
|
use mockall::automock;
|
||||||
|
|
||||||
use crate::core::{
|
use crate::core::{
|
||||||
collection::track::Format,
|
collection::track::TrackFormat,
|
||||||
library::{Error, Field, ILibrary, Item, Query},
|
interface::library::{Error, Field, ILibrary, Item, Query},
|
||||||
};
|
};
|
||||||
|
|
||||||
macro_rules! list_format_separator {
|
macro_rules! list_format_separator {
|
||||||
@ -27,6 +27,10 @@ const LIST_FORMAT_ARG: &str = concat!(
|
|||||||
list_format_separator!(),
|
list_format_separator!(),
|
||||||
"$year",
|
"$year",
|
||||||
list_format_separator!(),
|
list_format_separator!(),
|
||||||
|
"$month",
|
||||||
|
list_format_separator!(),
|
||||||
|
"$day",
|
||||||
|
list_format_separator!(),
|
||||||
"$album",
|
"$album",
|
||||||
list_format_separator!(),
|
list_format_separator!(),
|
||||||
"$track",
|
"$track",
|
||||||
@ -42,6 +46,21 @@ const LIST_FORMAT_ARG: &str = concat!(
|
|||||||
const TRACK_FORMAT_FLAC: &str = "FLAC";
|
const TRACK_FORMAT_FLAC: &str = "FLAC";
|
||||||
const TRACK_FORMAT_MP3: &str = "MP3";
|
const TRACK_FORMAT_MP3: &str = "MP3";
|
||||||
|
|
||||||
|
fn format_to_str(format: &TrackFormat) -> &'static str {
|
||||||
|
match format {
|
||||||
|
TrackFormat::Flac => TRACK_FORMAT_FLAC,
|
||||||
|
TrackFormat::Mp3 => TRACK_FORMAT_MP3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn str_to_format(format: &str) -> Option<TrackFormat> {
|
||||||
|
match format {
|
||||||
|
TRACK_FORMAT_FLAC => Some(TrackFormat::Flac),
|
||||||
|
TRACK_FORMAT_MP3 => Some(TrackFormat::Mp3),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
trait ToBeetsArg {
|
trait ToBeetsArg {
|
||||||
fn to_arg(&self, include: bool) -> String;
|
fn to_arg(&self, include: bool) -> String;
|
||||||
}
|
}
|
||||||
@ -57,10 +76,13 @@ impl ToBeetsArg for Field {
|
|||||||
Field::AlbumArtist(ref s) => format!("{negate}albumartist:{s}"),
|
Field::AlbumArtist(ref s) => format!("{negate}albumartist:{s}"),
|
||||||
Field::AlbumArtistSort(ref s) => format!("{negate}albumartist_sort:{s}"),
|
Field::AlbumArtistSort(ref s) => format!("{negate}albumartist_sort:{s}"),
|
||||||
Field::AlbumYear(ref u) => format!("{negate}year:{u}"),
|
Field::AlbumYear(ref u) => format!("{negate}year:{u}"),
|
||||||
|
Field::AlbumMonth(ref e) => format!("{negate}month:{}", { *e }),
|
||||||
|
Field::AlbumDay(ref u) => format!("{negate}day:{u}"),
|
||||||
Field::AlbumTitle(ref s) => format!("{negate}album:{s}"),
|
Field::AlbumTitle(ref s) => format!("{negate}album:{s}"),
|
||||||
Field::TrackNumber(ref u) => format!("{negate}track:{u}"),
|
Field::TrackNumber(ref u) => format!("{negate}track:{u}"),
|
||||||
Field::TrackTitle(ref s) => format!("{negate}title:{s}"),
|
Field::TrackTitle(ref s) => format!("{negate}title:{s}"),
|
||||||
Field::TrackArtist(ref v) => format!("{negate}artist:{}", v.join("; ")),
|
Field::TrackArtist(ref v) => format!("{negate}artist:{}", v.join("; ")),
|
||||||
|
Field::TrackFormat(ref f) => format!("{negate}format:{}", format_to_str(f)),
|
||||||
Field::All(ref s) => format!("{negate}{s}"),
|
Field::All(ref s) => format!("{negate}{s}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -89,11 +111,6 @@ pub struct BeetsLibrary<BLE> {
|
|||||||
executor: BLE,
|
executor: BLE,
|
||||||
}
|
}
|
||||||
|
|
||||||
trait ILibraryPrivate {
|
|
||||||
fn list_cmd_and_args(query: &Query) -> Vec<String>;
|
|
||||||
fn list_to_items<S: AsRef<str>>(list_output: &[S]) -> Result<Vec<Item>, Error>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<BLE: IBeetsLibraryExecutor> BeetsLibrary<BLE> {
|
impl<BLE: IBeetsLibraryExecutor> BeetsLibrary<BLE> {
|
||||||
/// Create a new beets library with the provided executor, e.g.
|
/// Create a new beets library with the provided executor, e.g.
|
||||||
/// [`executor::BeetsLibraryProcessExecutor`].
|
/// [`executor::BeetsLibraryProcessExecutor`].
|
||||||
@ -110,7 +127,7 @@ impl<BLE: IBeetsLibraryExecutor> ILibrary for BeetsLibrary<BLE> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<BLE: IBeetsLibraryExecutor> ILibraryPrivate for BeetsLibrary<BLE> {
|
impl<BLE: IBeetsLibraryExecutor> BeetsLibrary<BLE> {
|
||||||
fn list_cmd_and_args(query: &Query) -> Vec<String> {
|
fn list_cmd_and_args(query: &Query) -> Vec<String> {
|
||||||
let mut cmd: Vec<String> = vec![String::from(CMD_LIST)];
|
let mut cmd: Vec<String> = vec![String::from(CMD_LIST)];
|
||||||
cmd.push(LIST_FORMAT_ARG.to_string());
|
cmd.push(LIST_FORMAT_ARG.to_string());
|
||||||
@ -127,36 +144,34 @@ impl<BLE: IBeetsLibraryExecutor> ILibraryPrivate for BeetsLibrary<BLE> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let split: Vec<&str> = line.split(LIST_FORMAT_SEPARATOR).collect();
|
let split: Vec<&str> = line.split(LIST_FORMAT_SEPARATOR).collect();
|
||||||
if split.len() != 9 {
|
if split.len() != 11 {
|
||||||
return Err(Error::Invalid(line.to_string()));
|
return Err(Error::Invalid(line.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let album_artist = split[0].to_string();
|
let album_artist = split[0].to_string();
|
||||||
let album_artist_sort = if !split[1].is_empty() {
|
let album_artist_sort = match !split[1].is_empty() {
|
||||||
Some(split[1].to_string())
|
true => Some(split[1].to_string()),
|
||||||
} else {
|
false => None,
|
||||||
None
|
|
||||||
};
|
};
|
||||||
let album_year = split[2].parse::<u32>()?;
|
let album_year = split[2].parse::<u32>()?;
|
||||||
let album_title = split[3].to_string();
|
let album_month = split[3].parse::<u8>()?;
|
||||||
let track_number = split[4].parse::<u32>()?;
|
let album_day = split[4].parse::<u8>()?;
|
||||||
let track_title = split[5].to_string();
|
let album_title = split[5].to_string();
|
||||||
let track_artist = split[6]
|
let track_number = split[6].parse::<u32>()?;
|
||||||
.to_string()
|
let track_title = split[7].to_string();
|
||||||
.split("; ")
|
let track_artist = split[8].split("; ").map(|s| s.to_owned()).collect();
|
||||||
.map(|s| s.to_owned())
|
let track_format = match str_to_format(split[9].to_string().as_str()) {
|
||||||
.collect();
|
Some(format) => format,
|
||||||
let track_format = match split[7].to_string().as_str() {
|
None => return Err(Error::Invalid(line.to_string())),
|
||||||
TRACK_FORMAT_FLAC => Format::Flac,
|
|
||||||
TRACK_FORMAT_MP3 => Format::Mp3,
|
|
||||||
_ => return Err(Error::Invalid(line.to_string())),
|
|
||||||
};
|
};
|
||||||
let track_bitrate = split[8].trim_end_matches("kbps").parse::<u32>()?;
|
let track_bitrate = split[10].trim_end_matches("kbps").parse::<u32>()?;
|
||||||
|
|
||||||
items.push(Item {
|
items.push(Item {
|
||||||
album_artist,
|
album_artist,
|
||||||
album_artist_sort,
|
album_artist_sort,
|
||||||
album_year,
|
album_year,
|
||||||
|
album_month,
|
||||||
|
album_day,
|
||||||
album_title,
|
album_title,
|
||||||
track_number,
|
track_number,
|
||||||
track_title,
|
track_title,
|
||||||
@ -177,7 +192,7 @@ mod testmod;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use mockall::predicate;
|
use mockall::predicate;
|
||||||
|
|
||||||
use crate::core::library::testmod::LIBRARY_ITEMS;
|
use crate::core::interface::library::testmod::LIBRARY_ITEMS;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use testmod::LIBRARY_BEETS;
|
use testmod::LIBRARY_BEETS;
|
||||||
@ -191,6 +206,7 @@ mod tests {
|
|||||||
String::from("some.artist.1"),
|
String::from("some.artist.1"),
|
||||||
String::from("some.artist.2"),
|
String::from("some.artist.2"),
|
||||||
]))
|
]))
|
||||||
|
.exclude(Field::TrackFormat(TrackFormat::Mp3))
|
||||||
.exclude(Field::All(String::from("some.all")))
|
.exclude(Field::All(String::from("some.all")))
|
||||||
.to_args();
|
.to_args();
|
||||||
query.sort();
|
query.sort();
|
||||||
@ -199,6 +215,7 @@ mod tests {
|
|||||||
query,
|
query,
|
||||||
vec![
|
vec![
|
||||||
String::from("^album:some.album"),
|
String::from("^album:some.album"),
|
||||||
|
String::from("^format:MP3"),
|
||||||
String::from("^some.all"),
|
String::from("^some.all"),
|
||||||
String::from("artist:some.artist.1; some.artist.2"),
|
String::from("artist:some.artist.1; some.artist.2"),
|
||||||
String::from("track:5"),
|
String::from("track:5"),
|
||||||
@ -209,7 +226,10 @@ mod tests {
|
|||||||
.exclude(Field::AlbumArtist(String::from("some.albumartist")))
|
.exclude(Field::AlbumArtist(String::from("some.albumartist")))
|
||||||
.exclude(Field::AlbumArtistSort(String::from("some.albumartist")))
|
.exclude(Field::AlbumArtistSort(String::from("some.albumartist")))
|
||||||
.include(Field::AlbumYear(3030))
|
.include(Field::AlbumYear(3030))
|
||||||
|
.include(Field::AlbumMonth(4))
|
||||||
|
.include(Field::AlbumDay(6))
|
||||||
.include(Field::TrackTitle(String::from("some.track")))
|
.include(Field::TrackTitle(String::from("some.track")))
|
||||||
|
.include(Field::TrackFormat(TrackFormat::Flac))
|
||||||
.exclude(Field::TrackArtist(vec![
|
.exclude(Field::TrackArtist(vec![
|
||||||
String::from("some.artist.1"),
|
String::from("some.artist.1"),
|
||||||
String::from("some.artist.2"),
|
String::from("some.artist.2"),
|
||||||
@ -223,6 +243,9 @@ mod tests {
|
|||||||
String::from("^albumartist:some.albumartist"),
|
String::from("^albumartist:some.albumartist"),
|
||||||
String::from("^albumartist_sort:some.albumartist"),
|
String::from("^albumartist_sort:some.albumartist"),
|
||||||
String::from("^artist:some.artist.1; some.artist.2"),
|
String::from("^artist:some.artist.1; some.artist.2"),
|
||||||
|
String::from("day:6"),
|
||||||
|
String::from("format:FLAC"),
|
||||||
|
String::from("month:4"),
|
||||||
String::from("title:some.track"),
|
String::from("title:some.track"),
|
||||||
String::from("year:3030"),
|
String::from("year:3030"),
|
||||||
]
|
]
|
||||||
@ -335,8 +358,8 @@ mod tests {
|
|||||||
.split(LIST_FORMAT_SEPARATOR)
|
.split(LIST_FORMAT_SEPARATOR)
|
||||||
.map(|s| s.to_owned())
|
.map(|s| s.to_owned())
|
||||||
.collect::<Vec<String>>();
|
.collect::<Vec<String>>();
|
||||||
invalid_string[7].clear();
|
invalid_string[9].clear();
|
||||||
invalid_string[7].push_str("invalid format");
|
invalid_string[9].push_str("invalid format");
|
||||||
let invalid_string = invalid_string.join(LIST_FORMAT_SEPARATOR);
|
let invalid_string = invalid_string.join(LIST_FORMAT_SEPARATOR);
|
||||||
output[2] = invalid_string.clone();
|
output[2] = invalid_string.clone();
|
||||||
let result = Ok(output);
|
let result = Ok(output);
|
116
src/external/library/beets/testmod.rs
vendored
Normal file
116
src/external/library/beets/testmod.rs
vendored
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
|
pub static LIBRARY_BEETS: Lazy<Vec<String>> = Lazy::new(|| -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘A’ -*^- -*^- \
|
||||||
|
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
|
||||||
|
1 -*^- track a.a.1 -*^- artist a.a.1 -*^- FLAC -*^- 992",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘A’ -*^- -*^- \
|
||||||
|
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
|
||||||
|
2 -*^- track a.a.2 -*^- artist a.a.2.1; artist a.a.2.2 -*^- MP3 -*^- 320",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘A’ -*^- -*^- \
|
||||||
|
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
|
||||||
|
3 -*^- track a.a.3 -*^- artist a.a.3 -*^- FLAC -*^- 1061",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘A’ -*^- -*^- \
|
||||||
|
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
|
||||||
|
4 -*^- track a.a.4 -*^- artist a.a.4 -*^- FLAC -*^- 1042",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘A’ -*^- -*^- \
|
||||||
|
2015 -*^- 04 -*^- 00 -*^- album_title a.b -*^- \
|
||||||
|
1 -*^- track a.b.1 -*^- artist a.b.1 -*^- FLAC -*^- 1004",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘A’ -*^- -*^- \
|
||||||
|
2015 -*^- 04 -*^- 00 -*^- album_title a.b -*^- \
|
||||||
|
2 -*^- track a.b.2 -*^- artist a.b.2 -*^- FLAC -*^- 1077",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘B’ -*^- -*^- \
|
||||||
|
2003 -*^- 06 -*^- 06 -*^- album_title b.a -*^- \
|
||||||
|
1 -*^- track b.a.1 -*^- artist b.a.1 -*^- MP3 -*^- 190",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘B’ -*^- -*^- \
|
||||||
|
2003 -*^- 06 -*^- 06 -*^- album_title b.a -*^- \
|
||||||
|
2 -*^- track b.a.2 -*^- artist b.a.2.1; artist b.a.2.2 -*^- MP3 -*^- 120",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘B’ -*^- -*^- \
|
||||||
|
2008 -*^- 00 -*^- 00 -*^- album_title b.b -*^- \
|
||||||
|
1 -*^- track b.b.1 -*^- artist b.b.1 -*^- FLAC -*^- 1077",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘B’ -*^- -*^- \
|
||||||
|
2008 -*^- 00 -*^- 00 -*^- album_title b.b -*^- \
|
||||||
|
2 -*^- track b.b.2 -*^- artist b.b.2.1; artist b.b.2.2 -*^- MP3 -*^- 320",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘B’ -*^- -*^- \
|
||||||
|
2009 -*^- 00 -*^- 00 -*^- album_title b.c -*^- \
|
||||||
|
1 -*^- track b.c.1 -*^- artist b.c.1 -*^- MP3 -*^- 190",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘B’ -*^- -*^- \
|
||||||
|
2009 -*^- 00 -*^- 00 -*^- album_title b.c -*^- \
|
||||||
|
2 -*^- track b.c.2 -*^- artist b.c.2.1; artist b.c.2.2 -*^- MP3 -*^- 120",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘B’ -*^- -*^- \
|
||||||
|
2015 -*^- 00 -*^- 00 -*^- album_title b.d -*^- \
|
||||||
|
1 -*^- track b.d.1 -*^- artist b.d.1 -*^- MP3 -*^- 190",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘B’ -*^- -*^- \
|
||||||
|
2015 -*^- 00 -*^- 00 -*^- album_title b.d -*^- \
|
||||||
|
2 -*^- track b.d.2 -*^- artist b.d.2.1; artist b.d.2.2 -*^- MP3 -*^- 120",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- \
|
||||||
|
1985 -*^- 00 -*^- 00 -*^- album_title c.a -*^- \
|
||||||
|
1 -*^- track c.a.1 -*^- artist c.a.1 -*^- MP3 -*^- 320",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- \
|
||||||
|
1985 -*^- 00 -*^- 00 -*^- album_title c.a -*^- \
|
||||||
|
2 -*^- track c.a.2 -*^- artist c.a.2.1; artist c.a.2.2 -*^- MP3 -*^- 120",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- \
|
||||||
|
2018 -*^- 00 -*^- 00 -*^- album_title c.b -*^- \
|
||||||
|
1 -*^- track c.b.1 -*^- artist c.b.1 -*^- FLAC -*^- 1041",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- \
|
||||||
|
2018 -*^- 00 -*^- 00 -*^- album_title c.b -*^- \
|
||||||
|
2 -*^- track c.b.2 -*^- artist c.b.2.1; artist c.b.2.2 -*^- FLAC -*^- 756",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘D’ -*^- -*^- \
|
||||||
|
1995 -*^- 00 -*^- 00 -*^- album_title d.a -*^- \
|
||||||
|
1 -*^- track d.a.1 -*^- artist d.a.1 -*^- MP3 -*^- 120",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘D’ -*^- -*^- \
|
||||||
|
1995 -*^- 00 -*^- 00 -*^- album_title d.a -*^- \
|
||||||
|
2 -*^- track d.a.2 -*^- artist d.a.2.1; artist d.a.2.2 -*^- MP3 -*^- 120",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘D’ -*^- -*^- \
|
||||||
|
2028 -*^- 00 -*^- 00 -*^- album_title d.b -*^- \
|
||||||
|
1 -*^- track d.b.1 -*^- artist d.b.1 -*^- FLAC -*^- 841",
|
||||||
|
),
|
||||||
|
String::from(
|
||||||
|
"Album_Artist ‘D’ -*^- -*^- \
|
||||||
|
2028 -*^- 00 -*^- 00 -*^- album_title d.b -*^- \
|
||||||
|
2 -*^- track d.b.2 -*^- artist d.b.2.1; artist d.b.2.2 -*^- FLAC -*^- 756",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
});
|
2
src/external/library/mod.rs
vendored
Normal file
2
src/external/library/mod.rs
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
#[cfg(feature = "library-beets")]
|
||||||
|
pub mod beets;
|
4
src/external/mod.rs
vendored
Normal file
4
src/external/mod.rs
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
pub mod database;
|
||||||
|
pub mod library;
|
||||||
|
#[cfg(feature = "musicbrainz")]
|
||||||
|
pub mod musicbrainz;
|
178
src/external/musicbrainz/api/browse.rs
vendored
Normal file
178
src/external/musicbrainz/api/browse.rs
vendored
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::musicbrainz::Mbid,
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
ApiDisplay, Error, MbReleaseGroupMeta, MusicBrainzClient, PageSettings,
|
||||||
|
SerdeMbReleaseGroupMeta, MB_BASE_URL,
|
||||||
|
},
|
||||||
|
IMusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
pub struct BrowseReleaseGroupPage {
|
||||||
|
release_group_offset: usize,
|
||||||
|
release_group_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowseReleaseGroupPage {
|
||||||
|
pub fn next_page(&self, settings: PageSettings, page_count: usize) -> Option<PageSettings> {
|
||||||
|
settings.next_page(
|
||||||
|
self.release_group_offset,
|
||||||
|
self.release_group_count,
|
||||||
|
page_count,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SerdeBrowseReleaseGroupPage = BrowseReleaseGroupPage;
|
||||||
|
|
||||||
|
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||||
|
pub fn browse_release_group(
|
||||||
|
&mut self,
|
||||||
|
request: &BrowseReleaseGroupRequest,
|
||||||
|
paging: &PageSettings,
|
||||||
|
) -> Result<BrowseReleaseGroupResponse, Error> {
|
||||||
|
let entity = &request.entity;
|
||||||
|
let mbid = request.mbid.uuid().as_hyphenated();
|
||||||
|
let page = ApiDisplay::format_page_settings(paging);
|
||||||
|
|
||||||
|
let url = format!("{MB_BASE_URL}/release-group?{entity}={mbid}{page}");
|
||||||
|
|
||||||
|
let response: DeserializeBrowseReleaseGroupResponse = self.http.get(&url)?;
|
||||||
|
Ok(response.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BrowseReleaseGroupRequest<'a> {
|
||||||
|
entity: BrowseReleaseGroupRequestEntity,
|
||||||
|
mbid: &'a Mbid,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BrowseReleaseGroupRequestEntity {
|
||||||
|
Artist,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BrowseReleaseGroupRequestEntity {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
BrowseReleaseGroupRequestEntity::Artist => write!(f, "artist"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> BrowseReleaseGroupRequest<'a> {
|
||||||
|
pub fn artist(mbid: &'a Mbid) -> Self {
|
||||||
|
BrowseReleaseGroupRequest {
|
||||||
|
entity: BrowseReleaseGroupRequestEntity::Artist,
|
||||||
|
mbid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct BrowseReleaseGroupResponse {
|
||||||
|
pub release_groups: Vec<MbReleaseGroupMeta>,
|
||||||
|
pub page: BrowseReleaseGroupPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
struct DeserializeBrowseReleaseGroupResponse {
|
||||||
|
release_groups: Option<Vec<SerdeMbReleaseGroupMeta>>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
page: SerdeBrowseReleaseGroupPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeBrowseReleaseGroupResponse> for BrowseReleaseGroupResponse {
|
||||||
|
fn from(value: DeserializeBrowseReleaseGroupResponse) -> Self {
|
||||||
|
BrowseReleaseGroupResponse {
|
||||||
|
page: value.page,
|
||||||
|
release_groups: value
|
||||||
|
.release_groups
|
||||||
|
.map(|rgs| rgs.into_iter().map(Into::into).collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockall::predicate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
tests::next_page_test, SerdeAlbumDate, SerdeAlbumPrimaryType,
|
||||||
|
SerdeAlbumSecondaryType, SerdeMbid, MB_MAX_PAGE_LIMIT,
|
||||||
|
},
|
||||||
|
MockIMusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browse_release_group_next_page() {
|
||||||
|
let page = BrowseReleaseGroupPage {
|
||||||
|
release_group_offset: 5,
|
||||||
|
release_group_count: 45,
|
||||||
|
};
|
||||||
|
|
||||||
|
next_page_test(|val| page.next_page(PageSettings::default(), val));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn browse_release_group() {
|
||||||
|
let mbid = "00000000-0000-0000-0000-000000000000";
|
||||||
|
let mut http = MockIMusicBrainzHttp::new();
|
||||||
|
|
||||||
|
let de_release_group_offset = 24;
|
||||||
|
let de_release_group_count = 302;
|
||||||
|
let de_meta = SerdeMbReleaseGroupMeta {
|
||||||
|
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||||
|
title: String::from("an album"),
|
||||||
|
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||||
|
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||||
|
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
||||||
|
AlbumSecondaryType::Compilation,
|
||||||
|
)]),
|
||||||
|
};
|
||||||
|
let de_response = DeserializeBrowseReleaseGroupResponse {
|
||||||
|
page: SerdeBrowseReleaseGroupPage {
|
||||||
|
release_group_offset: de_release_group_offset,
|
||||||
|
release_group_count: de_release_group_count,
|
||||||
|
},
|
||||||
|
release_groups: Some(vec![de_meta.clone()]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = BrowseReleaseGroupResponse {
|
||||||
|
page: de_response.page,
|
||||||
|
release_groups: vec![de_meta.clone().into()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"https://musicbrainz.org/ws/2/release-group?artist={mbid}&limit={MB_MAX_PAGE_LIMIT}",
|
||||||
|
);
|
||||||
|
let expect_response = de_response.clone();
|
||||||
|
http.expect_get()
|
||||||
|
.times(1)
|
||||||
|
.with(predicate::eq(url))
|
||||||
|
.return_once(|_| Ok(expect_response));
|
||||||
|
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
let mbid: Mbid = mbid.try_into().unwrap();
|
||||||
|
|
||||||
|
let request = BrowseReleaseGroupRequest::artist(&mbid);
|
||||||
|
let paging = PageSettings::with_max_limit();
|
||||||
|
let result = client.browse_release_group(&request, &paging).unwrap();
|
||||||
|
assert_eq!(result, response);
|
||||||
|
}
|
||||||
|
}
|
221
src/external/musicbrainz/api/lookup.rs
vendored
Normal file
221
src/external/musicbrainz/api/lookup.rs
vendored
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
use serde::Deserialize;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::musicbrainz::Mbid,
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{Error, MusicBrainzClient, MB_BASE_URL},
|
||||||
|
IMusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{MbArtistMeta, MbReleaseGroupMeta, SerdeMbArtistMeta, SerdeMbReleaseGroupMeta};
|
||||||
|
|
||||||
|
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||||
|
pub fn lookup_artist(
|
||||||
|
&mut self,
|
||||||
|
request: &LookupArtistRequest,
|
||||||
|
) -> Result<LookupArtistResponse, Error> {
|
||||||
|
let mut include: Vec<String> = vec![];
|
||||||
|
|
||||||
|
if request.release_groups {
|
||||||
|
include.push(String::from("release-groups"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let include: String =
|
||||||
|
form_urlencoded::byte_serialize(include.join("+").as_bytes()).collect();
|
||||||
|
let url = format!(
|
||||||
|
"{MB_BASE_URL}/artist/{mbid}?inc={include}",
|
||||||
|
mbid = request.mbid.uuid().as_hyphenated()
|
||||||
|
);
|
||||||
|
|
||||||
|
let response: DeserializeLookupArtistResponse = self.http.get(&url)?;
|
||||||
|
Ok(response.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup_release_group(
|
||||||
|
&mut self,
|
||||||
|
request: &LookupReleaseGroupRequest,
|
||||||
|
) -> Result<LookupReleaseGroupResponse, Error> {
|
||||||
|
let url = format!(
|
||||||
|
"{MB_BASE_URL}/release-group/{mbid}",
|
||||||
|
mbid = request.mbid.uuid().as_hyphenated()
|
||||||
|
);
|
||||||
|
|
||||||
|
let response: DeserializeLookupReleaseGroupResponse = self.http.get(&url)?;
|
||||||
|
Ok(response.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LookupArtistRequest<'a> {
|
||||||
|
mbid: &'a Mbid,
|
||||||
|
release_groups: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> LookupArtistRequest<'a> {
|
||||||
|
pub fn new(mbid: &'a Mbid) -> Self {
|
||||||
|
LookupArtistRequest {
|
||||||
|
mbid,
|
||||||
|
release_groups: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn include_release_groups(&mut self) -> &mut Self {
|
||||||
|
self.release_groups = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct LookupArtistResponse {
|
||||||
|
pub meta: MbArtistMeta,
|
||||||
|
pub release_groups: Vec<MbReleaseGroupMeta>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
struct DeserializeLookupArtistResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
meta: SerdeMbArtistMeta,
|
||||||
|
release_groups: Option<Vec<SerdeMbReleaseGroupMeta>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeLookupArtistResponse> for LookupArtistResponse {
|
||||||
|
fn from(value: DeserializeLookupArtistResponse) -> Self {
|
||||||
|
LookupArtistResponse {
|
||||||
|
meta: value.meta.into(),
|
||||||
|
release_groups: value
|
||||||
|
.release_groups
|
||||||
|
.map(|rgs| rgs.into_iter().map(Into::into).collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LookupReleaseGroupRequest<'a> {
|
||||||
|
mbid: &'a Mbid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> LookupReleaseGroupRequest<'a> {
|
||||||
|
pub fn new(mbid: &'a Mbid) -> Self {
|
||||||
|
LookupReleaseGroupRequest { mbid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct LookupReleaseGroupResponse {
|
||||||
|
pub meta: MbReleaseGroupMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
struct DeserializeLookupReleaseGroupResponse {
|
||||||
|
#[serde(flatten)]
|
||||||
|
meta: SerdeMbReleaseGroupMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeLookupReleaseGroupResponse> for LookupReleaseGroupResponse {
|
||||||
|
fn from(value: DeserializeLookupReleaseGroupResponse) -> Self {
|
||||||
|
LookupReleaseGroupResponse {
|
||||||
|
meta: value.meta.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockall::predicate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid},
|
||||||
|
MockIMusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_artist() {
|
||||||
|
let mbid = "00000000-0000-0000-0000-000000000000";
|
||||||
|
let mut http = MockIMusicBrainzHttp::new();
|
||||||
|
let url = format!("https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups",);
|
||||||
|
|
||||||
|
let de_meta = SerdeMbArtistMeta {
|
||||||
|
id: SerdeMbid(mbid.try_into().unwrap()),
|
||||||
|
name: String::from("the artist"),
|
||||||
|
sort_name: String::from("artist, the"),
|
||||||
|
disambiguation: Some(String::from("disambig")),
|
||||||
|
};
|
||||||
|
let de_release_group = SerdeMbReleaseGroupMeta {
|
||||||
|
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||||
|
title: String::from("an album"),
|
||||||
|
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||||
|
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||||
|
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
||||||
|
AlbumSecondaryType::Compilation,
|
||||||
|
)]),
|
||||||
|
};
|
||||||
|
let de_response = DeserializeLookupArtistResponse {
|
||||||
|
meta: de_meta.clone(),
|
||||||
|
release_groups: Some(vec![de_release_group.clone()]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = LookupArtistResponse {
|
||||||
|
meta: de_meta.into(),
|
||||||
|
release_groups: vec![de_release_group.into()],
|
||||||
|
};
|
||||||
|
|
||||||
|
http.expect_get()
|
||||||
|
.times(1)
|
||||||
|
.with(predicate::eq(url))
|
||||||
|
.return_once(|_| Ok(de_response));
|
||||||
|
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||||
|
let mut request = LookupArtistRequest::new(&mbid);
|
||||||
|
request.include_release_groups();
|
||||||
|
let result = client.lookup_artist(&request).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_release_group() {
|
||||||
|
let mbid = "00000000-0000-0000-0000-000000000000";
|
||||||
|
let mut http = MockIMusicBrainzHttp::new();
|
||||||
|
let url = format!("https://musicbrainz.org/ws/2/release-group/{mbid}",);
|
||||||
|
|
||||||
|
let de_meta = SerdeMbReleaseGroupMeta {
|
||||||
|
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||||
|
title: String::from("an album"),
|
||||||
|
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||||
|
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||||
|
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
||||||
|
AlbumSecondaryType::Compilation,
|
||||||
|
)]),
|
||||||
|
};
|
||||||
|
let de_response = DeserializeLookupReleaseGroupResponse {
|
||||||
|
meta: de_meta.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = LookupReleaseGroupResponse {
|
||||||
|
meta: de_meta.into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
http.expect_get()
|
||||||
|
.times(1)
|
||||||
|
.with(predicate::eq(url))
|
||||||
|
.return_once(|_| Ok(de_response));
|
||||||
|
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||||
|
let request = LookupReleaseGroupRequest::new(&mbid);
|
||||||
|
let result = client.lookup_release_group(&request).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result, response);
|
||||||
|
}
|
||||||
|
}
|
461
src/external/musicbrainz/api/mod.rs
vendored
Normal file
461
src/external/musicbrainz/api/mod.rs
vendored
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
use std::{fmt, num};
|
||||||
|
|
||||||
|
use serde::{de::Visitor, Deserialize, Deserializer};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::{
|
||||||
|
album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
musicbrainz::Mbid,
|
||||||
|
Error as CollectionError,
|
||||||
|
},
|
||||||
|
external::musicbrainz::HttpError,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod browse;
|
||||||
|
pub mod lookup;
|
||||||
|
pub mod search;
|
||||||
|
|
||||||
|
const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2";
|
||||||
|
const MB_RATE_LIMIT_CODE: u16 = 503;
|
||||||
|
const MB_MAX_PAGE_LIMIT: usize = 100;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Error {
|
||||||
|
/// The HTTP client failed.
|
||||||
|
Http(String),
|
||||||
|
/// The client reached the API rate limit.
|
||||||
|
RateLimit,
|
||||||
|
/// The API response could not be understood.
|
||||||
|
Unknown(u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Error::Http(s) => write!(f, "the HTTP client failed: {s}"),
|
||||||
|
Error::RateLimit => write!(f, "the API rate limit has been reached"),
|
||||||
|
Error::Unknown(u) => write!(f, "the API response could not be understood: status {u}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HttpError> for Error {
|
||||||
|
fn from(err: HttpError) -> Self {
|
||||||
|
match err {
|
||||||
|
HttpError::Client(s) => Error::Http(s),
|
||||||
|
HttpError::Status(status) => match status {
|
||||||
|
MB_RATE_LIMIT_CODE => Error::RateLimit,
|
||||||
|
_ => Error::Unknown(status),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MusicBrainzClient<Http> {
|
||||||
|
http: Http,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Http> MusicBrainzClient<Http> {
|
||||||
|
pub fn new(http: Http) -> Self {
|
||||||
|
MusicBrainzClient { http }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct PageSettings {
|
||||||
|
limit: Option<usize>,
|
||||||
|
offset: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PageSettings {
|
||||||
|
pub fn with_limit(limit: usize) -> Self {
|
||||||
|
PageSettings {
|
||||||
|
limit: Some(limit),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_max_limit() -> Self {
|
||||||
|
Self::with_limit(MB_MAX_PAGE_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_offset(mut self, offset: usize) -> Self {
|
||||||
|
self.set_offset(offset);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_offset(&mut self, offset: usize) {
|
||||||
|
self.offset = Some(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_page(self, offset: usize, total_count: usize, page_count: usize) -> Option<Self> {
|
||||||
|
let next_offset = offset + page_count;
|
||||||
|
if next_offset < total_count {
|
||||||
|
Some(self.with_offset(next_offset))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct MbArtistMeta {
|
||||||
|
pub id: Mbid,
|
||||||
|
pub name: String,
|
||||||
|
pub sort_name: String,
|
||||||
|
pub disambiguation: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
struct SerdeMbArtistMeta {
|
||||||
|
id: SerdeMbid,
|
||||||
|
name: String,
|
||||||
|
sort_name: String,
|
||||||
|
disambiguation: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SerdeMbArtistMeta> for MbArtistMeta {
|
||||||
|
fn from(value: SerdeMbArtistMeta) -> Self {
|
||||||
|
MbArtistMeta {
|
||||||
|
id: value.id.into(),
|
||||||
|
name: value.name,
|
||||||
|
sort_name: value.sort_name,
|
||||||
|
disambiguation: value.disambiguation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct MbReleaseGroupMeta {
|
||||||
|
pub id: Mbid,
|
||||||
|
pub title: String,
|
||||||
|
pub first_release_date: AlbumDate,
|
||||||
|
pub primary_type: Option<AlbumPrimaryType>,
|
||||||
|
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
pub struct SerdeMbReleaseGroupMeta {
|
||||||
|
id: SerdeMbid,
|
||||||
|
title: String,
|
||||||
|
first_release_date: SerdeAlbumDate,
|
||||||
|
primary_type: Option<SerdeAlbumPrimaryType>,
|
||||||
|
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SerdeMbReleaseGroupMeta> for MbReleaseGroupMeta {
|
||||||
|
fn from(value: SerdeMbReleaseGroupMeta) -> Self {
|
||||||
|
MbReleaseGroupMeta {
|
||||||
|
id: value.id.into(),
|
||||||
|
title: value.title,
|
||||||
|
first_release_date: value.first_release_date.into(),
|
||||||
|
primary_type: value.primary_type.map(Into::into),
|
||||||
|
secondary_types: value
|
||||||
|
.secondary_types
|
||||||
|
.map(|v| v.into_iter().map(Into::into).collect()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ApiDisplay;
|
||||||
|
|
||||||
|
impl ApiDisplay {
|
||||||
|
fn format_page_settings(paging: &PageSettings) -> String {
|
||||||
|
let limit = paging
|
||||||
|
.limit
|
||||||
|
.map(|l| format!("&limit={l}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let offset = paging
|
||||||
|
.offset
|
||||||
|
.map(|o| format!("&offset={o}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{limit}{offset}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_album_date(date: &AlbumDate) -> String {
|
||||||
|
match date.year {
|
||||||
|
Some(year) => match date.month {
|
||||||
|
Some(month) => match date.day {
|
||||||
|
Some(day) => format!("{year}-{month:02}-{day:02}"),
|
||||||
|
None => format!("{year}-{month:02}"),
|
||||||
|
},
|
||||||
|
None => format!("{year}"),
|
||||||
|
},
|
||||||
|
None => String::from("*"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SerdeMbid(Mbid);
|
||||||
|
|
||||||
|
impl From<SerdeMbid> for Mbid {
|
||||||
|
fn from(value: SerdeMbid) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SerdeMbidVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for SerdeMbidVisitor {
|
||||||
|
type Value = SerdeMbid;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a valid MusicBrainz identifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
Ok(SerdeMbid(
|
||||||
|
v.try_into()
|
||||||
|
.map_err(|e: CollectionError| E::custom(e.to_string()))?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SerdeMbid {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
deserializer.deserialize_str(SerdeMbidVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SerdeAlbumDate(AlbumDate);
|
||||||
|
|
||||||
|
impl From<SerdeAlbumDate> for AlbumDate {
|
||||||
|
fn from(value: SerdeAlbumDate) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SerdeAlbumDateVisitor;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for SerdeAlbumDateVisitor {
|
||||||
|
type Value = SerdeAlbumDate;
|
||||||
|
|
||||||
|
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
formatter.write_str("a valid YYYY(-MM-(-DD)) date")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||||
|
where
|
||||||
|
E: serde::de::Error,
|
||||||
|
{
|
||||||
|
let mut elems = v.split('-');
|
||||||
|
|
||||||
|
let elem = elems.next();
|
||||||
|
let year = elem
|
||||||
|
.and_then(|s| if s.is_empty() { None } else { Some(s.parse()) })
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||||
|
|
||||||
|
let elem = elems.next();
|
||||||
|
let month = elem
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||||
|
|
||||||
|
let elem = elems.next();
|
||||||
|
let day = elem
|
||||||
|
.map(|s| s.parse())
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(SerdeAlbumDate(AlbumDate::new(year, month, day)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SerdeAlbumDate {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
deserializer.deserialize_str(SerdeAlbumDateVisitor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(remote = "AlbumPrimaryType")]
|
||||||
|
pub enum AlbumPrimaryTypeDef {
|
||||||
|
Album,
|
||||||
|
Single,
|
||||||
|
#[serde(rename = "EP")]
|
||||||
|
Ep,
|
||||||
|
Broadcast,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
|
||||||
|
|
||||||
|
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
||||||
|
fn from(value: SerdeAlbumPrimaryType) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(remote = "AlbumSecondaryType")]
|
||||||
|
pub enum AlbumSecondaryTypeDef {
|
||||||
|
Compilation,
|
||||||
|
Soundtrack,
|
||||||
|
Spokenword,
|
||||||
|
Interview,
|
||||||
|
Audiobook,
|
||||||
|
#[serde(rename = "Audio drama")]
|
||||||
|
AudioDrama,
|
||||||
|
Live,
|
||||||
|
Remix,
|
||||||
|
#[serde(rename = "DJ-mix")]
|
||||||
|
DjMix,
|
||||||
|
#[serde(rename = "Mixtape/Street")]
|
||||||
|
MixtapeStreet,
|
||||||
|
Demo,
|
||||||
|
#[serde(rename = "Field recording")]
|
||||||
|
FieldRecording,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
|
||||||
|
|
||||||
|
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
||||||
|
fn from(value: SerdeAlbumSecondaryType) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors() {
|
||||||
|
let http_err = HttpError::Client(String::from("a http error"));
|
||||||
|
let http_err: Error = http_err.into();
|
||||||
|
assert!(matches!(http_err, Error::Http(_)));
|
||||||
|
assert!(!http_err.to_string().is_empty());
|
||||||
|
assert!(!format!("{http_err:?}").is_empty());
|
||||||
|
|
||||||
|
let rate_err = HttpError::Status(MB_RATE_LIMIT_CODE);
|
||||||
|
let rate_err: Error = rate_err.into();
|
||||||
|
assert!(matches!(rate_err, Error::RateLimit));
|
||||||
|
assert!(!rate_err.to_string().is_empty());
|
||||||
|
assert!(!format!("{rate_err:?}").is_empty());
|
||||||
|
|
||||||
|
let unk_err = HttpError::Status(404);
|
||||||
|
let unk_err: Error = unk_err.into();
|
||||||
|
assert!(matches!(unk_err, Error::Unknown(_)));
|
||||||
|
assert!(!unk_err.to_string().is_empty());
|
||||||
|
assert!(!format!("{unk_err:?}").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_page_test<Fn>(mut f: Fn)
|
||||||
|
where
|
||||||
|
Fn: FnMut(usize) -> Option<PageSettings>,
|
||||||
|
{
|
||||||
|
let next = f(20);
|
||||||
|
assert_eq!(next.unwrap().offset, Some(25));
|
||||||
|
|
||||||
|
let next = f(40);
|
||||||
|
assert!(next.is_none());
|
||||||
|
|
||||||
|
let next = f(100);
|
||||||
|
assert!(next.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_page() {
|
||||||
|
next_page_test(|val| PageSettings::default().next_page(5, 45, val));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_page_settings() {
|
||||||
|
let paging = PageSettings::default();
|
||||||
|
assert_eq!(ApiDisplay::format_page_settings(&paging), "");
|
||||||
|
|
||||||
|
let paging = PageSettings::with_max_limit();
|
||||||
|
assert_eq!(ApiDisplay::format_page_settings(&paging), "&limit=100");
|
||||||
|
|
||||||
|
let mut paging = PageSettings::with_limit(45);
|
||||||
|
paging.set_offset(145);
|
||||||
|
assert_eq!(
|
||||||
|
ApiDisplay::format_page_settings(&paging),
|
||||||
|
"&limit=45&offset=145"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut paging = PageSettings::default();
|
||||||
|
paging.set_offset(26);
|
||||||
|
assert_eq!(ApiDisplay::format_page_settings(&paging), "&offset=26");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_album_date() {
|
||||||
|
assert_eq!(
|
||||||
|
ApiDisplay::format_album_date(&AlbumDate::new(None, None, None)),
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
assert_eq!(ApiDisplay::format_album_date(&(1986).into()), "1986");
|
||||||
|
assert_eq!(ApiDisplay::format_album_date(&(1986, 4).into()), "1986-04");
|
||||||
|
assert_eq!(
|
||||||
|
ApiDisplay::format_album_date(&(1986, 4, 21).into()),
|
||||||
|
"1986-04-21"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serde() {
|
||||||
|
let mbid = "\"d368baa8-21ca-4759-9731-0b2753071ad8\"";
|
||||||
|
let mbid: SerdeMbid = serde_json::from_str(mbid).unwrap();
|
||||||
|
let mbid: Mbid = mbid.into();
|
||||||
|
assert_eq!(
|
||||||
|
mbid,
|
||||||
|
"d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let mbid = "0";
|
||||||
|
let result: Result<SerdeMbid, _> = serde_json::from_str(mbid);
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("a valid MusicBrainz identifier"));
|
||||||
|
|
||||||
|
let album_date = "\"1986-04-21\"";
|
||||||
|
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
|
||||||
|
let album_date: AlbumDate = album_date.into();
|
||||||
|
assert_eq!(album_date, AlbumDate::new(Some(1986), Some(4), Some(21)));
|
||||||
|
|
||||||
|
let album_date = "\"1986-04\"";
|
||||||
|
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
|
||||||
|
let album_date: AlbumDate = album_date.into();
|
||||||
|
assert_eq!(album_date, AlbumDate::new(Some(1986), Some(4), None));
|
||||||
|
|
||||||
|
let album_date = "\"1986\"";
|
||||||
|
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
|
||||||
|
let album_date: AlbumDate = album_date.into();
|
||||||
|
assert_eq!(album_date, AlbumDate::new(Some(1986), None, None));
|
||||||
|
|
||||||
|
let album_date = "0";
|
||||||
|
let result: Result<SerdeAlbumDate, _> = serde_json::from_str(album_date);
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("a valid YYYY(-MM-(-DD)) date"));
|
||||||
|
|
||||||
|
let primary_type = "\"EP\"";
|
||||||
|
let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap();
|
||||||
|
let primary_type: AlbumPrimaryType = primary_type.into();
|
||||||
|
assert_eq!(primary_type, AlbumPrimaryType::Ep);
|
||||||
|
|
||||||
|
let secondary_type = "\"Field recording\"";
|
||||||
|
let secondary_type: SerdeAlbumSecondaryType = serde_json::from_str(secondary_type).unwrap();
|
||||||
|
let secondary_type: AlbumSecondaryType = secondary_type.into();
|
||||||
|
assert_eq!(secondary_type, AlbumSecondaryType::FieldRecording);
|
||||||
|
}
|
||||||
|
}
|
149
src/external/musicbrainz/api/search/artist.rs
vendored
Normal file
149
src/external/musicbrainz/api/search/artist.rs
vendored
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::external::musicbrainz::api::{
|
||||||
|
search::{
|
||||||
|
query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
|
||||||
|
SearchPage, SerdeSearchPage,
|
||||||
|
},
|
||||||
|
MbArtistMeta, SerdeMbArtistMeta,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub enum SearchArtist<'a> {
|
||||||
|
String(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> fmt::Display for SearchArtist<'a> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::String(s) => write!(f, "\"{s}\""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SearchArtistRequest<'a> = Query<SearchArtist<'a>>;
|
||||||
|
|
||||||
|
impl_term!(string, SearchArtist<'a>, String, &'a str);
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct SearchArtistResponse {
|
||||||
|
pub artists: Vec<SearchArtistResponseArtist>,
|
||||||
|
pub page: SearchPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
pub struct DeserializeSearchArtistResponse {
|
||||||
|
artists: Vec<DeserializeSearchArtistResponseArtist>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
page: SerdeSearchPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeSearchArtistResponse> for SearchArtistResponse {
|
||||||
|
fn from(value: DeserializeSearchArtistResponse) -> Self {
|
||||||
|
SearchArtistResponse {
|
||||||
|
artists: value.artists.into_iter().map(Into::into).collect(),
|
||||||
|
page: value.page,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SearchArtistResponseArtist {
|
||||||
|
pub score: u8,
|
||||||
|
pub meta: MbArtistMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
struct DeserializeSearchArtistResponseArtist {
|
||||||
|
score: u8,
|
||||||
|
#[serde(flatten)]
|
||||||
|
meta: SerdeMbArtistMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeSearchArtistResponseArtist> for SearchArtistResponseArtist {
|
||||||
|
fn from(value: DeserializeSearchArtistResponseArtist) -> Self {
|
||||||
|
SearchArtistResponseArtist {
|
||||||
|
score: value.score,
|
||||||
|
meta: value.meta.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockall::predicate;
|
||||||
|
|
||||||
|
use crate::external::musicbrainz::{
|
||||||
|
api::{MusicBrainzClient, PageSettings, SerdeMbid},
|
||||||
|
MockIMusicBrainzHttp,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn de_response() -> DeserializeSearchArtistResponse {
|
||||||
|
let de_offset = 24;
|
||||||
|
let de_count = 124;
|
||||||
|
let de_artist = DeserializeSearchArtistResponseArtist {
|
||||||
|
score: 67,
|
||||||
|
meta: SerdeMbArtistMeta {
|
||||||
|
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||||
|
name: String::from("an artist"),
|
||||||
|
sort_name: String::from("artist, an"),
|
||||||
|
disambiguation: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
DeserializeSearchArtistResponse {
|
||||||
|
artists: vec![de_artist.clone()],
|
||||||
|
page: SerdeSearchPage {
|
||||||
|
offset: de_offset,
|
||||||
|
count: de_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response(de_response: DeserializeSearchArtistResponse) -> SearchArtistResponse {
|
||||||
|
SearchArtistResponse {
|
||||||
|
artists: de_response
|
||||||
|
.artists
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| SearchArtistResponseArtist {
|
||||||
|
score: 67,
|
||||||
|
meta: a.meta.into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
page: de_response.page,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_string() {
|
||||||
|
let mut http = MockIMusicBrainzHttp::new();
|
||||||
|
let url = format!(
|
||||||
|
"https://musicbrainz.org/ws/2\
|
||||||
|
/artist\
|
||||||
|
?query=%22{no_field}%22",
|
||||||
|
no_field = "an+artist",
|
||||||
|
);
|
||||||
|
|
||||||
|
let de_response = de_response();
|
||||||
|
let response = response(de_response.clone());
|
||||||
|
|
||||||
|
http.expect_get()
|
||||||
|
.times(1)
|
||||||
|
.with(predicate::eq(url))
|
||||||
|
.return_once(|_| Ok(de_response));
|
||||||
|
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
let name = "an artist";
|
||||||
|
|
||||||
|
let query = SearchArtistRequest::new().string(name);
|
||||||
|
|
||||||
|
let paging = PageSettings::default();
|
||||||
|
let matches = client.search_artist(&query, &paging).unwrap();
|
||||||
|
assert_eq!(matches, response);
|
||||||
|
}
|
||||||
|
}
|
81
src/external/musicbrainz/api/search/mod.rs
vendored
Normal file
81
src/external/musicbrainz/api/search/mod.rs
vendored
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||||
|
mod artist;
|
||||||
|
mod query;
|
||||||
|
mod release_group;
|
||||||
|
|
||||||
|
pub use artist::{SearchArtistRequest, SearchArtistResponse, SearchArtistResponseArtist};
|
||||||
|
pub use release_group::{
|
||||||
|
SearchReleaseGroupRequest, SearchReleaseGroupResponse, SearchReleaseGroupResponseReleaseGroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
use paste::paste;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
|
use crate::external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
search::{
|
||||||
|
artist::DeserializeSearchArtistResponse,
|
||||||
|
release_group::DeserializeSearchReleaseGroupResponse,
|
||||||
|
},
|
||||||
|
ApiDisplay, Error, MusicBrainzClient, PageSettings, MB_BASE_URL,
|
||||||
|
},
|
||||||
|
IMusicBrainzHttp,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
pub struct SearchPage {
|
||||||
|
offset: usize,
|
||||||
|
count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchPage {
|
||||||
|
pub fn next_page(&self, settings: PageSettings, page_count: usize) -> Option<PageSettings> {
|
||||||
|
settings.next_page(self.offset, self.count, page_count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SerdeSearchPage = SearchPage;
|
||||||
|
|
||||||
|
macro_rules! impl_search_entity {
|
||||||
|
($name:ident, $entity:literal) => {
|
||||||
|
paste! {
|
||||||
|
pub fn [<search_ $name:snake>](
|
||||||
|
&mut self,
|
||||||
|
query: &[<Search $name Request>],
|
||||||
|
paging: &PageSettings,
|
||||||
|
) -> Result<[<Search $name Response>], Error> {
|
||||||
|
let query: String =
|
||||||
|
form_urlencoded::byte_serialize(format!("{query}").as_bytes()).collect();
|
||||||
|
let page = ApiDisplay::format_page_settings(paging);
|
||||||
|
let url = format!("{MB_BASE_URL}/{entity}?query={query}{page}", entity = $entity);
|
||||||
|
|
||||||
|
let response: [<DeserializeSearch $name Response>] = self.http.get(&url)?;
|
||||||
|
Ok(response.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||||
|
impl_search_entity!(Artist, "artist");
|
||||||
|
impl_search_entity!(ReleaseGroup, "release-group");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::external::musicbrainz::api::tests::next_page_test;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_next_page() {
|
||||||
|
let page = SearchPage {
|
||||||
|
offset: 5,
|
||||||
|
count: 45,
|
||||||
|
};
|
||||||
|
|
||||||
|
next_page_test(|val| page.next_page(PageSettings::default(), val));
|
||||||
|
}
|
||||||
|
}
|
312
src/external/musicbrainz/api/search/query.rs
vendored
Normal file
312
src/external/musicbrainz/api/search/query.rs
vendored
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
use std::{fmt, marker::PhantomData};
|
||||||
|
|
||||||
|
pub enum Logical {
|
||||||
|
Unary(Unary),
|
||||||
|
Binary(Boolean),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Logical {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Logical::Unary(u) => write!(f, "{u}"),
|
||||||
|
Logical::Binary(b) => write!(f, "{b}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Unary {
|
||||||
|
Require,
|
||||||
|
Prohibit,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Unary {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Unary::Require => write!(f, "+"),
|
||||||
|
Unary::Prohibit => write!(f, "-"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Boolean {
|
||||||
|
And,
|
||||||
|
Or,
|
||||||
|
Not,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Boolean {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Boolean::And => write!(f, "AND "),
|
||||||
|
Boolean::Or => write!(f, "OR "),
|
||||||
|
Boolean::Not => write!(f, "NOT "),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Expression<Entity> {
|
||||||
|
Term(Entity),
|
||||||
|
Expr(Query<Entity>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Entity> From<Entity> for Expression<Entity> {
|
||||||
|
fn from(value: Entity) -> Self {
|
||||||
|
Expression::Term(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Entity> From<Query<Entity>> for Expression<Entity> {
|
||||||
|
fn from(value: Query<Entity>) -> Self {
|
||||||
|
Expression::Expr(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Entity: fmt::Display> fmt::Display for Expression<Entity> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Expression::Term(t) => write!(f, "{t}"),
|
||||||
|
Expression::Expr(q) => write!(f, "({q})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EmptyQuery<Entity> {
|
||||||
|
_marker: PhantomData<Entity>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Entity> Default for EmptyQuery<Entity> {
|
||||||
|
fn default() -> Self {
|
||||||
|
EmptyQuery {
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Entity> EmptyQuery<Entity> {
|
||||||
|
pub fn expression<Expr: Into<Expression<Entity>>>(self, expr: Expr) -> Query<Entity> {
|
||||||
|
Query {
|
||||||
|
left: (None, Box::new(expr.into())),
|
||||||
|
right: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require(self) -> EmptyQueryJoin<Entity> {
|
||||||
|
EmptyQueryJoin {
|
||||||
|
unary: Unary::Require,
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prohibit(self) -> EmptyQueryJoin<Entity> {
|
||||||
|
EmptyQueryJoin {
|
||||||
|
unary: Unary::Prohibit,
|
||||||
|
_marker: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EmptyQueryJoin<Entity> {
|
||||||
|
unary: Unary,
|
||||||
|
_marker: PhantomData<Entity>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Entity> EmptyQueryJoin<Entity> {
|
||||||
|
pub fn expression<Expr: Into<Expression<Entity>>>(self, expr: Expr) -> Query<Entity> {
|
||||||
|
Query {
|
||||||
|
left: (Some(self.unary), Box::new(expr.into())),
|
||||||
|
right: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Query<Entity> {
|
||||||
|
left: (Option<Unary>, Box<Expression<Entity>>),
|
||||||
|
right: Vec<(Logical, Box<Expression<Entity>>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Entity: fmt::Display> fmt::Display for Query<Entity> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
if let Some(u) = &self.left.0 {
|
||||||
|
write!(f, "{u}")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write!(f, "{}", self.left.1)?;
|
||||||
|
|
||||||
|
for (logical, expr) in self.right.iter() {
|
||||||
|
write!(f, " {logical}{expr}")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Entity> Query<Entity> {
|
||||||
|
#[allow(clippy::new_ret_no_self)]
|
||||||
|
pub fn new() -> EmptyQuery<Entity> {
|
||||||
|
EmptyQuery::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn require(self) -> QueryJoin<Entity> {
|
||||||
|
QueryJoin {
|
||||||
|
logical: Logical::Unary(Unary::Require),
|
||||||
|
query: self,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prohibit(self) -> QueryJoin<Entity> {
|
||||||
|
QueryJoin {
|
||||||
|
logical: Logical::Unary(Unary::Prohibit),
|
||||||
|
query: self,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn and(self) -> QueryJoin<Entity> {
|
||||||
|
QueryJoin {
|
||||||
|
logical: Logical::Binary(Boolean::And),
|
||||||
|
query: self,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn or(self) -> QueryJoin<Entity> {
|
||||||
|
QueryJoin {
|
||||||
|
logical: Logical::Binary(Boolean::Or),
|
||||||
|
query: self,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn not(self) -> QueryJoin<Entity> {
|
||||||
|
QueryJoin {
|
||||||
|
logical: Logical::Binary(Boolean::Not),
|
||||||
|
query: self,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct QueryJoin<Entity> {
|
||||||
|
logical: Logical,
|
||||||
|
query: Query<Entity>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Entity> QueryJoin<Entity> {
|
||||||
|
pub fn expression<Expr: Into<Expression<Entity>>>(mut self, expr: Expr) -> Query<Entity> {
|
||||||
|
self.query.right.push((self.logical, Box::new(expr.into())));
|
||||||
|
self.query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_term {
|
||||||
|
($name:ident, $enum:ty, $variant:ident, $type:ty) => {
|
||||||
|
impl<'a> EmptyQuery<$enum> {
|
||||||
|
pub fn $name(self, $name: $type) -> Query<$enum> {
|
||||||
|
self.expression(<$enum>::$variant($name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> EmptyQueryJoin<$enum> {
|
||||||
|
pub fn $name(self, $name: $type) -> Query<$enum> {
|
||||||
|
self.expression(<$enum>::$variant($name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> QueryJoin<$enum> {
|
||||||
|
pub fn $name(self, $name: $type) -> Query<$enum> {
|
||||||
|
self.expression(<$enum>::$variant($name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use impl_term;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub enum TestEntity<'a> {
|
||||||
|
String(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> fmt::Display for TestEntity<'a> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::String(s) => write!(f, "\"{s}\""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestEntityRequest<'a> = Query<TestEntity<'a>>;
|
||||||
|
|
||||||
|
impl_term!(string, TestEntity<'a>, String, &'a str);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lucene_logical() {
|
||||||
|
let query = TestEntityRequest::new()
|
||||||
|
.string("jakarta apache")
|
||||||
|
.or()
|
||||||
|
.string("jakarta");
|
||||||
|
assert_eq!(format!("{query}"), "\"jakarta apache\" OR \"jakarta\"");
|
||||||
|
|
||||||
|
let query = TestEntityRequest::new()
|
||||||
|
.string("jakarta apache")
|
||||||
|
.and()
|
||||||
|
.string("jakarta");
|
||||||
|
assert_eq!(format!("{query}"), "\"jakarta apache\" AND \"jakarta\"");
|
||||||
|
|
||||||
|
let query = TestEntityRequest::new()
|
||||||
|
.require()
|
||||||
|
.string("jakarta")
|
||||||
|
.or()
|
||||||
|
.string("lucene");
|
||||||
|
assert_eq!(format!("{query}"), "+\"jakarta\" OR \"lucene\"");
|
||||||
|
|
||||||
|
let query = TestEntityRequest::new()
|
||||||
|
.string("lucene")
|
||||||
|
.require()
|
||||||
|
.string("jakarta");
|
||||||
|
assert_eq!(format!("{query}"), "\"lucene\" +\"jakarta\"");
|
||||||
|
|
||||||
|
let query = TestEntityRequest::new()
|
||||||
|
.string("jakarta apache")
|
||||||
|
.not()
|
||||||
|
.string("Apache Lucene");
|
||||||
|
assert_eq!(
|
||||||
|
format!("{query}"),
|
||||||
|
"\"jakarta apache\" NOT \"Apache Lucene\""
|
||||||
|
);
|
||||||
|
|
||||||
|
let query = TestEntityRequest::new()
|
||||||
|
.prohibit()
|
||||||
|
.string("Apache Lucene")
|
||||||
|
.or()
|
||||||
|
.string("jakarta apache");
|
||||||
|
assert_eq!(
|
||||||
|
format!("{query}"),
|
||||||
|
"-\"Apache Lucene\" OR \"jakarta apache\""
|
||||||
|
);
|
||||||
|
|
||||||
|
let query = TestEntityRequest::new()
|
||||||
|
.string("jakarta apache")
|
||||||
|
.prohibit()
|
||||||
|
.string("Apache Lucene");
|
||||||
|
assert_eq!(format!("{query}"), "\"jakarta apache\" -\"Apache Lucene\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lucene_grouping() {
|
||||||
|
let query = TestEntityRequest::new()
|
||||||
|
.expression(
|
||||||
|
TestEntityRequest::new()
|
||||||
|
.string("jakarta")
|
||||||
|
.or()
|
||||||
|
.string("apache"),
|
||||||
|
)
|
||||||
|
.and()
|
||||||
|
.string("website");
|
||||||
|
assert_eq!(
|
||||||
|
format!("{query}"),
|
||||||
|
"(\"jakarta\" OR \"apache\") AND \"website\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
249
src/external/musicbrainz/api/search/release_group.rs
vendored
Normal file
249
src/external/musicbrainz/api/search/release_group.rs
vendored
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::{album::AlbumDate, musicbrainz::Mbid},
|
||||||
|
external::musicbrainz::api::{
|
||||||
|
search::{
|
||||||
|
query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
|
||||||
|
SearchPage, SerdeSearchPage,
|
||||||
|
},
|
||||||
|
ApiDisplay, MbReleaseGroupMeta, SerdeMbReleaseGroupMeta,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub enum SearchReleaseGroup<'a> {
|
||||||
|
String(&'a str),
|
||||||
|
Arid(&'a Mbid),
|
||||||
|
FirstReleaseDate(&'a AlbumDate),
|
||||||
|
ReleaseGroup(&'a str),
|
||||||
|
Rgid(&'a Mbid),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> fmt::Display for SearchReleaseGroup<'a> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::String(s) => write!(f, "\"{s}\""),
|
||||||
|
Self::Arid(arid) => write!(f, "arid:{}", arid.uuid().as_hyphenated()),
|
||||||
|
Self::FirstReleaseDate(date) => write!(
|
||||||
|
f,
|
||||||
|
"firstreleasedate:{}",
|
||||||
|
ApiDisplay::format_album_date(date)
|
||||||
|
),
|
||||||
|
Self::ReleaseGroup(release_group) => write!(f, "releasegroup:\"{release_group}\""),
|
||||||
|
Self::Rgid(rgid) => write!(f, "rgid:{}", rgid.uuid().as_hyphenated()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SearchReleaseGroupRequest<'a> = Query<SearchReleaseGroup<'a>>;
|
||||||
|
|
||||||
|
impl_term!(string, SearchReleaseGroup<'a>, String, &'a str);
|
||||||
|
impl_term!(arid, SearchReleaseGroup<'a>, Arid, &'a Mbid);
|
||||||
|
impl_term!(
|
||||||
|
first_release_date,
|
||||||
|
SearchReleaseGroup<'a>,
|
||||||
|
FirstReleaseDate,
|
||||||
|
&'a AlbumDate
|
||||||
|
);
|
||||||
|
impl_term!(release_group, SearchReleaseGroup<'a>, ReleaseGroup, &'a str);
|
||||||
|
impl_term!(rgid, SearchReleaseGroup<'a>, Rgid, &'a Mbid);
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct SearchReleaseGroupResponse {
|
||||||
|
pub release_groups: Vec<SearchReleaseGroupResponseReleaseGroup>,
|
||||||
|
pub page: SearchPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
pub struct DeserializeSearchReleaseGroupResponse {
|
||||||
|
release_groups: Vec<DeserializeSearchReleaseGroupResponseReleaseGroup>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
page: SerdeSearchPage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeSearchReleaseGroupResponse> for SearchReleaseGroupResponse {
|
||||||
|
fn from(value: DeserializeSearchReleaseGroupResponse) -> Self {
|
||||||
|
SearchReleaseGroupResponse {
|
||||||
|
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
|
||||||
|
page: value.page,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SearchReleaseGroupResponseReleaseGroup {
|
||||||
|
pub score: u8,
|
||||||
|
pub meta: MbReleaseGroupMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize)]
|
||||||
|
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||||
|
pub struct DeserializeSearchReleaseGroupResponseReleaseGroup {
|
||||||
|
score: u8,
|
||||||
|
#[serde(flatten)]
|
||||||
|
meta: SerdeMbReleaseGroupMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DeserializeSearchReleaseGroupResponseReleaseGroup>
|
||||||
|
for SearchReleaseGroupResponseReleaseGroup
|
||||||
|
{
|
||||||
|
fn from(value: DeserializeSearchReleaseGroupResponseReleaseGroup) -> Self {
|
||||||
|
SearchReleaseGroupResponseReleaseGroup {
|
||||||
|
score: value.score,
|
||||||
|
meta: value.meta.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockall::predicate;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
MusicBrainzClient, PageSettings, SerdeAlbumDate, SerdeAlbumPrimaryType,
|
||||||
|
SerdeAlbumSecondaryType, SerdeMbid,
|
||||||
|
},
|
||||||
|
MockIMusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn de_response() -> DeserializeSearchReleaseGroupResponse {
|
||||||
|
let de_offset = 26;
|
||||||
|
let de_count = 126;
|
||||||
|
let de_release_group = DeserializeSearchReleaseGroupResponseReleaseGroup {
|
||||||
|
score: 67,
|
||||||
|
meta: SerdeMbReleaseGroupMeta {
|
||||||
|
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||||
|
title: String::from("an album"),
|
||||||
|
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||||
|
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||||
|
secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
DeserializeSearchReleaseGroupResponse {
|
||||||
|
release_groups: vec![de_release_group.clone()],
|
||||||
|
page: SerdeSearchPage {
|
||||||
|
offset: de_offset,
|
||||||
|
count: de_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response(de_response: DeserializeSearchReleaseGroupResponse) -> SearchReleaseGroupResponse {
|
||||||
|
SearchReleaseGroupResponse {
|
||||||
|
release_groups: de_response
|
||||||
|
.release_groups
|
||||||
|
.into_iter()
|
||||||
|
.map(|rg| SearchReleaseGroupResponseReleaseGroup {
|
||||||
|
score: 67,
|
||||||
|
meta: rg.meta.into(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
page: de_response.page,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_string() {
|
||||||
|
let mut http = MockIMusicBrainzHttp::new();
|
||||||
|
let url = format!(
|
||||||
|
"https://musicbrainz.org/ws/2\
|
||||||
|
/release-group\
|
||||||
|
?query=%22{title}%22",
|
||||||
|
title = "an+album",
|
||||||
|
);
|
||||||
|
|
||||||
|
let de_response = de_response();
|
||||||
|
let response = response(de_response.clone());
|
||||||
|
|
||||||
|
http.expect_get()
|
||||||
|
.times(1)
|
||||||
|
.with(predicate::eq(url))
|
||||||
|
.return_once(|_| Ok(de_response));
|
||||||
|
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
let title = "an album";
|
||||||
|
|
||||||
|
let query = SearchReleaseGroupRequest::new().string(title);
|
||||||
|
|
||||||
|
let paging = PageSettings::default();
|
||||||
|
let matches = client.search_release_group(&query, &paging).unwrap();
|
||||||
|
assert_eq!(matches, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_arid_album_date_release_group() {
|
||||||
|
let mut http = MockIMusicBrainzHttp::new();
|
||||||
|
let url = format!(
|
||||||
|
"https://musicbrainz.org/ws/2\
|
||||||
|
/release-group\
|
||||||
|
?query=arid%3A{arid}+AND+releasegroup%3A%22{title}%22+AND+firstreleasedate%3A{date}",
|
||||||
|
arid = "00000000-0000-0000-0000-000000000000",
|
||||||
|
date = "1986-04",
|
||||||
|
title = "an+album",
|
||||||
|
);
|
||||||
|
|
||||||
|
let de_response = de_response();
|
||||||
|
let response = response(de_response.clone());
|
||||||
|
|
||||||
|
http.expect_get()
|
||||||
|
.times(1)
|
||||||
|
.with(predicate::eq(url))
|
||||||
|
.return_once(|_| Ok(de_response));
|
||||||
|
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||||
|
let title = "an album";
|
||||||
|
let date = (1986, 4).into();
|
||||||
|
|
||||||
|
let query = SearchReleaseGroupRequest::new()
|
||||||
|
.arid(&arid)
|
||||||
|
.and()
|
||||||
|
.release_group(title)
|
||||||
|
.and()
|
||||||
|
.first_release_date(&date);
|
||||||
|
|
||||||
|
let paging = PageSettings::default();
|
||||||
|
let matches = client.search_release_group(&query, &paging).unwrap();
|
||||||
|
assert_eq!(matches, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_rgid() {
|
||||||
|
let mut http = MockIMusicBrainzHttp::new();
|
||||||
|
let url = format!(
|
||||||
|
"https://musicbrainz.org/ws/2\
|
||||||
|
/release-group\
|
||||||
|
?query=rgid%3A{rgid}",
|
||||||
|
rgid = "11111111-1111-1111-1111-111111111111",
|
||||||
|
);
|
||||||
|
|
||||||
|
let de_response = de_response();
|
||||||
|
let response = response(de_response.clone());
|
||||||
|
|
||||||
|
http.expect_get()
|
||||||
|
.times(1)
|
||||||
|
.with(predicate::eq(url))
|
||||||
|
.return_once(|_| Ok(de_response));
|
||||||
|
|
||||||
|
let mut client = MusicBrainzClient::new(http);
|
||||||
|
|
||||||
|
let rgid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
|
||||||
|
|
||||||
|
let query = SearchReleaseGroupRequest::new().rgid(&rgid);
|
||||||
|
|
||||||
|
let paging = PageSettings::default();
|
||||||
|
let matches = client.search_release_group(&query, &paging).unwrap();
|
||||||
|
assert_eq!(matches, response);
|
||||||
|
}
|
||||||
|
}
|
46
src/external/musicbrainz/http.rs
vendored
Normal file
46
src/external/musicbrainz/http.rs
vendored
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
//! Module for interacting with the MusicBrainz API via an HTTP client.
|
||||||
|
|
||||||
|
use reqwest::{self, blocking::Client, header};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
|
use crate::external::musicbrainz::{HttpError, IMusicBrainzHttp};
|
||||||
|
|
||||||
|
// GRCOV_EXCL_START
|
||||||
|
pub struct MusicBrainzHttp(Client);
|
||||||
|
|
||||||
|
impl MusicBrainzHttp {
|
||||||
|
pub fn new(user_agent: &'static str) -> Result<Self, HttpError> {
|
||||||
|
let mut headers = header::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
header::USER_AGENT,
|
||||||
|
header::HeaderValue::from_static(user_agent),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
header::ACCEPT,
|
||||||
|
header::HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(MusicBrainzHttp(
|
||||||
|
Client::builder().default_headers(headers).build()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IMusicBrainzHttp for MusicBrainzHttp {
|
||||||
|
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, HttpError> {
|
||||||
|
let response = self.0.get(url).send()?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
Ok(response.json()?)
|
||||||
|
} else {
|
||||||
|
Err(HttpError::Status(response.status().as_u16()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for HttpError {
|
||||||
|
fn from(err: reqwest::Error) -> Self {
|
||||||
|
HttpError::Client(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GRCOV_EXCL_STOP
|
19
src/external/musicbrainz/mod.rs
vendored
Normal file
19
src/external/musicbrainz/mod.rs
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
pub mod http;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use mockall::automock;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
|
#[cfg_attr(test, automock)]
|
||||||
|
pub trait IMusicBrainzHttp {
|
||||||
|
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, HttpError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum HttpError {
|
||||||
|
Client(String),
|
||||||
|
Status(u16),
|
||||||
|
}
|
11
src/lib.rs
11
src/lib.rs
@ -1,17 +1,16 @@
|
|||||||
//! MusicHoard - a music collection manager.
|
//! MusicHoard - a music collection manager.
|
||||||
|
|
||||||
mod core;
|
mod core;
|
||||||
|
pub mod external;
|
||||||
|
|
||||||
pub use core::collection;
|
pub use core::collection;
|
||||||
pub use core::database;
|
pub use core::interface;
|
||||||
pub use core::library;
|
|
||||||
|
|
||||||
pub use core::musichoard::{
|
pub use core::musichoard::{
|
||||||
musichoard::{MusicHoard, NoDatabase, NoLibrary},
|
builder::MusicHoardBuilder, Error, IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary,
|
||||||
musichoard_builder::MusicHoardBuilder,
|
MusicHoard, NoDatabase, NoLibrary,
|
||||||
Error,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
mod tests;
|
mod testmod;
|
||||||
|
78
src/main.rs
78
src/main.rs
@ -4,27 +4,37 @@ extern crate test;
|
|||||||
|
|
||||||
mod tui;
|
mod tui;
|
||||||
|
|
||||||
use std::{ffi::OsString, fs::OpenOptions, io, path::PathBuf};
|
use std::{ffi::OsString, fs::OpenOptions, io, path::PathBuf, thread};
|
||||||
|
|
||||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
use structopt::StructOpt;
|
use structopt::StructOpt;
|
||||||
|
|
||||||
use musichoard::{
|
use musichoard::{
|
||||||
database::{
|
external::{
|
||||||
json::{backend::JsonDatabaseFileBackend, JsonDatabase},
|
database::json::{backend::JsonDatabaseFileBackend, JsonDatabase},
|
||||||
IDatabase, NullDatabase,
|
library::beets::{
|
||||||
},
|
|
||||||
library::{
|
|
||||||
beets::{
|
|
||||||
executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor},
|
executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor},
|
||||||
BeetsLibrary,
|
BeetsLibrary,
|
||||||
},
|
},
|
||||||
ILibrary, NullLibrary,
|
musicbrainz::{api::MusicBrainzClient, http::MusicBrainzHttp},
|
||||||
|
},
|
||||||
|
interface::{
|
||||||
|
database::{IDatabase, NullDatabase},
|
||||||
|
library::{ILibrary, NullLibrary},
|
||||||
},
|
},
|
||||||
MusicHoardBuilder, NoDatabase, NoLibrary,
|
MusicHoardBuilder, NoDatabase, NoLibrary,
|
||||||
};
|
};
|
||||||
|
|
||||||
use tui::{App, EventChannel, EventHandler, EventListener, Tui, Ui};
|
use tui::{
|
||||||
|
App, EventChannel, EventHandler, EventListener, JobChannel, MusicBrainz, MusicBrainzDaemon,
|
||||||
|
Tui, Ui,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MUSICHOARD_HTTP_USER_AGENT: &str = concat!(
|
||||||
|
"MusicHoard/",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
" ( musichoard@thenineworlds.net )"
|
||||||
|
);
|
||||||
|
|
||||||
#[derive(StructOpt)]
|
#[derive(StructOpt)]
|
||||||
struct Opt {
|
struct Opt {
|
||||||
@ -40,7 +50,10 @@ struct LibOpt {
|
|||||||
#[structopt(long = "ssh", help = "Beets SSH URI")]
|
#[structopt(long = "ssh", help = "Beets SSH URI")]
|
||||||
beets_ssh_uri: Option<OsString>,
|
beets_ssh_uri: Option<OsString>,
|
||||||
|
|
||||||
#[structopt(long = "beets", help = "Beets config file path")]
|
#[structopt(long = "beets-bin", help = "Beets binary path")]
|
||||||
|
beets_bin_path: Option<OsString>,
|
||||||
|
|
||||||
|
#[structopt(long = "beets-config", help = "Beets config file path")]
|
||||||
beets_config_file_path: Option<OsString>,
|
beets_config_file_path: Option<OsString>,
|
||||||
|
|
||||||
#[structopt(long = "no-library", help = "Do not connect to the library")]
|
#[structopt(long = "no-library", help = "Do not connect to the library")]
|
||||||
@ -60,25 +73,41 @@ struct DbOpt {
|
|||||||
no_database: bool,
|
no_database: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with<LIB: ILibrary, DB: IDatabase>(builder: MusicHoardBuilder<LIB, DB>) {
|
fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>(
|
||||||
let music_hoard = builder.build();
|
builder: MusicHoardBuilder<Database, Library>,
|
||||||
|
) {
|
||||||
|
let music_hoard = builder.build().expect("failed to initialise MusicHoard");
|
||||||
|
|
||||||
// Initialize the terminal user interface.
|
// Initialize the terminal user interface.
|
||||||
let backend = CrosstermBackend::new(io::stdout());
|
let backend = CrosstermBackend::new(io::stdout());
|
||||||
let terminal = Terminal::new(backend).expect("failed to initialise terminal");
|
let terminal = Terminal::new(backend).expect("failed to initialise terminal");
|
||||||
|
|
||||||
|
let http =
|
||||||
|
MusicBrainzHttp::new(MUSICHOARD_HTTP_USER_AGENT).expect("failed to initialise HTTP client");
|
||||||
|
let client = MusicBrainzClient::new(http);
|
||||||
|
let musicbrainz = MusicBrainz::new(client);
|
||||||
|
|
||||||
let channel = EventChannel::new();
|
let channel = EventChannel::new();
|
||||||
let listener = EventListener::new(channel.sender());
|
let listener_sender = channel.sender();
|
||||||
|
let app_sender = channel.sender();
|
||||||
|
|
||||||
|
let listener = EventListener::new(listener_sender);
|
||||||
let handler = EventHandler::new(channel.receiver());
|
let handler = EventHandler::new(channel.receiver());
|
||||||
|
|
||||||
let app = App::new(music_hoard);
|
let mb_job_channel = JobChannel::new();
|
||||||
|
|
||||||
|
let app = App::new(music_hoard, mb_job_channel.sender());
|
||||||
let ui = Ui;
|
let ui = Ui;
|
||||||
|
|
||||||
// Run the TUI application.
|
// Run the TUI application.
|
||||||
|
thread::spawn(|| MusicBrainzDaemon::run(musicbrainz, mb_job_channel.receiver(), app_sender));
|
||||||
Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui");
|
Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_database<LIB: ILibrary>(db_opt: DbOpt, builder: MusicHoardBuilder<LIB, NoDatabase>) {
|
fn with_database<Library: ILibrary + 'static>(
|
||||||
|
db_opt: DbOpt,
|
||||||
|
builder: MusicHoardBuilder<NoDatabase, Library>,
|
||||||
|
) {
|
||||||
if db_opt.no_database {
|
if db_opt.no_database {
|
||||||
with(builder.set_database(NullDatabase));
|
with(builder.set_database(NullDatabase));
|
||||||
} else {
|
} else {
|
||||||
@ -105,7 +134,7 @@ fn with_database<LIB: ILibrary>(db_opt: DbOpt, builder: MusicHoardBuilder<LIB, N
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_library(lib_opt: LibOpt, db_opt: DbOpt, builder: MusicHoardBuilder<NoLibrary, NoDatabase>) {
|
fn with_library(lib_opt: LibOpt, db_opt: DbOpt, builder: MusicHoardBuilder<NoDatabase, NoLibrary>) {
|
||||||
if lib_opt.no_library {
|
if lib_opt.no_library {
|
||||||
with_database(db_opt, builder.set_library(NullLibrary));
|
with_database(db_opt, builder.set_library(NullLibrary));
|
||||||
} else if let Some(uri) = lib_opt.beets_ssh_uri {
|
} else if let Some(uri) = lib_opt.beets_ssh_uri {
|
||||||
@ -115,13 +144,22 @@ fn with_library(lib_opt: LibOpt, db_opt: DbOpt, builder: MusicHoardBuilder<NoLib
|
|||||||
.map(|s| s.into_string())
|
.map(|s| s.into_string())
|
||||||
.transpose()
|
.transpose()
|
||||||
.expect("failed to extract beets config file path");
|
.expect("failed to extract beets config file path");
|
||||||
let lib_exec = BeetsLibrarySshExecutor::new(uri)
|
let lib_exec = match lib_opt.beets_bin_path {
|
||||||
|
Some(beets_bin) => {
|
||||||
|
let bin = beets_bin.into_string().expect("invalid beets binary path");
|
||||||
|
BeetsLibrarySshExecutor::bin(uri, bin)
|
||||||
|
}
|
||||||
|
None => BeetsLibrarySshExecutor::new(uri),
|
||||||
|
}
|
||||||
.expect("failed to initialise beets")
|
.expect("failed to initialise beets")
|
||||||
.config(beets_config_file_path);
|
.config(beets_config_file_path);
|
||||||
with_database(db_opt, builder.set_library(BeetsLibrary::new(lib_exec)));
|
with_database(db_opt, builder.set_library(BeetsLibrary::new(lib_exec)));
|
||||||
} else {
|
} else {
|
||||||
let lib_exec =
|
let lib_exec = match lib_opt.beets_bin_path {
|
||||||
BeetsLibraryProcessExecutor::default().config(lib_opt.beets_config_file_path);
|
Some(beets_bin) => BeetsLibraryProcessExecutor::bin(beets_bin),
|
||||||
|
None => BeetsLibraryProcessExecutor::default(),
|
||||||
|
}
|
||||||
|
.config(lib_opt.beets_config_file_path);
|
||||||
with_database(db_opt, builder.set_library(BeetsLibrary::new(lib_exec)));
|
with_database(db_opt, builder.set_library(BeetsLibrary::new(lib_exec)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -134,4 +172,4 @@ fn main() {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
mod tests;
|
mod testmod;
|
||||||
|
525
src/testmod/full.rs
Normal file
525
src/testmod/full.rs
Normal file
@ -0,0 +1,525 @@
|
|||||||
|
macro_rules! full_collection {
|
||||||
|
() => {
|
||||||
|
vec![
|
||||||
|
Artist {
|
||||||
|
meta: ArtistMeta {
|
||||||
|
id: ArtistId {
|
||||||
|
name: "Album_Artist ‘A’".to_string(),
|
||||||
|
},
|
||||||
|
sort: None,
|
||||||
|
info: ArtistInfo {
|
||||||
|
musicbrainz: MbRefOption::Some(MbArtistRef::from_url_str(
|
||||||
|
"https://musicbrainz.org/artist/00000000-0000-0000-0000-000000000000"
|
||||||
|
).unwrap()),
|
||||||
|
properties: HashMap::from([
|
||||||
|
(String::from("MusicButler"), vec![
|
||||||
|
String::from("https://www.musicbutler.io/artist-page/000000000"),
|
||||||
|
]),
|
||||||
|
(String::from("Qobuz"), vec![
|
||||||
|
String::from(
|
||||||
|
"https://www.qobuz.com/nl-nl/interpreter/artist-a/download-streaming-albums",
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
albums: vec![
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId {
|
||||||
|
title: "album_title a.a".to_string(),
|
||||||
|
},
|
||||||
|
date: 1998.into(),
|
||||||
|
seq: AlbumSeq(1),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::Some(MbAlbumRef::from_url_str(
|
||||||
|
"https://musicbrainz.org/release-group/00000000-0000-0000-0000-000000000000"
|
||||||
|
).unwrap()),
|
||||||
|
primary_type: Some(AlbumPrimaryType::Album),
|
||||||
|
secondary_types: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track a.a.1".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
|
artist: vec!["artist a.a.1".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Flac,
|
||||||
|
bitrate: 992,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track a.a.2".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
|
artist: vec![
|
||||||
|
"artist a.a.2.1".to_string(),
|
||||||
|
"artist a.a.2.2".to_string(),
|
||||||
|
],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 320,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track a.a.3".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(3),
|
||||||
|
artist: vec!["artist a.a.3".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Flac,
|
||||||
|
bitrate: 1061,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track a.a.4".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(4),
|
||||||
|
artist: vec!["artist a.a.4".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Flac,
|
||||||
|
bitrate: 1042,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId {
|
||||||
|
title: "album_title a.b".to_string(),
|
||||||
|
},
|
||||||
|
date: (2015, 4).into(),
|
||||||
|
seq: AlbumSeq(1),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
|
primary_type: Some(AlbumPrimaryType::Album),
|
||||||
|
secondary_types: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track a.b.1".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
|
artist: vec!["artist a.b.1".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Flac,
|
||||||
|
bitrate: 1004,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track a.b.2".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
|
artist: vec!["artist a.b.2".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Flac,
|
||||||
|
bitrate: 1077,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Artist {
|
||||||
|
meta: ArtistMeta {
|
||||||
|
id: ArtistId {
|
||||||
|
name: "Album_Artist ‘B’".to_string(),
|
||||||
|
},
|
||||||
|
sort: None,
|
||||||
|
info: ArtistInfo {
|
||||||
|
musicbrainz: MbRefOption::Some(MbArtistRef::from_url_str(
|
||||||
|
"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111"
|
||||||
|
).unwrap()),
|
||||||
|
properties: HashMap::from([
|
||||||
|
(String::from("MusicButler"), vec![
|
||||||
|
String::from("https://www.musicbutler.io/artist-page/111111111"),
|
||||||
|
String::from("https://www.musicbutler.io/artist-page/111111112"),
|
||||||
|
]),
|
||||||
|
(String::from("Bandcamp"), vec![
|
||||||
|
String::from("https://artist-b.bandcamp.com/")
|
||||||
|
]),
|
||||||
|
(String::from("Qobuz"), vec![
|
||||||
|
String::from(
|
||||||
|
"https://www.qobuz.com/nl-nl/interpreter/artist-b/download-streaming-albums",
|
||||||
|
)
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
albums: vec![
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId {
|
||||||
|
title: "album_title b.a".to_string(),
|
||||||
|
},
|
||||||
|
date: (2003, 6, 6).into(),
|
||||||
|
seq: AlbumSeq(1),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
|
primary_type: Some(AlbumPrimaryType::Album),
|
||||||
|
secondary_types: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track b.a.1".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
|
artist: vec!["artist b.a.1".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 190,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track b.a.2".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
|
artist: vec![
|
||||||
|
"artist b.a.2.1".to_string(),
|
||||||
|
"artist b.a.2.2".to_string(),
|
||||||
|
],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId {
|
||||||
|
title: "album_title b.b".to_string(),
|
||||||
|
},
|
||||||
|
date: 2008.into(),
|
||||||
|
seq: AlbumSeq(3),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::Some(MbAlbumRef::from_url_str(
|
||||||
|
"https://musicbrainz.org/release-group/11111111-1111-1111-1111-111111111111"
|
||||||
|
).unwrap()),
|
||||||
|
primary_type: Some(AlbumPrimaryType::Album),
|
||||||
|
secondary_types: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track b.b.1".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
|
artist: vec!["artist b.b.1".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Flac,
|
||||||
|
bitrate: 1077,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track b.b.2".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
|
artist: vec![
|
||||||
|
"artist b.b.2.1".to_string(),
|
||||||
|
"artist b.b.2.2".to_string(),
|
||||||
|
],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 320,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId {
|
||||||
|
title: "album_title b.c".to_string(),
|
||||||
|
},
|
||||||
|
date: 2009.into(),
|
||||||
|
seq: AlbumSeq(2),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::Some(MbAlbumRef::from_url_str(
|
||||||
|
"https://musicbrainz.org/release-group/11111111-1111-1111-1111-111111111112"
|
||||||
|
).unwrap()),
|
||||||
|
primary_type: Some(AlbumPrimaryType::Album),
|
||||||
|
secondary_types: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track b.c.1".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
|
artist: vec!["artist b.c.1".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 190,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track b.c.2".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
|
artist: vec![
|
||||||
|
"artist b.c.2.1".to_string(),
|
||||||
|
"artist b.c.2.2".to_string(),
|
||||||
|
],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId {
|
||||||
|
title: "album_title b.d".to_string(),
|
||||||
|
},
|
||||||
|
date: 2015.into(),
|
||||||
|
seq: AlbumSeq(4),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
|
primary_type: Some(AlbumPrimaryType::Album),
|
||||||
|
secondary_types: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track b.d.1".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
|
artist: vec!["artist b.d.1".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 190,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track b.d.2".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
|
artist: vec![
|
||||||
|
"artist b.d.2.1".to_string(),
|
||||||
|
"artist b.d.2.2".to_string(),
|
||||||
|
],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Artist {
|
||||||
|
meta: ArtistMeta {
|
||||||
|
id: ArtistId {
|
||||||
|
name: "The Album_Artist ‘C’".to_string(),
|
||||||
|
},
|
||||||
|
sort: Some("Album_Artist ‘C’, The".to_string()),
|
||||||
|
info: ArtistInfo {
|
||||||
|
musicbrainz: MbRefOption::CannotHaveMbid,
|
||||||
|
properties: HashMap::new(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
albums: vec![
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId {
|
||||||
|
title: "album_title c.a".to_string(),
|
||||||
|
},
|
||||||
|
date: 1985.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
|
primary_type: Some(AlbumPrimaryType::Album),
|
||||||
|
secondary_types: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track c.a.1".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
|
artist: vec!["artist c.a.1".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 320,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track c.a.2".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
|
artist: vec![
|
||||||
|
"artist c.a.2.1".to_string(),
|
||||||
|
"artist c.a.2.2".to_string(),
|
||||||
|
],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId {
|
||||||
|
title: "album_title c.b".to_string(),
|
||||||
|
},
|
||||||
|
date: 2018.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
|
primary_type: Some(AlbumPrimaryType::Album),
|
||||||
|
secondary_types: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track c.b.1".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
|
artist: vec!["artist c.b.1".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Flac,
|
||||||
|
bitrate: 1041,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track c.b.2".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
|
artist: vec![
|
||||||
|
"artist c.b.2.1".to_string(),
|
||||||
|
"artist c.b.2.2".to_string(),
|
||||||
|
],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Flac,
|
||||||
|
bitrate: 756,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Artist {
|
||||||
|
meta: ArtistMeta {
|
||||||
|
id: ArtistId {
|
||||||
|
name: "Album_Artist ‘D’".to_string(),
|
||||||
|
},
|
||||||
|
sort: None,
|
||||||
|
info: ArtistInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
|
properties: HashMap::new(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
albums: vec![
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId {
|
||||||
|
title: "album_title d.a".to_string(),
|
||||||
|
},
|
||||||
|
date: 1995.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
|
primary_type: Some(AlbumPrimaryType::Album),
|
||||||
|
secondary_types: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track d.a.1".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
|
artist: vec!["artist d.a.1".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track d.a.2".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
|
artist: vec![
|
||||||
|
"artist d.a.2.1".to_string(),
|
||||||
|
"artist d.a.2.2".to_string(),
|
||||||
|
],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Mp3,
|
||||||
|
bitrate: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
|
id: AlbumId {
|
||||||
|
title: "album_title d.b".to_string(),
|
||||||
|
},
|
||||||
|
date: 2028.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
|
primary_type: Some(AlbumPrimaryType::Album),
|
||||||
|
secondary_types: vec![],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tracks: vec![
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track d.b.1".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
|
artist: vec!["artist d.b.1".to_string()],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Flac,
|
||||||
|
bitrate: 841,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Track {
|
||||||
|
id: TrackId {
|
||||||
|
title: "track d.b.2".to_string(),
|
||||||
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
|
artist: vec![
|
||||||
|
"artist d.b.2.1".to_string(),
|
||||||
|
"artist d.b.2.2".to_string(),
|
||||||
|
],
|
||||||
|
quality: TrackQuality {
|
||||||
|
format: TrackFormat::Flac,
|
||||||
|
bitrate: 756,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use full_collection;
|
@ -1,94 +1,107 @@
|
|||||||
|
#[allow(unused_macros)]
|
||||||
macro_rules! library_collection {
|
macro_rules! library_collection {
|
||||||
() => {
|
() => {
|
||||||
vec![
|
vec![
|
||||||
Artist {
|
Artist {
|
||||||
|
meta: ArtistMeta {
|
||||||
id: ArtistId {
|
id: ArtistId {
|
||||||
name: "Album_Artist ‘A’".to_string(),
|
name: "Album_Artist ‘A’".to_string(),
|
||||||
},
|
},
|
||||||
sort: None,
|
sort: None,
|
||||||
musicbrainz: None,
|
info: ArtistInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
properties: HashMap::new(),
|
properties: HashMap::new(),
|
||||||
|
},
|
||||||
|
},
|
||||||
albums: vec![
|
albums: vec![
|
||||||
Album {
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
id: AlbumId {
|
id: AlbumId {
|
||||||
year: 1998,
|
|
||||||
title: "album_title a.a".to_string(),
|
title: "album_title a.a".to_string(),
|
||||||
},
|
},
|
||||||
|
date: 1998.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo::default(),
|
||||||
|
},
|
||||||
tracks: vec![
|
tracks: vec![
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 1,
|
|
||||||
title: "track a.a.1".to_string(),
|
title: "track a.a.1".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
artist: vec!["artist a.a.1".to_string()],
|
artist: vec!["artist a.a.1".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 992,
|
bitrate: 992,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 2,
|
|
||||||
title: "track a.a.2".to_string(),
|
title: "track a.a.2".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
artist: vec![
|
artist: vec![
|
||||||
"artist a.a.2.1".to_string(),
|
"artist a.a.2.1".to_string(),
|
||||||
"artist a.a.2.2".to_string(),
|
"artist a.a.2.2".to_string(),
|
||||||
],
|
],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 320,
|
bitrate: 320,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 3,
|
|
||||||
title: "track a.a.3".to_string(),
|
title: "track a.a.3".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(3),
|
||||||
artist: vec!["artist a.a.3".to_string()],
|
artist: vec!["artist a.a.3".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 1061,
|
bitrate: 1061,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 4,
|
|
||||||
title: "track a.a.4".to_string(),
|
title: "track a.a.4".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(4),
|
||||||
artist: vec!["artist a.a.4".to_string()],
|
artist: vec!["artist a.a.4".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 1042,
|
bitrate: 1042,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
Album {
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
id: AlbumId {
|
id: AlbumId {
|
||||||
year: 2015,
|
|
||||||
title: "album_title a.b".to_string(),
|
title: "album_title a.b".to_string(),
|
||||||
},
|
},
|
||||||
|
date: (2015, 4).into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo::default(),
|
||||||
|
},
|
||||||
tracks: vec![
|
tracks: vec![
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 1,
|
|
||||||
title: "track a.b.1".to_string(),
|
title: "track a.b.1".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
artist: vec!["artist a.b.1".to_string()],
|
artist: vec!["artist a.b.1".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 1004,
|
bitrate: 1004,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 2,
|
|
||||||
title: "track a.b.2".to_string(),
|
title: "track a.b.2".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
artist: vec!["artist a.b.2".to_string()],
|
artist: vec!["artist a.b.2".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 1077,
|
bitrate: 1077,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -97,140 +110,160 @@ macro_rules! library_collection {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
Artist {
|
Artist {
|
||||||
|
meta: ArtistMeta {
|
||||||
id: ArtistId {
|
id: ArtistId {
|
||||||
name: "Album_Artist ‘B’".to_string(),
|
name: "Album_Artist ‘B’".to_string(),
|
||||||
},
|
},
|
||||||
sort: None,
|
sort: None,
|
||||||
musicbrainz: None,
|
info: ArtistInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
properties: HashMap::new(),
|
properties: HashMap::new(),
|
||||||
|
},
|
||||||
|
},
|
||||||
albums: vec![
|
albums: vec![
|
||||||
Album {
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
id: AlbumId {
|
id: AlbumId {
|
||||||
year: 2003,
|
|
||||||
title: "album_title b.a".to_string(),
|
title: "album_title b.a".to_string(),
|
||||||
},
|
},
|
||||||
|
date: (2003, 6, 6).into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo::default(),
|
||||||
|
},
|
||||||
tracks: vec![
|
tracks: vec![
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 1,
|
|
||||||
title: "track b.a.1".to_string(),
|
title: "track b.a.1".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
artist: vec!["artist b.a.1".to_string()],
|
artist: vec!["artist b.a.1".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 190,
|
bitrate: 190,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 2,
|
|
||||||
title: "track b.a.2".to_string(),
|
title: "track b.a.2".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
artist: vec![
|
artist: vec![
|
||||||
"artist b.a.2.1".to_string(),
|
"artist b.a.2.1".to_string(),
|
||||||
"artist b.a.2.2".to_string(),
|
"artist b.a.2.2".to_string(),
|
||||||
],
|
],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 120,
|
bitrate: 120,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
Album {
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
id: AlbumId {
|
id: AlbumId {
|
||||||
year: 2008,
|
|
||||||
title: "album_title b.b".to_string(),
|
title: "album_title b.b".to_string(),
|
||||||
},
|
},
|
||||||
|
date: 2008.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo::default(),
|
||||||
|
},
|
||||||
tracks: vec![
|
tracks: vec![
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 1,
|
|
||||||
title: "track b.b.1".to_string(),
|
title: "track b.b.1".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
artist: vec!["artist b.b.1".to_string()],
|
artist: vec!["artist b.b.1".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 1077,
|
bitrate: 1077,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 2,
|
|
||||||
title: "track b.b.2".to_string(),
|
title: "track b.b.2".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
artist: vec![
|
artist: vec![
|
||||||
"artist b.b.2.1".to_string(),
|
"artist b.b.2.1".to_string(),
|
||||||
"artist b.b.2.2".to_string(),
|
"artist b.b.2.2".to_string(),
|
||||||
],
|
],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 320,
|
bitrate: 320,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
Album {
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
id: AlbumId {
|
id: AlbumId {
|
||||||
year: 2009,
|
|
||||||
title: "album_title b.c".to_string(),
|
title: "album_title b.c".to_string(),
|
||||||
},
|
},
|
||||||
|
date: 2009.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo::default(),
|
||||||
|
},
|
||||||
tracks: vec![
|
tracks: vec![
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 1,
|
|
||||||
title: "track b.c.1".to_string(),
|
title: "track b.c.1".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
artist: vec!["artist b.c.1".to_string()],
|
artist: vec!["artist b.c.1".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 190,
|
bitrate: 190,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 2,
|
|
||||||
title: "track b.c.2".to_string(),
|
title: "track b.c.2".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
artist: vec![
|
artist: vec![
|
||||||
"artist b.c.2.1".to_string(),
|
"artist b.c.2.1".to_string(),
|
||||||
"artist b.c.2.2".to_string(),
|
"artist b.c.2.2".to_string(),
|
||||||
],
|
],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 120,
|
bitrate: 120,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
Album {
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
id: AlbumId {
|
id: AlbumId {
|
||||||
year: 2015,
|
|
||||||
title: "album_title b.d".to_string(),
|
title: "album_title b.d".to_string(),
|
||||||
},
|
},
|
||||||
|
date: 2015.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo::default(),
|
||||||
|
},
|
||||||
tracks: vec![
|
tracks: vec![
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 1,
|
|
||||||
title: "track b.d.1".to_string(),
|
title: "track b.d.1".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
artist: vec!["artist b.d.1".to_string()],
|
artist: vec!["artist b.d.1".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 190,
|
bitrate: 190,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 2,
|
|
||||||
title: "track b.d.2".to_string(),
|
title: "track b.d.2".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
artist: vec![
|
artist: vec![
|
||||||
"artist b.d.2.1".to_string(),
|
"artist b.d.2.1".to_string(),
|
||||||
"artist b.d.2.2".to_string(),
|
"artist b.d.2.2".to_string(),
|
||||||
],
|
],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 120,
|
bitrate: 120,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -239,76 +272,86 @@ macro_rules! library_collection {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
Artist {
|
Artist {
|
||||||
|
meta: ArtistMeta {
|
||||||
id: ArtistId {
|
id: ArtistId {
|
||||||
name: "The Album_Artist ‘C’".to_string(),
|
name: "The Album_Artist ‘C’".to_string(),
|
||||||
},
|
},
|
||||||
sort: Some(ArtistId {
|
sort: Some("Album_Artist ‘C’, The".to_string()),
|
||||||
name: "Album_Artist ‘C’, The".to_string(),
|
info: ArtistInfo {
|
||||||
}),
|
musicbrainz: MbRefOption::None,
|
||||||
musicbrainz: None,
|
|
||||||
properties: HashMap::new(),
|
properties: HashMap::new(),
|
||||||
|
},
|
||||||
|
},
|
||||||
albums: vec![
|
albums: vec![
|
||||||
Album {
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
id: AlbumId {
|
id: AlbumId {
|
||||||
year: 1985,
|
|
||||||
title: "album_title c.a".to_string(),
|
title: "album_title c.a".to_string(),
|
||||||
},
|
},
|
||||||
|
date: 1985.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo::default(),
|
||||||
|
},
|
||||||
tracks: vec![
|
tracks: vec![
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 1,
|
|
||||||
title: "track c.a.1".to_string(),
|
title: "track c.a.1".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
artist: vec!["artist c.a.1".to_string()],
|
artist: vec!["artist c.a.1".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 320,
|
bitrate: 320,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 2,
|
|
||||||
title: "track c.a.2".to_string(),
|
title: "track c.a.2".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
artist: vec![
|
artist: vec![
|
||||||
"artist c.a.2.1".to_string(),
|
"artist c.a.2.1".to_string(),
|
||||||
"artist c.a.2.2".to_string(),
|
"artist c.a.2.2".to_string(),
|
||||||
],
|
],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 120,
|
bitrate: 120,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
Album {
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
id: AlbumId {
|
id: AlbumId {
|
||||||
year: 2018,
|
|
||||||
title: "album_title c.b".to_string(),
|
title: "album_title c.b".to_string(),
|
||||||
},
|
},
|
||||||
|
date: 2018.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo::default(),
|
||||||
|
},
|
||||||
tracks: vec![
|
tracks: vec![
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 1,
|
|
||||||
title: "track c.b.1".to_string(),
|
title: "track c.b.1".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
artist: vec!["artist c.b.1".to_string()],
|
artist: vec!["artist c.b.1".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 1041,
|
bitrate: 1041,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 2,
|
|
||||||
title: "track c.b.2".to_string(),
|
title: "track c.b.2".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
artist: vec![
|
artist: vec![
|
||||||
"artist c.b.2.1".to_string(),
|
"artist c.b.2.1".to_string(),
|
||||||
"artist c.b.2.2".to_string(),
|
"artist c.b.2.2".to_string(),
|
||||||
],
|
],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 756,
|
bitrate: 756,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -317,74 +360,86 @@ macro_rules! library_collection {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
Artist {
|
Artist {
|
||||||
|
meta: ArtistMeta {
|
||||||
id: ArtistId {
|
id: ArtistId {
|
||||||
name: "Album_Artist ‘D’".to_string(),
|
name: "Album_Artist ‘D’".to_string(),
|
||||||
},
|
},
|
||||||
sort: None,
|
sort: None,
|
||||||
musicbrainz: None,
|
info: ArtistInfo {
|
||||||
|
musicbrainz: MbRefOption::None,
|
||||||
properties: HashMap::new(),
|
properties: HashMap::new(),
|
||||||
|
},
|
||||||
|
},
|
||||||
albums: vec![
|
albums: vec![
|
||||||
Album {
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
id: AlbumId {
|
id: AlbumId {
|
||||||
year: 1995,
|
|
||||||
title: "album_title d.a".to_string(),
|
title: "album_title d.a".to_string(),
|
||||||
},
|
},
|
||||||
|
date: 1995.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo::default(),
|
||||||
|
},
|
||||||
tracks: vec![
|
tracks: vec![
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 1,
|
|
||||||
title: "track d.a.1".to_string(),
|
title: "track d.a.1".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
artist: vec!["artist d.a.1".to_string()],
|
artist: vec!["artist d.a.1".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 120,
|
bitrate: 120,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 2,
|
|
||||||
title: "track d.a.2".to_string(),
|
title: "track d.a.2".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
artist: vec![
|
artist: vec![
|
||||||
"artist d.a.2.1".to_string(),
|
"artist d.a.2.1".to_string(),
|
||||||
"artist d.a.2.2".to_string(),
|
"artist d.a.2.2".to_string(),
|
||||||
],
|
],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Mp3,
|
format: TrackFormat::Mp3,
|
||||||
bitrate: 120,
|
bitrate: 120,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
Album {
|
Album {
|
||||||
|
meta: AlbumMeta {
|
||||||
id: AlbumId {
|
id: AlbumId {
|
||||||
year: 2028,
|
|
||||||
title: "album_title d.b".to_string(),
|
title: "album_title d.b".to_string(),
|
||||||
},
|
},
|
||||||
|
date: 2028.into(),
|
||||||
|
seq: AlbumSeq(0),
|
||||||
|
info: AlbumInfo::default(),
|
||||||
|
},
|
||||||
tracks: vec![
|
tracks: vec![
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 1,
|
|
||||||
title: "track d.b.1".to_string(),
|
title: "track d.b.1".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(1),
|
||||||
artist: vec!["artist d.b.1".to_string()],
|
artist: vec!["artist d.b.1".to_string()],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 841,
|
bitrate: 841,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Track {
|
Track {
|
||||||
id: TrackId {
|
id: TrackId {
|
||||||
number: 2,
|
|
||||||
title: "track d.b.2".to_string(),
|
title: "track d.b.2".to_string(),
|
||||||
},
|
},
|
||||||
|
number: TrackNum(2),
|
||||||
artist: vec![
|
artist: vec![
|
||||||
"artist d.b.2.1".to_string(),
|
"artist d.b.2.1".to_string(),
|
||||||
"artist d.b.2.2".to_string(),
|
"artist d.b.2.2".to_string(),
|
||||||
],
|
],
|
||||||
quality: Quality {
|
quality: TrackQuality {
|
||||||
format: Format::Flac,
|
format: TrackFormat::Flac,
|
||||||
bitrate: 756,
|
bitrate: 756,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -396,68 +451,5 @@ macro_rules! library_collection {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! full_collection {
|
#[allow(unused_imports)]
|
||||||
() => {{
|
|
||||||
let mut collection = library_collection!();
|
|
||||||
let mut iter = collection.iter_mut();
|
|
||||||
|
|
||||||
let artist_a = iter.next().unwrap();
|
|
||||||
assert_eq!(artist_a.id.name, "Album_Artist ‘A’");
|
|
||||||
|
|
||||||
artist_a.musicbrainz = Some(
|
|
||||||
MusicBrainz::new(
|
|
||||||
"https://musicbrainz.org/artist/00000000-0000-0000-0000-000000000000",
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
artist_a.properties = HashMap::from([
|
|
||||||
(String::from("MusicButler"), vec![
|
|
||||||
String::from("https://www.musicbutler.io/artist-page/000000000"),
|
|
||||||
]),
|
|
||||||
(String::from("Qobuz"), vec![
|
|
||||||
String::from(
|
|
||||||
"https://www.qobuz.com/nl-nl/interpreter/artist-a/download-streaming-albums",
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let artist_b = iter.next().unwrap();
|
|
||||||
assert_eq!(artist_b.id.name, "Album_Artist ‘B’");
|
|
||||||
|
|
||||||
artist_b.musicbrainz = Some(
|
|
||||||
MusicBrainz::new(
|
|
||||||
"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111",
|
|
||||||
).unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
artist_b.properties = HashMap::from([
|
|
||||||
(String::from("MusicButler"), vec![
|
|
||||||
String::from("https://www.musicbutler.io/artist-page/111111111"),
|
|
||||||
String::from("https://www.musicbutler.io/artist-page/111111112"),
|
|
||||||
]),
|
|
||||||
(String::from("Bandcamp"), vec![String::from("https://artist-b.bandcamp.com/")]),
|
|
||||||
(String::from("Qobuz"), vec![
|
|
||||||
String::from(
|
|
||||||
"https://www.qobuz.com/nl-nl/interpreter/artist-b/download-streaming-albums",
|
|
||||||
)
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let artist_c = iter.next().unwrap();
|
|
||||||
assert_eq!(artist_c.id.name, "The Album_Artist ‘C’");
|
|
||||||
|
|
||||||
artist_c.musicbrainz = Some(
|
|
||||||
MusicBrainz::new(
|
|
||||||
"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111",
|
|
||||||
).unwrap(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Nothing for artist_d
|
|
||||||
|
|
||||||
collection
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) use full_collection;
|
|
||||||
pub(crate) use library_collection;
|
pub(crate) use library_collection;
|
2
src/testmod/mod.rs
Normal file
2
src/testmod/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod full;
|
||||||
|
pub mod library;
|
@ -1,208 +0,0 @@
|
|||||||
use crate::tui::{
|
|
||||||
app::{
|
|
||||||
machine::{App, AppInner, AppMachine},
|
|
||||||
selection::{Delta, ListSelection},
|
|
||||||
AppPublic, AppState, IAppInteractBrowse,
|
|
||||||
},
|
|
||||||
lib::IMusicHoard,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct AppBrowse;
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> AppMachine<MH, AppBrowse> {
|
|
||||||
pub fn browse(inner: AppInner<MH>) -> Self {
|
|
||||||
AppMachine {
|
|
||||||
inner,
|
|
||||||
state: AppBrowse,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> From<AppMachine<MH, AppBrowse>> for App<MH> {
|
|
||||||
fn from(machine: AppMachine<MH, AppBrowse>) -> Self {
|
|
||||||
AppState::Browse(machine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppBrowse>> for AppPublic<'a> {
|
|
||||||
fn from(machine: &'a mut AppMachine<MH, AppBrowse>) -> Self {
|
|
||||||
AppPublic {
|
|
||||||
inner: (&mut machine.inner).into(),
|
|
||||||
state: AppState::Browse(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> IAppInteractBrowse for AppMachine<MH, AppBrowse> {
|
|
||||||
type APP = App<MH>;
|
|
||||||
|
|
||||||
fn save_and_quit(mut self) -> Self::APP {
|
|
||||||
match self.inner.music_hoard.save_to_database() {
|
|
||||||
Ok(_) => {
|
|
||||||
self.inner.running = false;
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
Err(err) => AppMachine::error(self.inner, err.to_string()).into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_category(mut self) -> Self::APP {
|
|
||||||
self.inner.selection.increment_category();
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_category(mut self) -> Self::APP {
|
|
||||||
self.inner.selection.decrement_category();
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_selection(mut self, delta: Delta) -> Self::APP {
|
|
||||||
self.inner
|
|
||||||
.selection
|
|
||||||
.increment_selection(self.inner.music_hoard.get_collection(), delta);
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_selection(mut self, delta: Delta) -> Self::APP {
|
|
||||||
self.inner
|
|
||||||
.selection
|
|
||||||
.decrement_selection(self.inner.music_hoard.get_collection(), delta);
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_info_overlay(self) -> Self::APP {
|
|
||||||
AppMachine::info(self.inner).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_reload_menu(self) -> Self::APP {
|
|
||||||
AppMachine::reload(self.inner).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn begin_search(mut self) -> Self::APP {
|
|
||||||
let orig = ListSelection::get(&self.inner.selection);
|
|
||||||
self.inner
|
|
||||||
.selection
|
|
||||||
.reset_artist(self.inner.music_hoard.get_collection());
|
|
||||||
AppMachine::search(self.inner, orig).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn no_op(self) -> Self::APP {
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::tui::{
|
|
||||||
app::{
|
|
||||||
machine::tests::{inner, music_hoard},
|
|
||||||
Category, IAppInteract,
|
|
||||||
},
|
|
||||||
testmod::COLLECTION,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn save_and_quit() {
|
|
||||||
let mut music_hoard = music_hoard(vec![]);
|
|
||||||
|
|
||||||
music_hoard
|
|
||||||
.expect_save_to_database()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|| Ok(()));
|
|
||||||
|
|
||||||
let browse = AppMachine::browse(inner(music_hoard));
|
|
||||||
|
|
||||||
let app = browse.save_and_quit();
|
|
||||||
assert!(!app.is_running());
|
|
||||||
app.unwrap_browse();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn save_and_quit_error() {
|
|
||||||
let mut music_hoard = music_hoard(vec![]);
|
|
||||||
|
|
||||||
music_hoard
|
|
||||||
.expect_save_to_database()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt"))));
|
|
||||||
|
|
||||||
let browse = AppMachine::browse(inner(music_hoard));
|
|
||||||
|
|
||||||
let app = browse.save_and_quit();
|
|
||||||
assert!(app.is_running());
|
|
||||||
app.unwrap_error();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn increment_decrement() {
|
|
||||||
let mut browse = AppMachine::browse(inner(music_hoard(COLLECTION.to_owned())));
|
|
||||||
let sel = &browse.inner.selection;
|
|
||||||
assert_eq!(sel.active, Category::Artist);
|
|
||||||
assert_eq!(sel.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
|
||||||
let sel = &browse.inner.selection;
|
|
||||||
assert_eq!(sel.active, Category::Artist);
|
|
||||||
assert_eq!(sel.artist.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
browse = browse.increment_category().unwrap_browse();
|
|
||||||
let sel = &browse.inner.selection;
|
|
||||||
assert_eq!(sel.active, Category::Album);
|
|
||||||
assert_eq!(sel.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(sel.artist.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
|
||||||
let sel = &browse.inner.selection;
|
|
||||||
assert_eq!(sel.active, Category::Album);
|
|
||||||
assert_eq!(sel.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(sel.artist.album.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
browse = browse.decrement_selection(Delta::Line).unwrap_browse();
|
|
||||||
let sel = &browse.inner.selection;
|
|
||||||
assert_eq!(sel.active, Category::Album);
|
|
||||||
assert_eq!(sel.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(sel.artist.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
browse = browse.decrement_category().unwrap_browse();
|
|
||||||
let sel = &browse.inner.selection;
|
|
||||||
assert_eq!(sel.active, Category::Artist);
|
|
||||||
assert_eq!(sel.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(sel.artist.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
browse = browse.decrement_selection(Delta::Line).unwrap_browse();
|
|
||||||
let sel = &browse.inner.selection;
|
|
||||||
assert_eq!(sel.active, Category::Artist);
|
|
||||||
assert_eq!(sel.artist.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.artist.album.state.list.selected(), Some(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn show_info_overlay() {
|
|
||||||
let browse = AppMachine::browse(inner(music_hoard(vec![])));
|
|
||||||
let app = browse.show_info_overlay();
|
|
||||||
app.unwrap_info();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn show_reload_menu() {
|
|
||||||
let browse = AppMachine::browse(inner(music_hoard(vec![])));
|
|
||||||
let app = browse.show_reload_menu();
|
|
||||||
app.unwrap_reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn begin_search() {
|
|
||||||
let browse = AppMachine::browse(inner(music_hoard(vec![])));
|
|
||||||
let app = browse.begin_search();
|
|
||||||
app.unwrap_search();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_op() {
|
|
||||||
let browse = AppMachine::browse(inner(music_hoard(vec![])));
|
|
||||||
let app = browse.no_op();
|
|
||||||
app.unwrap_browse();
|
|
||||||
}
|
|
||||||
}
|
|
188
src/tui/app/machine/browse_state.rs
Normal file
188
src/tui/app/machine/browse_state.rs
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
use crate::tui::app::{
|
||||||
|
machine::{App, AppInner, AppMachine},
|
||||||
|
selection::ListSelection,
|
||||||
|
AppPublicState, AppState, Delta, IAppInteractBrowse,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct BrowseState;
|
||||||
|
|
||||||
|
impl AppMachine<BrowseState> {
|
||||||
|
pub fn browse_state(inner: AppInner) -> Self {
|
||||||
|
AppMachine::new(inner, BrowseState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMachine<BrowseState>> for App {
|
||||||
|
fn from(machine: AppMachine<BrowseState>) -> Self {
|
||||||
|
AppState::Browse(machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a mut BrowseState> for AppPublicState<'a> {
|
||||||
|
fn from(_state: &'a mut BrowseState) -> Self {
|
||||||
|
AppState::Browse(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppInteractBrowse for AppMachine<BrowseState> {
|
||||||
|
type APP = App;
|
||||||
|
|
||||||
|
fn quit(mut self) -> Self::APP {
|
||||||
|
self.inner.running = false;
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_category(mut self) -> Self::APP {
|
||||||
|
self.inner.selection.increment_category();
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrement_category(mut self) -> Self::APP {
|
||||||
|
self.inner.selection.decrement_category();
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_selection(mut self, delta: Delta) -> Self::APP {
|
||||||
|
self.inner
|
||||||
|
.selection
|
||||||
|
.increment_selection(self.inner.music_hoard.get_collection(), delta);
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrement_selection(mut self, delta: Delta) -> Self::APP {
|
||||||
|
self.inner
|
||||||
|
.selection
|
||||||
|
.decrement_selection(self.inner.music_hoard.get_collection(), delta);
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_info_overlay(self) -> Self::APP {
|
||||||
|
AppMachine::info_state(self.inner).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_reload_menu(self) -> Self::APP {
|
||||||
|
AppMachine::reload_state(self.inner).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_search(mut self) -> Self::APP {
|
||||||
|
let orig = ListSelection::get(&self.inner.selection);
|
||||||
|
self.inner
|
||||||
|
.selection
|
||||||
|
.reset(self.inner.music_hoard.get_collection());
|
||||||
|
AppMachine::search_state(self.inner, orig).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_musicbrainz(self) -> Self::APP {
|
||||||
|
AppMachine::app_fetch_first(self.inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tui::{
|
||||||
|
app::{
|
||||||
|
machine::tests::{inner, inner_with_mb, music_hoard},
|
||||||
|
Category, IApp, IAppAccess,
|
||||||
|
},
|
||||||
|
lib::interface::musicbrainz::daemon::MockIMbJobSender,
|
||||||
|
testmod::COLLECTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quit() {
|
||||||
|
let music_hoard = music_hoard(vec![]);
|
||||||
|
|
||||||
|
let browse = AppMachine::browse_state(inner(music_hoard));
|
||||||
|
|
||||||
|
let app = browse.quit();
|
||||||
|
assert!(!app.is_running());
|
||||||
|
app.unwrap_browse();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn increment_decrement() {
|
||||||
|
let mut browse = AppMachine::browse_state(inner(music_hoard(COLLECTION.to_owned())));
|
||||||
|
let sel = &browse.inner.selection;
|
||||||
|
assert_eq!(sel.category(), Category::Artist);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let sel = &browse.inner.selection;
|
||||||
|
assert_eq!(sel.category(), Category::Artist);
|
||||||
|
assert_eq!(sel.selected(), Some(1));
|
||||||
|
|
||||||
|
browse = browse.increment_category().unwrap_browse();
|
||||||
|
let sel = &browse.inner.selection;
|
||||||
|
assert_eq!(sel.category(), Category::Album);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let sel = &browse.inner.selection;
|
||||||
|
assert_eq!(sel.category(), Category::Album);
|
||||||
|
assert_eq!(sel.selected(), Some(1));
|
||||||
|
|
||||||
|
browse = browse.decrement_selection(Delta::Line).unwrap_browse();
|
||||||
|
let sel = &browse.inner.selection;
|
||||||
|
assert_eq!(sel.category(), Category::Album);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
browse = browse.decrement_category().unwrap_browse();
|
||||||
|
let sel = &browse.inner.selection;
|
||||||
|
assert_eq!(sel.category(), Category::Artist);
|
||||||
|
assert_eq!(sel.selected(), Some(1));
|
||||||
|
|
||||||
|
browse = browse.decrement_selection(Delta::Line).unwrap_browse();
|
||||||
|
let sel = &browse.inner.selection;
|
||||||
|
assert_eq!(sel.category(), Category::Artist);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_info_overlay() {
|
||||||
|
let browse = AppMachine::browse_state(inner(music_hoard(vec![])));
|
||||||
|
let app = browse.show_info_overlay();
|
||||||
|
app.unwrap_info();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn show_reload_menu() {
|
||||||
|
let browse = AppMachine::browse_state(inner(music_hoard(vec![])));
|
||||||
|
let app = browse.show_reload_menu();
|
||||||
|
app.unwrap_reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn begin_search() {
|
||||||
|
let browse = AppMachine::browse_state(inner(music_hoard(vec![])));
|
||||||
|
let app = browse.begin_search();
|
||||||
|
app.unwrap_search();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_musicbrainz() {
|
||||||
|
let mut mb_job_sender = MockIMbJobSender::new();
|
||||||
|
mb_job_sender
|
||||||
|
.expect_submit_background_job()
|
||||||
|
.times(1)
|
||||||
|
.returning(|_, _| Ok(()));
|
||||||
|
|
||||||
|
let browse = AppMachine::browse_state(inner_with_mb(
|
||||||
|
music_hoard(COLLECTION.to_owned()),
|
||||||
|
mb_job_sender,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Use the second artist for this test.
|
||||||
|
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let mut app = browse.fetch_musicbrainz();
|
||||||
|
|
||||||
|
let public = app.get();
|
||||||
|
|
||||||
|
// Because of fetch's threaded behaviour, this unit test cannot expect one or the other.
|
||||||
|
assert!(
|
||||||
|
matches!(public.state, AppState::Match(_))
|
||||||
|
|| matches!(public.state, AppState::Fetch(_))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,59 +0,0 @@
|
|||||||
use crate::tui::{
|
|
||||||
app::{
|
|
||||||
machine::{App, AppInner, AppMachine},
|
|
||||||
AppPublic, AppState, IAppInteractCritical,
|
|
||||||
},
|
|
||||||
lib::IMusicHoard,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct AppCritical {
|
|
||||||
string: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> AppMachine<MH, AppCritical> {
|
|
||||||
pub fn critical<S: Into<String>>(inner: AppInner<MH>, string: S) -> Self {
|
|
||||||
AppMachine {
|
|
||||||
inner,
|
|
||||||
state: AppCritical {
|
|
||||||
string: string.into(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> From<AppMachine<MH, AppCritical>> for App<MH> {
|
|
||||||
fn from(machine: AppMachine<MH, AppCritical>) -> Self {
|
|
||||||
AppState::Critical(machine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppCritical>> for AppPublic<'a> {
|
|
||||||
fn from(machine: &'a mut AppMachine<MH, AppCritical>) -> Self {
|
|
||||||
AppPublic {
|
|
||||||
inner: (&mut machine.inner).into(),
|
|
||||||
state: AppState::Critical(&machine.state.string),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> IAppInteractCritical for AppMachine<MH, AppCritical> {
|
|
||||||
type APP = App<MH>;
|
|
||||||
|
|
||||||
fn no_op(self) -> Self::APP {
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::tui::app::machine::tests::{inner, music_hoard};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_op() {
|
|
||||||
let critical = AppMachine::critical(inner(music_hoard(vec![])), "get rekt");
|
|
||||||
let app = critical.no_op();
|
|
||||||
app.unwrap_critical();
|
|
||||||
}
|
|
||||||
}
|
|
34
src/tui/app/machine/critical_state.rs
Normal file
34
src/tui/app/machine/critical_state.rs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
use crate::tui::app::{
|
||||||
|
machine::{App, AppInner, AppMachine},
|
||||||
|
AppPublicState, AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct CriticalState {
|
||||||
|
string: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CriticalState {
|
||||||
|
fn new<S: Into<String>>(string: S) -> Self {
|
||||||
|
CriticalState {
|
||||||
|
string: string.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppMachine<CriticalState> {
|
||||||
|
pub fn critical_state<S: Into<String>>(inner: AppInner, string: S) -> Self {
|
||||||
|
AppMachine::new(inner, CriticalState::new(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMachine<CriticalState>> for App {
|
||||||
|
fn from(machine: AppMachine<CriticalState>) -> Self {
|
||||||
|
AppState::Critical(machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a mut CriticalState> for AppPublicState<'a> {
|
||||||
|
fn from(state: &'a mut CriticalState) -> Self {
|
||||||
|
AppState::Critical(&state.string)
|
||||||
|
}
|
||||||
|
}
|
@ -1,59 +0,0 @@
|
|||||||
use crate::tui::{
|
|
||||||
app::{
|
|
||||||
machine::{App, AppInner, AppMachine},
|
|
||||||
AppPublic, AppState, IAppInteractError,
|
|
||||||
},
|
|
||||||
lib::IMusicHoard,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct AppError {
|
|
||||||
string: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> AppMachine<MH, AppError> {
|
|
||||||
pub fn error<S: Into<String>>(inner: AppInner<MH>, string: S) -> Self {
|
|
||||||
AppMachine {
|
|
||||||
inner,
|
|
||||||
state: AppError {
|
|
||||||
string: string.into(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> From<AppMachine<MH, AppError>> for App<MH> {
|
|
||||||
fn from(machine: AppMachine<MH, AppError>) -> Self {
|
|
||||||
AppState::Error(machine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppError>> for AppPublic<'a> {
|
|
||||||
fn from(machine: &'a mut AppMachine<MH, AppError>) -> Self {
|
|
||||||
AppPublic {
|
|
||||||
inner: (&mut machine.inner).into(),
|
|
||||||
state: AppState::Error(&machine.state.string),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> IAppInteractError for AppMachine<MH, AppError> {
|
|
||||||
type APP = App<MH>;
|
|
||||||
|
|
||||||
fn dismiss_error(self) -> Self::APP {
|
|
||||||
AppMachine::browse(self.inner).into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::tui::app::machine::tests::{inner, music_hoard};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dismiss_error() {
|
|
||||||
let error = AppMachine::error(inner(music_hoard(vec![])), "get rekt");
|
|
||||||
let app = error.dismiss_error();
|
|
||||||
app.unwrap_browse();
|
|
||||||
}
|
|
||||||
}
|
|
56
src/tui/app/machine/error_state.rs
Normal file
56
src/tui/app/machine/error_state.rs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
use crate::tui::app::{
|
||||||
|
machine::{App, AppInner, AppMachine},
|
||||||
|
AppPublicState, AppState, IAppInteractError,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct ErrorState {
|
||||||
|
string: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorState {
|
||||||
|
fn new<S: Into<String>>(string: S) -> Self {
|
||||||
|
ErrorState {
|
||||||
|
string: string.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppMachine<ErrorState> {
|
||||||
|
pub fn error_state<S: Into<String>>(inner: AppInner, string: S) -> Self {
|
||||||
|
AppMachine::new(inner, ErrorState::new(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMachine<ErrorState>> for App {
|
||||||
|
fn from(machine: AppMachine<ErrorState>) -> Self {
|
||||||
|
AppState::Error(machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a mut ErrorState> for AppPublicState<'a> {
|
||||||
|
fn from(state: &'a mut ErrorState) -> Self {
|
||||||
|
AppState::Error(&state.string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppInteractError for AppMachine<ErrorState> {
|
||||||
|
type APP = App;
|
||||||
|
|
||||||
|
fn dismiss_error(self) -> Self::APP {
|
||||||
|
AppMachine::browse_state(self.inner).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tui::app::machine::tests::{inner, music_hoard};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dismiss_error() {
|
||||||
|
let error = AppMachine::error_state(inner(music_hoard(vec![])), "get rekt");
|
||||||
|
let app = error.dismiss_error();
|
||||||
|
app.unwrap_browse();
|
||||||
|
}
|
||||||
|
}
|
705
src/tui/app/machine/fetch_state.rs
Normal file
705
src/tui/app/machine/fetch_state.rs
Normal file
@ -0,0 +1,705 @@
|
|||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
slice,
|
||||||
|
sync::mpsc::{self, TryRecvError},
|
||||||
|
};
|
||||||
|
|
||||||
|
use musichoard::collection::{
|
||||||
|
album::{Album, AlbumId},
|
||||||
|
artist::{Artist, ArtistId, ArtistMeta},
|
||||||
|
musicbrainz::{IMusicBrainzRef, MbArtistRef, MbRefOption, Mbid},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::{
|
||||||
|
app::{
|
||||||
|
machine::{match_state::MatchState, App, AppInner, AppMachine},
|
||||||
|
AppPublicState, AppState, Category, IAppEventFetch, IAppInteractFetch,
|
||||||
|
},
|
||||||
|
lib::interface::musicbrainz::daemon::{
|
||||||
|
Error as DaemonError, IMbJobSender, MbApiResult, MbParams, MbReturn, ResultSender,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub type FetchReceiver = mpsc::Receiver<MbApiResult>;
|
||||||
|
pub struct FetchState {
|
||||||
|
fetch_rx: FetchReceiver,
|
||||||
|
lookup_rx: Option<FetchReceiver>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FetchState {
|
||||||
|
pub fn new(fetch_rx: FetchReceiver) -> Self {
|
||||||
|
FetchState {
|
||||||
|
fetch_rx,
|
||||||
|
lookup_rx: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_recv(&mut self) -> Result<MbApiResult, TryRecvError> {
|
||||||
|
if let Some(lookup_rx) = &self.lookup_rx {
|
||||||
|
match lookup_rx.try_recv() {
|
||||||
|
x @ Ok(_) | x @ Err(TryRecvError::Empty) => return x,
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
self.lookup_rx.take();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.fetch_rx.try_recv()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FetchError {
|
||||||
|
NothingToFetch,
|
||||||
|
SubmitError(DaemonError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DaemonError> for FetchError {
|
||||||
|
fn from(value: DaemonError) -> Self {
|
||||||
|
FetchError::SubmitError(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppMachine<FetchState> {
|
||||||
|
fn fetch_state(inner: AppInner, state: FetchState) -> Self {
|
||||||
|
AppMachine::new(inner, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_fetch_first(inner: AppInner) -> App {
|
||||||
|
Self::app_fetch_new(inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_fetch_new(inner: AppInner) -> App {
|
||||||
|
let coll = inner.music_hoard.get_collection();
|
||||||
|
|
||||||
|
let artist = match inner.selection.state_artist(coll) {
|
||||||
|
Some(artist_state) => &coll[artist_state.index],
|
||||||
|
None => {
|
||||||
|
let err = "cannot fetch artist: no artist selected";
|
||||||
|
return AppMachine::error_state(inner, err).into();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (fetch_tx, fetch_rx) = mpsc::channel::<MbApiResult>();
|
||||||
|
let fetch = FetchState::new(fetch_rx);
|
||||||
|
|
||||||
|
let mb = &*inner.musicbrainz;
|
||||||
|
let result = match inner.selection.category() {
|
||||||
|
Category::Artist => Self::submit_search_artist_job(mb, fetch_tx, artist),
|
||||||
|
_ => {
|
||||||
|
let arid = match artist.meta.info.musicbrainz {
|
||||||
|
MbRefOption::Some(ref mbref) => mbref,
|
||||||
|
_ => {
|
||||||
|
let err = "cannot fetch album: artist has no MBID";
|
||||||
|
return AppMachine::error_state(inner, err).into();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let album = match inner.selection.state_album(coll) {
|
||||||
|
Some(album_state) => &artist.albums[album_state.index],
|
||||||
|
None => {
|
||||||
|
let err = "cannot fetch album: no album selected";
|
||||||
|
return AppMachine::error_state(inner, err).into();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let artist_id = &artist.meta.id;
|
||||||
|
Self::submit_search_release_group_job(mb, fetch_tx, artist_id, arid, album)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(()) => AppMachine::fetch_state(inner, fetch).into(),
|
||||||
|
Err(FetchError::NothingToFetch) => AppMachine::browse_state(inner).into(),
|
||||||
|
Err(FetchError::SubmitError(daemon_err)) => {
|
||||||
|
AppMachine::error_state(inner, daemon_err.to_string()).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_fetch_next(inner: AppInner, mut fetch: FetchState) -> App {
|
||||||
|
match fetch.try_recv() {
|
||||||
|
Ok(fetch_result) => match fetch_result {
|
||||||
|
Ok(retval) => Self::handle_mb_api_return(inner, fetch, retval),
|
||||||
|
Err(fetch_err) => {
|
||||||
|
AppMachine::error_state(inner, format!("fetch failed: {fetch_err}")).into()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(recv_err) => match recv_err {
|
||||||
|
TryRecvError::Empty => AppMachine::fetch_state(inner, fetch).into(),
|
||||||
|
TryRecvError::Disconnected => Self::app_fetch_new(inner),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_mb_api_return(inner: AppInner, fetch: FetchState, retval: MbReturn) -> App {
|
||||||
|
match retval {
|
||||||
|
MbReturn::Match(next_match) => {
|
||||||
|
AppMachine::match_state(inner, MatchState::new(next_match, fetch)).into()
|
||||||
|
}
|
||||||
|
_ => unimplemented!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_lookup_artist(
|
||||||
|
inner: AppInner,
|
||||||
|
fetch: FetchState,
|
||||||
|
artist: &ArtistMeta,
|
||||||
|
mbid: Mbid,
|
||||||
|
) -> App {
|
||||||
|
let f = Self::submit_lookup_artist_job;
|
||||||
|
Self::app_lookup(f, inner, fetch, artist, mbid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_lookup_album(
|
||||||
|
inner: AppInner,
|
||||||
|
fetch: FetchState,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
album_id: &AlbumId,
|
||||||
|
mbid: Mbid,
|
||||||
|
) -> App {
|
||||||
|
let f = |mb: &dyn IMbJobSender, rs, album, mbid| {
|
||||||
|
Self::submit_lookup_release_group_job(mb, rs, artist_id, album, mbid)
|
||||||
|
};
|
||||||
|
Self::app_lookup(f, inner, fetch, album_id, mbid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_lookup<F, Meta>(
|
||||||
|
submit: F,
|
||||||
|
inner: AppInner,
|
||||||
|
mut fetch: FetchState,
|
||||||
|
meta: Meta,
|
||||||
|
mbid: Mbid,
|
||||||
|
) -> App
|
||||||
|
where
|
||||||
|
F: FnOnce(&dyn IMbJobSender, ResultSender, Meta, Mbid) -> Result<(), DaemonError>,
|
||||||
|
{
|
||||||
|
let (lookup_tx, lookup_rx) = mpsc::channel::<MbApiResult>();
|
||||||
|
if let Err(err) = submit(&*inner.musicbrainz, lookup_tx, meta, mbid) {
|
||||||
|
return AppMachine::error_state(inner, err.to_string()).into();
|
||||||
|
}
|
||||||
|
fetch.lookup_rx.replace(lookup_rx);
|
||||||
|
Self::app_fetch_next(inner, fetch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_search_artist_job(
|
||||||
|
musicbrainz: &dyn IMbJobSender,
|
||||||
|
result_sender: ResultSender,
|
||||||
|
artist: &Artist,
|
||||||
|
) -> Result<(), FetchError> {
|
||||||
|
let requests = match artist.meta.info.musicbrainz {
|
||||||
|
MbRefOption::Some(ref arid) => {
|
||||||
|
Self::search_albums_requests(&artist.meta.id, arid, &artist.albums)
|
||||||
|
}
|
||||||
|
MbRefOption::CannotHaveMbid => VecDeque::new(),
|
||||||
|
MbRefOption::None => Self::search_artist_request(&artist.meta),
|
||||||
|
};
|
||||||
|
if requests.is_empty() {
|
||||||
|
return Err(FetchError::NothingToFetch);
|
||||||
|
}
|
||||||
|
Ok(musicbrainz.submit_background_job(result_sender, requests)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_search_release_group_job(
|
||||||
|
musicbrainz: &dyn IMbJobSender,
|
||||||
|
result_sender: ResultSender,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
artist_mbid: &MbArtistRef,
|
||||||
|
album: &Album,
|
||||||
|
) -> Result<(), FetchError> {
|
||||||
|
if !matches!(album.meta.info.musicbrainz, MbRefOption::None) {
|
||||||
|
return Err(FetchError::NothingToFetch);
|
||||||
|
}
|
||||||
|
let requests = Self::search_albums_requests(artist_id, artist_mbid, slice::from_ref(album));
|
||||||
|
Ok(musicbrainz.submit_background_job(result_sender, requests)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_albums_requests(
|
||||||
|
artist: &ArtistId,
|
||||||
|
arid: &MbArtistRef,
|
||||||
|
albums: &[Album],
|
||||||
|
) -> VecDeque<MbParams> {
|
||||||
|
let arid = arid.mbid();
|
||||||
|
albums
|
||||||
|
.iter()
|
||||||
|
.filter(|album| matches!(album.meta.info.musicbrainz, MbRefOption::None))
|
||||||
|
.map(|album| {
|
||||||
|
MbParams::search_release_group(artist.clone(), arid.clone(), album.meta.clone())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_artist_request(meta: &ArtistMeta) -> VecDeque<MbParams> {
|
||||||
|
VecDeque::from([MbParams::search_artist(meta.clone())])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_lookup_artist_job(
|
||||||
|
musicbrainz: &dyn IMbJobSender,
|
||||||
|
result_sender: ResultSender,
|
||||||
|
artist: &ArtistMeta,
|
||||||
|
mbid: Mbid,
|
||||||
|
) -> Result<(), DaemonError> {
|
||||||
|
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid)]);
|
||||||
|
musicbrainz.submit_foreground_job(result_sender, requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_lookup_release_group_job(
|
||||||
|
musicbrainz: &dyn IMbJobSender,
|
||||||
|
result_sender: ResultSender,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
album_id: &AlbumId,
|
||||||
|
mbid: Mbid,
|
||||||
|
) -> Result<(), DaemonError> {
|
||||||
|
let requests = VecDeque::from([MbParams::lookup_release_group(
|
||||||
|
artist_id.clone(),
|
||||||
|
album_id.clone(),
|
||||||
|
mbid,
|
||||||
|
)]);
|
||||||
|
musicbrainz.submit_foreground_job(result_sender, requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMachine<FetchState>> for App {
|
||||||
|
fn from(machine: AppMachine<FetchState>) -> Self {
|
||||||
|
AppState::Fetch(machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a mut FetchState> for AppPublicState<'a> {
|
||||||
|
fn from(_state: &'a mut FetchState) -> Self {
|
||||||
|
AppState::Fetch(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppInteractFetch for AppMachine<FetchState> {
|
||||||
|
type APP = App;
|
||||||
|
|
||||||
|
fn abort(self) -> Self::APP {
|
||||||
|
AppMachine::browse_state(self.inner).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppEventFetch for AppMachine<FetchState> {
|
||||||
|
type APP = App;
|
||||||
|
|
||||||
|
fn fetch_result_ready(self) -> Self::APP {
|
||||||
|
Self::app_fetch_next(self.inner, self.state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockall::predicate;
|
||||||
|
use musichoard::collection::{
|
||||||
|
album::AlbumMeta,
|
||||||
|
artist::{ArtistId, ArtistMeta},
|
||||||
|
musicbrainz::Mbid,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::{
|
||||||
|
app::{
|
||||||
|
machine::tests::{inner, music_hoard},
|
||||||
|
Delta, EntityMatches, IApp, IAppAccess, IAppInteractBrowse, MatchOption,
|
||||||
|
},
|
||||||
|
lib::interface::musicbrainz::{self, api::Entity, daemon::MockIMbJobSender},
|
||||||
|
testmod::COLLECTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mbid() -> Mbid {
|
||||||
|
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn try_recv() {
|
||||||
|
let (fetch_tx, fetch_rx) = mpsc::channel();
|
||||||
|
let (lookup_tx, lookup_rx) = mpsc::channel();
|
||||||
|
|
||||||
|
let mut fetch = FetchState::new(fetch_rx);
|
||||||
|
fetch.lookup_rx.replace(lookup_rx);
|
||||||
|
|
||||||
|
let artist = COLLECTION[3].meta.clone();
|
||||||
|
|
||||||
|
let matches: Vec<Entity<ArtistMeta>> = vec![];
|
||||||
|
let fetch_result = MbReturn::Match(EntityMatches::artist_search(artist.clone(), matches));
|
||||||
|
fetch_tx.send(Ok(fetch_result.clone())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fetch.try_recv(), Err(TryRecvError::Empty));
|
||||||
|
|
||||||
|
let lookup = Entity::new(artist.clone());
|
||||||
|
let lookup_result = MbReturn::Match(EntityMatches::artist_lookup(artist.clone(), lookup));
|
||||||
|
lookup_tx.send(Ok(lookup_result.clone())).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fetch.try_recv(), Ok(Ok(lookup_result)));
|
||||||
|
|
||||||
|
assert_eq!(fetch.try_recv(), Err(TryRecvError::Empty));
|
||||||
|
drop(lookup_tx);
|
||||||
|
assert_eq!(fetch.try_recv(), Ok(Ok(fetch_result)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_no_artist() {
|
||||||
|
let app = AppMachine::app_fetch_first(inner(music_hoard(vec![])));
|
||||||
|
assert!(matches!(app.state(), AppState::Error(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_release_group_expectation(
|
||||||
|
job_sender: &mut MockIMbJobSender,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
artist_mbid: &Mbid,
|
||||||
|
albums: &[AlbumMeta],
|
||||||
|
) {
|
||||||
|
let mut requests = VecDeque::new();
|
||||||
|
for album in albums.iter() {
|
||||||
|
requests.push_back(MbParams::search_release_group(
|
||||||
|
artist_id.clone(),
|
||||||
|
artist_mbid.clone(),
|
||||||
|
album.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
job_sender
|
||||||
|
.expect_submit_background_job()
|
||||||
|
.with(predicate::always(), predicate::eq(requests))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_, _| Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_single_album() {
|
||||||
|
let mut mb_job_sender = MockIMbJobSender::new();
|
||||||
|
|
||||||
|
let artist_id = COLLECTION[1].meta.id.clone();
|
||||||
|
let artist_mbid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
|
||||||
|
|
||||||
|
let album_meta = COLLECTION[1].albums[0].meta.clone();
|
||||||
|
|
||||||
|
search_release_group_expectation(
|
||||||
|
&mut mb_job_sender,
|
||||||
|
&artist_id,
|
||||||
|
&artist_mbid,
|
||||||
|
&[album_meta],
|
||||||
|
);
|
||||||
|
|
||||||
|
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||||
|
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||||
|
|
||||||
|
// Use second artist and have album selected to match the expectation.
|
||||||
|
let browse = AppMachine::browse_state(inner);
|
||||||
|
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let app = browse.increment_category();
|
||||||
|
|
||||||
|
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||||
|
assert!(matches!(app, AppState::Fetch(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_single_album_nothing_to_fetch() {
|
||||||
|
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||||
|
let inner = inner(music_hoard);
|
||||||
|
|
||||||
|
// Use second artist, have second album selected (has MBID) to match the expectation.
|
||||||
|
let browse = AppMachine::browse_state(inner);
|
||||||
|
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let browse = browse.increment_category().unwrap_browse();
|
||||||
|
let app = browse.increment_selection(Delta::Line);
|
||||||
|
|
||||||
|
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||||
|
assert!(matches!(app, AppState::Browse(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_single_album_no_artist_mbid() {
|
||||||
|
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||||
|
let inner = inner(music_hoard);
|
||||||
|
|
||||||
|
// Use third artist and have album selected to match the expectation.
|
||||||
|
let browse = AppMachine::browse_state(inner);
|
||||||
|
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let app = browse.increment_category();
|
||||||
|
|
||||||
|
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||||
|
assert!(matches!(app, AppState::Error(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_single_album_no_album() {
|
||||||
|
let mut collection = COLLECTION.to_owned();
|
||||||
|
collection[1].albums.clear();
|
||||||
|
|
||||||
|
let music_hoard = music_hoard(collection);
|
||||||
|
let inner = inner(music_hoard);
|
||||||
|
|
||||||
|
// Use second artist and have album selected to match the expectation.
|
||||||
|
let browse = AppMachine::browse_state(inner);
|
||||||
|
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let app = browse.increment_category();
|
||||||
|
|
||||||
|
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||||
|
assert!(matches!(app, AppState::Error(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_albums() {
|
||||||
|
let mut mb_job_sender = MockIMbJobSender::new();
|
||||||
|
|
||||||
|
let artist_id = COLLECTION[1].meta.id.clone();
|
||||||
|
let artist_mbid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
|
||||||
|
|
||||||
|
let album_1_meta = COLLECTION[1].albums[0].meta.clone();
|
||||||
|
let album_4_meta = COLLECTION[1].albums[3].meta.clone();
|
||||||
|
|
||||||
|
// Other albums have an MBID and so they will be skipped.
|
||||||
|
search_release_group_expectation(
|
||||||
|
&mut mb_job_sender,
|
||||||
|
&artist_id,
|
||||||
|
&artist_mbid,
|
||||||
|
&[album_1_meta, album_4_meta],
|
||||||
|
);
|
||||||
|
|
||||||
|
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||||
|
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||||
|
|
||||||
|
// Use second artist to match the expectation.
|
||||||
|
let browse = AppMachine::browse_state(inner);
|
||||||
|
let app = browse.increment_selection(Delta::Line);
|
||||||
|
|
||||||
|
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||||
|
assert!(matches!(app, AppState::Fetch(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_album_expectation(
|
||||||
|
job_sender: &mut MockIMbJobSender,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
album_id: &AlbumId,
|
||||||
|
) {
|
||||||
|
let requests = VecDeque::from([MbParams::lookup_release_group(
|
||||||
|
artist_id.clone(),
|
||||||
|
album_id.clone(),
|
||||||
|
mbid(),
|
||||||
|
)]);
|
||||||
|
job_sender
|
||||||
|
.expect_submit_foreground_job()
|
||||||
|
.with(predicate::always(), predicate::eq(requests))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_, _| Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_album() {
|
||||||
|
let mut mb_job_sender = MockIMbJobSender::new();
|
||||||
|
|
||||||
|
let artist_id = COLLECTION[1].meta.id.clone();
|
||||||
|
let album_id = COLLECTION[1].albums[0].meta.id.clone();
|
||||||
|
lookup_album_expectation(&mut mb_job_sender, &artist_id, &album_id);
|
||||||
|
|
||||||
|
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||||
|
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||||
|
|
||||||
|
let (_fetch_tx, fetch_rx) = mpsc::channel();
|
||||||
|
let fetch = FetchState::new(fetch_rx);
|
||||||
|
|
||||||
|
AppMachine::app_lookup_album(inner, fetch, &artist_id, &album_id, mbid());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_artist_expectation(job_sender: &mut MockIMbJobSender, artist: &ArtistMeta) {
|
||||||
|
let requests = VecDeque::from([MbParams::search_artist(artist.clone())]);
|
||||||
|
job_sender
|
||||||
|
.expect_submit_background_job()
|
||||||
|
.with(predicate::always(), predicate::eq(requests))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_, _| Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_artist() {
|
||||||
|
let mut mb_job_sender = MockIMbJobSender::new();
|
||||||
|
|
||||||
|
let artist = COLLECTION[3].meta.clone();
|
||||||
|
search_artist_expectation(&mut mb_job_sender, &artist);
|
||||||
|
|
||||||
|
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||||
|
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||||
|
|
||||||
|
// Use fourth artist to match the expectation.
|
||||||
|
let browse = AppMachine::browse_state(inner);
|
||||||
|
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let app = browse.increment_selection(Delta::Line);
|
||||||
|
|
||||||
|
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||||
|
assert!(matches!(app, AppState::Fetch(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_artist_expectation(job_sender: &mut MockIMbJobSender, artist: &ArtistMeta) {
|
||||||
|
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid())]);
|
||||||
|
job_sender
|
||||||
|
.expect_submit_foreground_job()
|
||||||
|
.with(predicate::always(), predicate::eq(requests))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_, _| Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_artist() {
|
||||||
|
let mut mb_job_sender = MockIMbJobSender::new();
|
||||||
|
|
||||||
|
let artist = COLLECTION[3].meta.clone();
|
||||||
|
lookup_artist_expectation(&mut mb_job_sender, &artist);
|
||||||
|
|
||||||
|
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||||
|
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||||
|
|
||||||
|
let (_fetch_tx, fetch_rx) = mpsc::channel();
|
||||||
|
let fetch = FetchState::new(fetch_rx);
|
||||||
|
|
||||||
|
AppMachine::app_lookup_artist(inner, fetch, &artist, mbid());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_artist_cannot_have_mbid() {
|
||||||
|
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||||
|
let inner = inner(music_hoard);
|
||||||
|
|
||||||
|
// Use third artist to match the expectation.
|
||||||
|
let browse = AppMachine::browse_state(inner);
|
||||||
|
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||||
|
let app = browse.increment_selection(Delta::Line);
|
||||||
|
|
||||||
|
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||||
|
assert!(matches!(app, AppState::Browse(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_artist_job_sender_err() {
|
||||||
|
let mut mb_job_sender = MockIMbJobSender::new();
|
||||||
|
|
||||||
|
mb_job_sender
|
||||||
|
.expect_submit_background_job()
|
||||||
|
.return_once(|_, _| Err(DaemonError::JobChannelDisconnected));
|
||||||
|
|
||||||
|
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||||
|
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||||
|
let browse = AppMachine::browse_state(inner);
|
||||||
|
|
||||||
|
let app = browse.fetch_musicbrainz();
|
||||||
|
assert!(matches!(app, AppState::Error(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lookup_artist_job_sender_err() {
|
||||||
|
let mut mb_job_sender = MockIMbJobSender::new();
|
||||||
|
|
||||||
|
mb_job_sender
|
||||||
|
.expect_submit_foreground_job()
|
||||||
|
.return_once(|_, _| Err(DaemonError::JobChannelDisconnected));
|
||||||
|
|
||||||
|
let artist = COLLECTION[3].meta.clone();
|
||||||
|
|
||||||
|
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||||
|
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||||
|
|
||||||
|
let (_fetch_tx, fetch_rx) = mpsc::channel();
|
||||||
|
let fetch = FetchState::new(fetch_rx);
|
||||||
|
|
||||||
|
let app = AppMachine::app_lookup_artist(inner, fetch, &artist, mbid());
|
||||||
|
assert!(matches!(app, AppState::Error(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recv_ok_fetch_ok() {
|
||||||
|
let (tx, rx) = mpsc::channel::<MbApiResult>();
|
||||||
|
|
||||||
|
let artist = COLLECTION[3].meta.clone();
|
||||||
|
let artist_match = Entity::with_score(COLLECTION[2].meta.clone(), 80);
|
||||||
|
let artist_match_info =
|
||||||
|
EntityMatches::artist_search(artist.clone(), vec![artist_match.clone()]);
|
||||||
|
let fetch_result = Ok(MbReturn::Match(artist_match_info));
|
||||||
|
tx.send(fetch_result).unwrap();
|
||||||
|
|
||||||
|
let inner = inner(music_hoard(COLLECTION.clone()));
|
||||||
|
let fetch = FetchState::new(rx);
|
||||||
|
let mut app = AppMachine::app_fetch_next(inner, fetch);
|
||||||
|
assert!(matches!(app, AppState::Match(_)));
|
||||||
|
|
||||||
|
let public = app.get();
|
||||||
|
let match_state = public.state.unwrap_match();
|
||||||
|
let match_options = vec![
|
||||||
|
artist_match.into(),
|
||||||
|
MatchOption::CannotHaveMbid,
|
||||||
|
MatchOption::ManualInputMbid,
|
||||||
|
];
|
||||||
|
let expected = EntityMatches::artist_search(artist, match_options);
|
||||||
|
assert_eq!(match_state.matches, &expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recv_ok_fetch_err() {
|
||||||
|
let (tx, rx) = mpsc::channel::<MbApiResult>();
|
||||||
|
|
||||||
|
let fetch_result = Err(musicbrainz::api::Error::RateLimit);
|
||||||
|
tx.send(fetch_result).unwrap();
|
||||||
|
|
||||||
|
let inner = inner(music_hoard(COLLECTION.clone()));
|
||||||
|
let fetch = FetchState::new(rx);
|
||||||
|
let app = AppMachine::app_fetch_next(inner, fetch);
|
||||||
|
assert!(matches!(app, AppState::Error(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recv_err_empty() {
|
||||||
|
let (_tx, rx) = mpsc::channel::<MbApiResult>();
|
||||||
|
|
||||||
|
let inner = inner(music_hoard(COLLECTION.clone()));
|
||||||
|
let fetch = FetchState::new(rx);
|
||||||
|
let app = AppMachine::app_fetch_next(inner, fetch);
|
||||||
|
assert!(matches!(app, AppState::Fetch(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recv_err_empty_first() {
|
||||||
|
let mut collection = COLLECTION.clone();
|
||||||
|
collection[0].albums.clear();
|
||||||
|
|
||||||
|
let app = AppMachine::app_fetch_first(inner(music_hoard(collection)));
|
||||||
|
assert!(matches!(app, AppState::Browse(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recv_err_empty_next() {
|
||||||
|
let mut collection = COLLECTION.clone();
|
||||||
|
collection[0].albums.clear();
|
||||||
|
|
||||||
|
let (_, rx) = mpsc::channel::<MbApiResult>();
|
||||||
|
let fetch = FetchState::new(rx);
|
||||||
|
|
||||||
|
let app = AppMachine::app_fetch_next(inner(music_hoard(collection)), fetch);
|
||||||
|
assert!(matches!(app, AppState::Browse(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_first_then_ready() {
|
||||||
|
let (tx, rx) = mpsc::channel::<MbApiResult>();
|
||||||
|
|
||||||
|
let inner = inner(music_hoard(COLLECTION.clone()));
|
||||||
|
let fetch = FetchState::new(rx);
|
||||||
|
let app = AppMachine::app_fetch_next(inner, fetch);
|
||||||
|
assert!(matches!(app, AppState::Fetch(_)));
|
||||||
|
|
||||||
|
let artist = COLLECTION[3].meta.clone();
|
||||||
|
let match_info = EntityMatches::artist_search::<Entity<ArtistMeta>>(artist, vec![]);
|
||||||
|
let fetch_result = Ok(MbReturn::Match(match_info));
|
||||||
|
tx.send(fetch_result).unwrap();
|
||||||
|
|
||||||
|
let app = app.unwrap_fetch().fetch_result_ready();
|
||||||
|
assert!(matches!(app, AppState::Match(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abort() {
|
||||||
|
let (_, rx) = mpsc::channel::<MbApiResult>();
|
||||||
|
|
||||||
|
let fetch = FetchState::new(rx);
|
||||||
|
let app = AppMachine::fetch_state(inner(music_hoard(COLLECTION.clone())), fetch);
|
||||||
|
|
||||||
|
let app = app.abort();
|
||||||
|
assert!(matches!(app, AppState::Browse(_)));
|
||||||
|
}
|
||||||
|
}
|
@ -1,66 +0,0 @@
|
|||||||
use crate::tui::{
|
|
||||||
app::{
|
|
||||||
machine::{App, AppInner, AppMachine},
|
|
||||||
AppPublic, AppState, IAppInteractInfo,
|
|
||||||
},
|
|
||||||
lib::IMusicHoard,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct AppInfo;
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> AppMachine<MH, AppInfo> {
|
|
||||||
pub fn info(inner: AppInner<MH>) -> Self {
|
|
||||||
AppMachine {
|
|
||||||
inner,
|
|
||||||
state: AppInfo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> From<AppMachine<MH, AppInfo>> for App<MH> {
|
|
||||||
fn from(machine: AppMachine<MH, AppInfo>) -> Self {
|
|
||||||
AppState::Info(machine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppInfo>> for AppPublic<'a> {
|
|
||||||
fn from(machine: &'a mut AppMachine<MH, AppInfo>) -> Self {
|
|
||||||
AppPublic {
|
|
||||||
inner: (&mut machine.inner).into(),
|
|
||||||
state: AppState::Info(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> IAppInteractInfo for AppMachine<MH, AppInfo> {
|
|
||||||
type APP = App<MH>;
|
|
||||||
|
|
||||||
fn hide_info_overlay(self) -> Self::APP {
|
|
||||||
AppMachine::browse(self.inner).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn no_op(self) -> Self::APP {
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::tui::app::machine::tests::{inner, music_hoard};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hide_info_overlay() {
|
|
||||||
let info = AppMachine::info(inner(music_hoard(vec![])));
|
|
||||||
let app = info.hide_info_overlay();
|
|
||||||
app.unwrap_browse();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_op() {
|
|
||||||
let info = AppMachine::info(inner(music_hoard(vec![])));
|
|
||||||
let app = info.no_op();
|
|
||||||
app.unwrap_info();
|
|
||||||
}
|
|
||||||
}
|
|
46
src/tui/app/machine/info_state.rs
Normal file
46
src/tui/app/machine/info_state.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
use crate::tui::app::{
|
||||||
|
machine::{App, AppInner, AppMachine},
|
||||||
|
AppPublicState, AppState, IAppInteractInfo,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct InfoState;
|
||||||
|
|
||||||
|
impl AppMachine<InfoState> {
|
||||||
|
pub fn info_state(inner: AppInner) -> Self {
|
||||||
|
AppMachine::new(inner, InfoState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMachine<InfoState>> for App {
|
||||||
|
fn from(machine: AppMachine<InfoState>) -> Self {
|
||||||
|
AppState::Info(machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a mut InfoState> for AppPublicState<'a> {
|
||||||
|
fn from(_state: &'a mut InfoState) -> Self {
|
||||||
|
AppState::Info(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppInteractInfo for AppMachine<InfoState> {
|
||||||
|
type APP = App;
|
||||||
|
|
||||||
|
fn hide_info_overlay(self) -> Self::APP {
|
||||||
|
AppMachine::browse_state(self.inner).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tui::app::machine::tests::{inner, music_hoard};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hide_info_overlay() {
|
||||||
|
let info = AppMachine::info_state(inner(music_hoard(vec![])));
|
||||||
|
let app = info.hide_info_overlay();
|
||||||
|
app.unwrap_browse();
|
||||||
|
}
|
||||||
|
}
|
97
src/tui/app/machine/input.rs
Normal file
97
src/tui/app/machine/input.rs
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
use tui_input::backend::crossterm::EventHandler;
|
||||||
|
|
||||||
|
use crate::tui::app::{machine::App, AppMode, AppState, IAppInput, InputEvent, InputPublic};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Input(tui_input::Input);
|
||||||
|
|
||||||
|
impl<'app> From<&'app Input> for InputPublic<'app> {
|
||||||
|
fn from(value: &'app Input) -> Self {
|
||||||
|
&value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Input {
|
||||||
|
pub fn value(&self) -> &str {
|
||||||
|
self.0.value()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<App> for AppMode<App, AppInputMode> {
|
||||||
|
fn from(mut app: App) -> Self {
|
||||||
|
if let Some(input) = app.input_mut().take() {
|
||||||
|
AppMode::Input(AppInputMode::new(input, app))
|
||||||
|
} else {
|
||||||
|
AppMode::State(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppInputMode {
|
||||||
|
input: Input,
|
||||||
|
app: App,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppInputMode {
|
||||||
|
pub fn new(input: Input, app: App) -> Self {
|
||||||
|
AppInputMode { input, app }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppInput for AppInputMode {
|
||||||
|
type APP = App;
|
||||||
|
|
||||||
|
fn input(mut self, input: InputEvent) -> Self::APP {
|
||||||
|
self.input
|
||||||
|
.0
|
||||||
|
.handle_event(&crossterm::event::Event::Key(input.into()));
|
||||||
|
self.app.input_mut().replace(self.input);
|
||||||
|
self.app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm(self) -> Self::APP {
|
||||||
|
if let AppState::Match(state) = self.app {
|
||||||
|
return state.submit_input(self.input);
|
||||||
|
}
|
||||||
|
self.app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(self) -> Self::APP {
|
||||||
|
self.app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tui::app::{
|
||||||
|
machine::tests::{input_event, mb_job_sender, music_hoard_init},
|
||||||
|
IApp,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_input() {
|
||||||
|
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||||
|
app.input_mut().replace(Input::default());
|
||||||
|
|
||||||
|
let input = app.mode().unwrap_input();
|
||||||
|
let app = input.input(input_event('H'));
|
||||||
|
|
||||||
|
let input = app.mode().unwrap_input();
|
||||||
|
let app = input.input(input_event('e'));
|
||||||
|
|
||||||
|
let input = app.mode().unwrap_input();
|
||||||
|
let app = input.input(input_event('l'));
|
||||||
|
|
||||||
|
let input = app.mode().unwrap_input();
|
||||||
|
let app = input.input(input_event('l'));
|
||||||
|
|
||||||
|
let input = app.mode().unwrap_input();
|
||||||
|
let app = input.input(input_event('o'));
|
||||||
|
|
||||||
|
assert_eq!(app.input_ref().as_ref().unwrap().0.value(), "Hello");
|
||||||
|
|
||||||
|
app.mode().unwrap_input().confirm().unwrap_browse();
|
||||||
|
}
|
||||||
|
}
|
657
src/tui/app/machine/match_state.rs
Normal file
657
src/tui/app/machine/match_state.rs
Normal file
@ -0,0 +1,657 @@
|
|||||||
|
use std::cmp;
|
||||||
|
|
||||||
|
use musichoard::collection::{
|
||||||
|
album::{AlbumInfo, AlbumMeta},
|
||||||
|
artist::{ArtistInfo, ArtistMeta},
|
||||||
|
musicbrainz::{MbRefOption, Mbid},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::app::{
|
||||||
|
machine::{fetch_state::FetchState, input::Input, App, AppInner, AppMachine},
|
||||||
|
AlbumMatches, AppPublicState, AppState, ArtistMatches, Delta, EntityMatches, IAppInteractMatch,
|
||||||
|
MatchOption, MatchStatePublic, WidgetState,
|
||||||
|
};
|
||||||
|
|
||||||
|
trait GetInfoMeta {
|
||||||
|
type InfoType;
|
||||||
|
}
|
||||||
|
impl GetInfoMeta for ArtistMeta {
|
||||||
|
type InfoType = ArtistInfo;
|
||||||
|
}
|
||||||
|
impl GetInfoMeta for AlbumMeta {
|
||||||
|
type InfoType = AlbumInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
trait GetInfo {
|
||||||
|
type InfoType;
|
||||||
|
fn get_info(&self) -> InfoOption<Self::InfoType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InfoOption<T> {
|
||||||
|
Info(T),
|
||||||
|
NeedInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GetInfo for MatchOption<ArtistMeta> {
|
||||||
|
type InfoType = ArtistInfo;
|
||||||
|
|
||||||
|
fn get_info(&self) -> InfoOption<Self::InfoType> {
|
||||||
|
let mut info = ArtistInfo::default();
|
||||||
|
match self {
|
||||||
|
MatchOption::Some(option) => info.musicbrainz = option.entity.info.musicbrainz.clone(),
|
||||||
|
MatchOption::CannotHaveMbid => info.musicbrainz = MbRefOption::CannotHaveMbid,
|
||||||
|
MatchOption::ManualInputMbid => return InfoOption::NeedInput,
|
||||||
|
}
|
||||||
|
InfoOption::Info(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GetInfo for MatchOption<AlbumMeta> {
|
||||||
|
type InfoType = AlbumInfo;
|
||||||
|
|
||||||
|
fn get_info(&self) -> InfoOption<Self::InfoType> {
|
||||||
|
let mut info = AlbumInfo::default();
|
||||||
|
match self {
|
||||||
|
MatchOption::Some(option) => info = option.entity.info.clone(),
|
||||||
|
MatchOption::CannotHaveMbid => info.musicbrainz = MbRefOption::CannotHaveMbid,
|
||||||
|
MatchOption::ManualInputMbid => return InfoOption::NeedInput,
|
||||||
|
}
|
||||||
|
InfoOption::Info(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait ExtractInfo {
|
||||||
|
type InfoType;
|
||||||
|
fn extract_info(&self, index: usize) -> InfoOption<Self::InfoType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: GetInfoMeta> ExtractInfo for Vec<MatchOption<T>>
|
||||||
|
where
|
||||||
|
MatchOption<T>: GetInfo<InfoType = T::InfoType>,
|
||||||
|
MatchOption<T>: GetInfo<InfoType = T::InfoType>,
|
||||||
|
{
|
||||||
|
type InfoType = T::InfoType;
|
||||||
|
|
||||||
|
fn extract_info(&self, index: usize) -> InfoOption<Self::InfoType> {
|
||||||
|
self.get(index).unwrap().get_info()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArtistMatches {
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.list.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_cannot_have_mbid(&mut self) {
|
||||||
|
self.list.push(MatchOption::CannotHaveMbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_manual_input_mbid(&mut self) {
|
||||||
|
self.list.push(MatchOption::ManualInputMbid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumMatches {
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.list.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_cannot_have_mbid(&mut self) {
|
||||||
|
self.list.push(MatchOption::CannotHaveMbid);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_manual_input_mbid(&mut self) {
|
||||||
|
self.list.push(MatchOption::ManualInputMbid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntityMatches {
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Artist(a) => a.len(),
|
||||||
|
Self::Album(a) => a.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_cannot_have_mbid(&mut self) {
|
||||||
|
match self {
|
||||||
|
Self::Artist(a) => a.push_cannot_have_mbid(),
|
||||||
|
Self::Album(a) => a.push_cannot_have_mbid(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_manual_input_mbid(&mut self) {
|
||||||
|
match self {
|
||||||
|
Self::Artist(a) => a.push_manual_input_mbid(),
|
||||||
|
Self::Album(a) => a.push_manual_input_mbid(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MatchState {
|
||||||
|
current: EntityMatches,
|
||||||
|
state: WidgetState,
|
||||||
|
fetch: FetchState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MatchState {
|
||||||
|
pub fn new(mut current: EntityMatches, fetch: FetchState) -> Self {
|
||||||
|
current.push_cannot_have_mbid();
|
||||||
|
current.push_manual_input_mbid();
|
||||||
|
|
||||||
|
let state = WidgetState::default().with_selected(Some(0));
|
||||||
|
|
||||||
|
MatchState {
|
||||||
|
current,
|
||||||
|
state,
|
||||||
|
fetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppMachine<MatchState> {
|
||||||
|
pub fn match_state(inner: AppInner, state: MatchState) -> Self {
|
||||||
|
AppMachine::new(inner, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn submit_input(self, input: Input) -> App {
|
||||||
|
let mbid: Mbid = match input.value().try_into() {
|
||||||
|
Ok(mbid) => mbid,
|
||||||
|
Err(err) => return AppMachine::error_state(self.inner, err.to_string()).into(),
|
||||||
|
};
|
||||||
|
match self.state.current {
|
||||||
|
EntityMatches::Artist(artist_matches) => {
|
||||||
|
let matching = &artist_matches.matching;
|
||||||
|
AppMachine::app_lookup_artist(self.inner, self.state.fetch, matching, mbid)
|
||||||
|
}
|
||||||
|
EntityMatches::Album(album_matches) => {
|
||||||
|
let artist_id = &album_matches.artist;
|
||||||
|
let matching = &album_matches.matching;
|
||||||
|
AppMachine::app_lookup_album(
|
||||||
|
self.inner,
|
||||||
|
self.state.fetch,
|
||||||
|
artist_id,
|
||||||
|
matching,
|
||||||
|
mbid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_input(mut self) -> App {
|
||||||
|
self.input.replace(Input::default());
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMachine<MatchState>> for App {
|
||||||
|
fn from(machine: AppMachine<MatchState>) -> Self {
|
||||||
|
AppState::Match(machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a mut MatchState> for AppPublicState<'a> {
|
||||||
|
fn from(state: &'a mut MatchState) -> Self {
|
||||||
|
AppState::Match(MatchStatePublic {
|
||||||
|
matches: &state.current,
|
||||||
|
state: &mut state.state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppInteractMatch for AppMachine<MatchState> {
|
||||||
|
type APP = App;
|
||||||
|
|
||||||
|
fn decrement_match(mut self, delta: Delta) -> Self::APP {
|
||||||
|
if let Some(index) = self.state.state.list.selected() {
|
||||||
|
let result = index.saturating_sub(delta.as_usize(&self.state.state));
|
||||||
|
self.state.state.list.select(Some(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_match(mut self, delta: Delta) -> Self::APP {
|
||||||
|
let index = self.state.state.list.selected().unwrap();
|
||||||
|
let to = cmp::min(
|
||||||
|
index.saturating_add(delta.as_usize(&self.state.state)),
|
||||||
|
self.state.current.len().saturating_sub(1),
|
||||||
|
);
|
||||||
|
self.state.state.list.select(Some(to));
|
||||||
|
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select(mut self) -> Self::APP {
|
||||||
|
let index = self.state.state.list.selected().unwrap();
|
||||||
|
|
||||||
|
let mh = &mut self.inner.music_hoard;
|
||||||
|
let result = match self.state.current {
|
||||||
|
EntityMatches::Artist(ref mut matches) => match matches.list.extract_info(index) {
|
||||||
|
InfoOption::Info(info) => mh.merge_artist_info(&matches.matching.id, info),
|
||||||
|
InfoOption::NeedInput => return self.get_input(),
|
||||||
|
},
|
||||||
|
EntityMatches::Album(ref mut matches) => match matches.list.extract_info(index) {
|
||||||
|
InfoOption::Info(info) => {
|
||||||
|
mh.merge_album_info(&matches.artist, &matches.matching, info)
|
||||||
|
}
|
||||||
|
InfoOption::NeedInput => return self.get_input(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
return AppMachine::error_state(self.inner, err.to_string()).into();
|
||||||
|
}
|
||||||
|
|
||||||
|
AppMachine::app_fetch_next(self.inner, self.state.fetch)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn abort(self) -> Self::APP {
|
||||||
|
AppMachine::browse_state(self.inner).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{collections::VecDeque, sync::mpsc};
|
||||||
|
|
||||||
|
use mockall::predicate::{self, eq};
|
||||||
|
use musichoard::collection::{
|
||||||
|
album::{AlbumDate, AlbumId, AlbumInfo, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType},
|
||||||
|
artist::{ArtistId, ArtistMeta},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::{
|
||||||
|
app::{
|
||||||
|
machine::tests::{inner, inner_with_mb, input_event, music_hoard},
|
||||||
|
IApp, IAppAccess, IAppInput,
|
||||||
|
},
|
||||||
|
lib::interface::musicbrainz::{
|
||||||
|
api::Entity,
|
||||||
|
daemon::{MbParams, MockIMbJobSender},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl<T> Entity<T> {
|
||||||
|
pub fn with_score(entity: T, score: u8) -> Self {
|
||||||
|
Entity {
|
||||||
|
score: Some(score),
|
||||||
|
entity,
|
||||||
|
disambiguation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mbid() -> Mbid {
|
||||||
|
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artist_meta() -> ArtistMeta {
|
||||||
|
let mut meta = ArtistMeta::new(ArtistId::new("Artist"));
|
||||||
|
meta.info.musicbrainz = MbRefOption::Some(mbid().into());
|
||||||
|
meta
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artist_match() -> EntityMatches {
|
||||||
|
let artist = artist_meta();
|
||||||
|
|
||||||
|
let artist_1 = artist.clone();
|
||||||
|
let artist_match_1 = Entity::with_score(artist_1, 100);
|
||||||
|
|
||||||
|
let artist_2 = artist.clone();
|
||||||
|
let mut artist_match_2 = Entity::with_score(artist_2, 100);
|
||||||
|
artist_match_2.disambiguation = Some(String::from("some disambiguation"));
|
||||||
|
|
||||||
|
let list = vec![artist_match_1.clone(), artist_match_2.clone()];
|
||||||
|
EntityMatches::artist_search(artist, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn artist_lookup() -> EntityMatches {
|
||||||
|
let artist = artist_meta();
|
||||||
|
let lookup = Entity::new(artist.clone());
|
||||||
|
EntityMatches::artist_lookup(artist, lookup)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_id() -> AlbumId {
|
||||||
|
AlbumId::new("Album")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_meta(id: AlbumId) -> AlbumMeta {
|
||||||
|
AlbumMeta::new(
|
||||||
|
id,
|
||||||
|
AlbumDate::new(Some(1990), Some(5), None),
|
||||||
|
AlbumInfo::new(
|
||||||
|
MbRefOption::Some(mbid().into()),
|
||||||
|
Some(AlbumPrimaryType::Album),
|
||||||
|
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_match() -> EntityMatches {
|
||||||
|
let artist_id = ArtistId::new("Artist");
|
||||||
|
let album_id = album_id();
|
||||||
|
let album_meta = album_meta(album_id.clone());
|
||||||
|
|
||||||
|
let album_1 = album_meta.clone();
|
||||||
|
let album_match_1 = Entity::with_score(album_1, 100);
|
||||||
|
|
||||||
|
let mut album_2 = album_meta.clone();
|
||||||
|
album_2.id.title.push_str(" extra title part");
|
||||||
|
album_2.info.secondary_types.pop();
|
||||||
|
let album_match_2 = Entity::with_score(album_2, 100);
|
||||||
|
|
||||||
|
let list = vec![album_match_1.clone(), album_match_2.clone()];
|
||||||
|
EntityMatches::album_search(artist_id, album_id, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_lookup() -> EntityMatches {
|
||||||
|
let artist_id = ArtistId::new("Artist");
|
||||||
|
let album_id = album_id();
|
||||||
|
let album_meta = album_meta(album_id.clone());
|
||||||
|
let lookup = Entity::new(album_meta.clone());
|
||||||
|
EntityMatches::album_lookup(artist_id, album_id, lookup)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_state() -> FetchState {
|
||||||
|
let (_, rx) = mpsc::channel();
|
||||||
|
FetchState::new(rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match_state(match_state_info: EntityMatches) -> MatchState {
|
||||||
|
MatchState::new(match_state_info, fetch_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create() {
|
||||||
|
let mut album_match = album_match();
|
||||||
|
let matches =
|
||||||
|
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match.clone()));
|
||||||
|
album_match.push_cannot_have_mbid();
|
||||||
|
album_match.push_manual_input_mbid();
|
||||||
|
|
||||||
|
let widget_state = WidgetState::default().with_selected(Some(0));
|
||||||
|
|
||||||
|
assert_eq!(matches.state.current, album_match);
|
||||||
|
assert_eq!(matches.state.state, widget_state);
|
||||||
|
|
||||||
|
let mut app: App = matches.into();
|
||||||
|
let public = app.get();
|
||||||
|
let public_matches = public.state.unwrap_match();
|
||||||
|
|
||||||
|
assert_eq!(public_matches.matches, &album_match);
|
||||||
|
assert_eq!(public_matches.state, &widget_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn match_state_flow(mut matches_info: EntityMatches, len: usize) {
|
||||||
|
// tx must exist for rx to return Empty rather than Disconnected.
|
||||||
|
let (_tx, rx) = mpsc::channel();
|
||||||
|
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
|
||||||
|
|
||||||
|
let mut music_hoard = music_hoard(vec![]);
|
||||||
|
let artist_id = ArtistId::new("Artist");
|
||||||
|
match matches_info {
|
||||||
|
EntityMatches::Album(_) => {
|
||||||
|
let album_id = AlbumId::new("Album");
|
||||||
|
let info = AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::CannotHaveMbid,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
music_hoard
|
||||||
|
.expect_merge_album_info()
|
||||||
|
.with(eq(artist_id.clone()), eq(album_id.clone()), eq(info))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_, _, _| Ok(()));
|
||||||
|
}
|
||||||
|
EntityMatches::Artist(_) => {
|
||||||
|
let info = ArtistInfo {
|
||||||
|
musicbrainz: MbRefOption::CannotHaveMbid,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
music_hoard
|
||||||
|
.expect_merge_artist_info()
|
||||||
|
.with(eq(artist_id.clone()), eq(info))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_, _| Ok(()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
|
||||||
|
matches_info.push_cannot_have_mbid();
|
||||||
|
matches_info.push_manual_input_mbid();
|
||||||
|
|
||||||
|
let widget_state = WidgetState::default().with_selected(Some(0));
|
||||||
|
|
||||||
|
assert_eq!(matches.state.current, matches_info);
|
||||||
|
assert_eq!(matches.state.state, widget_state);
|
||||||
|
|
||||||
|
let matches = matches.decrement_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
|
assert_eq!(matches.state.current, matches_info);
|
||||||
|
assert_eq!(matches.state.state.list.selected(), Some(0));
|
||||||
|
|
||||||
|
let mut matches = matches;
|
||||||
|
for ii in 1..len {
|
||||||
|
matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
|
assert_eq!(matches.state.current, matches_info);
|
||||||
|
assert_eq!(matches.state.state.list.selected(), Some(ii));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next is CannotHaveMBID
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
|
assert_eq!(matches.state.current, matches_info);
|
||||||
|
assert_eq!(matches.state.state.list.selected(), Some(len));
|
||||||
|
|
||||||
|
// Next is ManualInputMbid
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
|
assert_eq!(matches.state.current, matches_info);
|
||||||
|
assert_eq!(matches.state.state.list.selected(), Some(len + 1));
|
||||||
|
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
|
assert_eq!(matches.state.current, matches_info);
|
||||||
|
assert_eq!(matches.state.state.list.selected(), Some(len + 1));
|
||||||
|
|
||||||
|
// Go prev_match first as selecting on manual input does not go back to fetch.
|
||||||
|
let matches = matches.decrement_match(Delta::Line).unwrap_match();
|
||||||
|
matches.select().unwrap_fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_matches_flow() {
|
||||||
|
match_state_flow(artist_match(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_lookup_flow() {
|
||||||
|
match_state_flow(artist_lookup(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_matches_flow() {
|
||||||
|
match_state_flow(album_match(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_lookup_flow() {
|
||||||
|
match_state_flow(album_lookup(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_artist_info() {
|
||||||
|
let matches_info = artist_match();
|
||||||
|
|
||||||
|
let (_tx, rx) = mpsc::channel();
|
||||||
|
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
|
||||||
|
|
||||||
|
let mut music_hoard = music_hoard(vec![]);
|
||||||
|
match matches_info {
|
||||||
|
EntityMatches::Album(_) => panic!(),
|
||||||
|
EntityMatches::Artist(_) => {
|
||||||
|
let meta = artist_meta();
|
||||||
|
music_hoard
|
||||||
|
.expect_merge_artist_info()
|
||||||
|
.with(eq(meta.id), eq(meta.info))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_, _| Ok(()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
|
||||||
|
matches.select().unwrap_fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_album_info() {
|
||||||
|
let matches_info = album_match();
|
||||||
|
|
||||||
|
let (_tx, rx) = mpsc::channel();
|
||||||
|
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
|
||||||
|
|
||||||
|
let mut music_hoard = music_hoard(vec![]);
|
||||||
|
match matches_info {
|
||||||
|
EntityMatches::Artist(_) => panic!(),
|
||||||
|
EntityMatches::Album(matches) => {
|
||||||
|
let meta = album_meta(album_id());
|
||||||
|
music_hoard
|
||||||
|
.expect_merge_album_info()
|
||||||
|
.with(eq(matches.artist), eq(meta.id), eq(meta.info))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_, _, _| Ok(()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
|
||||||
|
matches.select().unwrap_fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_info_error() {
|
||||||
|
let matches_info = artist_match();
|
||||||
|
|
||||||
|
let (_tx, rx) = mpsc::channel();
|
||||||
|
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
|
||||||
|
|
||||||
|
let mut music_hoard = music_hoard(vec![]);
|
||||||
|
match matches_info {
|
||||||
|
EntityMatches::Album(_) => panic!(),
|
||||||
|
EntityMatches::Artist(_) => {
|
||||||
|
music_hoard.expect_merge_artist_info().return_once(|_, _| {
|
||||||
|
Err(musichoard::Error::DatabaseError(String::from("error")))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
|
||||||
|
matches.select().unwrap_error();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abort() {
|
||||||
|
let mut album_match = album_match();
|
||||||
|
let matches =
|
||||||
|
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match.clone()));
|
||||||
|
album_match.push_cannot_have_mbid();
|
||||||
|
album_match.push_manual_input_mbid();
|
||||||
|
|
||||||
|
let widget_state = WidgetState::default().with_selected(Some(0));
|
||||||
|
|
||||||
|
assert_eq!(matches.state.current, album_match);
|
||||||
|
assert_eq!(matches.state.state, widget_state);
|
||||||
|
|
||||||
|
matches.abort().unwrap_browse();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_manual_input_empty() {
|
||||||
|
let matches =
|
||||||
|
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match()));
|
||||||
|
|
||||||
|
// album_match has two matches which means that the fourth option should be manual input.
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
|
let app = matches.select();
|
||||||
|
|
||||||
|
let input = app.mode().unwrap_input();
|
||||||
|
input.confirm().unwrap_error();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_mbid(mut app: App) -> App {
|
||||||
|
let mbid = mbid().uuid().to_string();
|
||||||
|
for c in mbid.chars() {
|
||||||
|
let input = app.mode().unwrap_input();
|
||||||
|
app = input.input(input_event(c));
|
||||||
|
}
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_manual_input_artist() {
|
||||||
|
let mut mb_job_sender = MockIMbJobSender::new();
|
||||||
|
let artist = ArtistMeta::new(ArtistId::new("Artist"));
|
||||||
|
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid())]);
|
||||||
|
mb_job_sender
|
||||||
|
.expect_submit_foreground_job()
|
||||||
|
.with(predicate::always(), predicate::eq(requests))
|
||||||
|
.return_once(|_, _| Ok(()));
|
||||||
|
|
||||||
|
let matches_vec: Vec<Entity<ArtistMeta>> = vec![];
|
||||||
|
let artist_match = EntityMatches::artist_search(artist.clone(), matches_vec);
|
||||||
|
let matches = AppMachine::match_state(
|
||||||
|
inner_with_mb(music_hoard(vec![]), mb_job_sender),
|
||||||
|
match_state(artist_match),
|
||||||
|
);
|
||||||
|
|
||||||
|
// There are no matches which means that the second option should be manual input.
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
|
let mut app = matches.select();
|
||||||
|
app = input_mbid(app);
|
||||||
|
|
||||||
|
let input = app.mode().unwrap_input();
|
||||||
|
input.confirm();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_manual_input_album() {
|
||||||
|
let mut mb_job_sender = MockIMbJobSender::new();
|
||||||
|
let artist_id = ArtistId::new("Artist");
|
||||||
|
let album = AlbumMeta::new("Album", 1990, AlbumInfo::default());
|
||||||
|
let requests = VecDeque::from([MbParams::lookup_release_group(
|
||||||
|
artist_id.clone(),
|
||||||
|
album.id.clone(),
|
||||||
|
mbid(),
|
||||||
|
)]);
|
||||||
|
mb_job_sender
|
||||||
|
.expect_submit_foreground_job()
|
||||||
|
.with(predicate::always(), predicate::eq(requests))
|
||||||
|
.return_once(|_, _| Ok(()));
|
||||||
|
|
||||||
|
let matches_vec: Vec<Entity<AlbumMeta>> = vec![];
|
||||||
|
let album_match =
|
||||||
|
EntityMatches::album_search(artist_id.clone(), album.id.clone(), matches_vec);
|
||||||
|
let matches = AppMachine::match_state(
|
||||||
|
inner_with_mb(music_hoard(vec![]), mb_job_sender),
|
||||||
|
match_state(album_match),
|
||||||
|
);
|
||||||
|
|
||||||
|
// There are no matches which means that the second option should be manual input.
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||||
|
|
||||||
|
let mut app = matches.select();
|
||||||
|
app = input_mbid(app);
|
||||||
|
|
||||||
|
let input = app.mode().unwrap_input();
|
||||||
|
input.confirm();
|
||||||
|
}
|
||||||
|
}
|
@ -1,88 +1,131 @@
|
|||||||
mod browse;
|
mod browse_state;
|
||||||
mod critical;
|
mod critical_state;
|
||||||
mod error;
|
mod error_state;
|
||||||
mod info;
|
mod fetch_state;
|
||||||
mod reload;
|
mod info_state;
|
||||||
mod search;
|
mod input;
|
||||||
|
mod match_state;
|
||||||
|
mod reload_state;
|
||||||
|
mod search_state;
|
||||||
|
|
||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
app::{selection::Selection, AppPublic, AppPublicInner, AppState, IAppAccess, IAppInteract},
|
app::{
|
||||||
lib::IMusicHoard,
|
selection::Selection, AppMode, AppPublic, AppPublicInner, AppPublicState, AppState, IApp,
|
||||||
|
IAppAccess, IAppBase, IAppState,
|
||||||
|
},
|
||||||
|
lib::{interface::musicbrainz::daemon::IMbJobSender, IMusicHoard},
|
||||||
};
|
};
|
||||||
|
|
||||||
use browse::AppBrowse;
|
use browse_state::BrowseState;
|
||||||
use critical::AppCritical;
|
use critical_state::CriticalState;
|
||||||
use error::AppError;
|
use error_state::ErrorState;
|
||||||
use info::AppInfo;
|
use fetch_state::FetchState;
|
||||||
use reload::AppReload;
|
use info_state::InfoState;
|
||||||
use search::AppSearch;
|
use input::{AppInputMode, Input};
|
||||||
|
use match_state::MatchState;
|
||||||
|
use reload_state::ReloadState;
|
||||||
|
use search_state::SearchState;
|
||||||
|
|
||||||
pub type App<MH> = AppState<
|
pub type App = AppState<
|
||||||
AppMachine<MH, AppBrowse>,
|
AppMachine<BrowseState>,
|
||||||
AppMachine<MH, AppInfo>,
|
AppMachine<InfoState>,
|
||||||
AppMachine<MH, AppReload>,
|
AppMachine<ReloadState>,
|
||||||
AppMachine<MH, AppSearch>,
|
AppMachine<SearchState>,
|
||||||
AppMachine<MH, AppError>,
|
AppMachine<FetchState>,
|
||||||
AppMachine<MH, AppCritical>,
|
AppMachine<MatchState>,
|
||||||
|
AppMachine<ErrorState>,
|
||||||
|
AppMachine<CriticalState>,
|
||||||
>;
|
>;
|
||||||
|
|
||||||
pub struct AppMachine<MH: IMusicHoard, STATE> {
|
pub struct AppMachine<STATE> {
|
||||||
inner: AppInner<MH>,
|
inner: AppInner,
|
||||||
state: STATE,
|
state: STATE,
|
||||||
|
input: Option<Input>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppInner<MH: IMusicHoard> {
|
pub struct AppInner {
|
||||||
running: bool,
|
running: bool,
|
||||||
music_hoard: MH,
|
music_hoard: Box<dyn IMusicHoard>,
|
||||||
|
musicbrainz: Box<dyn IMbJobSender>,
|
||||||
selection: Selection,
|
selection: Selection,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<MH: IMusicHoard> App<MH> {
|
macro_rules! app_field_ref {
|
||||||
pub fn new(mut music_hoard: MH) -> Self {
|
($app:ident, $field:ident) => {
|
||||||
|
match $app {
|
||||||
|
AppState::Browse(state) => &state.$field,
|
||||||
|
AppState::Info(state) => &state.$field,
|
||||||
|
AppState::Reload(state) => &state.$field,
|
||||||
|
AppState::Search(state) => &state.$field,
|
||||||
|
AppState::Fetch(state) => &state.$field,
|
||||||
|
AppState::Match(state) => &state.$field,
|
||||||
|
AppState::Error(state) => &state.$field,
|
||||||
|
AppState::Critical(state) => &state.$field,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! app_field_mut {
|
||||||
|
($app:ident, $field:ident) => {
|
||||||
|
match $app {
|
||||||
|
AppState::Browse(state) => &mut state.$field,
|
||||||
|
AppState::Info(state) => &mut state.$field,
|
||||||
|
AppState::Reload(state) => &mut state.$field,
|
||||||
|
AppState::Search(state) => &mut state.$field,
|
||||||
|
AppState::Fetch(state) => &mut state.$field,
|
||||||
|
AppState::Match(state) => &mut state.$field,
|
||||||
|
AppState::Error(state) => &mut state.$field,
|
||||||
|
AppState::Critical(state) => &mut state.$field,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new<MH: IMusicHoard + 'static, MB: IMbJobSender + 'static>(
|
||||||
|
mut music_hoard: MH,
|
||||||
|
musicbrainz: MB,
|
||||||
|
) -> Self {
|
||||||
let init_result = Self::init(&mut music_hoard);
|
let init_result = Self::init(&mut music_hoard);
|
||||||
let inner = AppInner::new(music_hoard);
|
let inner = AppInner::new(music_hoard, musicbrainz);
|
||||||
match init_result {
|
match init_result {
|
||||||
Ok(()) => AppMachine::browse(inner).into(),
|
Ok(()) => AppMachine::browse_state(inner).into(),
|
||||||
Err(err) => AppMachine::critical(inner, err.to_string()).into(),
|
Err(err) => AppMachine::critical_state(inner, err.to_string()).into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init(music_hoard: &mut MH) -> Result<(), musichoard::Error> {
|
fn init<MH: IMusicHoard>(music_hoard: &mut MH) -> Result<(), musichoard::Error> {
|
||||||
music_hoard.load_from_database()?;
|
|
||||||
music_hoard.rescan_library()?;
|
music_hoard.rescan_library()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inner_ref(&self) -> &AppInner<MH> {
|
fn inner_ref(&self) -> &AppInner {
|
||||||
match self {
|
app_field_ref!(self, inner)
|
||||||
AppState::Browse(browse) => &browse.inner,
|
}
|
||||||
AppState::Info(info) => &info.inner,
|
|
||||||
AppState::Reload(reload) => &reload.inner,
|
fn inner_mut(&mut self) -> &mut AppInner {
|
||||||
AppState::Search(search) => &search.inner,
|
app_field_mut!(self, inner)
|
||||||
AppState::Error(error) => &error.inner,
|
}
|
||||||
AppState::Critical(critical) => &critical.inner,
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn input_ref(&self) -> &Option<Input> {
|
||||||
|
app_field_ref!(self, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_mut(&mut self) -> &mut Option<Input> {
|
||||||
|
app_field_mut!(self, input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inner_mut(&mut self) -> &mut AppInner<MH> {
|
impl IApp for App {
|
||||||
match self {
|
type BrowseState = AppMachine<BrowseState>;
|
||||||
AppState::Browse(browse) => &mut browse.inner,
|
type InfoState = AppMachine<InfoState>;
|
||||||
AppState::Info(info) => &mut info.inner,
|
type ReloadState = AppMachine<ReloadState>;
|
||||||
AppState::Reload(reload) => &mut reload.inner,
|
type SearchState = AppMachine<SearchState>;
|
||||||
AppState::Search(search) => &mut search.inner,
|
type FetchState = AppMachine<FetchState>;
|
||||||
AppState::Error(error) => &mut error.inner,
|
type MatchState = AppMachine<MatchState>;
|
||||||
AppState::Critical(critical) => &mut critical.inner,
|
type ErrorState = AppMachine<ErrorState>;
|
||||||
}
|
type CriticalState = AppMachine<CriticalState>;
|
||||||
}
|
type InputMode = AppInputMode;
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> IAppInteract for App<MH> {
|
|
||||||
type BS = AppMachine<MH, AppBrowse>;
|
|
||||||
type IS = AppMachine<MH, AppInfo>;
|
|
||||||
type RS = AppMachine<MH, AppReload>;
|
|
||||||
type SS = AppMachine<MH, AppSearch>;
|
|
||||||
type ES = AppMachine<MH, AppError>;
|
|
||||||
type CS = AppMachine<MH, AppCritical>;
|
|
||||||
|
|
||||||
fn is_running(&self) -> bool {
|
fn is_running(&self) -> bool {
|
||||||
self.inner_ref().running
|
self.inner_ref().running
|
||||||
@ -93,37 +136,55 @@ impl<MH: IMusicHoard> IAppInteract for App<MH> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state(self) -> AppState<Self::BS, Self::IS, Self::RS, Self::SS, Self::ES, Self::CS> {
|
fn state(self) -> IAppState!() {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mode(self) -> AppMode<IAppState!(), Self::InputMode> {
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<MH: IMusicHoard> IAppAccess for App<MH> {
|
impl<T: Into<App>> IAppBase for T {
|
||||||
|
type APP = App;
|
||||||
|
|
||||||
|
fn no_op(self) -> Self::APP {
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppAccess for App {
|
||||||
fn get(&mut self) -> AppPublic {
|
fn get(&mut self) -> AppPublic {
|
||||||
match self {
|
match self {
|
||||||
AppState::Browse(browse) => browse.into(),
|
AppState::Browse(state) => state.into(),
|
||||||
AppState::Info(info) => info.into(),
|
AppState::Info(state) => state.into(),
|
||||||
AppState::Reload(reload) => reload.into(),
|
AppState::Reload(state) => state.into(),
|
||||||
AppState::Search(search) => search.into(),
|
AppState::Search(state) => state.into(),
|
||||||
AppState::Error(error) => error.into(),
|
AppState::Fetch(state) => state.into(),
|
||||||
AppState::Critical(critical) => critical.into(),
|
AppState::Match(state) => state.into(),
|
||||||
|
AppState::Error(state) => state.into(),
|
||||||
|
AppState::Critical(state) => state.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<MH: IMusicHoard> AppInner<MH> {
|
impl AppInner {
|
||||||
pub fn new(music_hoard: MH) -> Self {
|
pub fn new<MH: IMusicHoard + 'static, MB: IMbJobSender + 'static>(
|
||||||
|
music_hoard: MH,
|
||||||
|
musicbrainz: MB,
|
||||||
|
) -> Self {
|
||||||
let selection = Selection::new(music_hoard.get_collection());
|
let selection = Selection::new(music_hoard.get_collection());
|
||||||
AppInner {
|
AppInner {
|
||||||
running: true,
|
running: true,
|
||||||
music_hoard,
|
music_hoard: Box::new(music_hoard),
|
||||||
|
musicbrainz: Box::new(musicbrainz),
|
||||||
selection,
|
selection,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, MH: IMusicHoard> From<&'a mut AppInner<MH>> for AppPublicInner<'a> {
|
impl<'a> From<&'a mut AppInner> for AppPublicInner<'a> {
|
||||||
fn from(inner: &'a mut AppInner<MH>) -> Self {
|
fn from(inner: &'a mut AppInner) -> Self {
|
||||||
AppPublicInner {
|
AppPublicInner {
|
||||||
collection: inner.music_hoard.get_collection(),
|
collection: inner.music_hoard.get_collection(),
|
||||||
selection: &mut inner.selection,
|
selection: &mut inner.selection,
|
||||||
@ -131,54 +192,135 @@ impl<'a, MH: IMusicHoard> From<&'a mut AppInner<MH>> for AppPublicInner<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<State> AppMachine<State> {
|
||||||
|
pub fn new(inner: AppInner, state: State) -> Self {
|
||||||
|
AppMachine {
|
||||||
|
inner,
|
||||||
|
state,
|
||||||
|
input: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, State> From<&'a mut AppMachine<State>> for AppPublic<'a>
|
||||||
|
where
|
||||||
|
&'a mut State: Into<AppPublicState<'a>>,
|
||||||
|
{
|
||||||
|
fn from(machine: &'a mut AppMachine<State>) -> Self {
|
||||||
|
AppPublic {
|
||||||
|
inner: (&mut machine.inner).into(),
|
||||||
|
state: (&mut machine.state).into(),
|
||||||
|
input: machine.input.as_ref().map(Into::into),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use musichoard::collection::Collection;
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
use musichoard::collection::{
|
||||||
|
artist::{ArtistId, ArtistMeta},
|
||||||
|
Collection,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
app::{AppState, IAppInteract, IAppInteractBrowse},
|
app::{AppState, EntityMatches, IApp, IAppInput, IAppInteractBrowse, InputEvent},
|
||||||
lib::MockIMusicHoard,
|
lib::{
|
||||||
|
interface::musicbrainz::{api::Entity, daemon::MockIMbJobSender},
|
||||||
|
MockIMusicHoard,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl<BS, IS, RS, SS, ES, CS> AppState<BS, IS, RS, SS, ES, CS> {
|
impl<StateMode, InputMode> AppMode<StateMode, InputMode> {
|
||||||
pub fn unwrap_browse(self) -> BS {
|
fn unwrap_state(self) -> StateMode {
|
||||||
|
match self {
|
||||||
|
AppMode::State(state) => state,
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unwrap_input(self) -> InputMode {
|
||||||
|
match self {
|
||||||
|
AppMode::Input(input) => input,
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<
|
||||||
|
BrowseState,
|
||||||
|
InfoState,
|
||||||
|
ReloadState,
|
||||||
|
SearchState,
|
||||||
|
FetchState,
|
||||||
|
MatchState,
|
||||||
|
ErrorState,
|
||||||
|
CriticalState,
|
||||||
|
>
|
||||||
|
AppState<
|
||||||
|
BrowseState,
|
||||||
|
InfoState,
|
||||||
|
ReloadState,
|
||||||
|
SearchState,
|
||||||
|
FetchState,
|
||||||
|
MatchState,
|
||||||
|
ErrorState,
|
||||||
|
CriticalState,
|
||||||
|
>
|
||||||
|
{
|
||||||
|
pub fn unwrap_browse(self) -> BrowseState {
|
||||||
match self {
|
match self {
|
||||||
AppState::Browse(browse) => browse,
|
AppState::Browse(browse) => browse,
|
||||||
_ => panic!(),
|
_ => panic!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unwrap_info(self) -> IS {
|
pub fn unwrap_info(self) -> InfoState {
|
||||||
match self {
|
match self {
|
||||||
AppState::Info(info) => info,
|
AppState::Info(info) => info,
|
||||||
_ => panic!(),
|
_ => panic!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unwrap_reload(self) -> RS {
|
pub fn unwrap_reload(self) -> ReloadState {
|
||||||
match self {
|
match self {
|
||||||
AppState::Reload(reload) => reload,
|
AppState::Reload(reload) => reload,
|
||||||
_ => panic!(),
|
_ => panic!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unwrap_search(self) -> SS {
|
pub fn unwrap_search(self) -> SearchState {
|
||||||
match self {
|
match self {
|
||||||
AppState::Search(search) => search,
|
AppState::Search(search) => search,
|
||||||
_ => panic!(),
|
_ => panic!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unwrap_error(self) -> ES {
|
pub fn unwrap_fetch(self) -> FetchState {
|
||||||
|
match self {
|
||||||
|
AppState::Fetch(fetch) => fetch,
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unwrap_match(self) -> MatchState {
|
||||||
|
match self {
|
||||||
|
AppState::Match(matches) => matches,
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unwrap_error(self) -> ErrorState {
|
||||||
match self {
|
match self {
|
||||||
AppState::Error(error) => error,
|
AppState::Error(error) => error,
|
||||||
_ => panic!(),
|
_ => panic!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unwrap_critical(self) -> CS {
|
pub fn unwrap_critical(self) -> CriticalState {
|
||||||
match self {
|
match self {
|
||||||
AppState::Critical(critical) => critical,
|
AppState::Critical(critical) => critical,
|
||||||
_ => panic!(),
|
_ => panic!(),
|
||||||
@ -193,13 +335,9 @@ mod tests {
|
|||||||
music_hoard
|
music_hoard
|
||||||
}
|
}
|
||||||
|
|
||||||
fn music_hoard_init(collection: Collection) -> MockIMusicHoard {
|
pub fn music_hoard_init(collection: Collection) -> MockIMusicHoard {
|
||||||
let mut music_hoard = music_hoard(collection);
|
let mut music_hoard = music_hoard(collection);
|
||||||
|
|
||||||
music_hoard
|
|
||||||
.expect_load_from_database()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|| Ok(()));
|
|
||||||
music_hoard
|
music_hoard
|
||||||
.expect_rescan_library()
|
.expect_rescan_library()
|
||||||
.times(1)
|
.times(1)
|
||||||
@ -208,21 +346,72 @@ mod tests {
|
|||||||
music_hoard
|
music_hoard
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn inner(music_hoard: MockIMusicHoard) -> AppInner<MockIMusicHoard> {
|
pub fn mb_job_sender() -> MockIMbJobSender {
|
||||||
AppInner::new(music_hoard)
|
MockIMbJobSender::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inner(music_hoard: MockIMusicHoard) -> AppInner {
|
||||||
|
AppInner::new(music_hoard, mb_job_sender())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inner_with_mb(
|
||||||
|
music_hoard: MockIMusicHoard,
|
||||||
|
mb_job_sender: MockIMbJobSender,
|
||||||
|
) -> AppInner {
|
||||||
|
AppInner::new(music_hoard, mb_job_sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input_event(c: char) -> InputEvent {
|
||||||
|
crossterm::event::KeyEvent::new(
|
||||||
|
crossterm::event::KeyCode::Char(c),
|
||||||
|
crossterm::event::KeyModifiers::empty(),
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_mode() {
|
||||||
|
let app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||||
|
assert!(app.is_running());
|
||||||
|
|
||||||
|
let mode = app.mode();
|
||||||
|
assert!(matches!(mode, AppMode::State(_)));
|
||||||
|
|
||||||
|
let state = mode.unwrap_state();
|
||||||
|
assert!(matches!(state, AppState::Browse(_)));
|
||||||
|
|
||||||
|
let mut app = state;
|
||||||
|
app.input_mut().replace(Input::default());
|
||||||
|
|
||||||
|
let public = app.get();
|
||||||
|
assert!(public.input.is_some());
|
||||||
|
|
||||||
|
let mode = app.mode();
|
||||||
|
assert!(matches!(mode, AppMode::Input(_)));
|
||||||
|
|
||||||
|
let mut app = mode.unwrap_input().cancel();
|
||||||
|
assert!(matches!(app, AppState::Browse(_)));
|
||||||
|
|
||||||
|
let public = app.get();
|
||||||
|
assert!(public.input.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_browse() {
|
fn state_browse() {
|
||||||
let mut app = App::new(music_hoard_init(vec![]));
|
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||||
assert!(app.is_running());
|
assert!(app.is_running());
|
||||||
|
|
||||||
let state = app.state();
|
let state = app.state();
|
||||||
matches!(state, AppState::Browse(_));
|
assert!(matches!(state, AppState::Browse(_)));
|
||||||
|
app = state;
|
||||||
|
|
||||||
|
app = app.no_op();
|
||||||
|
let state = app.state();
|
||||||
|
assert!(matches!(state, AppState::Browse(_)));
|
||||||
app = state;
|
app = state;
|
||||||
|
|
||||||
let public = app.get();
|
let public = app.get();
|
||||||
matches!(public.state, AppState::Browse(_));
|
assert!(matches!(public.state, AppState::Browse(_)));
|
||||||
|
|
||||||
let app = app.force_quit();
|
let app = app.force_quit();
|
||||||
assert!(!app.is_running());
|
assert!(!app.is_running());
|
||||||
@ -230,17 +419,22 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_info() {
|
fn state_info() {
|
||||||
let mut app = App::new(music_hoard_init(vec![]));
|
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||||
assert!(app.is_running());
|
assert!(app.is_running());
|
||||||
|
|
||||||
app = app.unwrap_browse().show_info_overlay();
|
app = app.unwrap_browse().show_info_overlay();
|
||||||
|
|
||||||
let state = app.state();
|
let state = app.state();
|
||||||
matches!(state, AppState::Info(_));
|
assert!(matches!(state, AppState::Info(_)));
|
||||||
|
app = state;
|
||||||
|
|
||||||
|
app = app.no_op();
|
||||||
|
let state = app.state();
|
||||||
|
assert!(matches!(state, AppState::Info(_)));
|
||||||
app = state;
|
app = state;
|
||||||
|
|
||||||
let public = app.get();
|
let public = app.get();
|
||||||
matches!(public.state, AppState::Info(_));
|
assert!(matches!(public.state, AppState::Info(_)));
|
||||||
|
|
||||||
let app = app.force_quit();
|
let app = app.force_quit();
|
||||||
assert!(!app.is_running());
|
assert!(!app.is_running());
|
||||||
@ -248,17 +442,22 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_reload() {
|
fn state_reload() {
|
||||||
let mut app = App::new(music_hoard_init(vec![]));
|
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||||
assert!(app.is_running());
|
assert!(app.is_running());
|
||||||
|
|
||||||
app = app.unwrap_browse().show_reload_menu();
|
app = app.unwrap_browse().show_reload_menu();
|
||||||
|
|
||||||
let state = app.state();
|
let state = app.state();
|
||||||
matches!(state, AppState::Reload(_));
|
assert!(matches!(state, AppState::Reload(_)));
|
||||||
|
app = state;
|
||||||
|
|
||||||
|
app = app.no_op();
|
||||||
|
let state = app.state();
|
||||||
|
assert!(matches!(state, AppState::Reload(_)));
|
||||||
app = state;
|
app = state;
|
||||||
|
|
||||||
let public = app.get();
|
let public = app.get();
|
||||||
matches!(public.state, AppState::Reload(_));
|
assert!(matches!(public.state, AppState::Reload(_)));
|
||||||
|
|
||||||
let app = app.force_quit();
|
let app = app.force_quit();
|
||||||
assert!(!app.is_running());
|
assert!(!app.is_running());
|
||||||
@ -266,17 +465,76 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_search() {
|
fn state_search() {
|
||||||
let mut app = App::new(music_hoard_init(vec![]));
|
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||||
assert!(app.is_running());
|
assert!(app.is_running());
|
||||||
|
|
||||||
app = app.unwrap_browse().begin_search();
|
app = app.unwrap_browse().begin_search();
|
||||||
|
|
||||||
let state = app.state();
|
let state = app.state();
|
||||||
matches!(state, AppState::Search(_));
|
assert!(matches!(state, AppState::Search(_)));
|
||||||
|
app = state;
|
||||||
|
|
||||||
|
app = app.no_op();
|
||||||
|
let state = app.state();
|
||||||
|
assert!(matches!(state, AppState::Search(_)));
|
||||||
app = state;
|
app = state;
|
||||||
|
|
||||||
let public = app.get();
|
let public = app.get();
|
||||||
matches!(public.state, AppState::Search(""));
|
assert!(matches!(public.state, AppState::Search("")));
|
||||||
|
|
||||||
|
let app = app.force_quit();
|
||||||
|
assert!(!app.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_fetch() {
|
||||||
|
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||||
|
assert!(app.is_running());
|
||||||
|
|
||||||
|
let (_, rx) = mpsc::channel();
|
||||||
|
let inner = app.unwrap_browse().inner;
|
||||||
|
let state = FetchState::new(rx);
|
||||||
|
app = AppMachine::new(inner, state).into();
|
||||||
|
|
||||||
|
let state = app.state();
|
||||||
|
assert!(matches!(state, AppState::Fetch(_)));
|
||||||
|
app = state;
|
||||||
|
|
||||||
|
app = app.no_op();
|
||||||
|
let state = app.state();
|
||||||
|
assert!(matches!(state, AppState::Fetch(_)));
|
||||||
|
app = state;
|
||||||
|
|
||||||
|
let public = app.get();
|
||||||
|
assert!(matches!(public.state, AppState::Fetch(_)));
|
||||||
|
|
||||||
|
let app = app.force_quit();
|
||||||
|
assert!(!app.is_running());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_match() {
|
||||||
|
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||||
|
assert!(app.is_running());
|
||||||
|
|
||||||
|
let (_, rx) = mpsc::channel();
|
||||||
|
let fetch = FetchState::new(rx);
|
||||||
|
let artist = ArtistMeta::new(ArtistId::new("Artist"));
|
||||||
|
let info = EntityMatches::artist_lookup(artist.clone(), Entity::new(artist.clone()));
|
||||||
|
app =
|
||||||
|
AppMachine::match_state(app.unwrap_browse().inner, MatchState::new(info, fetch)).into();
|
||||||
|
|
||||||
|
let state = app.state();
|
||||||
|
assert!(matches!(state, AppState::Match(_)));
|
||||||
|
app = state;
|
||||||
|
|
||||||
|
app = app.no_op();
|
||||||
|
let state = app.state();
|
||||||
|
assert!(matches!(state, AppState::Match(_)));
|
||||||
|
app = state;
|
||||||
|
|
||||||
|
let public = app.get();
|
||||||
|
assert!(matches!(public.state, AppState::Match(_)));
|
||||||
|
|
||||||
let app = app.force_quit();
|
let app = app.force_quit();
|
||||||
assert!(!app.is_running());
|
assert!(!app.is_running());
|
||||||
@ -284,17 +542,22 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_error() {
|
fn state_error() {
|
||||||
let mut app = App::new(music_hoard_init(vec![]));
|
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||||
assert!(app.is_running());
|
assert!(app.is_running());
|
||||||
|
|
||||||
app = AppMachine::error(app.unwrap_browse().inner, "get rekt").into();
|
app = AppMachine::error_state(app.unwrap_browse().inner, "get rekt").into();
|
||||||
|
|
||||||
let state = app.state();
|
let state = app.state();
|
||||||
matches!(state, AppState::Error(_));
|
assert!(matches!(state, AppState::Error(_)));
|
||||||
|
app = state;
|
||||||
|
|
||||||
|
app = app.no_op();
|
||||||
|
let state = app.state();
|
||||||
|
assert!(matches!(state, AppState::Error(_)));
|
||||||
app = state;
|
app = state;
|
||||||
|
|
||||||
let public = app.get();
|
let public = app.get();
|
||||||
matches!(public.state, AppState::Error("get rekt"));
|
assert!(matches!(public.state, AppState::Error("get rekt")));
|
||||||
|
|
||||||
app = app.force_quit();
|
app = app.force_quit();
|
||||||
assert!(!app.is_running());
|
assert!(!app.is_running());
|
||||||
@ -302,17 +565,22 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_critical() {
|
fn state_critical() {
|
||||||
let mut app = App::new(music_hoard_init(vec![]));
|
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||||
assert!(app.is_running());
|
assert!(app.is_running());
|
||||||
|
|
||||||
app = AppMachine::critical(app.unwrap_browse().inner, "get rekt").into();
|
app = AppMachine::critical_state(app.unwrap_browse().inner, "get rekt").into();
|
||||||
|
|
||||||
let state = app.state();
|
let state = app.state();
|
||||||
matches!(state, AppState::Critical(_));
|
assert!(matches!(state, AppState::Critical(_)));
|
||||||
|
app = state;
|
||||||
|
|
||||||
|
app = app.no_op();
|
||||||
|
let state = app.state();
|
||||||
|
assert!(matches!(state, AppState::Critical(_)));
|
||||||
app = state;
|
app = state;
|
||||||
|
|
||||||
let public = app.get();
|
let public = app.get();
|
||||||
matches!(public.state, AppState::Critical("get rekt"));
|
assert!(matches!(public.state, AppState::Critical("get rekt")));
|
||||||
|
|
||||||
app = app.force_quit();
|
app = app.force_quit();
|
||||||
assert!(!app.is_running());
|
assert!(!app.is_running());
|
||||||
@ -323,12 +591,12 @@ mod tests {
|
|||||||
let mut music_hoard = MockIMusicHoard::new();
|
let mut music_hoard = MockIMusicHoard::new();
|
||||||
|
|
||||||
music_hoard
|
music_hoard
|
||||||
.expect_load_from_database()
|
.expect_rescan_library()
|
||||||
.times(1)
|
.times(1)
|
||||||
.return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt"))));
|
.return_once(|| Err(musichoard::Error::LibraryError(String::from("get rekt"))));
|
||||||
music_hoard.expect_get_collection().return_const(vec![]);
|
music_hoard.expect_get_collection().return_const(vec![]);
|
||||||
|
|
||||||
let app = App::new(music_hoard);
|
let app = App::new(music_hoard, mb_job_sender());
|
||||||
assert!(app.is_running());
|
assert!(app.is_running());
|
||||||
app.unwrap_critical();
|
app.unwrap_critical();
|
||||||
}
|
}
|
||||||
|
@ -1,144 +0,0 @@
|
|||||||
use crate::tui::{
|
|
||||||
app::{
|
|
||||||
machine::{App, AppInner, AppMachine},
|
|
||||||
selection::IdSelection,
|
|
||||||
AppPublic, AppState, IAppInteractReload,
|
|
||||||
},
|
|
||||||
lib::IMusicHoard,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct AppReload;
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> AppMachine<MH, AppReload> {
|
|
||||||
pub fn reload(inner: AppInner<MH>) -> Self {
|
|
||||||
AppMachine {
|
|
||||||
inner,
|
|
||||||
state: AppReload,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> From<AppMachine<MH, AppReload>> for App<MH> {
|
|
||||||
fn from(machine: AppMachine<MH, AppReload>) -> Self {
|
|
||||||
AppState::Reload(machine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppReload>> for AppPublic<'a> {
|
|
||||||
fn from(machine: &'a mut AppMachine<MH, AppReload>) -> Self {
|
|
||||||
AppPublic {
|
|
||||||
inner: (&mut machine.inner).into(),
|
|
||||||
state: AppState::Reload(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> IAppInteractReload for AppMachine<MH, AppReload> {
|
|
||||||
type APP = App<MH>;
|
|
||||||
|
|
||||||
fn reload_library(mut self) -> Self::APP {
|
|
||||||
let previous = IdSelection::get(
|
|
||||||
self.inner.music_hoard.get_collection(),
|
|
||||||
&self.inner.selection,
|
|
||||||
);
|
|
||||||
let result = self.inner.music_hoard.rescan_library();
|
|
||||||
self.refresh(previous, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reload_database(mut self) -> Self::APP {
|
|
||||||
let previous = IdSelection::get(
|
|
||||||
self.inner.music_hoard.get_collection(),
|
|
||||||
&self.inner.selection,
|
|
||||||
);
|
|
||||||
let result = self.inner.music_hoard.load_from_database();
|
|
||||||
self.refresh(previous, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hide_reload_menu(self) -> Self::APP {
|
|
||||||
AppMachine::browse(self.inner).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn no_op(self) -> Self::APP {
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trait IAppInteractReloadPrivate<MH: IMusicHoard> {
|
|
||||||
fn refresh(self, previous: IdSelection, result: Result<(), musichoard::Error>) -> App<MH>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> IAppInteractReloadPrivate<MH> for AppMachine<MH, AppReload> {
|
|
||||||
fn refresh(mut self, previous: IdSelection, result: Result<(), musichoard::Error>) -> App<MH> {
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
self.inner
|
|
||||||
.selection
|
|
||||||
.select_by_id(self.inner.music_hoard.get_collection(), previous);
|
|
||||||
AppMachine::browse(self.inner).into()
|
|
||||||
}
|
|
||||||
Err(err) => AppMachine::error(self.inner, err.to_string()).into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::tui::app::machine::tests::{inner, music_hoard};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hide_reload_menu() {
|
|
||||||
let reload = AppMachine::reload(inner(music_hoard(vec![])));
|
|
||||||
let app = reload.hide_reload_menu();
|
|
||||||
app.unwrap_browse();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reload_database() {
|
|
||||||
let mut music_hoard = music_hoard(vec![]);
|
|
||||||
|
|
||||||
music_hoard
|
|
||||||
.expect_load_from_database()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|| Ok(()));
|
|
||||||
|
|
||||||
let reload = AppMachine::reload(inner(music_hoard));
|
|
||||||
let app = reload.reload_database();
|
|
||||||
app.unwrap_browse();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reload_library() {
|
|
||||||
let mut music_hoard = music_hoard(vec![]);
|
|
||||||
|
|
||||||
music_hoard
|
|
||||||
.expect_rescan_library()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|| Ok(()));
|
|
||||||
|
|
||||||
let reload = AppMachine::reload(inner(music_hoard));
|
|
||||||
let app = reload.reload_library();
|
|
||||||
app.unwrap_browse();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn reload_error() {
|
|
||||||
let mut music_hoard = music_hoard(vec![]);
|
|
||||||
|
|
||||||
music_hoard
|
|
||||||
.expect_load_from_database()
|
|
||||||
.times(1)
|
|
||||||
.return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt"))));
|
|
||||||
|
|
||||||
let reload = AppMachine::reload(inner(music_hoard));
|
|
||||||
let app = reload.reload_database();
|
|
||||||
app.unwrap_error();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_op() {
|
|
||||||
let reload = AppMachine::reload(inner(music_hoard(vec![])));
|
|
||||||
let app = reload.no_op();
|
|
||||||
app.unwrap_reload();
|
|
||||||
}
|
|
||||||
}
|
|
125
src/tui/app/machine/reload_state.rs
Normal file
125
src/tui/app/machine/reload_state.rs
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
use crate::tui::app::{
|
||||||
|
machine::{App, AppInner, AppMachine},
|
||||||
|
selection::KeySelection,
|
||||||
|
AppPublicState, AppState, IAppInteractReload,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct ReloadState;
|
||||||
|
|
||||||
|
impl AppMachine<ReloadState> {
|
||||||
|
pub fn reload_state(inner: AppInner) -> Self {
|
||||||
|
AppMachine::new(inner, ReloadState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMachine<ReloadState>> for App {
|
||||||
|
fn from(machine: AppMachine<ReloadState>) -> Self {
|
||||||
|
AppState::Reload(machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a mut ReloadState> for AppPublicState<'a> {
|
||||||
|
fn from(_state: &'a mut ReloadState) -> Self {
|
||||||
|
AppState::Reload(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppInteractReload for AppMachine<ReloadState> {
|
||||||
|
type APP = App;
|
||||||
|
|
||||||
|
fn reload_library(mut self) -> Self::APP {
|
||||||
|
let previous = KeySelection::get(
|
||||||
|
self.inner.music_hoard.get_collection(),
|
||||||
|
&self.inner.selection,
|
||||||
|
);
|
||||||
|
let result = self.inner.music_hoard.rescan_library();
|
||||||
|
self.refresh(previous, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reload_database(mut self) -> Self::APP {
|
||||||
|
let previous = KeySelection::get(
|
||||||
|
self.inner.music_hoard.get_collection(),
|
||||||
|
&self.inner.selection,
|
||||||
|
);
|
||||||
|
let result = self.inner.music_hoard.reload_database();
|
||||||
|
self.refresh(previous, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hide_reload_menu(self) -> Self::APP {
|
||||||
|
AppMachine::browse_state(self.inner).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait IAppInteractReloadPrivate {
|
||||||
|
fn refresh(self, previous: KeySelection, result: Result<(), musichoard::Error>) -> App;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppInteractReloadPrivate for AppMachine<ReloadState> {
|
||||||
|
fn refresh(mut self, previous: KeySelection, result: Result<(), musichoard::Error>) -> App {
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
self.inner
|
||||||
|
.selection
|
||||||
|
.select_by_id(self.inner.music_hoard.get_collection(), previous);
|
||||||
|
AppMachine::browse_state(self.inner).into()
|
||||||
|
}
|
||||||
|
Err(err) => AppMachine::error_state(self.inner, err.to_string()).into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tui::app::machine::tests::{inner, music_hoard};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hide_reload_menu() {
|
||||||
|
let reload = AppMachine::reload_state(inner(music_hoard(vec![])));
|
||||||
|
let app = reload.hide_reload_menu();
|
||||||
|
app.unwrap_browse();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reload_database() {
|
||||||
|
let mut music_hoard = music_hoard(vec![]);
|
||||||
|
|
||||||
|
music_hoard
|
||||||
|
.expect_reload_database()
|
||||||
|
.times(1)
|
||||||
|
.return_once(|| Ok(()));
|
||||||
|
|
||||||
|
let reload = AppMachine::reload_state(inner(music_hoard));
|
||||||
|
let app = reload.reload_database();
|
||||||
|
app.unwrap_browse();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reload_library() {
|
||||||
|
let mut music_hoard = music_hoard(vec![]);
|
||||||
|
|
||||||
|
music_hoard
|
||||||
|
.expect_rescan_library()
|
||||||
|
.times(1)
|
||||||
|
.return_once(|| Ok(()));
|
||||||
|
|
||||||
|
let reload = AppMachine::reload_state(inner(music_hoard));
|
||||||
|
let app = reload.reload_library();
|
||||||
|
app.unwrap_browse();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reload_error() {
|
||||||
|
let mut music_hoard = music_hoard(vec![]);
|
||||||
|
|
||||||
|
music_hoard
|
||||||
|
.expect_reload_database()
|
||||||
|
.times(1)
|
||||||
|
.return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt"))));
|
||||||
|
|
||||||
|
let reload = AppMachine::reload_state(inner(music_hoard));
|
||||||
|
let app = reload.reload_database();
|
||||||
|
app.unwrap_error();
|
||||||
|
}
|
||||||
|
}
|
@ -1,489 +0,0 @@
|
|||||||
use aho_corasick::AhoCorasick;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
|
|
||||||
use musichoard::collection::artist::Artist;
|
|
||||||
|
|
||||||
use crate::tui::{
|
|
||||||
app::{
|
|
||||||
machine::{App, AppInner, AppMachine},
|
|
||||||
selection::ListSelection,
|
|
||||||
AppPublic, AppState, IAppInteractSearch,
|
|
||||||
},
|
|
||||||
lib::IMusicHoard,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Unlikely that this covers all possible strings, but it should at least cover strings
|
|
||||||
// relevant for music (at least in English). The list of characters handled is based on
|
|
||||||
// https://wiki.musicbrainz.org/User:Yurim/Punctuation_and_Special_Characters.
|
|
||||||
//
|
|
||||||
// U+2010 hyphen, U+2012 figure dash, U+2013 en dash, U+2014 em dash, U+2015 horizontal bar, U+2018,
|
|
||||||
// U+2019, U+201C, U+201D, U+2026, U+2212 minus sign
|
|
||||||
const SPECIAL: [char; 11] = ['‐', '‒', '–', '—', '―', '‘', '’', '“', '”', '…', '−'];
|
|
||||||
const REPLACE: [&str; 11] = ["-", "-", "-", "-", "-", "'", "'", "\"", "\"", "...", "-"];
|
|
||||||
static AC: Lazy<AhoCorasick> =
|
|
||||||
Lazy::new(|| AhoCorasick::new(SPECIAL.map(|ch| ch.to_string())).unwrap());
|
|
||||||
|
|
||||||
pub struct AppSearch {
|
|
||||||
string: String,
|
|
||||||
orig: ListSelection,
|
|
||||||
memo: Vec<AppSearchMemo>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AppSearchMemo {
|
|
||||||
index: Option<usize>,
|
|
||||||
char: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> AppMachine<MH, AppSearch> {
|
|
||||||
pub fn search(inner: AppInner<MH>, orig: ListSelection) -> Self {
|
|
||||||
AppMachine {
|
|
||||||
inner,
|
|
||||||
state: AppSearch {
|
|
||||||
string: String::new(),
|
|
||||||
orig,
|
|
||||||
memo: vec![],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> From<AppMachine<MH, AppSearch>> for App<MH> {
|
|
||||||
fn from(machine: AppMachine<MH, AppSearch>) -> Self {
|
|
||||||
AppState::Search(machine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, MH: IMusicHoard> From<&'a mut AppMachine<MH, AppSearch>> for AppPublic<'a> {
|
|
||||||
fn from(machine: &'a mut AppMachine<MH, AppSearch>) -> Self {
|
|
||||||
AppPublic {
|
|
||||||
inner: (&mut machine.inner).into(),
|
|
||||||
state: AppState::Search(&machine.state.string),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> IAppInteractSearch for AppMachine<MH, AppSearch> {
|
|
||||||
type APP = App<MH>;
|
|
||||||
|
|
||||||
fn append_character(mut self, ch: char) -> Self::APP {
|
|
||||||
self.state.string.push(ch);
|
|
||||||
let index = self.inner.selection.artist.state.list.selected();
|
|
||||||
self.state.memo.push(AppSearchMemo { index, char: true });
|
|
||||||
self.incremental_search(false);
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn search_next(mut self) -> Self::APP {
|
|
||||||
if !self.state.string.is_empty() {
|
|
||||||
let index = self.inner.selection.artist.state.list.selected();
|
|
||||||
self.state.memo.push(AppSearchMemo { index, char: false });
|
|
||||||
self.incremental_search(true);
|
|
||||||
}
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn step_back(mut self) -> Self::APP {
|
|
||||||
let collection = self.inner.music_hoard.get_collection();
|
|
||||||
if let Some(memo) = self.state.memo.pop() {
|
|
||||||
if memo.char {
|
|
||||||
self.state.string.pop();
|
|
||||||
}
|
|
||||||
self.inner.selection.select_artist(collection, memo.index);
|
|
||||||
}
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish_search(self) -> Self::APP {
|
|
||||||
AppMachine::browse(self.inner).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cancel_search(mut self) -> Self::APP {
|
|
||||||
self.inner.selection.select_by_list(self.state.orig);
|
|
||||||
AppMachine::browse(self.inner).into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn no_op(self) -> Self::APP {
|
|
||||||
self.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trait IAppInteractSearchPrivate {
|
|
||||||
fn incremental_search(&mut self, next: bool);
|
|
||||||
fn incremental_search_predicate(
|
|
||||||
case_sensitive: bool,
|
|
||||||
char_sensitive: bool,
|
|
||||||
search_name: &str,
|
|
||||||
probe: &Artist,
|
|
||||||
) -> bool;
|
|
||||||
|
|
||||||
fn is_case_sensitive(artist_name: &str) -> bool;
|
|
||||||
fn is_char_sensitive(artist_name: &str) -> bool;
|
|
||||||
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<MH: IMusicHoard> IAppInteractSearchPrivate for AppMachine<MH, AppSearch> {
|
|
||||||
fn incremental_search(&mut self, next: bool) {
|
|
||||||
let artists = self.inner.music_hoard.get_collection();
|
|
||||||
let artist_name = &self.state.string;
|
|
||||||
|
|
||||||
let sel = &mut self.inner.selection;
|
|
||||||
if let Some(mut index) = sel.selected_artist() {
|
|
||||||
let case_sensitive = Self::is_case_sensitive(artist_name);
|
|
||||||
let char_sensitive = Self::is_char_sensitive(artist_name);
|
|
||||||
let search = Self::normalize_search(artist_name, !case_sensitive, !char_sensitive);
|
|
||||||
|
|
||||||
if next && ((index + 1) < artists.len()) {
|
|
||||||
index += 1;
|
|
||||||
}
|
|
||||||
let slice = &artists[index..];
|
|
||||||
|
|
||||||
let result = slice.iter().position(|probe| {
|
|
||||||
Self::incremental_search_predicate(case_sensitive, char_sensitive, &search, probe)
|
|
||||||
});
|
|
||||||
|
|
||||||
if let Some(slice_index) = result {
|
|
||||||
sel.select_artist(artists, Some(index + slice_index));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn incremental_search_predicate(
|
|
||||||
case_sensitive: bool,
|
|
||||||
char_sensitive: bool,
|
|
||||||
search_name: &str,
|
|
||||||
probe: &Artist,
|
|
||||||
) -> bool {
|
|
||||||
let name = Self::normalize_search(&probe.id.name, !case_sensitive, !char_sensitive);
|
|
||||||
let mut result = name.starts_with(search_name);
|
|
||||||
|
|
||||||
if let Some(ref probe_sort) = probe.sort {
|
|
||||||
if !result {
|
|
||||||
let name =
|
|
||||||
Self::normalize_search(&probe_sort.name, !case_sensitive, !char_sensitive);
|
|
||||||
result = name.starts_with(search_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_case_sensitive(artist_name: &str) -> bool {
|
|
||||||
artist_name
|
|
||||||
.chars()
|
|
||||||
.any(|ch| ch.is_alphabetic() && ch.is_uppercase())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_char_sensitive(artist_name: &str) -> bool {
|
|
||||||
// Benchmarking reveals that using AhoCorasick is slower. At a guess, this is likely due to
|
|
||||||
// a high constant cost of AhoCorasick and the otherwise simple nature of the task.
|
|
||||||
artist_name.chars().any(|ch| SPECIAL.contains(&ch))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String {
|
|
||||||
if asciify {
|
|
||||||
if lowercase {
|
|
||||||
AC.replace_all(&search.to_lowercase(), &REPLACE)
|
|
||||||
} else {
|
|
||||||
AC.replace_all(search, &REPLACE)
|
|
||||||
}
|
|
||||||
} else if lowercase {
|
|
||||||
search.to_lowercase()
|
|
||||||
} else {
|
|
||||||
search.to_owned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use ratatui::widgets::ListState;
|
|
||||||
|
|
||||||
use crate::tui::{
|
|
||||||
app::machine::tests::{inner, music_hoard},
|
|
||||||
testmod::COLLECTION,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn orig(index: Option<usize>) -> ListSelection {
|
|
||||||
let mut artist = ListState::default();
|
|
||||||
artist.select(index);
|
|
||||||
|
|
||||||
ListSelection {
|
|
||||||
artist,
|
|
||||||
album: ListState::default(),
|
|
||||||
track: ListState::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artist_incremental_search() {
|
|
||||||
// Empty collection.
|
|
||||||
let mut search = AppMachine::search(inner(music_hoard(vec![])), orig(None));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), None);
|
|
||||||
|
|
||||||
search.state.string = String::from("album_artist 'a'");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), None);
|
|
||||||
|
|
||||||
// Basic test, first element.
|
|
||||||
let mut search =
|
|
||||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("album_artist ");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("album_artist 'a'");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
// Basic test, non-first element.
|
|
||||||
let mut search =
|
|
||||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("album_artist ");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("album_artist 'c'");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
// Non-lowercase.
|
|
||||||
let mut search =
|
|
||||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("Album_Artist ");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("Album_Artist 'C'");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
// Non-ascii.
|
|
||||||
let mut search =
|
|
||||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("album_artist ");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("album_artist ‘c’");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
// Non-lowercase, non-ascii.
|
|
||||||
let mut search =
|
|
||||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("Album_Artist ");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("Album_Artist ‘C’");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
// Stop at name, not sort name.
|
|
||||||
let mut search =
|
|
||||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("the ");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
search.state.string = String::from("the album_artist 'c'");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
// Search next with common prefix.
|
|
||||||
let mut search =
|
|
||||||
AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.state.string = String::from("album_artist");
|
|
||||||
search.incremental_search(false);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
search.incremental_search(true);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
search.incremental_search(true);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
search.incremental_search(true);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3));
|
|
||||||
|
|
||||||
search.incremental_search(true);
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn search() {
|
|
||||||
let search = AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
let search = search.append_character('a').unwrap_search();
|
|
||||||
let search = search.append_character('l').unwrap_search();
|
|
||||||
let search = search.append_character('b').unwrap_search();
|
|
||||||
let search = search.append_character('u').unwrap_search();
|
|
||||||
let search = search.append_character('m').unwrap_search();
|
|
||||||
let search = search.append_character('_').unwrap_search();
|
|
||||||
let search = search.append_character('a').unwrap_search();
|
|
||||||
let search = search.append_character('r').unwrap_search();
|
|
||||||
let search = search.append_character('t').unwrap_search();
|
|
||||||
let search = search.append_character('i').unwrap_search();
|
|
||||||
let search = search.append_character('s').unwrap_search();
|
|
||||||
let search = search.append_character('t').unwrap_search();
|
|
||||||
let search = search.append_character(' ').unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
let search = search.append_character('\'').unwrap_search();
|
|
||||||
let search = search.append_character('c').unwrap_search();
|
|
||||||
let search = search.append_character('\'').unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
let search = search.append_character('\'').unwrap_search();
|
|
||||||
let search = search.append_character('b').unwrap_search();
|
|
||||||
let search = search.append_character('\'').unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
let app = search.finish_search();
|
|
||||||
let browse = app.unwrap_browse();
|
|
||||||
assert_eq!(browse.inner.selection.artist.state.list.selected(), Some(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn search_next_step_back() {
|
|
||||||
let search = AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
let search = search.append_character('a').unwrap_search();
|
|
||||||
let search = search.search_next().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
let search = search.search_next().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
let search = search.search_next().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3));
|
|
||||||
|
|
||||||
let search = search.search_next().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3));
|
|
||||||
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(3));
|
|
||||||
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cancel_search() {
|
|
||||||
let search = AppMachine::search(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
let search = search.append_character('a').unwrap_search();
|
|
||||||
let search = search.append_character('l').unwrap_search();
|
|
||||||
let search = search.append_character('b').unwrap_search();
|
|
||||||
let search = search.append_character('u').unwrap_search();
|
|
||||||
let search = search.append_character('m').unwrap_search();
|
|
||||||
let search = search.append_character('_').unwrap_search();
|
|
||||||
let search = search.append_character('a').unwrap_search();
|
|
||||||
let search = search.append_character('r').unwrap_search();
|
|
||||||
let search = search.append_character('t').unwrap_search();
|
|
||||||
let search = search.append_character('i').unwrap_search();
|
|
||||||
let search = search.append_character('s').unwrap_search();
|
|
||||||
let search = search.append_character('t').unwrap_search();
|
|
||||||
let search = search.append_character(' ').unwrap_search();
|
|
||||||
let search = search.append_character('\'').unwrap_search();
|
|
||||||
let search = search.append_character('b').unwrap_search();
|
|
||||||
let search = search.append_character('\'').unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
let browse = search.cancel_search().unwrap_browse();
|
|
||||||
assert_eq!(browse.inner.selection.artist.state.list.selected(), Some(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty_search() {
|
|
||||||
let search = AppMachine::search(inner(music_hoard(vec![])), orig(None));
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), None);
|
|
||||||
|
|
||||||
let search = search.append_character('a').unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), None);
|
|
||||||
|
|
||||||
let search = search.search_next().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), None);
|
|
||||||
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), None);
|
|
||||||
|
|
||||||
let search = search.step_back().unwrap_search();
|
|
||||||
assert_eq!(search.inner.selection.artist.state.list.selected(), None);
|
|
||||||
|
|
||||||
let browse = search.cancel_search().unwrap_browse();
|
|
||||||
assert_eq!(browse.inner.selection.artist.state.list.selected(), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn no_op() {
|
|
||||||
let search = AppMachine::search(inner(music_hoard(vec![])), orig(None));
|
|
||||||
let app = search.no_op();
|
|
||||||
app.unwrap_search();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(nightly)]
|
|
||||||
#[cfg(test)]
|
|
||||||
mod benches {
|
|
||||||
// The purpose of these benches was to evaluate the benefit of AhoCorasick over std solutions.
|
|
||||||
use test::Bencher;
|
|
||||||
|
|
||||||
use crate::tui::{app::machine::benchmod::ARTISTS, lib::MockIMusicHoard};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
type Search = AppMachine<MockIMusicHoard, AppSearch>;
|
|
||||||
|
|
||||||
#[bench]
|
|
||||||
fn is_char_sensitive(b: &mut Bencher) {
|
|
||||||
let mut iter = ARTISTS.iter().cycle();
|
|
||||||
b.iter(|| test::black_box(Search::is_char_sensitive(&iter.next().unwrap())))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[bench]
|
|
||||||
fn normalize_search(b: &mut Bencher) {
|
|
||||||
let mut iter = ARTISTS.iter().cycle();
|
|
||||||
b.iter(|| test::black_box(Search::normalize_search(&iter.next().unwrap(), true, true)))
|
|
||||||
}
|
|
||||||
}
|
|
571
src/tui/app/machine/search_state.rs
Normal file
571
src/tui/app/machine/search_state.rs
Normal file
@ -0,0 +1,571 @@
|
|||||||
|
use aho_corasick::AhoCorasick;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
|
use musichoard::collection::{album::Album, artist::Artist, track::Track};
|
||||||
|
|
||||||
|
use crate::tui::app::{
|
||||||
|
machine::{App, AppInner, AppMachine},
|
||||||
|
selection::{ListSelection, SelectionState},
|
||||||
|
AppPublicState, AppState, Category, IAppInteractSearch,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unlikely that this covers all possible strings, but it should at least cover strings
|
||||||
|
// relevant for music (at least in English). The list of characters handled is based on
|
||||||
|
// https://wiki.musicbrainz.org/User:Yurim/Punctuation_and_Special_Characters.
|
||||||
|
//
|
||||||
|
// U+2010 hyphen, U+2012 figure dash, U+2013 en dash, U+2014 em dash, U+2015 horizontal bar, U+2018,
|
||||||
|
// U+2019, U+201C, U+201D, U+2026, U+2212 minus sign
|
||||||
|
const SPECIAL: [char; 11] = ['‐', '‒', '–', '—', '―', '‘', '’', '“', '”', '…', '−'];
|
||||||
|
const REPLACE: [&str; 11] = ["-", "-", "-", "-", "-", "'", "'", "\"", "\"", "...", "-"];
|
||||||
|
static AC: Lazy<AhoCorasick> =
|
||||||
|
Lazy::new(|| AhoCorasick::new(SPECIAL.map(|ch| ch.to_string())).unwrap());
|
||||||
|
|
||||||
|
pub struct SearchState {
|
||||||
|
string: String,
|
||||||
|
orig: ListSelection,
|
||||||
|
memo: Vec<SearchStateMemo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SearchStateMemo {
|
||||||
|
index: Option<usize>,
|
||||||
|
char: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SearchState {
|
||||||
|
fn new(orig: ListSelection) -> Self {
|
||||||
|
SearchState {
|
||||||
|
string: String::new(),
|
||||||
|
orig,
|
||||||
|
memo: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppMachine<SearchState> {
|
||||||
|
pub fn search_state(inner: AppInner, orig: ListSelection) -> Self {
|
||||||
|
AppMachine::new(inner, SearchState::new(orig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMachine<SearchState>> for App {
|
||||||
|
fn from(machine: AppMachine<SearchState>) -> Self {
|
||||||
|
AppState::Search(machine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<&'a mut SearchState> for AppPublicState<'a> {
|
||||||
|
fn from(state: &'a mut SearchState) -> Self {
|
||||||
|
AppState::Search(&state.string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppInteractSearch for AppMachine<SearchState> {
|
||||||
|
type APP = App;
|
||||||
|
|
||||||
|
fn append_character(mut self, ch: char) -> Self::APP {
|
||||||
|
self.state.string.push(ch);
|
||||||
|
let index = self.inner.selection.selected();
|
||||||
|
self.state.memo.push(SearchStateMemo { index, char: true });
|
||||||
|
self.incremental_search(false);
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_next(mut self) -> Self::APP {
|
||||||
|
if !self.state.string.is_empty() {
|
||||||
|
let index = self.inner.selection.selected();
|
||||||
|
self.state.memo.push(SearchStateMemo { index, char: false });
|
||||||
|
self.incremental_search(true);
|
||||||
|
}
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step_back(mut self) -> Self::APP {
|
||||||
|
let collection = self.inner.music_hoard.get_collection();
|
||||||
|
if let Some(memo) = self.state.memo.pop() {
|
||||||
|
if memo.char {
|
||||||
|
self.state.string.pop();
|
||||||
|
}
|
||||||
|
self.inner.selection.select(collection, memo.index);
|
||||||
|
}
|
||||||
|
self.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_search(self) -> Self::APP {
|
||||||
|
AppMachine::browse_state(self.inner).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_search(mut self) -> Self::APP {
|
||||||
|
self.inner.selection.select_by_list(self.state.orig);
|
||||||
|
AppMachine::browse_state(self.inner).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait IAppInteractSearchPrivate {
|
||||||
|
fn incremental_search(&mut self, next: bool);
|
||||||
|
fn next<P, T>(pred: P, name: &str, next: bool, st: SelectionState<'_, T>) -> Option<usize>
|
||||||
|
where
|
||||||
|
P: FnMut(bool, bool, &str, &T) -> bool;
|
||||||
|
|
||||||
|
fn search_artists(name: &str, next: bool, st: SelectionState<'_, Artist>) -> Option<usize>;
|
||||||
|
fn search_albums(name: &str, next: bool, st: SelectionState<'_, Album>) -> Option<usize>;
|
||||||
|
fn search_tracks(name: &str, next: bool, st: SelectionState<'_, Track>) -> Option<usize>;
|
||||||
|
|
||||||
|
fn predicate_artists(case_sens: bool, char_sens: bool, search: &str, probe: &Artist) -> bool;
|
||||||
|
fn predicate_albums(case_sens: bool, char_sens: bool, search: &str, probe: &Album) -> bool;
|
||||||
|
fn predicate_tracks(case_sens: bool, char_sens: bool, search: &str, probe: &Track) -> bool;
|
||||||
|
fn predicate_title(case_sens: bool, char_sens: bool, search: &str, title: &str) -> bool;
|
||||||
|
|
||||||
|
fn is_case_sensitive(artist_name: &str) -> bool;
|
||||||
|
fn is_char_sensitive(artist_name: &str) -> bool;
|
||||||
|
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IAppInteractSearchPrivate for AppMachine<SearchState> {
|
||||||
|
fn incremental_search(&mut self, next: bool) {
|
||||||
|
let collection = self.inner.music_hoard.get_collection();
|
||||||
|
let search = &self.state.string;
|
||||||
|
|
||||||
|
let sel = &self.inner.selection;
|
||||||
|
let result = match sel.category() {
|
||||||
|
Category::Artist => sel
|
||||||
|
.state_artist(collection)
|
||||||
|
.and_then(|state| Self::search_artists(search, next, state)),
|
||||||
|
Category::Album => sel
|
||||||
|
.state_album(collection)
|
||||||
|
.and_then(|state| Self::search_albums(search, next, state)),
|
||||||
|
Category::Track => sel
|
||||||
|
.state_track(collection)
|
||||||
|
.and_then(|state| Self::search_tracks(search, next, state)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if result.is_some() {
|
||||||
|
let collection = self.inner.music_hoard.get_collection();
|
||||||
|
self.inner.selection.select(collection, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_artists(name: &str, next: bool, st: SelectionState<'_, Artist>) -> Option<usize> {
|
||||||
|
Self::next(Self::predicate_artists, name, next, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_albums(name: &str, next: bool, st: SelectionState<'_, Album>) -> Option<usize> {
|
||||||
|
Self::next(Self::predicate_albums, name, next, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_tracks(name: &str, next: bool, st: SelectionState<'_, Track>) -> Option<usize> {
|
||||||
|
Self::next(Self::predicate_tracks, name, next, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next<P, T>(mut pred: P, name: &str, next: bool, st: SelectionState<'_, T>) -> Option<usize>
|
||||||
|
where
|
||||||
|
P: FnMut(bool, bool, &str, &T) -> bool,
|
||||||
|
{
|
||||||
|
let case_sens = Self::is_case_sensitive(name);
|
||||||
|
let char_sens = Self::is_char_sensitive(name);
|
||||||
|
let search = Self::normalize_search(name, !case_sens, !char_sens);
|
||||||
|
|
||||||
|
let mut index = st.index;
|
||||||
|
if next && ((index + 1) < st.list.len()) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slice = &st.list[index..];
|
||||||
|
slice
|
||||||
|
.iter()
|
||||||
|
.position(|probe| pred(case_sens, char_sens, &search, probe))
|
||||||
|
.map(|slice_index| index + slice_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn predicate_artists(case_sens: bool, char_sens: bool, search: &str, probe: &Artist) -> bool {
|
||||||
|
let name = Self::normalize_search(&probe.meta.id.name, !case_sens, !char_sens);
|
||||||
|
let mut result = name.starts_with(search);
|
||||||
|
|
||||||
|
if let Some(ref probe_sort) = probe.meta.sort {
|
||||||
|
if !result {
|
||||||
|
let name = Self::normalize_search(probe_sort, !case_sens, !char_sens);
|
||||||
|
result = name.starts_with(search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn predicate_albums(case_sens: bool, char_sens: bool, search: &str, probe: &Album) -> bool {
|
||||||
|
Self::predicate_title(case_sens, char_sens, search, &probe.meta.id.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn predicate_tracks(case_sens: bool, char_sens: bool, search: &str, probe: &Track) -> bool {
|
||||||
|
Self::predicate_title(case_sens, char_sens, search, &probe.id.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn predicate_title(case_sens: bool, char_sens: bool, search: &str, title: &str) -> bool {
|
||||||
|
Self::normalize_search(title, !case_sens, !char_sens).starts_with(search)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_case_sensitive(artist_name: &str) -> bool {
|
||||||
|
artist_name
|
||||||
|
.chars()
|
||||||
|
.any(|ch| ch.is_alphabetic() && ch.is_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_char_sensitive(artist_name: &str) -> bool {
|
||||||
|
// Benchmarking reveals that using AhoCorasick is slower. At a guess, this is likely due to
|
||||||
|
// a high constant cost of AhoCorasick and the otherwise simple nature of the task.
|
||||||
|
artist_name.chars().any(|ch| SPECIAL.contains(&ch))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String {
|
||||||
|
if asciify {
|
||||||
|
if lowercase {
|
||||||
|
AC.replace_all(&search.to_lowercase(), &REPLACE)
|
||||||
|
} else {
|
||||||
|
AC.replace_all(search, &REPLACE)
|
||||||
|
}
|
||||||
|
} else if lowercase {
|
||||||
|
search.to_lowercase()
|
||||||
|
} else {
|
||||||
|
search.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use ratatui::widgets::ListState;
|
||||||
|
|
||||||
|
use crate::tui::{
|
||||||
|
app::machine::tests::{inner, music_hoard},
|
||||||
|
testmod::COLLECTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn orig(index: Option<usize>) -> ListSelection {
|
||||||
|
let mut artist = ListState::default();
|
||||||
|
artist.select(index);
|
||||||
|
|
||||||
|
ListSelection {
|
||||||
|
artist,
|
||||||
|
album: ListState::default(),
|
||||||
|
track: ListState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_incremental_search() {
|
||||||
|
// Empty collection.
|
||||||
|
let mut search = AppMachine::search_state(inner(music_hoard(vec![])), orig(None));
|
||||||
|
assert_eq!(search.inner.selection.selected(), None);
|
||||||
|
|
||||||
|
search.state.string = String::from("album_artist 'a'");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), None);
|
||||||
|
|
||||||
|
// Basic test, first element.
|
||||||
|
let mut search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("album_artist ");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("album_artist 'a'");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
// Basic test, non-first element.
|
||||||
|
let mut search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("album_artist ");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("album_artist 'c'");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||||
|
|
||||||
|
// Non-lowercase.
|
||||||
|
let mut search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("Album_Artist ");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("Album_Artist 'C'");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||||
|
|
||||||
|
// Non-ascii.
|
||||||
|
let mut search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("album_artist ");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("album_artist ‘c’");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||||
|
|
||||||
|
// Non-lowercase, non-ascii.
|
||||||
|
let mut search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("Album_Artist ");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("Album_Artist ‘C’");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||||
|
|
||||||
|
// Stop at name, not sort name.
|
||||||
|
let mut search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("the ");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||||
|
|
||||||
|
search.state.string = String::from("the album_artist 'c'");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||||
|
|
||||||
|
// Search next with common prefix.
|
||||||
|
let mut search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("album_artist");
|
||||||
|
search.incremental_search(false);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
search.incremental_search(true);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(1));
|
||||||
|
|
||||||
|
search.incremental_search(true);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||||
|
|
||||||
|
search.incremental_search(true);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(3));
|
||||||
|
|
||||||
|
search.incremental_search(true);
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_incremental_search() {
|
||||||
|
let mut search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||||
|
search.inner.selection.increment_category();
|
||||||
|
assert_eq!(search.inner.selection.category(), Category::Album);
|
||||||
|
|
||||||
|
let sel = &search.inner.selection;
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("album_title ");
|
||||||
|
search.incremental_search(false);
|
||||||
|
|
||||||
|
let sel = &search.inner.selection;
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
let search = search.append_character('a').unwrap_search();
|
||||||
|
let search = search.append_character('.').unwrap_search();
|
||||||
|
let search = search.append_character('b').unwrap_search();
|
||||||
|
|
||||||
|
let sel = &search.inner.selection;
|
||||||
|
assert_eq!(sel.selected(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_incremental_search() {
|
||||||
|
let mut search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||||
|
search.inner.selection.increment_category();
|
||||||
|
search.inner.selection.increment_category();
|
||||||
|
assert_eq!(search.inner.selection.category(), Category::Track);
|
||||||
|
|
||||||
|
let sel = &search.inner.selection;
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
search.state.string = String::from("track ");
|
||||||
|
search.incremental_search(false);
|
||||||
|
|
||||||
|
let sel = &search.inner.selection;
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
let search = search.append_character('a').unwrap_search();
|
||||||
|
let search = search.append_character('.').unwrap_search();
|
||||||
|
let search = search.append_character('a').unwrap_search();
|
||||||
|
let search = search.append_character('.').unwrap_search();
|
||||||
|
let search = search.append_character('2').unwrap_search();
|
||||||
|
|
||||||
|
let sel = &search.inner.selection;
|
||||||
|
assert_eq!(sel.selected(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search() {
|
||||||
|
let search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
let search = search.append_character('a').unwrap_search();
|
||||||
|
let search = search.append_character('l').unwrap_search();
|
||||||
|
let search = search.append_character('b').unwrap_search();
|
||||||
|
let search = search.append_character('u').unwrap_search();
|
||||||
|
let search = search.append_character('m').unwrap_search();
|
||||||
|
let search = search.append_character('_').unwrap_search();
|
||||||
|
let search = search.append_character('a').unwrap_search();
|
||||||
|
let search = search.append_character('r').unwrap_search();
|
||||||
|
let search = search.append_character('t').unwrap_search();
|
||||||
|
let search = search.append_character('i').unwrap_search();
|
||||||
|
let search = search.append_character('s').unwrap_search();
|
||||||
|
let search = search.append_character('t').unwrap_search();
|
||||||
|
let search = search.append_character(' ').unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
let search = search.append_character('\'').unwrap_search();
|
||||||
|
let search = search.append_character('c').unwrap_search();
|
||||||
|
let search = search.append_character('\'').unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||||
|
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
let search = search.append_character('\'').unwrap_search();
|
||||||
|
let search = search.append_character('b').unwrap_search();
|
||||||
|
let search = search.append_character('\'').unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(1));
|
||||||
|
|
||||||
|
let app = search.finish_search();
|
||||||
|
let browse = app.unwrap_browse();
|
||||||
|
assert_eq!(browse.inner.selection.selected(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_next_step_back() {
|
||||||
|
let search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
let search = search.append_character('a').unwrap_search();
|
||||||
|
let search = search.search_next().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(1));
|
||||||
|
|
||||||
|
let search = search.search_next().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||||
|
|
||||||
|
let search = search.search_next().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(3));
|
||||||
|
|
||||||
|
let search = search.search_next().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(3));
|
||||||
|
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(3));
|
||||||
|
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||||
|
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(1));
|
||||||
|
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_search() {
|
||||||
|
let search =
|
||||||
|
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||||
|
|
||||||
|
let search = search.append_character('a').unwrap_search();
|
||||||
|
let search = search.append_character('l').unwrap_search();
|
||||||
|
let search = search.append_character('b').unwrap_search();
|
||||||
|
let search = search.append_character('u').unwrap_search();
|
||||||
|
let search = search.append_character('m').unwrap_search();
|
||||||
|
let search = search.append_character('_').unwrap_search();
|
||||||
|
let search = search.append_character('a').unwrap_search();
|
||||||
|
let search = search.append_character('r').unwrap_search();
|
||||||
|
let search = search.append_character('t').unwrap_search();
|
||||||
|
let search = search.append_character('i').unwrap_search();
|
||||||
|
let search = search.append_character('s').unwrap_search();
|
||||||
|
let search = search.append_character('t').unwrap_search();
|
||||||
|
let search = search.append_character(' ').unwrap_search();
|
||||||
|
let search = search.append_character('\'').unwrap_search();
|
||||||
|
let search = search.append_character('b').unwrap_search();
|
||||||
|
let search = search.append_character('\'').unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), Some(1));
|
||||||
|
|
||||||
|
let browse = search.cancel_search().unwrap_browse();
|
||||||
|
assert_eq!(browse.inner.selection.selected(), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_search() {
|
||||||
|
let search = AppMachine::search_state(inner(music_hoard(vec![])), orig(None));
|
||||||
|
assert_eq!(search.inner.selection.selected(), None);
|
||||||
|
|
||||||
|
let search = search.append_character('a').unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), None);
|
||||||
|
|
||||||
|
let search = search.search_next().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), None);
|
||||||
|
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), None);
|
||||||
|
|
||||||
|
let search = search.step_back().unwrap_search();
|
||||||
|
assert_eq!(search.inner.selection.selected(), None);
|
||||||
|
|
||||||
|
let browse = search.cancel_search().unwrap_browse();
|
||||||
|
assert_eq!(browse.inner.selection.selected(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(nightly)]
|
||||||
|
#[cfg(test)]
|
||||||
|
mod benches {
|
||||||
|
// The purpose of these benches was to evaluate the benefit of AhoCorasick over std solutions.
|
||||||
|
use test::Bencher;
|
||||||
|
|
||||||
|
use crate::tui::{app::machine::benchmod::ARTISTS, lib::MockIMusicHoard};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
type Search = AppMachine<MockIMusicHoard, SearchState>;
|
||||||
|
|
||||||
|
#[bench]
|
||||||
|
fn is_char_sensitive(b: &mut Bencher) {
|
||||||
|
let mut iter = ARTISTS.iter().cycle();
|
||||||
|
b.iter(|| test::black_box(Search::is_char_sensitive(&iter.next().unwrap())))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[bench]
|
||||||
|
fn normalize_search(b: &mut Bencher) {
|
||||||
|
let mut iter = ARTISTS.iter().cycle();
|
||||||
|
b.iter(|| test::black_box(Search::normalize_search(&iter.next().unwrap(), true, true)))
|
||||||
|
}
|
||||||
|
}
|
@ -2,38 +2,73 @@ mod machine;
|
|||||||
mod selection;
|
mod selection;
|
||||||
|
|
||||||
pub use machine::App;
|
pub use machine::App;
|
||||||
pub use selection::{Category, Delta, Selection, WidgetState};
|
use ratatui::widgets::ListState;
|
||||||
|
pub use selection::{Category, Selection};
|
||||||
|
|
||||||
use musichoard::collection::Collection;
|
use musichoard::collection::{
|
||||||
|
album::{AlbumId, AlbumMeta},
|
||||||
|
artist::{ArtistId, ArtistMeta},
|
||||||
|
Collection,
|
||||||
|
};
|
||||||
|
|
||||||
pub enum AppState<BS, IS, RS, SS, ES, CS> {
|
use crate::tui::lib::interface::musicbrainz::api::Entity;
|
||||||
Browse(BS),
|
|
||||||
Info(IS),
|
pub enum AppState<B, I, R, S, F, M, E, C> {
|
||||||
Reload(RS),
|
Browse(B),
|
||||||
Search(SS),
|
Info(I),
|
||||||
Error(ES),
|
Reload(R),
|
||||||
Critical(CS),
|
Search(S),
|
||||||
|
Fetch(F),
|
||||||
|
Match(M),
|
||||||
|
Error(E),
|
||||||
|
Critical(C),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait IAppInteract {
|
pub enum AppMode<StateMode, InputMode> {
|
||||||
type BS: IAppInteractBrowse<APP = Self>;
|
State(StateMode),
|
||||||
type IS: IAppInteractInfo<APP = Self>;
|
Input(InputMode),
|
||||||
type RS: IAppInteractReload<APP = Self>;
|
}
|
||||||
type SS: IAppInteractSearch<APP = Self>;
|
|
||||||
type ES: IAppInteractError<APP = Self>;
|
macro_rules! IAppState {
|
||||||
type CS: IAppInteractCritical<APP = Self>;
|
() => {
|
||||||
|
AppState<Self::BrowseState, Self::InfoState, Self::ReloadState, Self::SearchState,
|
||||||
|
Self::FetchState, Self::MatchState, Self::ErrorState, Self::CriticalState>
|
||||||
|
};
|
||||||
|
}
|
||||||
|
use IAppState;
|
||||||
|
|
||||||
|
pub trait IApp {
|
||||||
|
type BrowseState: IAppBase<APP = Self> + IAppInteractBrowse<APP = Self>;
|
||||||
|
type InfoState: IAppBase<APP = Self> + IAppInteractInfo<APP = Self>;
|
||||||
|
type ReloadState: IAppBase<APP = Self> + IAppInteractReload<APP = Self>;
|
||||||
|
type SearchState: IAppBase<APP = Self> + IAppInteractSearch<APP = Self>;
|
||||||
|
type FetchState: IAppBase<APP = Self>
|
||||||
|
+ IAppInteractFetch<APP = Self>
|
||||||
|
+ IAppEventFetch<APP = Self>;
|
||||||
|
type MatchState: IAppBase<APP = Self> + IAppInteractMatch<APP = Self>;
|
||||||
|
type ErrorState: IAppBase<APP = Self> + IAppInteractError<APP = Self>;
|
||||||
|
type CriticalState: IAppBase<APP = Self>;
|
||||||
|
type InputMode: IAppInput<APP = Self>;
|
||||||
|
|
||||||
fn is_running(&self) -> bool;
|
fn is_running(&self) -> bool;
|
||||||
fn force_quit(self) -> Self;
|
fn force_quit(self) -> Self;
|
||||||
|
|
||||||
|
fn state(self) -> IAppState!();
|
||||||
|
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
fn state(self) -> AppState<Self::BS, Self::IS, Self::RS, Self::SS, Self::ES, Self::CS>;
|
fn mode(self) -> AppMode<IAppState!(), Self::InputMode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IAppBase {
|
||||||
|
type APP: IApp;
|
||||||
|
|
||||||
|
fn no_op(self) -> Self::APP;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait IAppInteractBrowse {
|
pub trait IAppInteractBrowse {
|
||||||
type APP: IAppInteract;
|
type APP: IApp;
|
||||||
|
|
||||||
fn save_and_quit(self) -> Self::APP;
|
fn quit(self) -> Self::APP;
|
||||||
|
|
||||||
fn increment_category(self) -> Self::APP;
|
fn increment_category(self) -> Self::APP;
|
||||||
fn decrement_category(self) -> Self::APP;
|
fn decrement_category(self) -> Self::APP;
|
||||||
@ -46,49 +81,115 @@ pub trait IAppInteractBrowse {
|
|||||||
|
|
||||||
fn begin_search(self) -> Self::APP;
|
fn begin_search(self) -> Self::APP;
|
||||||
|
|
||||||
fn no_op(self) -> Self::APP;
|
fn fetch_musicbrainz(self) -> Self::APP;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait IAppInteractInfo {
|
pub trait IAppInteractInfo {
|
||||||
type APP: IAppInteract;
|
type APP: IApp;
|
||||||
|
|
||||||
fn hide_info_overlay(self) -> Self::APP;
|
fn hide_info_overlay(self) -> Self::APP;
|
||||||
|
|
||||||
fn no_op(self) -> Self::APP;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait IAppInteractReload {
|
pub trait IAppInteractReload {
|
||||||
type APP: IAppInteract;
|
type APP: IApp;
|
||||||
|
|
||||||
fn reload_library(self) -> Self::APP;
|
fn reload_library(self) -> Self::APP;
|
||||||
fn reload_database(self) -> Self::APP;
|
fn reload_database(self) -> Self::APP;
|
||||||
fn hide_reload_menu(self) -> Self::APP;
|
fn hide_reload_menu(self) -> Self::APP;
|
||||||
|
|
||||||
fn no_op(self) -> Self::APP;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait IAppInteractSearch {
|
pub trait IAppInteractSearch {
|
||||||
type APP: IAppInteract;
|
type APP: IApp;
|
||||||
|
|
||||||
fn append_character(self, ch: char) -> Self::APP;
|
fn append_character(self, ch: char) -> Self::APP;
|
||||||
fn search_next(self) -> Self::APP;
|
fn search_next(self) -> Self::APP;
|
||||||
fn step_back(self) -> Self::APP;
|
fn step_back(self) -> Self::APP;
|
||||||
fn finish_search(self) -> Self::APP;
|
fn finish_search(self) -> Self::APP;
|
||||||
fn cancel_search(self) -> Self::APP;
|
fn cancel_search(self) -> Self::APP;
|
||||||
|
}
|
||||||
|
|
||||||
fn no_op(self) -> Self::APP;
|
pub trait IAppInteractFetch {
|
||||||
|
type APP: IApp;
|
||||||
|
|
||||||
|
fn abort(self) -> Self::APP;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IAppEventFetch {
|
||||||
|
type APP: IApp;
|
||||||
|
|
||||||
|
fn fetch_result_ready(self) -> Self::APP;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IAppInteractMatch {
|
||||||
|
type APP: IApp;
|
||||||
|
|
||||||
|
fn decrement_match(self, delta: Delta) -> Self::APP;
|
||||||
|
fn increment_match(self, delta: Delta) -> Self::APP;
|
||||||
|
fn select(self) -> Self::APP;
|
||||||
|
|
||||||
|
fn abort(self) -> Self::APP;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InputEvent(crossterm::event::KeyEvent);
|
||||||
|
|
||||||
|
impl From<crossterm::event::KeyEvent> for InputEvent {
|
||||||
|
fn from(value: crossterm::event::KeyEvent) -> Self {
|
||||||
|
InputEvent(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<InputEvent> for crossterm::event::KeyEvent {
|
||||||
|
fn from(value: InputEvent) -> Self {
|
||||||
|
value.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IAppInput {
|
||||||
|
type APP: IApp;
|
||||||
|
|
||||||
|
fn input(self, input: InputEvent) -> Self::APP;
|
||||||
|
fn confirm(self) -> Self::APP;
|
||||||
|
fn cancel(self) -> Self::APP;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait IAppInteractError {
|
pub trait IAppInteractError {
|
||||||
type APP: IAppInteract;
|
type APP: IApp;
|
||||||
|
|
||||||
fn dismiss_error(self) -> Self::APP;
|
fn dismiss_error(self) -> Self::APP;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait IAppInteractCritical {
|
#[derive(Clone, Debug, Default)]
|
||||||
type APP: IAppInteract;
|
pub struct WidgetState {
|
||||||
|
pub list: ListState,
|
||||||
|
pub height: usize,
|
||||||
|
}
|
||||||
|
|
||||||
fn no_op(self) -> Self::APP;
|
impl PartialEq for WidgetState {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.list.selected().eq(&other.list.selected()) && self.height.eq(&other.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WidgetState {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn with_selected(mut self, selected: Option<usize>) -> Self {
|
||||||
|
self.list = self.list.with_selected(selected);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Delta {
|
||||||
|
Line,
|
||||||
|
Page,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Delta {
|
||||||
|
fn as_usize(&self, state: &WidgetState) -> usize {
|
||||||
|
match self {
|
||||||
|
Delta::Line => 1,
|
||||||
|
Delta::Page => state.height.saturating_sub(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// It would be preferable to have a getter for each field separately. However, the selection field
|
// It would be preferable to have a getter for each field separately. However, the selection field
|
||||||
@ -102,6 +203,7 @@ pub trait IAppAccess {
|
|||||||
pub struct AppPublic<'app> {
|
pub struct AppPublic<'app> {
|
||||||
pub inner: AppPublicInner<'app>,
|
pub inner: AppPublicInner<'app>,
|
||||||
pub state: AppPublicState<'app>,
|
pub state: AppPublicState<'app>,
|
||||||
|
pub input: Option<InputPublic<'app>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppPublicInner<'app> {
|
pub struct AppPublicInner<'app> {
|
||||||
@ -109,9 +211,90 @@ pub struct AppPublicInner<'app> {
|
|||||||
pub selection: &'app mut Selection,
|
pub selection: &'app mut Selection,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type AppPublicState<'app> = AppState<(), (), (), &'app str, &'app str, &'app str>;
|
pub type InputPublic<'app> = &'app tui_input::Input;
|
||||||
|
|
||||||
impl<BS, IS, RS, SS, ES, CS> AppState<BS, IS, RS, SS, ES, CS> {
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum MatchOption<T> {
|
||||||
|
Some(Entity<T>),
|
||||||
|
CannotHaveMbid,
|
||||||
|
ManualInputMbid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> From<Entity<T>> for MatchOption<T> {
|
||||||
|
fn from(value: Entity<T>) -> Self {
|
||||||
|
MatchOption::Some(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ArtistMatches {
|
||||||
|
pub matching: ArtistMeta,
|
||||||
|
pub list: Vec<MatchOption<ArtistMeta>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AlbumMatches {
|
||||||
|
pub artist: ArtistId,
|
||||||
|
pub matching: AlbumId,
|
||||||
|
pub list: Vec<MatchOption<AlbumMeta>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum EntityMatches {
|
||||||
|
Artist(ArtistMatches),
|
||||||
|
Album(AlbumMatches),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EntityMatches {
|
||||||
|
pub fn artist_search<M: Into<MatchOption<ArtistMeta>>>(
|
||||||
|
matching: ArtistMeta,
|
||||||
|
list: Vec<M>,
|
||||||
|
) -> Self {
|
||||||
|
let list = list.into_iter().map(Into::into).collect();
|
||||||
|
EntityMatches::Artist(ArtistMatches { matching, list })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn album_search<M: Into<MatchOption<AlbumMeta>>>(
|
||||||
|
artist: ArtistId,
|
||||||
|
matching: AlbumId,
|
||||||
|
list: Vec<M>,
|
||||||
|
) -> Self {
|
||||||
|
let list = list.into_iter().map(Into::into).collect();
|
||||||
|
EntityMatches::Album(AlbumMatches {
|
||||||
|
artist,
|
||||||
|
matching,
|
||||||
|
list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn artist_lookup<M: Into<MatchOption<ArtistMeta>>>(matching: ArtistMeta, item: M) -> Self {
|
||||||
|
let list = vec![item.into()];
|
||||||
|
EntityMatches::Artist(ArtistMatches { matching, list })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn album_lookup<M: Into<MatchOption<AlbumMeta>>>(
|
||||||
|
artist: ArtistId,
|
||||||
|
matching: AlbumId,
|
||||||
|
item: M,
|
||||||
|
) -> Self {
|
||||||
|
let list = vec![item.into()];
|
||||||
|
EntityMatches::Album(AlbumMatches {
|
||||||
|
artist,
|
||||||
|
matching,
|
||||||
|
list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MatchStatePublic<'app> {
|
||||||
|
pub matches: &'app EntityMatches,
|
||||||
|
pub state: &'app mut WidgetState,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type AppPublicState<'app> =
|
||||||
|
AppState<(), (), (), &'app str, (), MatchStatePublic<'app>, &'app str, &'app str>;
|
||||||
|
|
||||||
|
impl<B, I, R, S, F, M, E, C> AppState<B, I, R, S, F, M, E, C> {
|
||||||
pub fn is_search(&self) -> bool {
|
pub fn is_search(&self) -> bool {
|
||||||
matches!(self, AppState::Search(_))
|
matches!(self, AppState::Search(_))
|
||||||
}
|
}
|
||||||
|
@ -1,931 +0,0 @@
|
|||||||
use musichoard::collection::{
|
|
||||||
album::{Album, AlbumId},
|
|
||||||
artist::{Artist, ArtistId},
|
|
||||||
track::{Track, TrackId},
|
|
||||||
Collection,
|
|
||||||
};
|
|
||||||
use ratatui::widgets::ListState;
|
|
||||||
use std::cmp;
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum Category {
|
|
||||||
Artist,
|
|
||||||
Album,
|
|
||||||
Track,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct WidgetState {
|
|
||||||
pub list: ListState,
|
|
||||||
pub height: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PartialEq for WidgetState {
|
|
||||||
fn eq(&self, other: &Self) -> bool {
|
|
||||||
self.list.selected().eq(&other.list.selected()) && self.height.eq(&other.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Selection {
|
|
||||||
pub active: Category,
|
|
||||||
pub artist: ArtistSelection,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct ArtistSelection {
|
|
||||||
pub state: WidgetState,
|
|
||||||
pub album: AlbumSelection,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct AlbumSelection {
|
|
||||||
pub state: WidgetState,
|
|
||||||
pub track: TrackSelection,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
|
||||||
pub struct TrackSelection {
|
|
||||||
pub state: WidgetState,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Delta {
|
|
||||||
Line,
|
|
||||||
Page,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Delta {
|
|
||||||
fn as_usize(&self, state: &WidgetState) -> usize {
|
|
||||||
match self {
|
|
||||||
Delta::Line => 1,
|
|
||||||
Delta::Page => state.height.saturating_sub(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Selection {
|
|
||||||
pub fn new(artists: &[Artist]) -> Self {
|
|
||||||
Selection {
|
|
||||||
active: Category::Artist,
|
|
||||||
artist: ArtistSelection::initialise(artists),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_by_list(&mut self, selected: ListSelection) {
|
|
||||||
self.artist.state.list = selected.artist;
|
|
||||||
self.artist.album.state.list = selected.album;
|
|
||||||
self.artist.album.track.state.list = selected.track;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_by_id(&mut self, artists: &[Artist], selected: IdSelection) {
|
|
||||||
self.artist.reinitialise(artists, selected.artist);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_artist(&mut self, artists: &[Artist], index: Option<usize>) {
|
|
||||||
self.artist.select(artists, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn selected_artist(&self) -> Option<usize> {
|
|
||||||
self.artist.selected()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset_artist(&mut self, artists: &[Artist]) {
|
|
||||||
if self.artist.state.list.selected() != Some(0) {
|
|
||||||
self.select_by_id(artists, IdSelection { artist: None });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn increment_category(&mut self) {
|
|
||||||
self.active = match self.active {
|
|
||||||
Category::Artist => Category::Album,
|
|
||||||
Category::Album => Category::Track,
|
|
||||||
Category::Track => Category::Track,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decrement_category(&mut self) {
|
|
||||||
self.active = match self.active {
|
|
||||||
Category::Artist => Category::Artist,
|
|
||||||
Category::Album => Category::Artist,
|
|
||||||
Category::Track => Category::Album,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn increment_selection(&mut self, collection: &Collection, delta: Delta) {
|
|
||||||
match self.active {
|
|
||||||
Category::Artist => self.increment_artist(collection, delta),
|
|
||||||
Category::Album => self.increment_album(collection, delta),
|
|
||||||
Category::Track => self.increment_track(collection, delta),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decrement_selection(&mut self, collection: &Collection, delta: Delta) {
|
|
||||||
match self.active {
|
|
||||||
Category::Artist => self.decrement_artist(collection, delta),
|
|
||||||
Category::Album => self.decrement_album(collection, delta),
|
|
||||||
Category::Track => self.decrement_track(collection, delta),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_artist(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
self.artist.increment(artists, delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_artist(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
self.artist.decrement(artists, delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_album(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
self.artist.increment_album(artists, delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_album(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
self.artist.decrement_album(artists, delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_track(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
self.artist.increment_track(artists, delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_track(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
self.artist.decrement_track(artists, delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ArtistSelection {
|
|
||||||
fn initialise(artists: &[Artist]) -> Self {
|
|
||||||
let mut selection = ArtistSelection {
|
|
||||||
state: WidgetState::default(),
|
|
||||||
album: AlbumSelection::initialise(&[]),
|
|
||||||
};
|
|
||||||
selection.reinitialise(artists, None);
|
|
||||||
selection
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reinitialise(&mut self, artists: &[Artist], active: Option<IdSelectArtist>) {
|
|
||||||
if let Some(active) = active {
|
|
||||||
let result = artists.binary_search_by(|a| a.get_sort_key().cmp(&active.artist_id));
|
|
||||||
match result {
|
|
||||||
Ok(index) => self.reinitialise_with_index(artists, index, active.album),
|
|
||||||
Err(index) => self.reinitialise_with_index(artists, index, None),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.reinitialise_with_index(artists, 0, None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reinitialise_with_index(
|
|
||||||
&mut self,
|
|
||||||
artists: &[Artist],
|
|
||||||
index: usize,
|
|
||||||
active_album: Option<IdSelectAlbum>,
|
|
||||||
) {
|
|
||||||
if artists.is_empty() {
|
|
||||||
self.state.list.select(None);
|
|
||||||
self.album = AlbumSelection::initialise(&[]);
|
|
||||||
} else if index >= artists.len() {
|
|
||||||
let end = artists.len() - 1;
|
|
||||||
self.state.list.select(Some(end));
|
|
||||||
self.album = AlbumSelection::initialise(&artists[end].albums);
|
|
||||||
} else {
|
|
||||||
self.state.list.select(Some(index));
|
|
||||||
self.album
|
|
||||||
.reinitialise(&artists[index].albums, active_album);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selected(&self) -> Option<usize> {
|
|
||||||
self.state.list.selected()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select(&mut self, artists: &[Artist], to: Option<usize>) {
|
|
||||||
match to {
|
|
||||||
Some(to) => self.select_to(artists, to),
|
|
||||||
None => self.state.list.select(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn select_to(&mut self, artists: &[Artist], mut to: usize) {
|
|
||||||
to = cmp::min(to, artists.len() - 1);
|
|
||||||
if self.state.list.selected() != Some(to) {
|
|
||||||
self.state.list.select(Some(to));
|
|
||||||
self.album = AlbumSelection::initialise(&artists[to].albums);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_by(&mut self, artists: &[Artist], by: usize) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
let result = index.saturating_add(by);
|
|
||||||
self.select_to(artists, result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
self.increment_by(artists, delta.as_usize(&self.state));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_album(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
self.album.increment(&artists[index].albums, delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_track(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
self.album.increment_track(&artists[index].albums, delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_by(&mut self, artists: &[Artist], by: usize) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
let result = index.saturating_sub(by);
|
|
||||||
if self.state.list.selected() != Some(result) {
|
|
||||||
self.state.list.select(Some(result));
|
|
||||||
self.album = AlbumSelection::initialise(&artists[result].albums);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
self.decrement_by(artists, delta.as_usize(&self.state));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_album(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
self.album.decrement(&artists[index].albums, delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_track(&mut self, artists: &[Artist], delta: Delta) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
self.album.decrement_track(&artists[index].albums, delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AlbumSelection {
|
|
||||||
fn initialise(albums: &[Album]) -> Self {
|
|
||||||
let mut selection = AlbumSelection {
|
|
||||||
state: WidgetState::default(),
|
|
||||||
track: TrackSelection::initialise(&[]),
|
|
||||||
};
|
|
||||||
selection.reinitialise(albums, None);
|
|
||||||
selection
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reinitialise(&mut self, albums: &[Album], album: Option<IdSelectAlbum>) {
|
|
||||||
if let Some(album) = album {
|
|
||||||
let result = albums.binary_search_by(|a| a.get_sort_key().cmp(&album.album_id));
|
|
||||||
match result {
|
|
||||||
Ok(index) => self.reinitialise_with_index(albums, index, album.track),
|
|
||||||
Err(index) => self.reinitialise_with_index(albums, index, None),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.reinitialise_with_index(albums, 0, None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reinitialise_with_index(
|
|
||||||
&mut self,
|
|
||||||
albums: &[Album],
|
|
||||||
index: usize,
|
|
||||||
active_track: Option<IdSelectTrack>,
|
|
||||||
) {
|
|
||||||
if albums.is_empty() {
|
|
||||||
self.state.list.select(None);
|
|
||||||
self.track = TrackSelection::initialise(&[]);
|
|
||||||
} else if index >= albums.len() {
|
|
||||||
let end = albums.len() - 1;
|
|
||||||
self.state.list.select(Some(end));
|
|
||||||
self.track = TrackSelection::initialise(&albums[end].tracks);
|
|
||||||
} else {
|
|
||||||
self.state.list.select(Some(index));
|
|
||||||
self.track.reinitialise(&albums[index].tracks, active_track);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_by(&mut self, albums: &[Album], by: usize) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
let mut result = index.saturating_add(by);
|
|
||||||
if result >= albums.len() {
|
|
||||||
result = albums.len() - 1;
|
|
||||||
}
|
|
||||||
if self.state.list.selected() != Some(result) {
|
|
||||||
self.state.list.select(Some(result));
|
|
||||||
self.track = TrackSelection::initialise(&albums[result].tracks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment(&mut self, albums: &[Album], delta: Delta) {
|
|
||||||
self.increment_by(albums, delta.as_usize(&self.state));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_track(&mut self, albums: &[Album], delta: Delta) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
self.track.increment(&albums[index].tracks, delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_by(&mut self, albums: &[Album], by: usize) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
let result = index.saturating_sub(by);
|
|
||||||
if self.state.list.selected() != Some(result) {
|
|
||||||
self.state.list.select(Some(result));
|
|
||||||
self.track = TrackSelection::initialise(&albums[result].tracks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement(&mut self, albums: &[Album], delta: Delta) {
|
|
||||||
self.decrement_by(albums, delta.as_usize(&self.state));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_track(&mut self, albums: &[Album], delta: Delta) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
self.track.decrement(&albums[index].tracks, delta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TrackSelection {
|
|
||||||
fn initialise(tracks: &[Track]) -> Self {
|
|
||||||
let mut selection = TrackSelection {
|
|
||||||
state: WidgetState::default(),
|
|
||||||
};
|
|
||||||
selection.reinitialise(tracks, None);
|
|
||||||
selection
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reinitialise(&mut self, tracks: &[Track], track: Option<IdSelectTrack>) {
|
|
||||||
if let Some(track) = track {
|
|
||||||
let result = tracks.binary_search_by(|t| t.get_sort_key().cmp(&track.track_id));
|
|
||||||
match result {
|
|
||||||
Ok(index) | Err(index) => self.reinitialise_with_index(tracks, index),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.reinitialise_with_index(tracks, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reinitialise_with_index(&mut self, tracks: &[Track], index: usize) {
|
|
||||||
if tracks.is_empty() {
|
|
||||||
self.state.list.select(None);
|
|
||||||
} else if index >= tracks.len() {
|
|
||||||
self.state.list.select(Some(tracks.len() - 1));
|
|
||||||
} else {
|
|
||||||
self.state.list.select(Some(index));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_by(&mut self, tracks: &[Track], by: usize) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
let mut result = index.saturating_add(by);
|
|
||||||
if result >= tracks.len() {
|
|
||||||
result = tracks.len() - 1;
|
|
||||||
}
|
|
||||||
if self.state.list.selected() != Some(result) {
|
|
||||||
self.state.list.select(Some(result));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment(&mut self, tracks: &[Track], delta: Delta) {
|
|
||||||
self.increment_by(tracks, delta.as_usize(&self.state));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement_by(&mut self, _tracks: &[Track], by: usize) {
|
|
||||||
if let Some(index) = self.state.list.selected() {
|
|
||||||
let result = index.saturating_sub(by);
|
|
||||||
if self.state.list.selected() != Some(result) {
|
|
||||||
self.state.list.select(Some(result));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrement(&mut self, tracks: &[Track], delta: Delta) {
|
|
||||||
self.decrement_by(tracks, delta.as_usize(&self.state));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ListSelection {
|
|
||||||
pub artist: ListState,
|
|
||||||
pub album: ListState,
|
|
||||||
pub track: ListState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ListSelection {
|
|
||||||
pub fn get(selection: &Selection) -> Self {
|
|
||||||
ListSelection {
|
|
||||||
artist: selection.artist.state.list.clone(),
|
|
||||||
album: selection.artist.album.state.list.clone(),
|
|
||||||
track: selection.artist.album.track.state.list.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct IdSelection {
|
|
||||||
artist: Option<IdSelectArtist>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct IdSelectArtist {
|
|
||||||
artist_id: ArtistId,
|
|
||||||
album: Option<IdSelectAlbum>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct IdSelectAlbum {
|
|
||||||
album_id: AlbumId,
|
|
||||||
track: Option<IdSelectTrack>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct IdSelectTrack {
|
|
||||||
track_id: TrackId,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IdSelection {
|
|
||||||
pub fn get(collection: &Collection, selection: &Selection) -> Self {
|
|
||||||
IdSelection {
|
|
||||||
artist: IdSelectArtist::get(collection, &selection.artist),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IdSelectArtist {
|
|
||||||
fn get(artists: &[Artist], selection: &ArtistSelection) -> Option<Self> {
|
|
||||||
selection.state.list.selected().map(|index| {
|
|
||||||
let artist = &artists[index];
|
|
||||||
IdSelectArtist {
|
|
||||||
artist_id: artist.get_sort_key().clone(),
|
|
||||||
album: IdSelectAlbum::get(&artist.albums, &selection.album),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IdSelectAlbum {
|
|
||||||
fn get(albums: &[Album], selection: &AlbumSelection) -> Option<Self> {
|
|
||||||
selection.state.list.selected().map(|index| {
|
|
||||||
let album = &albums[index];
|
|
||||||
IdSelectAlbum {
|
|
||||||
album_id: album.get_sort_key().clone(),
|
|
||||||
track: IdSelectTrack::get(&album.tracks, &selection.track),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IdSelectTrack {
|
|
||||||
fn get(tracks: &[Track], selection: &TrackSelection) -> Option<Self> {
|
|
||||||
selection.state.list.selected().map(|index| {
|
|
||||||
let track = &tracks[index];
|
|
||||||
IdSelectTrack {
|
|
||||||
track_id: track.get_sort_key().clone(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::tui::testmod::COLLECTION;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn track_selection() {
|
|
||||||
let tracks = &COLLECTION[0].albums[0].tracks;
|
|
||||||
assert!(tracks.len() > 1);
|
|
||||||
|
|
||||||
let mut empty = TrackSelection::initialise(&[]);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
|
|
||||||
empty.increment(tracks, Delta::Line);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
|
|
||||||
empty.decrement(tracks, Delta::Line);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
|
|
||||||
let mut sel = TrackSelection::initialise(tracks);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.decrement(tracks, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.increment(tracks, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
sel.decrement(tracks, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
for _ in 0..(tracks.len() + 5) {
|
|
||||||
sel.increment(tracks, Delta::Line);
|
|
||||||
}
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(tracks.len() - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn track_delta_page() {
|
|
||||||
let tracks = &COLLECTION[0].albums[0].tracks;
|
|
||||||
assert!(tracks.len() > 1);
|
|
||||||
|
|
||||||
let empty = TrackSelection::initialise(&[]);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
|
|
||||||
let mut sel = TrackSelection::initialise(tracks);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
assert!(tracks.len() >= 4);
|
|
||||||
sel.state.height = 3;
|
|
||||||
|
|
||||||
sel.decrement(tracks, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.increment(tracks, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(2));
|
|
||||||
|
|
||||||
sel.decrement(tracks, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
for _ in 0..(tracks.len() + 5) {
|
|
||||||
sel.increment(tracks, Delta::Page);
|
|
||||||
}
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(tracks.len() - 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn track_reinitialise() {
|
|
||||||
let tracks = &COLLECTION[0].albums[0].tracks;
|
|
||||||
assert!(tracks.len() > 1);
|
|
||||||
|
|
||||||
let mut sel = TrackSelection::initialise(tracks);
|
|
||||||
sel.state.list.select(Some(tracks.len() - 1));
|
|
||||||
|
|
||||||
// Re-initialise.
|
|
||||||
let expected = sel.clone();
|
|
||||||
let active_track = IdSelectTrack::get(tracks, &sel);
|
|
||||||
sel.reinitialise(tracks, active_track);
|
|
||||||
assert_eq!(sel, expected);
|
|
||||||
|
|
||||||
// Re-initialise out-of-bounds.
|
|
||||||
let mut expected = sel.clone();
|
|
||||||
expected.decrement(tracks, Delta::Line);
|
|
||||||
let active_track = IdSelectTrack::get(tracks, &sel);
|
|
||||||
sel.reinitialise(&tracks[..(tracks.len() - 1)], active_track);
|
|
||||||
assert_eq!(sel, expected);
|
|
||||||
|
|
||||||
// Re-initialise empty.
|
|
||||||
let expected = TrackSelection::initialise(&[]);
|
|
||||||
let active_track = IdSelectTrack::get(tracks, &sel);
|
|
||||||
sel.reinitialise(&[], active_track);
|
|
||||||
assert_eq!(sel, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn album_selection() {
|
|
||||||
let albums = &COLLECTION[0].albums;
|
|
||||||
assert!(albums.len() > 1);
|
|
||||||
|
|
||||||
let mut empty = AlbumSelection::initialise(&[]);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
assert_eq!(empty.track.state.list.selected(), None);
|
|
||||||
|
|
||||||
empty.increment(albums, Delta::Line);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
assert_eq!(empty.track.state.list.selected(), None);
|
|
||||||
|
|
||||||
empty.decrement(albums, Delta::Line);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
assert_eq!(empty.track.state.list.selected(), None);
|
|
||||||
|
|
||||||
let mut sel = AlbumSelection::initialise(albums);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.increment_track(albums, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
// Verify that decrement that doesn't change index does not reset track.
|
|
||||||
sel.decrement(albums, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
sel.increment(albums, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.decrement(albums, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
for _ in 0..(albums.len() + 5) {
|
|
||||||
sel.increment(albums, Delta::Line);
|
|
||||||
}
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(albums.len() - 1));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.increment_track(albums, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(albums.len() - 1));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
// Verify that increment that doesn't change index does not reset track.
|
|
||||||
sel.increment(albums, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(albums.len() - 1));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn album_delta_page() {
|
|
||||||
let albums = &COLLECTION[1].albums;
|
|
||||||
assert!(albums.len() > 1);
|
|
||||||
|
|
||||||
let empty = AlbumSelection::initialise(&[]);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
|
|
||||||
let mut sel = AlbumSelection::initialise(albums);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
assert!(albums.len() >= 4);
|
|
||||||
sel.state.height = 3;
|
|
||||||
|
|
||||||
sel.increment_track(albums, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
// Verify that decrement that doesn't change index does not reset track.
|
|
||||||
sel.decrement(albums, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
sel.increment(albums, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(2));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.decrement(albums, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
for _ in 0..(albums.len() + 5) {
|
|
||||||
sel.increment(albums, Delta::Page);
|
|
||||||
}
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(albums.len() - 1));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.increment_track(albums, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(albums.len() - 1));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
// Verify that increment that doesn't change index does not reset track.
|
|
||||||
sel.increment(albums, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(albums.len() - 1));
|
|
||||||
assert_eq!(sel.track.state.list.selected(), Some(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn album_reinitialise() {
|
|
||||||
let albums = &COLLECTION[0].albums;
|
|
||||||
assert!(albums.len() > 1);
|
|
||||||
|
|
||||||
let mut sel = AlbumSelection::initialise(albums);
|
|
||||||
sel.state.list.select(Some(albums.len() - 1));
|
|
||||||
sel.track.state.list.select(Some(1));
|
|
||||||
|
|
||||||
// Re-initialise.
|
|
||||||
let expected = sel.clone();
|
|
||||||
let active_album = IdSelectAlbum::get(albums, &sel);
|
|
||||||
sel.reinitialise(albums, active_album);
|
|
||||||
assert_eq!(sel, expected);
|
|
||||||
|
|
||||||
// Re-initialise out-of-bounds.
|
|
||||||
let mut expected = sel.clone();
|
|
||||||
expected.decrement(albums, Delta::Line);
|
|
||||||
let active_album = IdSelectAlbum::get(albums, &sel);
|
|
||||||
sel.reinitialise(&albums[..(albums.len() - 1)], active_album);
|
|
||||||
assert_eq!(sel, expected);
|
|
||||||
|
|
||||||
// Re-initialise empty.
|
|
||||||
let expected = AlbumSelection::initialise(&[]);
|
|
||||||
let active_album = IdSelectAlbum::get(albums, &sel);
|
|
||||||
sel.reinitialise(&[], active_album);
|
|
||||||
assert_eq!(sel, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artist_selection() {
|
|
||||||
let artists = &COLLECTION;
|
|
||||||
assert!(artists.len() > 1);
|
|
||||||
|
|
||||||
let mut empty = ArtistSelection::initialise(&[]);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
assert_eq!(empty.album.state.list.selected(), None);
|
|
||||||
|
|
||||||
empty.increment(artists, Delta::Line);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
assert_eq!(empty.album.state.list.selected(), None);
|
|
||||||
|
|
||||||
empty.decrement(artists, Delta::Line);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
assert_eq!(empty.album.state.list.selected(), None);
|
|
||||||
|
|
||||||
let mut sel = ArtistSelection::initialise(artists);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.increment_album(artists, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
// Verify that decrement that doesn't change index does not reset album.
|
|
||||||
sel.decrement(artists, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
sel.increment(artists, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.decrement(artists, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
for _ in 0..(artists.len() + 5) {
|
|
||||||
sel.increment(artists, Delta::Line);
|
|
||||||
}
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(artists.len() - 1));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.increment_album(artists, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(artists.len() - 1));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
// Verify that increment that doesn't change index does not reset album.
|
|
||||||
sel.increment(artists, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(artists.len() - 1));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artist_delta_page() {
|
|
||||||
let artists = &COLLECTION;
|
|
||||||
assert!(artists.len() > 1);
|
|
||||||
|
|
||||||
let empty = ArtistSelection::initialise(&[]);
|
|
||||||
assert_eq!(empty.state.list.selected(), None);
|
|
||||||
|
|
||||||
let mut sel = ArtistSelection::initialise(artists);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
assert!(artists.len() >= 4);
|
|
||||||
sel.state.height = 3;
|
|
||||||
|
|
||||||
sel.increment_album(artists, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
// Verify that decrement that doesn't change index does not reset album.
|
|
||||||
sel.decrement(artists, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
sel.increment(artists, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(2));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.decrement(artists, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
for _ in 0..(artists.len() + 5) {
|
|
||||||
sel.increment(artists, Delta::Page);
|
|
||||||
}
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(artists.len() - 1));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
sel.increment_album(artists, Delta::Line);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(artists.len() - 1));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
// Verify that increment that doesn't change index does not reset album.
|
|
||||||
sel.increment(artists, Delta::Page);
|
|
||||||
assert_eq!(sel.state.list.selected(), Some(artists.len() - 1));
|
|
||||||
assert_eq!(sel.album.state.list.selected(), Some(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn artist_reinitialise() {
|
|
||||||
let artists = &COLLECTION;
|
|
||||||
assert!(artists.len() > 1);
|
|
||||||
|
|
||||||
let mut sel = ArtistSelection::initialise(artists);
|
|
||||||
sel.state.list.select(Some(artists.len() - 1));
|
|
||||||
sel.album.state.list.select(Some(1));
|
|
||||||
|
|
||||||
// Re-initialise.
|
|
||||||
let expected = sel.clone();
|
|
||||||
let active_artist = IdSelectArtist::get(artists, &sel);
|
|
||||||
sel.reinitialise(artists, active_artist);
|
|
||||||
assert_eq!(sel, expected);
|
|
||||||
|
|
||||||
// Re-initialise out-of-bounds.
|
|
||||||
let mut expected = sel.clone();
|
|
||||||
expected.decrement(artists, Delta::Line);
|
|
||||||
let active_artist = IdSelectArtist::get(artists, &sel);
|
|
||||||
sel.reinitialise(&artists[..(artists.len() - 1)], active_artist);
|
|
||||||
assert_eq!(sel, expected);
|
|
||||||
|
|
||||||
// Re-initialise empty.
|
|
||||||
let expected = ArtistSelection::initialise(&[]);
|
|
||||||
let active_artist = IdSelectArtist::get(artists, &sel);
|
|
||||||
sel.reinitialise(&[], active_artist);
|
|
||||||
assert_eq!(sel, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn selection() {
|
|
||||||
let mut selection = Selection::new(&COLLECTION);
|
|
||||||
|
|
||||||
assert_eq!(selection.active, Category::Artist);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
|
||||||
assert_eq!(selection.active, Category::Artist);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
selection.increment_category();
|
|
||||||
assert_eq!(selection.active, Category::Album);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
|
||||||
assert_eq!(selection.active, Category::Album);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
selection.increment_category();
|
|
||||||
assert_eq!(selection.active, Category::Track);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
|
||||||
assert_eq!(selection.active, Category::Track);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
selection.increment_category();
|
|
||||||
assert_eq!(selection.active, Category::Track);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
selection.decrement_selection(&COLLECTION, Delta::Line);
|
|
||||||
assert_eq!(selection.active, Category::Track);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
|
||||||
selection.decrement_category();
|
|
||||||
assert_eq!(selection.active, Category::Album);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(1));
|
|
||||||
|
|
||||||
selection.decrement_selection(&COLLECTION, Delta::Line);
|
|
||||||
assert_eq!(selection.active, Category::Album);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
|
||||||
selection.decrement_category();
|
|
||||||
assert_eq!(selection.active, Category::Artist);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
selection.decrement_selection(&COLLECTION, Delta::Line);
|
|
||||||
assert_eq!(selection.active, Category::Artist);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(0));
|
|
||||||
|
|
||||||
selection.increment_category();
|
|
||||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
|
||||||
selection.decrement_category();
|
|
||||||
selection.decrement_selection(&COLLECTION, Delta::Line);
|
|
||||||
selection.decrement_category();
|
|
||||||
assert_eq!(selection.active, Category::Artist);
|
|
||||||
assert_eq!(selection.artist.state.list.selected(), Some(0));
|
|
||||||
assert_eq!(selection.artist.album.state.list.selected(), Some(1));
|
|
||||||
assert_eq!(selection.artist.album.track.state.list.selected(), Some(0));
|
|
||||||
}
|
|
||||||
}
|
|
359
src/tui/app/selection/album.rs
Normal file
359
src/tui/app/selection/album.rs
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
use std::cmp;
|
||||||
|
|
||||||
|
use musichoard::collection::{
|
||||||
|
album::{Album, AlbumDate, AlbumId, AlbumSeq},
|
||||||
|
track::Track,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::app::{
|
||||||
|
selection::{
|
||||||
|
track::{KeySelectTrack, TrackSelection},
|
||||||
|
SelectionState,
|
||||||
|
},
|
||||||
|
Delta, WidgetState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct AlbumSelection {
|
||||||
|
pub state: WidgetState,
|
||||||
|
pub track: TrackSelection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlbumSelection {
|
||||||
|
pub fn initialise(albums: &[Album]) -> Self {
|
||||||
|
let mut selection = AlbumSelection {
|
||||||
|
state: WidgetState::default(),
|
||||||
|
track: TrackSelection::initialise(&[]),
|
||||||
|
};
|
||||||
|
selection.reinitialise(albums, None);
|
||||||
|
selection
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reinitialise(&mut self, albums: &[Album], album: Option<KeySelectAlbum>) {
|
||||||
|
if let Some(album) = album {
|
||||||
|
let result =
|
||||||
|
albums.binary_search_by(|a| a.meta.get_sort_key().cmp(&album.get_sort_key()));
|
||||||
|
match result {
|
||||||
|
Ok(index) => self.reinitialise_with_index(albums, index, album.track),
|
||||||
|
Err(index) => self.reinitialise_with_index(albums, index, None),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.reinitialise_with_index(albums, 0, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reinitialise_with_index(
|
||||||
|
&mut self,
|
||||||
|
albums: &[Album],
|
||||||
|
index: usize,
|
||||||
|
active_track: Option<KeySelectTrack>,
|
||||||
|
) {
|
||||||
|
if albums.is_empty() {
|
||||||
|
self.state.list.select(None);
|
||||||
|
self.track = TrackSelection::initialise(&[]);
|
||||||
|
} else if index >= albums.len() {
|
||||||
|
let end = albums.len() - 1;
|
||||||
|
self.state.list.select(Some(end));
|
||||||
|
self.track = TrackSelection::initialise(&albums[end].tracks);
|
||||||
|
} else {
|
||||||
|
self.state.list.select(Some(index));
|
||||||
|
self.track.reinitialise(&albums[index].tracks, active_track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> Option<usize> {
|
||||||
|
self.state.list.selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_track(&self) -> Option<usize> {
|
||||||
|
self.track.selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self, albums: &[Album], to: Option<usize>) {
|
||||||
|
match to {
|
||||||
|
Some(to) => self.select_to(albums, to),
|
||||||
|
None => self.state.list.select(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_track(&mut self, albums: &[Album], to: Option<usize>) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.track.select(&albums[index].tracks, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_to(&mut self, albums: &[Album], mut to: usize) {
|
||||||
|
to = cmp::min(to, albums.len() - 1);
|
||||||
|
if self.state.list.selected() != Some(to) {
|
||||||
|
self.state.list.select(Some(to));
|
||||||
|
self.track = TrackSelection::initialise(&albums[to].tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selection_state<'a>(&self, list: &'a [Album]) -> Option<SelectionState<'a, Album>> {
|
||||||
|
let selected = self.state.list.selected();
|
||||||
|
selected.map(|index| SelectionState { list, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_tracks<'a>(&self, albums: &'a [Album]) -> Option<SelectionState<'a, Track>> {
|
||||||
|
let selected = self.state.list.selected();
|
||||||
|
selected.and_then(|index| self.track.selection_state(&albums[index].tracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_state(&mut self) -> &mut WidgetState {
|
||||||
|
&mut self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_state_track(&mut self) -> &mut WidgetState {
|
||||||
|
self.track.widget_state()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self, albums: &[Album]) {
|
||||||
|
if self.state.list.selected() != Some(0) {
|
||||||
|
self.reinitialise(albums, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_track(&mut self, albums: &[Album]) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.track.reset(&albums[index].tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment(&mut self, albums: &[Album], delta: Delta) {
|
||||||
|
self.increment_by(albums, delta.as_usize(&self.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_track(&mut self, albums: &[Album], delta: Delta) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.track.increment(&albums[index].tracks, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_by(&mut self, albums: &[Album], by: usize) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
let mut result = index.saturating_add(by);
|
||||||
|
if result >= albums.len() {
|
||||||
|
result = albums.len() - 1;
|
||||||
|
}
|
||||||
|
if self.state.list.selected() != Some(result) {
|
||||||
|
self.state.list.select(Some(result));
|
||||||
|
self.track = TrackSelection::initialise(&albums[result].tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrement(&mut self, albums: &[Album], delta: Delta) {
|
||||||
|
self.decrement_by(albums, delta.as_usize(&self.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrement_track(&mut self, albums: &[Album], delta: Delta) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.track.decrement(&albums[index].tracks, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrement_by(&mut self, albums: &[Album], by: usize) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
let result = index.saturating_sub(by);
|
||||||
|
if self.state.list.selected() != Some(result) {
|
||||||
|
self.state.list.select(Some(result));
|
||||||
|
self.track = TrackSelection::initialise(&albums[result].tracks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct KeySelectAlbum {
|
||||||
|
key: (AlbumDate, AlbumSeq, AlbumId),
|
||||||
|
track: Option<KeySelectTrack>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeySelectAlbum {
|
||||||
|
pub fn get(albums: &[Album], selection: &AlbumSelection) -> Option<Self> {
|
||||||
|
selection.state.list.selected().map(|index| {
|
||||||
|
let album = &albums[index];
|
||||||
|
let key = album.meta.get_sort_key();
|
||||||
|
KeySelectAlbum {
|
||||||
|
key: (key.0.to_owned(), key.1.to_owned(), key.2.to_owned()),
|
||||||
|
track: KeySelectTrack::get(&album.tracks, &selection.track),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_sort_key(&self) -> (&AlbumDate, &AlbumSeq, &AlbumId) {
|
||||||
|
(&self.key.0, &self.key.1, &self.key.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tui::testmod::COLLECTION;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_select() {
|
||||||
|
let albums = &COLLECTION[0].albums;
|
||||||
|
assert!(albums.len() > 1);
|
||||||
|
|
||||||
|
let mut sel = AlbumSelection::initialise(albums);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.select(albums, None);
|
||||||
|
assert_eq!(sel.selected(), None);
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.select(albums, Some(albums.len()));
|
||||||
|
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.select_track(albums, None);
|
||||||
|
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||||
|
assert_eq!(sel.selected_track(), None);
|
||||||
|
|
||||||
|
sel.reset_track(albums);
|
||||||
|
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.select_track(albums, Some(1));
|
||||||
|
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(1));
|
||||||
|
|
||||||
|
sel.reset(albums);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_delta_line() {
|
||||||
|
let albums = &COLLECTION[0].albums;
|
||||||
|
assert!(albums.len() > 1);
|
||||||
|
|
||||||
|
let mut empty = AlbumSelection::initialise(&[]);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
assert_eq!(empty.selected_track(), None);
|
||||||
|
|
||||||
|
empty.increment(albums, Delta::Line);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
assert_eq!(empty.selected_track(), None);
|
||||||
|
|
||||||
|
empty.decrement(albums, Delta::Line);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
assert_eq!(empty.selected_track(), None);
|
||||||
|
|
||||||
|
let mut sel = AlbumSelection::initialise(albums);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.increment_track(albums, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(1));
|
||||||
|
|
||||||
|
// Verify that decrement that doesn't change index does not reset track.
|
||||||
|
sel.decrement(albums, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(1));
|
||||||
|
|
||||||
|
sel.increment(albums, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.decrement(albums, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
for _ in 0..(albums.len() + 5) {
|
||||||
|
sel.increment(albums, Delta::Line);
|
||||||
|
}
|
||||||
|
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.increment_track(albums, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(1));
|
||||||
|
|
||||||
|
// Verify that increment that doesn't change index does not reset track.
|
||||||
|
sel.increment(albums, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_delta_page() {
|
||||||
|
let albums = &COLLECTION[1].albums;
|
||||||
|
assert!(albums.len() > 1);
|
||||||
|
|
||||||
|
let empty = AlbumSelection::initialise(&[]);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
|
||||||
|
let mut sel = AlbumSelection::initialise(albums);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
assert!(albums.len() >= 4);
|
||||||
|
sel.state.height = 3;
|
||||||
|
|
||||||
|
sel.increment_track(albums, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(1));
|
||||||
|
|
||||||
|
// Verify that decrement that doesn't change index does not reset track.
|
||||||
|
sel.decrement(albums, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(1));
|
||||||
|
|
||||||
|
sel.increment(albums, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(2));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.decrement(albums, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
for _ in 0..(albums.len() + 5) {
|
||||||
|
sel.increment(albums, Delta::Page);
|
||||||
|
}
|
||||||
|
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.increment_track(albums, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(1));
|
||||||
|
|
||||||
|
// Verify that increment that doesn't change index does not reset track.
|
||||||
|
sel.increment(albums, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn album_reinitialise() {
|
||||||
|
let albums = &COLLECTION[0].albums;
|
||||||
|
assert!(albums.len() > 1);
|
||||||
|
|
||||||
|
let mut sel = AlbumSelection::initialise(albums);
|
||||||
|
sel.state.list.select(Some(albums.len() - 1));
|
||||||
|
sel.track.state.list.select(Some(1));
|
||||||
|
|
||||||
|
// Re-initialise.
|
||||||
|
let expected = sel.clone();
|
||||||
|
let active_album = KeySelectAlbum::get(albums, &sel);
|
||||||
|
sel.reinitialise(albums, active_album);
|
||||||
|
assert_eq!(sel, expected);
|
||||||
|
|
||||||
|
// Re-initialise out-of-bounds.
|
||||||
|
let mut expected = sel.clone();
|
||||||
|
expected.decrement(albums, Delta::Line);
|
||||||
|
let active_album = KeySelectAlbum::get(albums, &sel);
|
||||||
|
sel.reinitialise(&albums[..(albums.len() - 1)], active_album);
|
||||||
|
assert_eq!(sel, expected);
|
||||||
|
|
||||||
|
// Re-initialise empty.
|
||||||
|
let expected = AlbumSelection::initialise(&[]);
|
||||||
|
let active_album = KeySelectAlbum::get(albums, &sel);
|
||||||
|
sel.reinitialise(&[], active_album);
|
||||||
|
assert_eq!(sel, expected);
|
||||||
|
}
|
||||||
|
}
|
414
src/tui/app/selection/artist.rs
Normal file
414
src/tui/app/selection/artist.rs
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
use std::cmp;
|
||||||
|
|
||||||
|
use musichoard::collection::{
|
||||||
|
album::Album,
|
||||||
|
artist::{Artist, ArtistId},
|
||||||
|
track::Track,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::app::{
|
||||||
|
selection::{
|
||||||
|
album::{AlbumSelection, KeySelectAlbum},
|
||||||
|
SelectionState,
|
||||||
|
},
|
||||||
|
Delta, WidgetState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct ArtistSelection {
|
||||||
|
pub state: WidgetState,
|
||||||
|
pub album: AlbumSelection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArtistSelection {
|
||||||
|
pub fn initialise(artists: &[Artist]) -> Self {
|
||||||
|
let mut selection = ArtistSelection {
|
||||||
|
state: WidgetState::default(),
|
||||||
|
album: AlbumSelection::initialise(&[]),
|
||||||
|
};
|
||||||
|
selection.reinitialise(artists, None);
|
||||||
|
selection
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reinitialise(&mut self, artists: &[Artist], active: Option<KeySelectArtist>) {
|
||||||
|
if let Some(active) = active {
|
||||||
|
let result =
|
||||||
|
artists.binary_search_by(|a| a.meta.get_sort_key().cmp(&active.get_sort_key()));
|
||||||
|
match result {
|
||||||
|
Ok(index) => self.reinitialise_with_index(artists, index, active.album),
|
||||||
|
Err(index) => self.reinitialise_with_index(artists, index, None),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.reinitialise_with_index(artists, 0, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reinitialise_with_index(
|
||||||
|
&mut self,
|
||||||
|
artists: &[Artist],
|
||||||
|
index: usize,
|
||||||
|
active_album: Option<KeySelectAlbum>,
|
||||||
|
) {
|
||||||
|
if artists.is_empty() {
|
||||||
|
self.state.list.select(None);
|
||||||
|
self.album = AlbumSelection::initialise(&[]);
|
||||||
|
} else if index >= artists.len() {
|
||||||
|
let end = artists.len() - 1;
|
||||||
|
self.state.list.select(Some(end));
|
||||||
|
self.album = AlbumSelection::initialise(&artists[end].albums);
|
||||||
|
} else {
|
||||||
|
self.state.list.select(Some(index));
|
||||||
|
self.album
|
||||||
|
.reinitialise(&artists[index].albums, active_album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> Option<usize> {
|
||||||
|
self.state.list.selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_album(&self) -> Option<usize> {
|
||||||
|
self.album.selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_track(&self) -> Option<usize> {
|
||||||
|
self.album.selected_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self, artists: &[Artist], to: Option<usize>) {
|
||||||
|
match to {
|
||||||
|
Some(to) => self.select_to(artists, to),
|
||||||
|
None => self.state.list.select(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_album(&mut self, artists: &[Artist], to: Option<usize>) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.album.select(&artists[index].albums, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_track(&mut self, artists: &[Artist], to: Option<usize>) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.album.select_track(&artists[index].albums, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_to(&mut self, artists: &[Artist], mut to: usize) {
|
||||||
|
to = cmp::min(to, artists.len() - 1);
|
||||||
|
if self.state.list.selected() != Some(to) {
|
||||||
|
self.state.list.select(Some(to));
|
||||||
|
self.album = AlbumSelection::initialise(&artists[to].albums);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selection_state<'a>(&self, list: &'a [Artist]) -> Option<SelectionState<'a, Artist>> {
|
||||||
|
let selected = self.state.list.selected();
|
||||||
|
selected.map(|index| SelectionState { list, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_album<'a>(&self, artists: &'a [Artist]) -> Option<SelectionState<'a, Album>> {
|
||||||
|
let selected = self.state.list.selected();
|
||||||
|
selected.and_then(|index| self.album.selection_state(&artists[index].albums))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_track<'a>(&self, artists: &'a [Artist]) -> Option<SelectionState<'a, Track>> {
|
||||||
|
let selected = self.state.list.selected();
|
||||||
|
selected.and_then(|index| self.album.state_tracks(&artists[index].albums))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_state(&mut self) -> &mut WidgetState {
|
||||||
|
&mut self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_state_album(&mut self) -> &mut WidgetState {
|
||||||
|
self.album.widget_state()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_state_track(&mut self) -> &mut WidgetState {
|
||||||
|
self.album.widget_state_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self, artists: &[Artist]) {
|
||||||
|
if self.state.list.selected() != Some(0) {
|
||||||
|
self.reinitialise(artists, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_album(&mut self, artists: &[Artist]) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.album.reset(&artists[index].albums);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_track(&mut self, artists: &[Artist]) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.album.reset_track(&artists[index].albums);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
self.increment_by(artists, delta.as_usize(&self.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_album(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.album.increment(&artists[index].albums, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_track(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.album.increment_track(&artists[index].albums, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_by(&mut self, artists: &[Artist], by: usize) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
let result = index.saturating_add(by);
|
||||||
|
self.select_to(artists, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrement(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
self.decrement_by(artists, delta.as_usize(&self.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrement_album(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.album.decrement(&artists[index].albums, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrement_track(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
self.album.decrement_track(&artists[index].albums, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrement_by(&mut self, artists: &[Artist], by: usize) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
let result = index.saturating_sub(by);
|
||||||
|
if self.state.list.selected() != Some(result) {
|
||||||
|
self.state.list.select(Some(result));
|
||||||
|
self.album = AlbumSelection::initialise(&artists[result].albums);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct KeySelectArtist {
|
||||||
|
key: (ArtistId,),
|
||||||
|
album: Option<KeySelectAlbum>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeySelectArtist {
|
||||||
|
pub fn get(artists: &[Artist], selection: &ArtistSelection) -> Option<Self> {
|
||||||
|
selection.state.list.selected().map(|index| {
|
||||||
|
let artist = &artists[index];
|
||||||
|
let key = artist.meta.get_sort_key();
|
||||||
|
KeySelectArtist {
|
||||||
|
key: (key.0.into(),),
|
||||||
|
album: KeySelectAlbum::get(&artist.albums, &selection.album),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_sort_key(&self) -> (&str,) {
|
||||||
|
(&self.key.0.name,)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tui::testmod::COLLECTION;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_select() {
|
||||||
|
let artists = &COLLECTION;
|
||||||
|
assert!(artists.len() > 1);
|
||||||
|
|
||||||
|
let mut sel = ArtistSelection::initialise(artists);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_album(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.select(artists, None);
|
||||||
|
assert_eq!(sel.selected(), None);
|
||||||
|
assert_eq!(sel.selected_album(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.select(artists, Some(artists.len()));
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.selected_album(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.select_track(artists, None);
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.selected_album(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), None);
|
||||||
|
|
||||||
|
sel.select_album(artists, None);
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.selected_album(), None);
|
||||||
|
assert_eq!(sel.selected_track(), None);
|
||||||
|
|
||||||
|
sel.reset_album(artists);
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.selected_album(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.select_track(artists, None);
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.selected_album(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), None);
|
||||||
|
|
||||||
|
sel.reset_track(artists);
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.selected_album(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.select_album(artists, Some(1));
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.selected_album(), Some(1));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
|
||||||
|
sel.reset(artists);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.selected_album(), Some(0));
|
||||||
|
assert_eq!(sel.selected_track(), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_delta_line() {
|
||||||
|
let artists = &COLLECTION;
|
||||||
|
assert!(artists.len() > 1);
|
||||||
|
|
||||||
|
let mut empty = ArtistSelection::initialise(&[]);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
assert_eq!(empty.album.selected(), None);
|
||||||
|
|
||||||
|
empty.increment(artists, Delta::Line);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
assert_eq!(empty.album.selected(), None);
|
||||||
|
|
||||||
|
empty.decrement(artists, Delta::Line);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
assert_eq!(empty.album.selected(), None);
|
||||||
|
|
||||||
|
let mut sel = ArtistSelection::initialise(artists);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.album.selected(), Some(0));
|
||||||
|
|
||||||
|
sel.increment_album(artists, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.album.selected(), Some(1));
|
||||||
|
|
||||||
|
// Verify that decrement that doesn't change index does not reset album.
|
||||||
|
sel.decrement(artists, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.album.selected(), Some(1));
|
||||||
|
|
||||||
|
sel.increment(artists, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(1));
|
||||||
|
assert_eq!(sel.album.selected(), Some(0));
|
||||||
|
|
||||||
|
sel.decrement(artists, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.album.selected(), Some(0));
|
||||||
|
|
||||||
|
for _ in 0..(artists.len() + 5) {
|
||||||
|
sel.increment(artists, Delta::Line);
|
||||||
|
}
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.album.selected(), Some(0));
|
||||||
|
|
||||||
|
sel.increment_album(artists, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.album.selected(), Some(1));
|
||||||
|
|
||||||
|
// Verify that increment that doesn't change index does not reset album.
|
||||||
|
sel.increment(artists, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.album.selected(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_delta_page() {
|
||||||
|
let artists = &COLLECTION;
|
||||||
|
assert!(artists.len() > 1);
|
||||||
|
|
||||||
|
let empty = ArtistSelection::initialise(&[]);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
|
||||||
|
let mut sel = ArtistSelection::initialise(artists);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.album.selected(), Some(0));
|
||||||
|
|
||||||
|
assert!(artists.len() >= 4);
|
||||||
|
sel.state.height = 3;
|
||||||
|
|
||||||
|
sel.increment_album(artists, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.album.selected(), Some(1));
|
||||||
|
|
||||||
|
// Verify that decrement that doesn't change index does not reset album.
|
||||||
|
sel.decrement(artists, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.album.selected(), Some(1));
|
||||||
|
|
||||||
|
sel.increment(artists, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(2));
|
||||||
|
assert_eq!(sel.album.selected(), Some(0));
|
||||||
|
|
||||||
|
sel.decrement(artists, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
assert_eq!(sel.album.selected(), Some(0));
|
||||||
|
|
||||||
|
for _ in 0..(artists.len() + 5) {
|
||||||
|
sel.increment(artists, Delta::Page);
|
||||||
|
}
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.album.selected(), Some(0));
|
||||||
|
|
||||||
|
sel.increment_album(artists, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.album.selected(), Some(1));
|
||||||
|
|
||||||
|
// Verify that increment that doesn't change index does not reset album.
|
||||||
|
sel.increment(artists, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||||
|
assert_eq!(sel.album.selected(), Some(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_reinitialise() {
|
||||||
|
let artists = &COLLECTION;
|
||||||
|
assert!(artists.len() > 1);
|
||||||
|
|
||||||
|
let mut sel = ArtistSelection::initialise(artists);
|
||||||
|
sel.state.list.select(Some(artists.len() - 1));
|
||||||
|
sel.album.state.list.select(Some(1));
|
||||||
|
|
||||||
|
// Re-initialise.
|
||||||
|
let expected = sel.clone();
|
||||||
|
let active_artist = KeySelectArtist::get(artists, &sel);
|
||||||
|
sel.reinitialise(artists, active_artist);
|
||||||
|
assert_eq!(sel, expected);
|
||||||
|
|
||||||
|
// Re-initialise out-of-bounds.
|
||||||
|
let mut expected = sel.clone();
|
||||||
|
expected.decrement(artists, Delta::Line);
|
||||||
|
let active_artist = KeySelectArtist::get(artists, &sel);
|
||||||
|
sel.reinitialise(&artists[..(artists.len() - 1)], active_artist);
|
||||||
|
assert_eq!(sel, expected);
|
||||||
|
|
||||||
|
// Re-initialise empty.
|
||||||
|
let expected = ArtistSelection::initialise(&[]);
|
||||||
|
let active_artist = KeySelectArtist::get(artists, &sel);
|
||||||
|
sel.reinitialise(&[], active_artist);
|
||||||
|
assert_eq!(sel, expected);
|
||||||
|
}
|
||||||
|
}
|
382
src/tui/app/selection/mod.rs
Normal file
382
src/tui/app/selection/mod.rs
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
mod album;
|
||||||
|
mod artist;
|
||||||
|
mod track;
|
||||||
|
|
||||||
|
use musichoard::collection::{album::Album, artist::Artist, track::Track, Collection};
|
||||||
|
use ratatui::widgets::ListState;
|
||||||
|
|
||||||
|
use crate::tui::app::{
|
||||||
|
selection::artist::{ArtistSelection, KeySelectArtist},
|
||||||
|
Delta, WidgetState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Category {
|
||||||
|
Artist,
|
||||||
|
Album,
|
||||||
|
Track,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Selection {
|
||||||
|
active: Category,
|
||||||
|
artist: ArtistSelection,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SelectionState<'a, T> {
|
||||||
|
pub list: &'a [T],
|
||||||
|
pub index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Selection {
|
||||||
|
pub fn new(artists: &[Artist]) -> Self {
|
||||||
|
Selection {
|
||||||
|
active: Category::Artist,
|
||||||
|
artist: ArtistSelection::initialise(artists),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_by_list(&mut self, selected: ListSelection) {
|
||||||
|
self.artist.state.list = selected.artist;
|
||||||
|
self.artist.album.state.list = selected.album;
|
||||||
|
self.artist.album.track.state.list = selected.track;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_by_id(&mut self, artists: &[Artist], selected: KeySelection) {
|
||||||
|
self.artist.reinitialise(artists, selected.artist);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_category(&mut self) {
|
||||||
|
self.active = match self.active {
|
||||||
|
Category::Artist => Category::Album,
|
||||||
|
Category::Album => Category::Track,
|
||||||
|
Category::Track => Category::Track,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrement_category(&mut self) {
|
||||||
|
self.active = match self.active {
|
||||||
|
Category::Artist => Category::Artist,
|
||||||
|
Category::Album => Category::Artist,
|
||||||
|
Category::Track => Category::Album,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self, collection: &Collection, index: Option<usize>) {
|
||||||
|
match self.active {
|
||||||
|
Category::Artist => self.select_artist(collection, index),
|
||||||
|
Category::Album => self.select_album(collection, index),
|
||||||
|
Category::Track => self.select_track(collection, index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_artist(&mut self, artists: &[Artist], index: Option<usize>) {
|
||||||
|
self.artist.select(artists, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_album(&mut self, artists: &[Artist], index: Option<usize>) {
|
||||||
|
self.artist.select_album(artists, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_track(&mut self, artists: &[Artist], index: Option<usize>) {
|
||||||
|
self.artist.select_track(artists, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn category(&self) -> Category {
|
||||||
|
self.active
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> Option<usize> {
|
||||||
|
match self.active {
|
||||||
|
Category::Artist => self.selected_artist(),
|
||||||
|
Category::Album => self.selected_album(),
|
||||||
|
Category::Track => self.selected_track(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_artist(&self) -> Option<usize> {
|
||||||
|
self.artist.selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_album(&self) -> Option<usize> {
|
||||||
|
self.artist.selected_album()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_track(&self) -> Option<usize> {
|
||||||
|
self.artist.selected_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_artist<'a>(&self, coll: &'a Collection) -> Option<SelectionState<'a, Artist>> {
|
||||||
|
self.artist.selection_state(coll)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_album<'a>(&self, coll: &'a Collection) -> Option<SelectionState<'a, Album>> {
|
||||||
|
self.artist.state_album(coll)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_track<'a>(&self, coll: &'a Collection) -> Option<SelectionState<'a, Track>> {
|
||||||
|
self.artist.state_track(coll)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_state_artist(&mut self) -> &mut WidgetState {
|
||||||
|
self.artist.widget_state()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_state_album(&mut self) -> &mut WidgetState {
|
||||||
|
self.artist.widget_state_album()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_state_track(&mut self) -> &mut WidgetState {
|
||||||
|
self.artist.widget_state_track()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self, collection: &Collection) {
|
||||||
|
match self.active {
|
||||||
|
Category::Artist => self.reset_artist(collection),
|
||||||
|
Category::Album => self.reset_album(collection),
|
||||||
|
Category::Track => self.reset_track(collection),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_artist(&mut self, artists: &[Artist]) {
|
||||||
|
self.artist.reset(artists);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_album(&mut self, artists: &[Artist]) {
|
||||||
|
self.artist.reset_album(artists);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_track(&mut self, artists: &[Artist]) {
|
||||||
|
self.artist.reset_track(artists);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment_selection(&mut self, collection: &Collection, delta: Delta) {
|
||||||
|
match self.active {
|
||||||
|
Category::Artist => self.increment_artist(collection, delta),
|
||||||
|
Category::Album => self.increment_album(collection, delta),
|
||||||
|
Category::Track => self.increment_track(collection, delta),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_artist(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
self.artist.increment(artists, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_album(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
self.artist.increment_album(artists, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_track(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
self.artist.increment_track(artists, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrement_selection(&mut self, collection: &Collection, delta: Delta) {
|
||||||
|
match self.active {
|
||||||
|
Category::Artist => self.decrement_artist(collection, delta),
|
||||||
|
Category::Album => self.decrement_album(collection, delta),
|
||||||
|
Category::Track => self.decrement_track(collection, delta),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrement_artist(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
self.artist.decrement(artists, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrement_album(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
self.artist.decrement_album(artists, delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrement_track(&mut self, artists: &[Artist], delta: Delta) {
|
||||||
|
self.artist.decrement_track(artists, delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ListSelection {
|
||||||
|
pub artist: ListState,
|
||||||
|
pub album: ListState,
|
||||||
|
pub track: ListState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListSelection {
|
||||||
|
pub fn get(selection: &Selection) -> Self {
|
||||||
|
ListSelection {
|
||||||
|
artist: selection.artist.state.list.clone(),
|
||||||
|
album: selection.artist.album.state.list.clone(),
|
||||||
|
track: selection.artist.album.track.state.list.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct KeySelection {
|
||||||
|
artist: Option<KeySelectArtist>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeySelection {
|
||||||
|
pub fn get(collection: &Collection, selection: &Selection) -> Self {
|
||||||
|
KeySelection {
|
||||||
|
artist: KeySelectArtist::get(collection, &selection.artist),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tui::testmod::COLLECTION;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_select() {
|
||||||
|
let mut selection = Selection::new(&COLLECTION);
|
||||||
|
|
||||||
|
selection.select(&COLLECTION, Some(1));
|
||||||
|
selection.increment_category();
|
||||||
|
selection.select(&COLLECTION, Some(1));
|
||||||
|
selection.increment_category();
|
||||||
|
selection.select(&COLLECTION, Some(1));
|
||||||
|
|
||||||
|
assert_eq!(selection.active, Category::Track);
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(1));
|
||||||
|
|
||||||
|
selection.reset(&COLLECTION);
|
||||||
|
|
||||||
|
assert_eq!(selection.active, Category::Track);
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.select(&COLLECTION, Some(1));
|
||||||
|
|
||||||
|
assert_eq!(selection.active, Category::Track);
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(1));
|
||||||
|
|
||||||
|
selection.decrement_category();
|
||||||
|
|
||||||
|
selection.reset(&COLLECTION);
|
||||||
|
|
||||||
|
assert_eq!(selection.active, Category::Album);
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.select(&COLLECTION, Some(1));
|
||||||
|
|
||||||
|
assert_eq!(selection.active, Category::Album);
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.decrement_category();
|
||||||
|
|
||||||
|
selection.reset(&COLLECTION);
|
||||||
|
|
||||||
|
assert_eq!(selection.active, Category::Artist);
|
||||||
|
assert_eq!(selection.artist.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_delta() {
|
||||||
|
let mut selection = Selection::new(&COLLECTION);
|
||||||
|
|
||||||
|
assert_eq!(selection.active, Category::Artist);
|
||||||
|
assert_eq!(selection.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||||
|
assert_eq!(selection.active, Category::Artist);
|
||||||
|
assert_eq!(selection.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.increment_category();
|
||||||
|
assert_eq!(selection.active, Category::Album);
|
||||||
|
assert_eq!(selection.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||||
|
assert_eq!(selection.active, Category::Album);
|
||||||
|
assert_eq!(selection.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.increment_category();
|
||||||
|
assert_eq!(selection.active, Category::Track);
|
||||||
|
assert_eq!(selection.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||||
|
assert_eq!(selection.active, Category::Track);
|
||||||
|
assert_eq!(selection.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(1));
|
||||||
|
|
||||||
|
selection.increment_category();
|
||||||
|
assert_eq!(selection.active, Category::Track);
|
||||||
|
assert_eq!(selection.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(1));
|
||||||
|
|
||||||
|
selection.decrement_selection(&COLLECTION, Delta::Line);
|
||||||
|
assert_eq!(selection.active, Category::Track);
|
||||||
|
assert_eq!(selection.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||||
|
selection.decrement_category();
|
||||||
|
assert_eq!(selection.active, Category::Album);
|
||||||
|
assert_eq!(selection.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(1));
|
||||||
|
|
||||||
|
selection.decrement_selection(&COLLECTION, Delta::Line);
|
||||||
|
assert_eq!(selection.active, Category::Album);
|
||||||
|
assert_eq!(selection.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||||
|
selection.decrement_category();
|
||||||
|
assert_eq!(selection.active, Category::Artist);
|
||||||
|
assert_eq!(selection.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.decrement_selection(&COLLECTION, Delta::Line);
|
||||||
|
assert_eq!(selection.active, Category::Artist);
|
||||||
|
assert_eq!(selection.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
|
||||||
|
selection.increment_category();
|
||||||
|
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||||
|
selection.decrement_category();
|
||||||
|
selection.decrement_selection(&COLLECTION, Delta::Line);
|
||||||
|
selection.decrement_category();
|
||||||
|
assert_eq!(selection.active, Category::Artist);
|
||||||
|
assert_eq!(selection.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.selected(), Some(0));
|
||||||
|
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||||
|
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||||
|
}
|
||||||
|
}
|
235
src/tui/app/selection/track.rs
Normal file
235
src/tui/app/selection/track.rs
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
use std::cmp;
|
||||||
|
|
||||||
|
use musichoard::collection::track::{Track, TrackId, TrackNum};
|
||||||
|
|
||||||
|
use crate::tui::app::{selection::SelectionState, Delta, WidgetState};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct TrackSelection {
|
||||||
|
pub state: WidgetState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackSelection {
|
||||||
|
pub fn initialise(tracks: &[Track]) -> Self {
|
||||||
|
let mut selection = TrackSelection {
|
||||||
|
state: WidgetState::default(),
|
||||||
|
};
|
||||||
|
selection.reinitialise(tracks, None);
|
||||||
|
selection
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reinitialise(&mut self, tracks: &[Track], track: Option<KeySelectTrack>) {
|
||||||
|
if let Some(track) = track {
|
||||||
|
let result = tracks.binary_search_by(|t| t.get_sort_key().cmp(&track.get_sort_key()));
|
||||||
|
match result {
|
||||||
|
Ok(index) | Err(index) => self.reinitialise_with_index(tracks, index),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.reinitialise_with_index(tracks, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reinitialise_with_index(&mut self, tracks: &[Track], index: usize) {
|
||||||
|
if tracks.is_empty() {
|
||||||
|
self.state.list.select(None);
|
||||||
|
} else if index >= tracks.len() {
|
||||||
|
self.state.list.select(Some(tracks.len() - 1));
|
||||||
|
} else {
|
||||||
|
self.state.list.select(Some(index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> Option<usize> {
|
||||||
|
self.state.list.selected()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self, tracks: &[Track], to: Option<usize>) {
|
||||||
|
match to {
|
||||||
|
Some(to) => self.select_to(tracks, to),
|
||||||
|
None => self.state.list.select(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_to(&mut self, tracks: &[Track], mut to: usize) {
|
||||||
|
to = cmp::min(to, tracks.len() - 1);
|
||||||
|
self.state.list.select(Some(to));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selection_state<'a>(&self, list: &'a [Track]) -> Option<SelectionState<'a, Track>> {
|
||||||
|
let selected = self.state.list.selected();
|
||||||
|
selected.map(|index| SelectionState { list, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn widget_state(&mut self) -> &mut WidgetState {
|
||||||
|
&mut self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self, tracks: &[Track]) {
|
||||||
|
if self.state.list.selected() != Some(0) {
|
||||||
|
self.reinitialise(tracks, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn increment(&mut self, tracks: &[Track], delta: Delta) {
|
||||||
|
self.increment_by(tracks, delta.as_usize(&self.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn increment_by(&mut self, tracks: &[Track], by: usize) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
let mut result = index.saturating_add(by);
|
||||||
|
if result >= tracks.len() {
|
||||||
|
result = tracks.len() - 1;
|
||||||
|
}
|
||||||
|
if self.state.list.selected() != Some(result) {
|
||||||
|
self.state.list.select(Some(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrement(&mut self, tracks: &[Track], delta: Delta) {
|
||||||
|
self.decrement_by(tracks, delta.as_usize(&self.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decrement_by(&mut self, _tracks: &[Track], by: usize) {
|
||||||
|
if let Some(index) = self.state.list.selected() {
|
||||||
|
let result = index.saturating_sub(by);
|
||||||
|
if self.state.list.selected() != Some(result) {
|
||||||
|
self.state.list.select(Some(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct KeySelectTrack {
|
||||||
|
key: (TrackNum, TrackId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeySelectTrack {
|
||||||
|
pub fn get(tracks: &[Track], selection: &TrackSelection) -> Option<Self> {
|
||||||
|
selection.state.list.selected().map(|index| {
|
||||||
|
let track = &tracks[index];
|
||||||
|
let key = track.get_sort_key();
|
||||||
|
KeySelectTrack {
|
||||||
|
key: (key.0.to_owned(), key.1.to_owned()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_sort_key(&self) -> (&TrackNum, &TrackId) {
|
||||||
|
(&self.key.0, &self.key.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::tui::testmod::COLLECTION;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_select() {
|
||||||
|
let tracks = &COLLECTION[0].albums[0].tracks;
|
||||||
|
assert!(tracks.len() > 1);
|
||||||
|
|
||||||
|
let mut sel = TrackSelection::initialise(tracks);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
sel.select(tracks, None);
|
||||||
|
assert_eq!(sel.selected(), None);
|
||||||
|
|
||||||
|
sel.select(tracks, Some(tracks.len()));
|
||||||
|
assert_eq!(sel.selected(), Some(tracks.len() - 1));
|
||||||
|
|
||||||
|
sel.reset(tracks);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_delta_line() {
|
||||||
|
let tracks = &COLLECTION[0].albums[0].tracks;
|
||||||
|
assert!(tracks.len() > 1);
|
||||||
|
|
||||||
|
let mut empty = TrackSelection::initialise(&[]);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
|
||||||
|
empty.increment(tracks, Delta::Line);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
|
||||||
|
empty.decrement(tracks, Delta::Line);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
|
||||||
|
let mut sel = TrackSelection::initialise(tracks);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
sel.decrement(tracks, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
sel.increment(tracks, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(1));
|
||||||
|
|
||||||
|
sel.decrement(tracks, Delta::Line);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
for _ in 0..(tracks.len() + 5) {
|
||||||
|
sel.increment(tracks, Delta::Line);
|
||||||
|
}
|
||||||
|
assert_eq!(sel.selected(), Some(tracks.len() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_delta_page() {
|
||||||
|
let tracks = &COLLECTION[0].albums[0].tracks;
|
||||||
|
assert!(tracks.len() > 1);
|
||||||
|
|
||||||
|
let empty = TrackSelection::initialise(&[]);
|
||||||
|
assert_eq!(empty.selected(), None);
|
||||||
|
|
||||||
|
let mut sel = TrackSelection::initialise(tracks);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
assert!(tracks.len() >= 4);
|
||||||
|
sel.state.height = 3;
|
||||||
|
|
||||||
|
sel.decrement(tracks, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
sel.increment(tracks, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(2));
|
||||||
|
|
||||||
|
sel.decrement(tracks, Delta::Page);
|
||||||
|
assert_eq!(sel.selected(), Some(0));
|
||||||
|
|
||||||
|
for _ in 0..(tracks.len() + 5) {
|
||||||
|
sel.increment(tracks, Delta::Page);
|
||||||
|
}
|
||||||
|
assert_eq!(sel.selected(), Some(tracks.len() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn track_reinitialise() {
|
||||||
|
let tracks = &COLLECTION[0].albums[0].tracks;
|
||||||
|
assert!(tracks.len() > 1);
|
||||||
|
|
||||||
|
let mut sel = TrackSelection::initialise(tracks);
|
||||||
|
sel.state.list.select(Some(tracks.len() - 1));
|
||||||
|
|
||||||
|
// Re-initialise.
|
||||||
|
let expected = sel.clone();
|
||||||
|
let active_track = KeySelectTrack::get(tracks, &sel);
|
||||||
|
sel.reinitialise(tracks, active_track);
|
||||||
|
assert_eq!(sel, expected);
|
||||||
|
|
||||||
|
// Re-initialise out-of-bounds.
|
||||||
|
let mut expected = sel.clone();
|
||||||
|
expected.decrement(tracks, Delta::Line);
|
||||||
|
let active_track = KeySelectTrack::get(tracks, &sel);
|
||||||
|
sel.reinitialise(&tracks[..(tracks.len() - 1)], active_track);
|
||||||
|
assert_eq!(sel, expected);
|
||||||
|
|
||||||
|
// Re-initialise empty.
|
||||||
|
let expected = TrackSelection::initialise(&[]);
|
||||||
|
let active_track = KeySelectTrack::get(tracks, &sel);
|
||||||
|
sel.reinitialise(&[], active_track);
|
||||||
|
assert_eq!(sel, expected);
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,10 @@
|
|||||||
use crossterm::event::{KeyEvent, MouseEvent};
|
use crossterm::event::KeyEvent;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use mockall::automock;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum EventError {
|
pub enum EventError {
|
||||||
Send(Event),
|
Send(Event),
|
||||||
@ -33,11 +36,16 @@ impl From<mpsc::RecvError> for EventError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
impl From<mpsc::TryRecvError> for EventError {
|
||||||
|
fn from(_: mpsc::TryRecvError) -> EventError {
|
||||||
|
EventError::Recv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
Key(KeyEvent),
|
Key(KeyEvent),
|
||||||
Mouse(MouseEvent),
|
FetchComplete,
|
||||||
Resize(u16, u16),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct EventChannel {
|
pub struct EventChannel {
|
||||||
@ -45,6 +53,16 @@ pub struct EventChannel {
|
|||||||
receiver: mpsc::Receiver<Event>,
|
receiver: mpsc::Receiver<Event>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait IKeyEventSender {
|
||||||
|
fn send_key(&self, key_event: KeyEvent) -> Result<(), EventError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(test, automock)]
|
||||||
|
pub trait IFetchCompleteEventSender {
|
||||||
|
fn send_fetch_complete(&self) -> Result<(), EventError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct EventSender {
|
pub struct EventSender {
|
||||||
sender: mpsc::Sender<Event>,
|
sender: mpsc::Sender<Event>,
|
||||||
}
|
}
|
||||||
@ -72,9 +90,15 @@ impl EventChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventSender {
|
impl IKeyEventSender for EventSender {
|
||||||
pub fn send(&self, event: Event) -> Result<(), EventError> {
|
fn send_key(&self, key_event: KeyEvent) -> Result<(), EventError> {
|
||||||
Ok(self.sender.send(event)?)
|
Ok(self.sender.send(Event::Key(key_event))?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IFetchCompleteEventSender for EventSender {
|
||||||
|
fn send_fetch_complete(&self) -> Result<(), EventError> {
|
||||||
|
Ok(self.sender.send(Event::FetchComplete)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,6 +106,11 @@ impl EventReceiver {
|
|||||||
pub fn recv(&self) -> Result<Event, EventError> {
|
pub fn recv(&self) -> Result<Event, EventError> {
|
||||||
Ok(self.receiver.recv()?)
|
Ok(self.receiver.recv()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn try_recv(&self) -> Result<Event, EventError> {
|
||||||
|
Ok(self.receiver.try_recv()?)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@ -97,13 +126,13 @@ mod tests {
|
|||||||
let channel = EventChannel::new();
|
let channel = EventChannel::new();
|
||||||
let sender = channel.sender();
|
let sender = channel.sender();
|
||||||
let receiver = channel.receiver();
|
let receiver = channel.receiver();
|
||||||
let event = Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::empty()));
|
let key_event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
|
||||||
|
|
||||||
let result = sender.send(event);
|
let result = sender.send_key(key_event);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
drop(receiver);
|
drop(receiver);
|
||||||
let result = sender.send(event);
|
let result = sender.send_key(key_event);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,9 +141,9 @@ mod tests {
|
|||||||
let channel = EventChannel::new();
|
let channel = EventChannel::new();
|
||||||
let sender = channel.sender();
|
let sender = channel.sender();
|
||||||
let receiver = channel.receiver();
|
let receiver = channel.receiver();
|
||||||
let event = Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::empty()));
|
let key_event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
|
||||||
|
|
||||||
sender.send(event).unwrap();
|
sender.send_key(key_event).unwrap();
|
||||||
let result = receiver.recv();
|
let result = receiver.recv();
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
|
||||||
@ -123,6 +152,24 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_receiver_try() {
|
||||||
|
let channel = EventChannel::new();
|
||||||
|
let sender = channel.sender();
|
||||||
|
let receiver = channel.receiver();
|
||||||
|
|
||||||
|
let result = receiver.try_recv();
|
||||||
|
assert!(result.is_err());
|
||||||
|
|
||||||
|
sender.send_fetch_complete().unwrap();
|
||||||
|
let result = receiver.try_recv();
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
drop(sender);
|
||||||
|
let result = receiver.try_recv();
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn errors() {
|
fn errors() {
|
||||||
let send_err = EventError::Send(Event::Key(KeyEvent {
|
let send_err = EventError::Send(Event::Key(KeyEvent {
|
||||||
|
@ -5,25 +5,32 @@ use mockall::automock;
|
|||||||
|
|
||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
app::{
|
app::{
|
||||||
AppState, Delta, IAppInteract, IAppInteractBrowse, IAppInteractCritical, IAppInteractError,
|
AppMode, AppState, Delta, IApp, IAppBase, IAppEventFetch, IAppInput, IAppInteractBrowse,
|
||||||
IAppInteractInfo, IAppInteractReload, IAppInteractSearch,
|
IAppInteractError, IAppInteractFetch, IAppInteractInfo, IAppInteractMatch,
|
||||||
|
IAppInteractReload, IAppInteractSearch,
|
||||||
},
|
},
|
||||||
event::{Event, EventError, EventReceiver},
|
event::{Event, EventError, EventReceiver},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg_attr(test, automock)]
|
#[cfg_attr(test, automock)]
|
||||||
pub trait IEventHandler<APP: IAppInteract> {
|
pub trait IEventHandler<APP: IApp> {
|
||||||
fn handle_next_event(&self, app: APP) -> Result<APP, EventError>;
|
fn handle_next_event(&self, app: APP) -> Result<APP, EventError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
trait IEventHandlerPrivate<APP: IAppInteract> {
|
trait IEventHandlerPrivate<APP: IApp> {
|
||||||
fn handle_key_event(app: APP, key_event: KeyEvent) -> APP;
|
fn handle_key_event(app: APP, key_event: KeyEvent) -> APP;
|
||||||
fn handle_browse_key_event(app: <APP as IAppInteract>::BS, key_event: KeyEvent) -> APP;
|
fn handle_browse_key_event(app: <APP as IApp>::BrowseState, key_event: KeyEvent) -> APP;
|
||||||
fn handle_info_key_event(app: <APP as IAppInteract>::IS, key_event: KeyEvent) -> APP;
|
fn handle_info_key_event(app: <APP as IApp>::InfoState, key_event: KeyEvent) -> APP;
|
||||||
fn handle_reload_key_event(app: <APP as IAppInteract>::RS, key_event: KeyEvent) -> APP;
|
fn handle_reload_key_event(app: <APP as IApp>::ReloadState, key_event: KeyEvent) -> APP;
|
||||||
fn handle_search_key_event(app: <APP as IAppInteract>::SS, key_event: KeyEvent) -> APP;
|
fn handle_search_key_event(app: <APP as IApp>::SearchState, key_event: KeyEvent) -> APP;
|
||||||
fn handle_error_key_event(app: <APP as IAppInteract>::ES, key_event: KeyEvent) -> APP;
|
fn handle_fetch_key_event(app: <APP as IApp>::FetchState, key_event: KeyEvent) -> APP;
|
||||||
fn handle_critical_key_event(app: <APP as IAppInteract>::CS, key_event: KeyEvent) -> APP;
|
fn handle_match_key_event(app: <APP as IApp>::MatchState, key_event: KeyEvent) -> APP;
|
||||||
|
fn handle_error_key_event(app: <APP as IApp>::ErrorState, key_event: KeyEvent) -> APP;
|
||||||
|
fn handle_critical_key_event(app: <APP as IApp>::CriticalState, key_event: KeyEvent) -> APP;
|
||||||
|
|
||||||
|
fn handle_fetch_complete_event(app: APP) -> APP;
|
||||||
|
|
||||||
|
fn handle_input_key_event<Input: IAppInput<APP = APP>>(app: Input, key_event: KeyEvent) -> APP;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct EventHandler {
|
pub struct EventHandler {
|
||||||
@ -37,18 +44,16 @@ impl EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<APP: IAppInteract> IEventHandler<APP> for EventHandler {
|
impl<APP: IApp> IEventHandler<APP> for EventHandler {
|
||||||
fn handle_next_event(&self, mut app: APP) -> Result<APP, EventError> {
|
fn handle_next_event(&self, app: APP) -> Result<APP, EventError> {
|
||||||
match self.events.recv()? {
|
Ok(match self.events.recv()? {
|
||||||
Event::Key(key_event) => app = Self::handle_key_event(app, key_event),
|
Event::Key(key_event) => Self::handle_key_event(app, key_event),
|
||||||
Event::Mouse(_) => {}
|
Event::FetchComplete => Self::handle_fetch_complete_event(app),
|
||||||
Event::Resize(_, _) => {}
|
})
|
||||||
};
|
|
||||||
Ok(app)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
|
impl<APP: IApp> IEventHandlerPrivate<APP> for EventHandler {
|
||||||
fn handle_key_event(app: APP, key_event: KeyEvent) -> APP {
|
fn handle_key_event(app: APP, key_event: KeyEvent) -> APP {
|
||||||
if key_event.modifiers == KeyModifiers::CONTROL {
|
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||||
match key_event.code {
|
match key_event.code {
|
||||||
@ -58,32 +63,52 @@ impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
match app.state() {
|
match app.mode() {
|
||||||
AppState::Browse(browse) => {
|
AppMode::Input(input_mode) => Self::handle_input_key_event(input_mode, key_event),
|
||||||
<Self as IEventHandlerPrivate<APP>>::handle_browse_key_event(browse, key_event)
|
AppMode::State(state_mode) => match state_mode {
|
||||||
|
AppState::Browse(browse_state) => {
|
||||||
|
Self::handle_browse_key_event(browse_state, key_event)
|
||||||
}
|
}
|
||||||
AppState::Info(info) => {
|
AppState::Info(info_state) => Self::handle_info_key_event(info_state, key_event),
|
||||||
<Self as IEventHandlerPrivate<APP>>::handle_info_key_event(info, key_event)
|
AppState::Reload(reload_state) => {
|
||||||
|
Self::handle_reload_key_event(reload_state, key_event)
|
||||||
}
|
}
|
||||||
AppState::Reload(reload) => {
|
AppState::Search(search_state) => {
|
||||||
<Self as IEventHandlerPrivate<APP>>::handle_reload_key_event(reload, key_event)
|
Self::handle_search_key_event(search_state, key_event)
|
||||||
}
|
}
|
||||||
AppState::Search(search) => {
|
AppState::Fetch(fetch_state) => {
|
||||||
<Self as IEventHandlerPrivate<APP>>::handle_search_key_event(search, key_event)
|
Self::handle_fetch_key_event(fetch_state, key_event)
|
||||||
}
|
}
|
||||||
AppState::Error(error) => {
|
AppState::Match(match_state) => {
|
||||||
<Self as IEventHandlerPrivate<APP>>::handle_error_key_event(error, key_event)
|
Self::handle_match_key_event(match_state, key_event)
|
||||||
}
|
}
|
||||||
AppState::Critical(critical) => {
|
AppState::Error(error_state) => {
|
||||||
<Self as IEventHandlerPrivate<APP>>::handle_critical_key_event(critical, key_event)
|
Self::handle_error_key_event(error_state, key_event)
|
||||||
}
|
}
|
||||||
|
AppState::Critical(critical_state) => {
|
||||||
|
Self::handle_critical_key_event(critical_state, key_event)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_browse_key_event(app: <APP as IAppInteract>::BS, key_event: KeyEvent) -> APP {
|
fn handle_fetch_complete_event(app: APP) -> APP {
|
||||||
|
match app.state() {
|
||||||
|
AppState::Browse(state) => state.no_op(),
|
||||||
|
AppState::Info(state) => state.no_op(),
|
||||||
|
AppState::Reload(state) => state.no_op(),
|
||||||
|
AppState::Search(state) => state.no_op(),
|
||||||
|
AppState::Fetch(fetch_state) => fetch_state.fetch_result_ready(),
|
||||||
|
AppState::Match(state) => state.no_op(),
|
||||||
|
AppState::Error(state) => state.no_op(),
|
||||||
|
AppState::Critical(state) => state.no_op(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_browse_key_event(app: <APP as IApp>::BrowseState, key_event: KeyEvent) -> APP {
|
||||||
match key_event.code {
|
match key_event.code {
|
||||||
// Exit application on `ESC` or `q`.
|
// Exit application on `ESC` or `q`.
|
||||||
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.save_and_quit(),
|
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(),
|
||||||
// Category change.
|
// Category change.
|
||||||
KeyCode::Left => app.decrement_category(),
|
KeyCode::Left => app.decrement_category(),
|
||||||
KeyCode::Right => app.increment_category(),
|
KeyCode::Right => app.increment_category(),
|
||||||
@ -104,12 +129,13 @@ impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
|
|||||||
app.no_op()
|
app.no_op()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('f') | KeyCode::Char('F') => app.fetch_musicbrainz(),
|
||||||
// Othey keys.
|
// Othey keys.
|
||||||
_ => app.no_op(),
|
_ => app.no_op(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_info_key_event(app: <APP as IAppInteract>::IS, key_event: KeyEvent) -> APP {
|
fn handle_info_key_event(app: <APP as IApp>::InfoState, key_event: KeyEvent) -> APP {
|
||||||
match key_event.code {
|
match key_event.code {
|
||||||
// Toggle overlay.
|
// Toggle overlay.
|
||||||
KeyCode::Esc
|
KeyCode::Esc
|
||||||
@ -122,7 +148,7 @@ impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_reload_key_event(app: <APP as IAppInteract>::RS, key_event: KeyEvent) -> APP {
|
fn handle_reload_key_event(app: <APP as IApp>::ReloadState, key_event: KeyEvent) -> APP {
|
||||||
match key_event.code {
|
match key_event.code {
|
||||||
// Reload keys.
|
// Reload keys.
|
||||||
KeyCode::Char('l') | KeyCode::Char('L') => app.reload_library(),
|
KeyCode::Char('l') | KeyCode::Char('L') => app.reload_library(),
|
||||||
@ -138,7 +164,7 @@ impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_search_key_event(app: <APP as IAppInteract>::SS, key_event: KeyEvent) -> APP {
|
fn handle_search_key_event(app: <APP as IApp>::SearchState, key_event: KeyEvent) -> APP {
|
||||||
if key_event.modifiers == KeyModifiers::CONTROL {
|
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||||
return match key_event.code {
|
return match key_event.code {
|
||||||
KeyCode::Char('s') | KeyCode::Char('S') => app.search_next(),
|
KeyCode::Char('s') | KeyCode::Char('S') => app.search_next(),
|
||||||
@ -158,14 +184,69 @@ impl<APP: IAppInteract> IEventHandlerPrivate<APP> for EventHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_error_key_event(app: <APP as IAppInteract>::ES, _key_event: KeyEvent) -> APP {
|
fn handle_fetch_key_event(app: <APP as IApp>::FetchState, key_event: KeyEvent) -> APP {
|
||||||
|
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||||
|
return match key_event.code {
|
||||||
|
KeyCode::Char('g') | KeyCode::Char('G') => app.abort(),
|
||||||
|
_ => app.no_op(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match key_event.code {
|
||||||
|
// Abort.
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.abort(),
|
||||||
|
// Othey keys.
|
||||||
|
_ => app.no_op(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_match_key_event(app: <APP as IApp>::MatchState, key_event: KeyEvent) -> APP {
|
||||||
|
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||||
|
return match key_event.code {
|
||||||
|
KeyCode::Char('g') | KeyCode::Char('G') => app.abort(),
|
||||||
|
_ => app.no_op(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match key_event.code {
|
||||||
|
// Abort.
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.abort(),
|
||||||
|
// Select.
|
||||||
|
KeyCode::Up => app.decrement_match(Delta::Line),
|
||||||
|
KeyCode::Down => app.increment_match(Delta::Line),
|
||||||
|
KeyCode::PageUp => app.decrement_match(Delta::Page),
|
||||||
|
KeyCode::PageDown => app.increment_match(Delta::Page),
|
||||||
|
KeyCode::Enter => app.select(),
|
||||||
|
// Othey keys.
|
||||||
|
_ => app.no_op(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_error_key_event(app: <APP as IApp>::ErrorState, _key_event: KeyEvent) -> APP {
|
||||||
// Any key dismisses the error.
|
// Any key dismisses the error.
|
||||||
app.dismiss_error()
|
app.dismiss_error()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_critical_key_event(app: <APP as IAppInteract>::CS, _key_event: KeyEvent) -> APP {
|
fn handle_critical_key_event(app: <APP as IApp>::CriticalState, _key_event: KeyEvent) -> APP {
|
||||||
// No action is allowed.
|
// No action is allowed.
|
||||||
app.no_op()
|
app.no_op()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_input_key_event<Input: IAppInput<APP = APP>>(app: Input, key_event: KeyEvent) -> APP {
|
||||||
|
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||||
|
match key_event.code {
|
||||||
|
KeyCode::Char('g') | KeyCode::Char('G') => return app.cancel(),
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match key_event.code {
|
||||||
|
// Return.
|
||||||
|
KeyCode::Esc => app.cancel(),
|
||||||
|
KeyCode::Enter => app.confirm(),
|
||||||
|
// Othey keys.
|
||||||
|
_ => app.input(key_event.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// GRCOV_EXCL_STOP
|
// GRCOV_EXCL_STOP
|
||||||
|
@ -1,32 +0,0 @@
|
|||||||
use musichoard::{collection::Collection, database::IDatabase, library::ILibrary, MusicHoard};
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
use mockall::automock;
|
|
||||||
|
|
||||||
#[cfg_attr(test, automock)]
|
|
||||||
pub trait IMusicHoard {
|
|
||||||
fn rescan_library(&mut self) -> Result<(), musichoard::Error>;
|
|
||||||
fn load_from_database(&mut self) -> Result<(), musichoard::Error>;
|
|
||||||
fn save_to_database(&mut self) -> Result<(), musichoard::Error>;
|
|
||||||
fn get_collection(&self) -> &Collection;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GRCOV_EXCL_START
|
|
||||||
impl<LIB: ILibrary, DB: IDatabase> IMusicHoard for MusicHoard<LIB, DB> {
|
|
||||||
fn rescan_library(&mut self) -> Result<(), musichoard::Error> {
|
|
||||||
MusicHoard::rescan_library(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_from_database(&mut self) -> Result<(), musichoard::Error> {
|
|
||||||
MusicHoard::load_from_database(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_to_database(&mut self) -> Result<(), musichoard::Error> {
|
|
||||||
MusicHoard::save_to_database(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_collection(&self) -> &Collection {
|
|
||||||
MusicHoard::get_collection(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// GRCOV_EXCL_STOP
|
|
1
src/tui/lib/external/mod.rs
vendored
Normal file
1
src/tui/lib/external/mod.rs
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod musicbrainz;
|
185
src/tui/lib/external/musicbrainz/api/mod.rs
vendored
Normal file
185
src/tui/lib/external/musicbrainz/api/mod.rs
vendored
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use musichoard::{
|
||||||
|
collection::{
|
||||||
|
album::{AlbumDate, AlbumInfo, AlbumMeta, AlbumSeq},
|
||||||
|
artist::{ArtistInfo, ArtistMeta},
|
||||||
|
musicbrainz::{MbRefOption, Mbid},
|
||||||
|
},
|
||||||
|
external::musicbrainz::{
|
||||||
|
api::{
|
||||||
|
browse::{BrowseReleaseGroupRequest, BrowseReleaseGroupResponse},
|
||||||
|
lookup::{
|
||||||
|
LookupArtistRequest, LookupArtistResponse, LookupReleaseGroupRequest,
|
||||||
|
LookupReleaseGroupResponse,
|
||||||
|
},
|
||||||
|
search::{
|
||||||
|
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
|
||||||
|
SearchReleaseGroupResponseReleaseGroup,
|
||||||
|
},
|
||||||
|
MbArtistMeta, MbReleaseGroupMeta, MusicBrainzClient, PageSettings,
|
||||||
|
},
|
||||||
|
IMusicBrainzHttp,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::lib::interface::musicbrainz::api::{Entity, Error, IMusicBrainz};
|
||||||
|
|
||||||
|
// GRCOV_EXCL_START
|
||||||
|
pub struct MusicBrainz<Http> {
|
||||||
|
client: MusicBrainzClient<Http>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Http> MusicBrainz<Http> {
|
||||||
|
pub fn new(client: MusicBrainzClient<Http>) -> Self {
|
||||||
|
MusicBrainz { client }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
|
||||||
|
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Entity<ArtistMeta>, Error> {
|
||||||
|
let request = LookupArtistRequest::new(mbid);
|
||||||
|
|
||||||
|
let mb_response = self.client.lookup_artist(&request)?;
|
||||||
|
|
||||||
|
Ok(from_lookup_artist_response(mb_response))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Entity<AlbumMeta>, Error> {
|
||||||
|
let request = LookupReleaseGroupRequest::new(mbid);
|
||||||
|
|
||||||
|
let mb_response = self.client.lookup_release_group(&request)?;
|
||||||
|
|
||||||
|
Ok(from_lookup_release_group_response(mb_response))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Entity<ArtistMeta>>, Error> {
|
||||||
|
let query = SearchArtistRequest::new().string(&artist.id.name);
|
||||||
|
|
||||||
|
let paging = PageSettings::default();
|
||||||
|
let mb_response = self.client.search_artist(&query, &paging)?;
|
||||||
|
|
||||||
|
Ok(mb_response
|
||||||
|
.artists
|
||||||
|
.into_iter()
|
||||||
|
.map(from_search_artist_response_artist)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_release_group(
|
||||||
|
&mut self,
|
||||||
|
arid: &Mbid,
|
||||||
|
album: &AlbumMeta,
|
||||||
|
) -> Result<Vec<Entity<AlbumMeta>>, Error> {
|
||||||
|
// Some release groups may have a promotional early release messing up the search. Searching
|
||||||
|
// with just the year should be enough anyway.
|
||||||
|
let date = AlbumDate::new(album.date.year, None, None);
|
||||||
|
|
||||||
|
let query = SearchReleaseGroupRequest::new()
|
||||||
|
.arid(arid)
|
||||||
|
.and()
|
||||||
|
.first_release_date(&date)
|
||||||
|
.and()
|
||||||
|
.release_group(&album.id.title);
|
||||||
|
|
||||||
|
let paging = PageSettings::default();
|
||||||
|
let mb_response = self.client.search_release_group(&query, &paging)?;
|
||||||
|
|
||||||
|
Ok(mb_response
|
||||||
|
.release_groups
|
||||||
|
.into_iter()
|
||||||
|
.map(from_search_release_group_response_release_group)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browse_release_group(
|
||||||
|
&mut self,
|
||||||
|
artist: &Mbid,
|
||||||
|
paging: &mut Option<PageSettings>,
|
||||||
|
) -> Result<Vec<Entity<AlbumMeta>>, Error> {
|
||||||
|
let request = BrowseReleaseGroupRequest::artist(artist);
|
||||||
|
|
||||||
|
let page = paging.take().unwrap_or_default();
|
||||||
|
let mb_response = self.client.browse_release_group(&request, &page)?;
|
||||||
|
|
||||||
|
let page_count = mb_response.release_groups.len();
|
||||||
|
*paging = mb_response.page.next_page(page, page_count);
|
||||||
|
|
||||||
|
Ok(from_browse_release_group_response(mb_response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_mb_artist_meta(meta: MbArtistMeta) -> (ArtistMeta, Option<String>) {
|
||||||
|
let sort = Some(meta.sort_name).filter(|s| s != &meta.name);
|
||||||
|
(
|
||||||
|
ArtistMeta {
|
||||||
|
id: meta.name.into(),
|
||||||
|
sort,
|
||||||
|
info: ArtistInfo {
|
||||||
|
musicbrainz: MbRefOption::Some(meta.id.into()),
|
||||||
|
properties: HashMap::new(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
meta.disambiguation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_mb_release_group_meta(meta: MbReleaseGroupMeta) -> AlbumMeta {
|
||||||
|
AlbumMeta {
|
||||||
|
id: meta.title.into(),
|
||||||
|
date: meta.first_release_date,
|
||||||
|
seq: AlbumSeq::default(),
|
||||||
|
info: AlbumInfo {
|
||||||
|
musicbrainz: MbRefOption::Some(meta.id.into()),
|
||||||
|
primary_type: meta.primary_type,
|
||||||
|
secondary_types: meta.secondary_types.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_lookup_artist_response(response: LookupArtistResponse) -> Entity<ArtistMeta> {
|
||||||
|
let (entity, disambiguation) = from_mb_artist_meta(response.meta);
|
||||||
|
Entity {
|
||||||
|
score: None,
|
||||||
|
entity,
|
||||||
|
disambiguation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_lookup_release_group_response(response: LookupReleaseGroupResponse) -> Entity<AlbumMeta> {
|
||||||
|
Entity {
|
||||||
|
score: None,
|
||||||
|
entity: from_mb_release_group_meta(response.meta),
|
||||||
|
disambiguation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_search_artist_response_artist(response: SearchArtistResponseArtist) -> Entity<ArtistMeta> {
|
||||||
|
let (entity, disambiguation) = from_mb_artist_meta(response.meta);
|
||||||
|
Entity {
|
||||||
|
score: Some(response.score),
|
||||||
|
entity,
|
||||||
|
disambiguation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_search_release_group_response_release_group(
|
||||||
|
response: SearchReleaseGroupResponseReleaseGroup,
|
||||||
|
) -> Entity<AlbumMeta> {
|
||||||
|
Entity {
|
||||||
|
score: Some(response.score),
|
||||||
|
entity: from_mb_release_group_meta(response.meta),
|
||||||
|
disambiguation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_browse_release_group_response(
|
||||||
|
entity: BrowseReleaseGroupResponse,
|
||||||
|
) -> Vec<Entity<AlbumMeta>> {
|
||||||
|
let rgs = entity.release_groups.into_iter();
|
||||||
|
let metas = rgs.map(from_mb_release_group_meta);
|
||||||
|
metas.map(Entity::new).collect()
|
||||||
|
}
|
||||||
|
// GRCOV_EXCL_STOP
|
963
src/tui/lib/external/musicbrainz/daemon/mod.rs
vendored
Normal file
963
src/tui/lib/external/musicbrainz/daemon/mod.rs
vendored
Normal file
@ -0,0 +1,963 @@
|
|||||||
|
use std::{collections::VecDeque, sync::mpsc, thread, time};
|
||||||
|
|
||||||
|
use musichoard::external::musicbrainz::api::PageSettings;
|
||||||
|
|
||||||
|
use crate::tui::{
|
||||||
|
app::EntityMatches,
|
||||||
|
event::IFetchCompleteEventSender,
|
||||||
|
lib::interface::musicbrainz::{
|
||||||
|
api::{Error as ApiError, IMusicBrainz},
|
||||||
|
daemon::{
|
||||||
|
BrowseParams, EntityList, Error, IMbJobSender, LookupParams, MbParams, MbReturn,
|
||||||
|
ResultSender, SearchParams,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct MusicBrainzDaemon {
|
||||||
|
musicbrainz: Box<dyn IMusicBrainz>,
|
||||||
|
job_receiver: mpsc::Receiver<Job>,
|
||||||
|
job_queue: JobQueue,
|
||||||
|
event_sender: Box<dyn IFetchCompleteEventSender>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct JobQueue {
|
||||||
|
foreground_queue: VecDeque<JobInstance>,
|
||||||
|
background_queue: VecDeque<JobInstance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Job {
|
||||||
|
priority: JobPriority,
|
||||||
|
instance: JobInstance,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Job {
|
||||||
|
pub fn new(priority: JobPriority, instance: JobInstance) -> Self {
|
||||||
|
Job { priority, instance }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum JobPriority {
|
||||||
|
Foreground,
|
||||||
|
Background,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct JobInstance {
|
||||||
|
result_sender: ResultSender,
|
||||||
|
requests: VecDeque<MbParams>,
|
||||||
|
paging: Option<PageSettings>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum JobInstanceStatus {
|
||||||
|
Continue,
|
||||||
|
Complete,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum JobInstanceError {
|
||||||
|
ReturnChannelDisconnected,
|
||||||
|
EventChannelDisconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobInstance {
|
||||||
|
fn new(result_sender: ResultSender, requests: VecDeque<MbParams>) -> Self {
|
||||||
|
JobInstance {
|
||||||
|
result_sender,
|
||||||
|
requests,
|
||||||
|
paging: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum JobError {
|
||||||
|
JobQueueEmpty,
|
||||||
|
EventChannelDisconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JobChannel {
|
||||||
|
sender: mpsc::Sender<Job>,
|
||||||
|
receiver: mpsc::Receiver<Job>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JobSender {
|
||||||
|
sender: mpsc::Sender<Job>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct JobReceiver {
|
||||||
|
receiver: mpsc::Receiver<Job>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobChannel {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
JobChannel { receiver, sender }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sender(&self) -> JobSender {
|
||||||
|
JobSender {
|
||||||
|
sender: self.sender.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn receiver(self) -> JobReceiver {
|
||||||
|
JobReceiver {
|
||||||
|
receiver: self.receiver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IMbJobSender for JobSender {
|
||||||
|
fn submit_foreground_job(
|
||||||
|
&self,
|
||||||
|
result_sender: ResultSender,
|
||||||
|
requests: VecDeque<MbParams>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.send_job(JobPriority::Foreground, result_sender, requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit_background_job(
|
||||||
|
&self,
|
||||||
|
result_sender: ResultSender,
|
||||||
|
requests: VecDeque<MbParams>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
self.send_job(JobPriority::Background, result_sender, requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobSender {
|
||||||
|
fn send_job(
|
||||||
|
&self,
|
||||||
|
priority: JobPriority,
|
||||||
|
result_sender: ResultSender,
|
||||||
|
requests: VecDeque<MbParams>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let instance = JobInstance::new(result_sender, requests);
|
||||||
|
let job = Job::new(priority, instance);
|
||||||
|
self.sender.send(job).or(Err(Error::JobChannelDisconnected))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MusicBrainzDaemon {
|
||||||
|
// GRCOV_EXCL_START
|
||||||
|
pub fn run<MB: IMusicBrainz + 'static, ES: IFetchCompleteEventSender + 'static>(
|
||||||
|
musicbrainz: MB,
|
||||||
|
job_receiver: JobReceiver,
|
||||||
|
event_sender: ES,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let daemon = MusicBrainzDaemon {
|
||||||
|
musicbrainz: Box::new(musicbrainz),
|
||||||
|
job_receiver: job_receiver.receiver,
|
||||||
|
job_queue: JobQueue::new(),
|
||||||
|
event_sender: Box::new(event_sender),
|
||||||
|
};
|
||||||
|
daemon.main()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main(mut self) -> Result<(), Error> {
|
||||||
|
loop {
|
||||||
|
self.enqueue_all_pending_jobs()?;
|
||||||
|
match self.execute_next_job() {
|
||||||
|
Ok(()) => {
|
||||||
|
// Sleep for one second. Required by MB API rate limiting. Assume all other
|
||||||
|
// processing takes negligible time such that regardless of how much other
|
||||||
|
// processing there is to be done, this one second sleep is necessary.
|
||||||
|
thread::sleep(time::Duration::from_secs(1));
|
||||||
|
}
|
||||||
|
Err(JobError::JobQueueEmpty) => {
|
||||||
|
self.wait_for_jobs()?;
|
||||||
|
}
|
||||||
|
Err(JobError::EventChannelDisconnected) => {
|
||||||
|
return Err(Error::EventChannelDisconnected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GRCOV_EXCL_STOP
|
||||||
|
|
||||||
|
fn enqueue_all_pending_jobs(&mut self) -> Result<(), Error> {
|
||||||
|
loop {
|
||||||
|
match self.job_receiver.try_recv() {
|
||||||
|
Ok(job) => self.job_queue.push_back(job),
|
||||||
|
Err(mpsc::TryRecvError::Empty) => return Ok(()),
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
|
return Err(Error::JobChannelDisconnected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_next_job(&mut self) -> Result<(), JobError> {
|
||||||
|
if let Some(instance) = self.job_queue.front_mut() {
|
||||||
|
let result = instance.execute_next(&mut *self.musicbrainz, &mut *self.event_sender);
|
||||||
|
match result {
|
||||||
|
Ok(JobInstanceStatus::Continue) => {}
|
||||||
|
Ok(JobInstanceStatus::Complete)
|
||||||
|
| Err(JobInstanceError::ReturnChannelDisconnected) => {
|
||||||
|
self.job_queue.pop_front();
|
||||||
|
}
|
||||||
|
Err(JobInstanceError::EventChannelDisconnected) => {
|
||||||
|
return Err(JobError::EventChannelDisconnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(JobError::JobQueueEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_jobs(&mut self) -> Result<(), Error> {
|
||||||
|
assert!(self.job_queue.is_empty());
|
||||||
|
|
||||||
|
match self.job_receiver.recv() {
|
||||||
|
Ok(job) => self.job_queue.push_back(job),
|
||||||
|
Err(mpsc::RecvError) => return Err(Error::JobChannelDisconnected),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobInstance {
|
||||||
|
fn execute_next(
|
||||||
|
&mut self,
|
||||||
|
musicbrainz: &mut dyn IMusicBrainz,
|
||||||
|
event_sender: &mut dyn IFetchCompleteEventSender,
|
||||||
|
) -> Result<JobInstanceStatus, JobInstanceError> {
|
||||||
|
// self.requests can be empty if the caller submits an empty job.
|
||||||
|
if let Some(params) = self.requests.front() {
|
||||||
|
let result_sender = &mut self.result_sender;
|
||||||
|
let paging = &mut self.paging;
|
||||||
|
Self::execute(musicbrainz, result_sender, event_sender, params, paging)?;
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.paging.is_none() {
|
||||||
|
self.requests.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.requests.is_empty() {
|
||||||
|
Ok(JobInstanceStatus::Complete)
|
||||||
|
} else {
|
||||||
|
Ok(JobInstanceStatus::Continue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(
|
||||||
|
musicbrainz: &mut dyn IMusicBrainz,
|
||||||
|
result_sender: &mut ResultSender,
|
||||||
|
event_sender: &mut dyn IFetchCompleteEventSender,
|
||||||
|
api_params: &MbParams,
|
||||||
|
paging: &mut Option<PageSettings>,
|
||||||
|
) -> Result<(), JobInstanceError> {
|
||||||
|
let result = match api_params {
|
||||||
|
MbParams::Lookup(lookup) => match lookup {
|
||||||
|
LookupParams::Artist(p) => musicbrainz
|
||||||
|
.lookup_artist(&p.mbid)
|
||||||
|
.map(|rv| EntityMatches::artist_lookup(p.artist.clone(), rv)),
|
||||||
|
LookupParams::ReleaseGroup(p) => {
|
||||||
|
musicbrainz.lookup_release_group(&p.mbid).map(|rv| {
|
||||||
|
EntityMatches::album_lookup(p.artist_id.clone(), p.album_id.clone(), rv)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(MbReturn::Match),
|
||||||
|
MbParams::Search(search) => match search {
|
||||||
|
SearchParams::Artist(p) => musicbrainz
|
||||||
|
.search_artist(&p.artist)
|
||||||
|
.map(|rv| EntityMatches::artist_search(p.artist.clone(), rv)),
|
||||||
|
SearchParams::ReleaseGroup(p) => musicbrainz
|
||||||
|
.search_release_group(&p.artist_mbid, &p.album)
|
||||||
|
.map(|rv| {
|
||||||
|
EntityMatches::album_search(p.artist_id.clone(), p.album.id.clone(), rv)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
.map(MbReturn::Match),
|
||||||
|
MbParams::Browse(browse) => match browse {
|
||||||
|
BrowseParams::ReleaseGroup(params) => {
|
||||||
|
Self::init_paging_if_none(paging);
|
||||||
|
musicbrainz
|
||||||
|
.browse_release_group(¶ms.artist, paging)
|
||||||
|
.map(|rv| EntityList::Album(rv.into_iter().map(|rg| rg.entity).collect()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(MbReturn::Fetch),
|
||||||
|
};
|
||||||
|
Self::return_result(result_sender, event_sender, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_paging_if_none(paging: &mut Option<PageSettings>) {
|
||||||
|
if paging.is_none() {
|
||||||
|
*paging = Some(PageSettings::with_max_limit());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn return_result(
|
||||||
|
result_sender: &mut ResultSender,
|
||||||
|
event_sender: &mut dyn IFetchCompleteEventSender,
|
||||||
|
result: Result<MbReturn, ApiError>,
|
||||||
|
) -> Result<(), JobInstanceError> {
|
||||||
|
result_sender
|
||||||
|
.send(result)
|
||||||
|
.map_err(|_| JobInstanceError::ReturnChannelDisconnected)?;
|
||||||
|
|
||||||
|
// If this send fails the event listener is dead. Don't panic as this function runs in a
|
||||||
|
// detached thread so this might be happening during normal shut down.
|
||||||
|
event_sender
|
||||||
|
.send_fetch_complete()
|
||||||
|
.map_err(|_| JobInstanceError::EventChannelDisconnected)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobQueue {
|
||||||
|
fn new() -> Self {
|
||||||
|
JobQueue {
|
||||||
|
foreground_queue: VecDeque::new(),
|
||||||
|
background_queue: VecDeque::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.foreground_queue.is_empty() && self.background_queue.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn front_mut(&mut self) -> Option<&mut JobInstance> {
|
||||||
|
self.foreground_queue
|
||||||
|
.front_mut()
|
||||||
|
.or_else(|| self.background_queue.front_mut())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop_front(&mut self) -> Option<JobInstance> {
|
||||||
|
self.foreground_queue
|
||||||
|
.pop_front()
|
||||||
|
.or_else(|| self.background_queue.pop_front())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_back(&mut self, job: Job) {
|
||||||
|
match job.priority {
|
||||||
|
JobPriority::Foreground => self.foreground_queue.push_back(job.instance),
|
||||||
|
JobPriority::Background => self.background_queue.push_back(job.instance),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use mockall::{predicate, Sequence};
|
||||||
|
use musichoard::collection::{
|
||||||
|
album::AlbumMeta,
|
||||||
|
artist::{ArtistId, ArtistMeta},
|
||||||
|
musicbrainz::{IMusicBrainzRef, MbRefOption, Mbid},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::{
|
||||||
|
event::{Event, EventError, MockIFetchCompleteEventSender},
|
||||||
|
lib::interface::musicbrainz::api::{Entity, MockIMusicBrainz},
|
||||||
|
testmod::COLLECTION,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn mb_ref_opt_unwrap<T>(opt: MbRefOption<T>) -> T {
|
||||||
|
match opt {
|
||||||
|
MbRefOption::Some(val) => val,
|
||||||
|
_ => panic!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mb_ref_opt_as_ref<T>(opt: &MbRefOption<T>) -> MbRefOption<&T> {
|
||||||
|
match *opt {
|
||||||
|
MbRefOption::Some(ref x) => MbRefOption::Some(x),
|
||||||
|
MbRefOption::CannotHaveMbid => MbRefOption::CannotHaveMbid,
|
||||||
|
MbRefOption::None => MbRefOption::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn musicbrainz() -> MockIMusicBrainz {
|
||||||
|
MockIMusicBrainz::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_channel() -> (JobSender, JobReceiver) {
|
||||||
|
let channel = JobChannel::new();
|
||||||
|
let sender = channel.sender();
|
||||||
|
let receiver = channel.receiver();
|
||||||
|
(sender, receiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_sender() -> MockIFetchCompleteEventSender {
|
||||||
|
MockIFetchCompleteEventSender::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_complete_expectation(event_sender: &mut MockIFetchCompleteEventSender, times: usize) {
|
||||||
|
event_sender
|
||||||
|
.expect_send_fetch_complete()
|
||||||
|
.times(times)
|
||||||
|
.returning(|| Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn daemon(job_receiver: JobReceiver) -> MusicBrainzDaemon {
|
||||||
|
MusicBrainzDaemon {
|
||||||
|
musicbrainz: Box::new(musicbrainz()),
|
||||||
|
job_receiver: job_receiver.receiver,
|
||||||
|
job_queue: JobQueue::new(),
|
||||||
|
event_sender: Box::new(event_sender()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn daemon_with(
|
||||||
|
musicbrainz: MockIMusicBrainz,
|
||||||
|
job_receiver: JobReceiver,
|
||||||
|
event_sender: MockIFetchCompleteEventSender,
|
||||||
|
) -> MusicBrainzDaemon {
|
||||||
|
MusicBrainzDaemon {
|
||||||
|
musicbrainz: Box::new(musicbrainz),
|
||||||
|
job_receiver: job_receiver.receiver,
|
||||||
|
job_queue: JobQueue::new(),
|
||||||
|
event_sender: Box::new(event_sender),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mbid() -> Mbid {
|
||||||
|
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_artist_requests() -> VecDeque<MbParams> {
|
||||||
|
let artist = COLLECTION[3].meta.clone();
|
||||||
|
let mbid = mbid();
|
||||||
|
VecDeque::from([MbParams::lookup_artist(artist, mbid)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_release_group_requests() -> VecDeque<MbParams> {
|
||||||
|
let artist_id = COLLECTION[1].meta.id.clone();
|
||||||
|
let album_id = COLLECTION[1].albums[0].meta.id.clone();
|
||||||
|
let mbid = mbid();
|
||||||
|
VecDeque::from([MbParams::lookup_release_group(artist_id, album_id, mbid)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_artist_requests() -> VecDeque<MbParams> {
|
||||||
|
let artist = COLLECTION[3].meta.clone();
|
||||||
|
VecDeque::from([MbParams::search_artist(artist)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_artist_expectations() -> (ArtistMeta, Vec<Entity<ArtistMeta>>) {
|
||||||
|
let artist = COLLECTION[3].meta.clone();
|
||||||
|
|
||||||
|
let artist_match_1 = Entity::with_score(artist.clone(), 100);
|
||||||
|
let artist_match_2 = Entity::with_score(artist.clone(), 50);
|
||||||
|
let matches = vec![artist_match_1.clone(), artist_match_2.clone()];
|
||||||
|
|
||||||
|
(artist, matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_albums_requests() -> VecDeque<MbParams> {
|
||||||
|
let mbref = mb_ref_opt_as_ref(&COLLECTION[1].meta.info.musicbrainz);
|
||||||
|
let arid = mb_ref_opt_unwrap(mbref).mbid().clone();
|
||||||
|
|
||||||
|
let artist_id = COLLECTION[1].meta.id.clone();
|
||||||
|
let album_1 = COLLECTION[1].albums[0].meta.clone();
|
||||||
|
let album_4 = COLLECTION[1].albums[3].meta.clone();
|
||||||
|
|
||||||
|
VecDeque::from([
|
||||||
|
MbParams::search_release_group(artist_id.clone(), arid.clone(), album_1),
|
||||||
|
MbParams::search_release_group(artist_id.clone(), arid.clone(), album_4),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browse_albums_requests() -> VecDeque<MbParams> {
|
||||||
|
let mbref = mb_ref_opt_as_ref(&COLLECTION[1].meta.info.musicbrainz);
|
||||||
|
let arid = mb_ref_opt_unwrap(mbref).mbid().clone();
|
||||||
|
VecDeque::from([MbParams::browse_release_group(arid)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_artist_id() -> ArtistId {
|
||||||
|
COLLECTION[1].meta.id.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_arid_expectation() -> Mbid {
|
||||||
|
let mbref = mb_ref_opt_as_ref(&COLLECTION[1].meta.info.musicbrainz);
|
||||||
|
mb_ref_opt_unwrap(mbref).mbid().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_album_expectations_1() -> (AlbumMeta, Vec<Entity<AlbumMeta>>) {
|
||||||
|
let album_1 = COLLECTION[1].albums[0].meta.clone();
|
||||||
|
let album_4 = COLLECTION[1].albums[3].meta.clone();
|
||||||
|
|
||||||
|
let album_match_1_1 = Entity::with_score(album_1.clone(), 100);
|
||||||
|
let album_match_1_2 = Entity::with_score(album_4.clone(), 50);
|
||||||
|
let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()];
|
||||||
|
|
||||||
|
(album_1, matches_1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_album_expectations_4() -> (AlbumMeta, Vec<Entity<AlbumMeta>>) {
|
||||||
|
let album_1 = COLLECTION[1].albums[0].meta.clone();
|
||||||
|
let album_4 = COLLECTION[1].albums[3].meta.clone();
|
||||||
|
|
||||||
|
let album_match_4_1 = Entity::with_score(album_4.clone(), 100);
|
||||||
|
let album_match_4_2 = Entity::with_score(album_1.clone(), 30);
|
||||||
|
let matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()];
|
||||||
|
|
||||||
|
(album_4, matches_4)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enqueue_job_channel_disconnected() {
|
||||||
|
let (_, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon(job_receiver);
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Err(Error::JobChannelDisconnected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wait_for_job_channel_disconnected() {
|
||||||
|
let (_, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon(job_receiver);
|
||||||
|
let result = daemon.wait_for_jobs();
|
||||||
|
assert_eq!(result, Err(Error::JobChannelDisconnected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enqueue_job_queue_empty() {
|
||||||
|
let (_job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon(job_receiver);
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enqueue_job() {
|
||||||
|
let (job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon(job_receiver);
|
||||||
|
|
||||||
|
let requests = search_artist_requests();
|
||||||
|
let (result_sender, _) = mpsc::channel();
|
||||||
|
let result = job_sender.submit_background_job(result_sender, requests.clone());
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
assert_eq!(daemon.job_queue.pop_front().unwrap().requests, requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wait_for_jobs_job() {
|
||||||
|
let (job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon(job_receiver);
|
||||||
|
|
||||||
|
let requests = search_artist_requests();
|
||||||
|
let (result_sender, _) = mpsc::channel();
|
||||||
|
let result = job_sender.submit_background_job(result_sender, requests.clone());
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.wait_for_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
assert_eq!(daemon.job_queue.pop_front().unwrap().requests, requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_empty() {
|
||||||
|
let (_job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon(job_receiver);
|
||||||
|
|
||||||
|
let (result_sender, _) = mpsc::channel();
|
||||||
|
let mut instance = JobInstance::new(result_sender, VecDeque::new());
|
||||||
|
let result = instance.execute_next(&mut musicbrainz(), &mut event_sender());
|
||||||
|
assert_eq!(result, Ok(JobInstanceStatus::Complete));
|
||||||
|
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_artist_expectation(
|
||||||
|
musicbrainz: &mut MockIMusicBrainz,
|
||||||
|
mbid: &Mbid,
|
||||||
|
lookup: &Entity<ArtistMeta>,
|
||||||
|
) {
|
||||||
|
let result = Ok(lookup.clone());
|
||||||
|
musicbrainz
|
||||||
|
.expect_lookup_artist()
|
||||||
|
.with(predicate::eq(mbid.clone()))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_| result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_lookup_artist() {
|
||||||
|
let mut musicbrainz = musicbrainz();
|
||||||
|
let mbid = mbid();
|
||||||
|
let artist = COLLECTION[3].meta.clone();
|
||||||
|
let lookup = Entity::new(artist.clone());
|
||||||
|
lookup_artist_expectation(&mut musicbrainz, &mbid, &lookup);
|
||||||
|
|
||||||
|
let mut event_sender = event_sender();
|
||||||
|
fetch_complete_expectation(&mut event_sender, 1);
|
||||||
|
|
||||||
|
let (job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||||
|
|
||||||
|
let requests = lookup_artist_requests();
|
||||||
|
let (result_sender, result_receiver) = mpsc::channel();
|
||||||
|
let result = job_sender.submit_foreground_job(result_sender, requests);
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||||
|
|
||||||
|
let result = result_receiver.try_recv().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(MbReturn::Match(EntityMatches::artist_lookup(
|
||||||
|
artist, lookup
|
||||||
|
)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup_release_group_expectation(
|
||||||
|
musicbrainz: &mut MockIMusicBrainz,
|
||||||
|
mbid: &Mbid,
|
||||||
|
lookup: &Entity<AlbumMeta>,
|
||||||
|
) {
|
||||||
|
let result = Ok(lookup.clone());
|
||||||
|
musicbrainz
|
||||||
|
.expect_lookup_release_group()
|
||||||
|
.with(predicate::eq(mbid.clone()))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_| result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_lookup_release_group() {
|
||||||
|
let mut musicbrainz = musicbrainz();
|
||||||
|
let mbid = mbid();
|
||||||
|
let album_id = COLLECTION[1].albums[0].meta.id.clone();
|
||||||
|
let album_meta = COLLECTION[1].albums[0].meta.clone();
|
||||||
|
let lookup = Entity::new(album_meta.clone());
|
||||||
|
lookup_release_group_expectation(&mut musicbrainz, &mbid, &lookup);
|
||||||
|
|
||||||
|
let mut event_sender = event_sender();
|
||||||
|
fetch_complete_expectation(&mut event_sender, 1);
|
||||||
|
|
||||||
|
let (job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||||
|
|
||||||
|
let requests = lookup_release_group_requests();
|
||||||
|
let (result_sender, result_receiver) = mpsc::channel();
|
||||||
|
let result = job_sender.submit_foreground_job(result_sender, requests);
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||||
|
|
||||||
|
let result = result_receiver.try_recv().unwrap();
|
||||||
|
let artist_id = album_artist_id();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(MbReturn::Match(EntityMatches::album_lookup(
|
||||||
|
artist_id, album_id, lookup
|
||||||
|
)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_artist_expectation(
|
||||||
|
musicbrainz: &mut MockIMusicBrainz,
|
||||||
|
artist: &ArtistMeta,
|
||||||
|
matches: &[Entity<ArtistMeta>],
|
||||||
|
) {
|
||||||
|
let result = Ok(matches.to_owned());
|
||||||
|
musicbrainz
|
||||||
|
.expect_search_artist()
|
||||||
|
.with(predicate::eq(artist.clone()))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_| result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_search_artist() {
|
||||||
|
let mut musicbrainz = musicbrainz();
|
||||||
|
let (artist, matches) = search_artist_expectations();
|
||||||
|
search_artist_expectation(&mut musicbrainz, &artist, &matches);
|
||||||
|
|
||||||
|
let mut event_sender = event_sender();
|
||||||
|
fetch_complete_expectation(&mut event_sender, 1);
|
||||||
|
|
||||||
|
let (job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||||
|
|
||||||
|
let requests = search_artist_requests();
|
||||||
|
let (result_sender, result_receiver) = mpsc::channel();
|
||||||
|
let result = job_sender.submit_background_job(result_sender, requests);
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||||
|
|
||||||
|
let result = result_receiver.try_recv().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Ok(MbReturn::Match(EntityMatches::artist_search(
|
||||||
|
artist, matches
|
||||||
|
)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_release_group_expectation(
|
||||||
|
musicbrainz: &mut MockIMusicBrainz,
|
||||||
|
seq: &mut Sequence,
|
||||||
|
arid: &Mbid,
|
||||||
|
album: &AlbumMeta,
|
||||||
|
matches: &[Entity<AlbumMeta>],
|
||||||
|
) {
|
||||||
|
let result = Ok(matches.to_owned());
|
||||||
|
musicbrainz
|
||||||
|
.expect_search_release_group()
|
||||||
|
.with(predicate::eq(arid.clone()), predicate::eq(album.clone()))
|
||||||
|
.times(1)
|
||||||
|
.in_sequence(seq)
|
||||||
|
.return_once(|_, _| result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_search_release_groups() {
|
||||||
|
let mut musicbrainz = musicbrainz();
|
||||||
|
let arid = album_arid_expectation();
|
||||||
|
let (album_1, matches_1) = search_album_expectations_1();
|
||||||
|
let (album_4, matches_4) = search_album_expectations_4();
|
||||||
|
|
||||||
|
let mut seq = Sequence::new();
|
||||||
|
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1);
|
||||||
|
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_4, &matches_4);
|
||||||
|
|
||||||
|
let mut event_sender = event_sender();
|
||||||
|
fetch_complete_expectation(&mut event_sender, 2);
|
||||||
|
|
||||||
|
let (job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||||
|
|
||||||
|
let requests = search_albums_requests();
|
||||||
|
let (result_sender, result_receiver) = mpsc::channel();
|
||||||
|
let result = job_sender.submit_background_job(result_sender, requests);
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||||
|
|
||||||
|
let artist_id = album_artist_id();
|
||||||
|
|
||||||
|
let result = result_receiver.try_recv().unwrap();
|
||||||
|
let matches = EntityMatches::album_search(artist_id.clone(), album_1.id, matches_1);
|
||||||
|
assert_eq!(result, Ok(MbReturn::Match(matches)));
|
||||||
|
|
||||||
|
let result = result_receiver.try_recv().unwrap();
|
||||||
|
let matches = EntityMatches::album_search(artist_id.clone(), album_4.id, matches_4);
|
||||||
|
assert_eq!(result, Ok(MbReturn::Match(matches)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_search_release_groups_result_disconnect() {
|
||||||
|
let mut musicbrainz = musicbrainz();
|
||||||
|
let arid = album_arid_expectation();
|
||||||
|
let (album_1, matches_1) = search_album_expectations_1();
|
||||||
|
|
||||||
|
let mut seq = Sequence::new();
|
||||||
|
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1);
|
||||||
|
|
||||||
|
let mut event_sender = event_sender();
|
||||||
|
fetch_complete_expectation(&mut event_sender, 0);
|
||||||
|
|
||||||
|
let (job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||||
|
|
||||||
|
let requests = search_albums_requests();
|
||||||
|
let (result_sender, _) = mpsc::channel();
|
||||||
|
let result = job_sender.submit_background_job(result_sender, requests);
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
// Two albums were submitted, but as one job. If the first one fails due to the
|
||||||
|
// result_channel disconnecting, all remaining requests in that job are dropped.
|
||||||
|
assert!(daemon.job_queue.pop_front().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_search_artist_event_disconnect() {
|
||||||
|
let mut musicbrainz = musicbrainz();
|
||||||
|
let (artist, matches) = search_artist_expectations();
|
||||||
|
search_artist_expectation(&mut musicbrainz, &artist, &matches);
|
||||||
|
|
||||||
|
let mut event_sender = event_sender();
|
||||||
|
event_sender
|
||||||
|
.expect_send_fetch_complete()
|
||||||
|
.times(1)
|
||||||
|
.return_once(|| Err(EventError::Send(Event::FetchComplete)));
|
||||||
|
|
||||||
|
let (job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||||
|
|
||||||
|
let requests = search_artist_requests();
|
||||||
|
let (result_sender, _result_receiver) = mpsc::channel();
|
||||||
|
let result = job_sender.submit_background_job(result_sender, requests);
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Err(JobError::EventChannelDisconnected));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browse_release_group_expectation(
|
||||||
|
musicbrainz: &mut MockIMusicBrainz,
|
||||||
|
seq: &mut Sequence,
|
||||||
|
mbid: &Mbid,
|
||||||
|
page: Option<PageSettings>,
|
||||||
|
matches: &[Entity<AlbumMeta>],
|
||||||
|
next_page: Option<PageSettings>,
|
||||||
|
) {
|
||||||
|
let result = Ok(matches.to_owned());
|
||||||
|
musicbrainz
|
||||||
|
.expect_browse_release_group()
|
||||||
|
.with(predicate::eq(mbid.clone()), predicate::eq(page))
|
||||||
|
.times(1)
|
||||||
|
.in_sequence(seq)
|
||||||
|
.return_once(move |_, paging| {
|
||||||
|
*paging = next_page;
|
||||||
|
result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn execute_browse_release_groups() {
|
||||||
|
let mut musicbrainz = musicbrainz();
|
||||||
|
let arid = album_arid_expectation();
|
||||||
|
let (_, matches_1) = search_album_expectations_1();
|
||||||
|
let (_, matches_4) = search_album_expectations_4();
|
||||||
|
|
||||||
|
let mut seq = Sequence::new();
|
||||||
|
|
||||||
|
let page = Some(PageSettings::with_max_limit());
|
||||||
|
let next = Some(PageSettings::with_max_limit().with_offset(10));
|
||||||
|
browse_release_group_expectation(&mut musicbrainz, &mut seq, &arid, page, &matches_1, next);
|
||||||
|
|
||||||
|
let page = next;
|
||||||
|
let next = None;
|
||||||
|
browse_release_group_expectation(&mut musicbrainz, &mut seq, &arid, page, &matches_4, next);
|
||||||
|
|
||||||
|
let mut event_sender = event_sender();
|
||||||
|
fetch_complete_expectation(&mut event_sender, 2);
|
||||||
|
|
||||||
|
let (job_sender, job_receiver) = job_channel();
|
||||||
|
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||||
|
|
||||||
|
let requests = browse_albums_requests();
|
||||||
|
let (result_sender, result_receiver) = mpsc::channel();
|
||||||
|
let result = job_sender.submit_background_job(result_sender, requests);
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.enqueue_all_pending_jobs();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Ok(()));
|
||||||
|
|
||||||
|
let result = daemon.execute_next_job();
|
||||||
|
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||||
|
|
||||||
|
let result = result_receiver.try_recv().unwrap();
|
||||||
|
let fetch = EntityList::Album(matches_1.into_iter().map(|m| m.entity).collect());
|
||||||
|
assert_eq!(result, Ok(MbReturn::Fetch(fetch)));
|
||||||
|
|
||||||
|
let result = result_receiver.try_recv().unwrap();
|
||||||
|
let fetch = EntityList::Album(matches_4.into_iter().map(|m| m.entity).collect());
|
||||||
|
assert_eq!(result, Ok(MbReturn::Fetch(fetch)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn job_queue() {
|
||||||
|
let mut queue = JobQueue::new();
|
||||||
|
assert!(queue.is_empty());
|
||||||
|
assert_eq!(queue.foreground_queue.len(), 0);
|
||||||
|
assert_eq!(queue.background_queue.len(), 0);
|
||||||
|
|
||||||
|
let (result_sender, _) = mpsc::channel();
|
||||||
|
let bg = Job::new(
|
||||||
|
JobPriority::Background,
|
||||||
|
JobInstance::new(result_sender, VecDeque::new()),
|
||||||
|
);
|
||||||
|
queue.push_back(bg);
|
||||||
|
|
||||||
|
assert!(!queue.is_empty());
|
||||||
|
assert_eq!(queue.foreground_queue.len(), 0);
|
||||||
|
assert_eq!(queue.background_queue.len(), 1);
|
||||||
|
|
||||||
|
let (result_sender, _) = mpsc::channel();
|
||||||
|
let fg = Job::new(
|
||||||
|
JobPriority::Foreground,
|
||||||
|
JobInstance::new(result_sender, VecDeque::new()),
|
||||||
|
);
|
||||||
|
queue.push_back(fg);
|
||||||
|
|
||||||
|
assert!(!queue.is_empty());
|
||||||
|
assert_eq!(queue.foreground_queue.len(), 1);
|
||||||
|
assert_eq!(queue.background_queue.len(), 1);
|
||||||
|
|
||||||
|
let instance = queue.pop_front();
|
||||||
|
assert!(instance.is_some());
|
||||||
|
assert!(!queue.is_empty());
|
||||||
|
assert_eq!(queue.foreground_queue.len(), 0);
|
||||||
|
assert_eq!(queue.background_queue.len(), 1);
|
||||||
|
|
||||||
|
let instance = queue.pop_front();
|
||||||
|
assert!(instance.is_some());
|
||||||
|
assert!(queue.is_empty());
|
||||||
|
assert_eq!(queue.foreground_queue.len(), 0);
|
||||||
|
assert_eq!(queue.background_queue.len(), 0);
|
||||||
|
|
||||||
|
let instance = queue.pop_front();
|
||||||
|
assert!(instance.is_none());
|
||||||
|
assert!(queue.is_empty());
|
||||||
|
}
|
||||||
|
}
|
2
src/tui/lib/external/musicbrainz/mod.rs
vendored
Normal file
2
src/tui/lib/external/musicbrainz/mod.rs
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod api;
|
||||||
|
pub mod daemon;
|
1
src/tui/lib/interface/mod.rs
Normal file
1
src/tui/lib/interface/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod musicbrainz;
|
45
src/tui/lib/interface/musicbrainz/api/mod.rs
Normal file
45
src/tui/lib/interface/musicbrainz/api/mod.rs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
//! Module for accessing MusicBrainz metadata.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use mockall::automock;
|
||||||
|
|
||||||
|
use musichoard::{
|
||||||
|
collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::Mbid},
|
||||||
|
external::musicbrainz::api::PageSettings,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg_attr(test, automock)]
|
||||||
|
pub trait IMusicBrainz {
|
||||||
|
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Entity<ArtistMeta>, Error>;
|
||||||
|
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Entity<AlbumMeta>, Error>;
|
||||||
|
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Entity<ArtistMeta>>, Error>;
|
||||||
|
fn search_release_group(
|
||||||
|
&mut self,
|
||||||
|
arid: &Mbid,
|
||||||
|
album: &AlbumMeta,
|
||||||
|
) -> Result<Vec<Entity<AlbumMeta>>, Error>;
|
||||||
|
fn browse_release_group(
|
||||||
|
&mut self,
|
||||||
|
artist: &Mbid,
|
||||||
|
paging: &mut Option<PageSettings>,
|
||||||
|
) -> Result<Vec<Entity<AlbumMeta>>, Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Entity<T> {
|
||||||
|
pub score: Option<u8>,
|
||||||
|
pub entity: T,
|
||||||
|
pub disambiguation: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Entity<T> {
|
||||||
|
pub fn new(entity: T) -> Self {
|
||||||
|
Entity {
|
||||||
|
score: None,
|
||||||
|
entity,
|
||||||
|
disambiguation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Error = musichoard::external::musicbrainz::api::Error;
|
156
src/tui/lib/interface/musicbrainz/daemon/mod.rs
Normal file
156
src/tui/lib/interface/musicbrainz/daemon/mod.rs
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
use std::{collections::VecDeque, fmt, sync::mpsc};
|
||||||
|
|
||||||
|
use musichoard::collection::{
|
||||||
|
album::{AlbumId, AlbumMeta},
|
||||||
|
artist::{ArtistId, ArtistMeta},
|
||||||
|
musicbrainz::Mbid,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::{app::EntityMatches, lib::interface::musicbrainz::api::Error as MbApiError};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use mockall::automock;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Error {
|
||||||
|
EventChannelDisconnected,
|
||||||
|
JobChannelDisconnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Error::EventChannelDisconnected => write!(f, "the event channel is disconnected"),
|
||||||
|
Error::JobChannelDisconnected => write!(f, "the job channel is disconnected"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type MbApiResult = Result<MbReturn, MbApiError>;
|
||||||
|
pub type ResultSender = mpsc::Sender<MbApiResult>;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum MbReturn {
|
||||||
|
Match(EntityMatches),
|
||||||
|
Fetch(EntityList),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum EntityList {
|
||||||
|
Album(Vec<AlbumMeta>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(test, automock)]
|
||||||
|
pub trait IMbJobSender {
|
||||||
|
fn submit_foreground_job(
|
||||||
|
&self,
|
||||||
|
result_sender: ResultSender,
|
||||||
|
requests: VecDeque<MbParams>,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
|
||||||
|
fn submit_background_job(
|
||||||
|
&self,
|
||||||
|
result_sender: ResultSender,
|
||||||
|
requests: VecDeque<MbParams>,
|
||||||
|
) -> Result<(), Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum MbParams {
|
||||||
|
Lookup(LookupParams),
|
||||||
|
Search(SearchParams),
|
||||||
|
#[allow(dead_code)] // TODO: remove with completion of #160
|
||||||
|
Browse(BrowseParams),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum LookupParams {
|
||||||
|
Artist(LookupArtistParams),
|
||||||
|
ReleaseGroup(LookupReleaseGroupParams),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct LookupArtistParams {
|
||||||
|
pub artist: ArtistMeta,
|
||||||
|
pub mbid: Mbid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct LookupReleaseGroupParams {
|
||||||
|
pub artist_id: ArtistId,
|
||||||
|
pub album_id: AlbumId,
|
||||||
|
pub mbid: Mbid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum SearchParams {
|
||||||
|
Artist(SearchArtistParams),
|
||||||
|
ReleaseGroup(SearchReleaseGroupParams),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SearchArtistParams {
|
||||||
|
pub artist: ArtistMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct SearchReleaseGroupParams {
|
||||||
|
pub artist_id: ArtistId,
|
||||||
|
pub artist_mbid: Mbid,
|
||||||
|
pub album: AlbumMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum BrowseParams {
|
||||||
|
#[allow(dead_code)] // TODO: remove with completion of #160
|
||||||
|
ReleaseGroup(BrowseReleaseGroupParams),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct BrowseReleaseGroupParams {
|
||||||
|
pub artist: Mbid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MbParams {
|
||||||
|
pub fn lookup_artist(artist: ArtistMeta, mbid: Mbid) -> Self {
|
||||||
|
MbParams::Lookup(LookupParams::Artist(LookupArtistParams { artist, mbid }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn lookup_release_group(artist_id: ArtistId, album_id: AlbumId, mbid: Mbid) -> Self {
|
||||||
|
MbParams::Lookup(LookupParams::ReleaseGroup(LookupReleaseGroupParams {
|
||||||
|
artist_id,
|
||||||
|
album_id,
|
||||||
|
mbid,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_artist(artist: ArtistMeta) -> Self {
|
||||||
|
MbParams::Search(SearchParams::Artist(SearchArtistParams { artist }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn search_release_group(artist_id: ArtistId, artist_mbid: Mbid, album: AlbumMeta) -> Self {
|
||||||
|
MbParams::Search(SearchParams::ReleaseGroup(SearchReleaseGroupParams {
|
||||||
|
artist_id,
|
||||||
|
artist_mbid,
|
||||||
|
album,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // TODO: to be removed by completion of #160
|
||||||
|
pub fn browse_release_group(artist: Mbid) -> Self {
|
||||||
|
MbParams::Browse(BrowseParams::ReleaseGroup(BrowseReleaseGroupParams {
|
||||||
|
artist,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors() {
|
||||||
|
assert!(!format!("{}", Error::EventChannelDisconnected).is_empty());
|
||||||
|
assert!(!format!("{}", Error::JobChannelDisconnected).is_empty());
|
||||||
|
}
|
||||||
|
}
|
2
src/tui/lib/interface/musicbrainz/mod.rs
Normal file
2
src/tui/lib/interface/musicbrainz/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod api;
|
||||||
|
pub mod daemon;
|
67
src/tui/lib/mod.rs
Normal file
67
src/tui/lib/mod.rs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
pub mod external;
|
||||||
|
pub mod interface;
|
||||||
|
|
||||||
|
use musichoard::{
|
||||||
|
collection::{
|
||||||
|
album::{AlbumId, AlbumInfo},
|
||||||
|
artist::{ArtistId, ArtistInfo},
|
||||||
|
Collection,
|
||||||
|
},
|
||||||
|
interface::{database::IDatabase, library::ILibrary},
|
||||||
|
IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary, MusicHoard,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use mockall::automock;
|
||||||
|
|
||||||
|
#[cfg_attr(test, automock)]
|
||||||
|
pub trait IMusicHoard {
|
||||||
|
fn rescan_library(&mut self) -> Result<(), musichoard::Error>;
|
||||||
|
fn reload_database(&mut self) -> Result<(), musichoard::Error>;
|
||||||
|
fn get_collection(&self) -> &Collection;
|
||||||
|
|
||||||
|
fn merge_artist_info(
|
||||||
|
&mut self,
|
||||||
|
id: &ArtistId,
|
||||||
|
info: ArtistInfo,
|
||||||
|
) -> Result<(), musichoard::Error>;
|
||||||
|
fn merge_album_info(
|
||||||
|
&mut self,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
album_id: &AlbumId,
|
||||||
|
info: AlbumInfo,
|
||||||
|
) -> Result<(), musichoard::Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GRCOV_EXCL_START
|
||||||
|
impl<Database: IDatabase, Library: ILibrary> IMusicHoard for MusicHoard<Database, Library> {
|
||||||
|
fn rescan_library(&mut self) -> Result<(), musichoard::Error> {
|
||||||
|
<Self as IMusicHoardLibrary>::rescan_library(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reload_database(&mut self) -> Result<(), musichoard::Error> {
|
||||||
|
<Self as IMusicHoardDatabase>::reload_database(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_collection(&self) -> &Collection {
|
||||||
|
<Self as IMusicHoardBase>::get_collection(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_artist_info(
|
||||||
|
&mut self,
|
||||||
|
id: &ArtistId,
|
||||||
|
info: ArtistInfo,
|
||||||
|
) -> Result<(), musichoard::Error> {
|
||||||
|
<Self as IMusicHoardDatabase>::merge_artist_info(self, id, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_album_info(
|
||||||
|
&mut self,
|
||||||
|
artist_id: &ArtistId,
|
||||||
|
album_id: &AlbumId,
|
||||||
|
info: AlbumInfo,
|
||||||
|
) -> Result<(), musichoard::Error> {
|
||||||
|
<Self as IMusicHoardDatabase>::merge_album_info(self, artist_id, album_id, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GRCOV_EXCL_STOP
|
@ -4,7 +4,7 @@ use std::thread;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use mockall::automock;
|
use mockall::automock;
|
||||||
|
|
||||||
use super::event::{Event, EventError, EventSender};
|
use crate::tui::event::{EventError, IKeyEventSender};
|
||||||
|
|
||||||
#[cfg_attr(test, automock)]
|
#[cfg_attr(test, automock)]
|
||||||
pub trait IEventListener {
|
pub trait IEventListener {
|
||||||
@ -12,13 +12,15 @@ pub trait IEventListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct EventListener {
|
pub struct EventListener {
|
||||||
events: EventSender,
|
event_sender: Box<dyn IKeyEventSender + Send>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// GRCOV_EXCL_START
|
// GRCOV_EXCL_START
|
||||||
impl EventListener {
|
impl EventListener {
|
||||||
pub fn new(events: EventSender) -> Self {
|
pub fn new<ES: IKeyEventSender + Send + 'static>(event_sender: ES) -> Self {
|
||||||
EventListener { events }
|
EventListener {
|
||||||
|
event_sender: Box::new(event_sender),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,10 +34,8 @@ impl IEventListener for EventListener {
|
|||||||
match event::read() {
|
match event::read() {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
if let Err(err) = match event {
|
if let Err(err) = match event {
|
||||||
CrosstermEvent::Key(e) => self.events.send(Event::Key(e)),
|
CrosstermEvent::Key(e) => self.event_sender.send_key(e),
|
||||||
CrosstermEvent::Mouse(e) => self.events.send(Event::Mouse(e)),
|
_ => Ok(()),
|
||||||
CrosstermEvent::Resize(w, h) => self.events.send(Event::Resize(w, h)),
|
|
||||||
_ => unimplemented!(),
|
|
||||||
} {
|
} {
|
||||||
return err;
|
return err;
|
||||||
}
|
}
|
||||||
|
@ -8,25 +8,29 @@ mod ui;
|
|||||||
pub use app::App;
|
pub use app::App;
|
||||||
pub use event::EventChannel;
|
pub use event::EventChannel;
|
||||||
pub use handler::EventHandler;
|
pub use handler::EventHandler;
|
||||||
|
pub use lib::external::musicbrainz::{
|
||||||
|
api::MusicBrainz,
|
||||||
|
daemon::{JobChannel, MusicBrainzDaemon},
|
||||||
|
};
|
||||||
pub use listener::EventListener;
|
pub use listener::EventListener;
|
||||||
pub use ui::Ui;
|
pub use ui::Ui;
|
||||||
|
|
||||||
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
|
use crossterm::{
|
||||||
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
event::{DisableMouseCapture, EnableMouseCapture},
|
||||||
use ratatui::backend::Backend;
|
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
use ratatui::Terminal;
|
};
|
||||||
use std::io;
|
use ratatui::{backend::Backend, Terminal};
|
||||||
use std::marker::PhantomData;
|
use std::{io, marker::PhantomData};
|
||||||
|
|
||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
app::{IAppAccess, IAppInteract},
|
app::{IApp, IAppAccess},
|
||||||
event::EventError,
|
event::EventError,
|
||||||
handler::IEventHandler,
|
handler::IEventHandler,
|
||||||
listener::IEventListener,
|
listener::IEventListener,
|
||||||
ui::IUi,
|
ui::IUi,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, Eq, PartialEq)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
Io(String),
|
Io(String),
|
||||||
Event(String),
|
Event(String),
|
||||||
@ -45,12 +49,12 @@ impl From<EventError> for Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Tui<B: Backend, UI: IUi, APP: IAppInteract + IAppAccess> {
|
pub struct Tui<B: Backend, UI: IUi, APP: IApp + IAppAccess> {
|
||||||
terminal: Terminal<B>,
|
terminal: Terminal<B>,
|
||||||
_phantom: PhantomData<(UI, APP)>,
|
_phantom: PhantomData<(UI, APP)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<B: Backend, UI: IUi, APP: IAppInteract + IAppAccess> Tui<B, UI, APP> {
|
impl<B: Backend, UI: IUi, APP: IApp + IAppAccess> Tui<B, UI, APP> {
|
||||||
fn init(&mut self) -> Result<(), Error> {
|
fn init(&mut self) -> Result<(), Error> {
|
||||||
self.terminal.hide_cursor()?;
|
self.terminal.hide_cursor()?;
|
||||||
self.terminal.clear()?;
|
self.terminal.clear()?;
|
||||||
@ -112,8 +116,8 @@ impl<B: Backend, UI: IUi, APP: IAppInteract + IAppAccess> Tui<B, UI, APP> {
|
|||||||
match listener_handle.join() {
|
match listener_handle.join() {
|
||||||
Ok(err) => return Err(err.into()),
|
Ok(err) => return Err(err.into()),
|
||||||
// Calling std::panic::resume_unwind(err) as recommended by the Rust docs
|
// Calling std::panic::resume_unwind(err) as recommended by the Rust docs
|
||||||
// will not produce an error message. The panic error message is printed at
|
// will not produce an error message. This may be due to the panic simply
|
||||||
// the location of the panic which at the time is hidden by the TUI.
|
// causing the process to abort in which case there is nothing to unwind.
|
||||||
Err(_) => return Err(Error::ListenerPanic),
|
Err(_) => return Err(Error::ListenerPanic),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -173,6 +177,7 @@ mod testmod;
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::{io, thread};
|
use std::{io, thread};
|
||||||
|
|
||||||
|
use lib::interface::musicbrainz::daemon::MockIMbJobSender;
|
||||||
use ratatui::{backend::TestBackend, Terminal};
|
use ratatui::{backend::TestBackend, Terminal};
|
||||||
|
|
||||||
use musichoard::collection::Collection;
|
use musichoard::collection::Collection;
|
||||||
@ -193,15 +198,15 @@ mod tests {
|
|||||||
fn music_hoard(collection: Collection) -> MockIMusicHoard {
|
fn music_hoard(collection: Collection) -> MockIMusicHoard {
|
||||||
let mut music_hoard = MockIMusicHoard::new();
|
let mut music_hoard = MockIMusicHoard::new();
|
||||||
|
|
||||||
music_hoard.expect_load_from_database().returning(|| Ok(()));
|
music_hoard.expect_reload_database().returning(|| Ok(()));
|
||||||
music_hoard.expect_rescan_library().returning(|| Ok(()));
|
music_hoard.expect_rescan_library().returning(|| Ok(()));
|
||||||
music_hoard.expect_get_collection().return_const(collection);
|
music_hoard.expect_get_collection().return_const(collection);
|
||||||
|
|
||||||
music_hoard
|
music_hoard
|
||||||
}
|
}
|
||||||
|
|
||||||
fn app(collection: Collection) -> App<MockIMusicHoard> {
|
fn app(collection: Collection) -> App {
|
||||||
App::new(music_hoard(collection))
|
App::new(music_hoard(collection), MockIMbJobSender::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn listener() -> MockIEventListener {
|
fn listener() -> MockIEventListener {
|
||||||
@ -215,11 +220,11 @@ mod tests {
|
|||||||
listener
|
listener
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handler() -> MockIEventHandler<App<MockIMusicHoard>> {
|
fn handler() -> MockIEventHandler<App> {
|
||||||
let mut handler = MockIEventHandler::new();
|
let mut handler = MockIEventHandler::new();
|
||||||
handler
|
handler
|
||||||
.expect_handle_next_event()
|
.expect_handle_next_event()
|
||||||
.return_once(|app: App<MockIMusicHoard>| Ok(app.force_quit()));
|
.return_once(|app: App| Ok(app.force_quit()));
|
||||||
handler
|
handler
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,10 +256,9 @@ mod tests {
|
|||||||
|
|
||||||
let result = Tui::main(terminal, app, ui, handler, listener);
|
let result = Tui::main(terminal, app, ui, handler, listener);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert_eq!(
|
|
||||||
result.unwrap_err(),
|
let error = EventError::Recv;
|
||||||
Error::Event(EventError::Recv.to_string())
|
assert_eq!(result.unwrap_err(), Error::Event(error.to_string()));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
use musichoard::collection::{
|
|
||||||
album::{Album, AlbumId},
|
|
||||||
artist::{Artist, ArtistId, MusicBrainz},
|
|
||||||
track::{Format, Quality, Track, TrackId},
|
|
||||||
};
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::tests::*;
|
use musichoard::collection::{
|
||||||
|
album::{Album, AlbumId, AlbumInfo, AlbumMeta, AlbumPrimaryType, AlbumSeq},
|
||||||
|
artist::{Artist, ArtistId, ArtistInfo, ArtistMeta},
|
||||||
|
musicbrainz::{MbAlbumRef, MbArtistRef, MbRefOption},
|
||||||
|
track::{Track, TrackFormat, TrackId, TrackNum, TrackQuality},
|
||||||
|
};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
|
||||||
pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| full_collection!());
|
use crate::testmod::*;
|
||||||
|
|
||||||
|
pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| full::full_collection!());
|
||||||
|
779
src/tui/ui.rs
779
src/tui/ui.rs
@ -1,779 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use musichoard::collection::{
|
|
||||||
album::Album,
|
|
||||||
artist::Artist,
|
|
||||||
track::{Format, Track},
|
|
||||||
Collection,
|
|
||||||
};
|
|
||||||
use ratatui::{
|
|
||||||
layout::{Alignment, Rect},
|
|
||||||
style::{Color, Style},
|
|
||||||
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::tui::app::{AppPublicState, AppState, Category, IAppAccess, Selection, WidgetState};
|
|
||||||
|
|
||||||
pub trait IUi {
|
|
||||||
fn render<APP: IAppAccess>(app: &mut APP, frame: &mut Frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ArtistArea {
|
|
||||||
list: Rect,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AlbumArea {
|
|
||||||
list: Rect,
|
|
||||||
info: Rect,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TrackArea {
|
|
||||||
list: Rect,
|
|
||||||
info: Rect,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FrameArea {
|
|
||||||
artist: ArtistArea,
|
|
||||||
album: AlbumArea,
|
|
||||||
track: TrackArea,
|
|
||||||
minibuffer: Rect,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FrameArea {
|
|
||||||
fn new(frame: Rect) -> Self {
|
|
||||||
let minibuffer_height = 3;
|
|
||||||
let buffer_height = frame.height.saturating_sub(minibuffer_height);
|
|
||||||
|
|
||||||
let width_one_third = frame.width / 3;
|
|
||||||
let height_one_third = buffer_height / 3;
|
|
||||||
|
|
||||||
let panel_width = width_one_third;
|
|
||||||
let panel_width_last = frame.width.saturating_sub(2 * panel_width);
|
|
||||||
let panel_height_top = buffer_height.saturating_sub(height_one_third);
|
|
||||||
let panel_height_bottom = height_one_third;
|
|
||||||
|
|
||||||
let artist_list = Rect {
|
|
||||||
x: frame.x,
|
|
||||||
y: frame.y,
|
|
||||||
width: panel_width,
|
|
||||||
height: buffer_height,
|
|
||||||
};
|
|
||||||
|
|
||||||
let album_list = Rect {
|
|
||||||
x: artist_list.x + artist_list.width,
|
|
||||||
y: frame.y,
|
|
||||||
width: panel_width,
|
|
||||||
height: panel_height_top,
|
|
||||||
};
|
|
||||||
|
|
||||||
let album_info = Rect {
|
|
||||||
x: album_list.x,
|
|
||||||
y: album_list.y + album_list.height,
|
|
||||||
width: album_list.width,
|
|
||||||
height: panel_height_bottom,
|
|
||||||
};
|
|
||||||
|
|
||||||
let track_list = Rect {
|
|
||||||
x: album_list.x + album_list.width,
|
|
||||||
y: frame.y,
|
|
||||||
width: panel_width_last,
|
|
||||||
height: panel_height_top,
|
|
||||||
};
|
|
||||||
|
|
||||||
let track_info = Rect {
|
|
||||||
x: track_list.x,
|
|
||||||
y: track_list.y + track_list.height,
|
|
||||||
width: track_list.width,
|
|
||||||
height: panel_height_bottom,
|
|
||||||
};
|
|
||||||
|
|
||||||
let minibuffer = Rect {
|
|
||||||
x: frame.x,
|
|
||||||
y: frame.y + buffer_height,
|
|
||||||
width: frame.width,
|
|
||||||
height: minibuffer_height,
|
|
||||||
};
|
|
||||||
|
|
||||||
FrameArea {
|
|
||||||
artist: ArtistArea { list: artist_list },
|
|
||||||
album: AlbumArea {
|
|
||||||
list: album_list,
|
|
||||||
info: album_info,
|
|
||||||
},
|
|
||||||
track: TrackArea {
|
|
||||||
list: track_list,
|
|
||||||
info: track_info,
|
|
||||||
},
|
|
||||||
minibuffer,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum OverlaySize {
|
|
||||||
MarginFactor(u16),
|
|
||||||
Value(u16),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for OverlaySize {
|
|
||||||
fn default() -> Self {
|
|
||||||
OverlaySize::MarginFactor(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OverlaySize {
|
|
||||||
fn get(&self, full: u16) -> (u16, u16) {
|
|
||||||
match self {
|
|
||||||
OverlaySize::MarginFactor(margin_factor) => {
|
|
||||||
let margin = full / margin_factor;
|
|
||||||
(margin, full.saturating_sub(2 * margin))
|
|
||||||
}
|
|
||||||
OverlaySize::Value(value) => {
|
|
||||||
let margin = (full.saturating_sub(*value)) / 2;
|
|
||||||
(margin, *value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct OverlayBuilder {
|
|
||||||
width: OverlaySize,
|
|
||||||
height: OverlaySize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OverlayBuilder {
|
|
||||||
fn with_width(mut self, width: OverlaySize) -> OverlayBuilder {
|
|
||||||
self.width = width;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn with_height(mut self, height: OverlaySize) -> OverlayBuilder {
|
|
||||||
self.height = height;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build(self, frame: Rect) -> Rect {
|
|
||||||
let (x, width) = self.width.get(frame.width);
|
|
||||||
let (y, height) = self.height.get(frame.height);
|
|
||||||
|
|
||||||
Rect {
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ArtistState<'a, 'b> {
|
|
||||||
active: bool,
|
|
||||||
list: List<'a>,
|
|
||||||
state: &'b mut WidgetState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, 'b> ArtistState<'a, 'b> {
|
|
||||||
fn new(active: bool, artists: &'a [Artist], state: &'b mut WidgetState) -> ArtistState<'a, 'b> {
|
|
||||||
let list = List::new(
|
|
||||||
artists
|
|
||||||
.iter()
|
|
||||||
.map(|a| ListItem::new(a.id.name.as_str()))
|
|
||||||
.collect::<Vec<ListItem>>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
ArtistState {
|
|
||||||
active,
|
|
||||||
list,
|
|
||||||
state,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ArtistOverlay<'a> {
|
|
||||||
properties: Paragraph<'a>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> ArtistOverlay<'a> {
|
|
||||||
fn opt_opt_to_str<S: AsRef<str>>(opt: Option<Option<&S>>) -> &str {
|
|
||||||
opt.flatten().map(|item| item.as_ref()).unwrap_or("")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn opt_hashmap_to_string<K: Ord + AsRef<str>, T: AsRef<str>>(
|
|
||||||
opt_map: Option<&HashMap<K, Vec<T>>>,
|
|
||||||
item_indent: &str,
|
|
||||||
list_indent: &str,
|
|
||||||
) -> String {
|
|
||||||
opt_map
|
|
||||||
.map(|map| Self::hashmap_to_string(map, item_indent, list_indent))
|
|
||||||
.unwrap_or_else(|| String::from(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hashmap_to_string<K: AsRef<str>, T: AsRef<str>>(
|
|
||||||
map: &HashMap<K, Vec<T>>,
|
|
||||||
item_indent: &str,
|
|
||||||
list_indent: &str,
|
|
||||||
) -> String {
|
|
||||||
let mut vec: Vec<(&str, &Vec<T>)> = map.iter().map(|(k, v)| (k.as_ref(), v)).collect();
|
|
||||||
vec.sort_by(|x, y| x.0.cmp(y.0));
|
|
||||||
|
|
||||||
let indent = format!("\n{item_indent}");
|
|
||||||
let list = vec
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| format!("{k}: {}", Self::vec_to_string(v, list_indent)))
|
|
||||||
.collect::<Vec<String>>()
|
|
||||||
.join(&indent);
|
|
||||||
format!("{indent}{list}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn vec_to_string<S: AsRef<str>>(vec: &Vec<S>, indent: &str) -> String {
|
|
||||||
if vec.len() < 2 {
|
|
||||||
vec.first()
|
|
||||||
.map(|item| item.as_ref())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string()
|
|
||||||
} else {
|
|
||||||
let indent = format!("\n{indent}");
|
|
||||||
let list = vec
|
|
||||||
.iter()
|
|
||||||
.map(|item| item.as_ref())
|
|
||||||
.collect::<Vec<&str>>()
|
|
||||||
.join(&indent);
|
|
||||||
format!("{indent}{list}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new(artists: &'a [Artist], state: &ListState) -> ArtistOverlay<'a> {
|
|
||||||
let artist = state.selected().map(|i| &artists[i]);
|
|
||||||
|
|
||||||
let item_indent = " ";
|
|
||||||
let list_indent = " - ";
|
|
||||||
|
|
||||||
let double_item_indent = format!("{item_indent}{item_indent}");
|
|
||||||
let double_list_indent = format!("{item_indent}{list_indent}");
|
|
||||||
|
|
||||||
let properties = Paragraph::new(format!(
|
|
||||||
"Artist: {}\n\n{item_indent}\
|
|
||||||
MusicBrainz: {}\n{item_indent}\
|
|
||||||
Properties: {}",
|
|
||||||
artist.map(|a| a.id.name.as_str()).unwrap_or(""),
|
|
||||||
Self::opt_opt_to_str(artist.map(|a| a.musicbrainz.as_ref())),
|
|
||||||
Self::opt_hashmap_to_string(
|
|
||||||
artist.map(|a| &a.properties),
|
|
||||||
&double_item_indent,
|
|
||||||
&double_list_indent
|
|
||||||
),
|
|
||||||
));
|
|
||||||
|
|
||||||
ArtistOverlay { properties }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AlbumState<'a, 'b> {
|
|
||||||
active: bool,
|
|
||||||
list: List<'a>,
|
|
||||||
state: &'b mut WidgetState,
|
|
||||||
info: Paragraph<'a>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, 'b> AlbumState<'a, 'b> {
|
|
||||||
fn new(active: bool, albums: &'a [Album], state: &'b mut WidgetState) -> AlbumState<'a, 'b> {
|
|
||||||
let list = List::new(
|
|
||||||
albums
|
|
||||||
.iter()
|
|
||||||
.map(|a| ListItem::new(a.id.title.as_str()))
|
|
||||||
.collect::<Vec<ListItem>>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let album = state.list.selected().map(|i| &albums[i]);
|
|
||||||
let info = Paragraph::new(format!(
|
|
||||||
"Title: {}\n\
|
|
||||||
Year: {}",
|
|
||||||
album.map(|a| a.id.title.as_str()).unwrap_or(""),
|
|
||||||
album.map(|a| a.id.year.to_string()).unwrap_or_default(),
|
|
||||||
));
|
|
||||||
|
|
||||||
AlbumState {
|
|
||||||
active,
|
|
||||||
list,
|
|
||||||
state,
|
|
||||||
info,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TrackState<'a, 'b> {
|
|
||||||
active: bool,
|
|
||||||
list: List<'a>,
|
|
||||||
state: &'b mut WidgetState,
|
|
||||||
info: Paragraph<'a>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a, 'b> TrackState<'a, 'b> {
|
|
||||||
fn new(active: bool, tracks: &'a [Track], state: &'b mut WidgetState) -> TrackState<'a, 'b> {
|
|
||||||
let list = List::new(
|
|
||||||
tracks
|
|
||||||
.iter()
|
|
||||||
.map(|tr| ListItem::new(tr.id.title.as_str()))
|
|
||||||
.collect::<Vec<ListItem>>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let track = state.list.selected().map(|i| &tracks[i]);
|
|
||||||
let info = Paragraph::new(format!(
|
|
||||||
"Track: {}\n\
|
|
||||||
Title: {}\n\
|
|
||||||
Artist: {}\n\
|
|
||||||
Quality: {}",
|
|
||||||
track.map(|t| t.id.number.to_string()).unwrap_or_default(),
|
|
||||||
track.map(|t| t.id.title.as_str()).unwrap_or(""),
|
|
||||||
track.map(|t| t.artist.join("; ")).unwrap_or_default(),
|
|
||||||
track
|
|
||||||
.map(|t| match t.quality.format {
|
|
||||||
Format::Flac => "FLAC".to_string(),
|
|
||||||
Format::Mp3 => format!("MP3 {}kbps", t.quality.bitrate),
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
));
|
|
||||||
|
|
||||||
TrackState {
|
|
||||||
active,
|
|
||||||
list,
|
|
||||||
state,
|
|
||||||
info,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Minibuffer<'a> {
|
|
||||||
paragraphs: Vec<Paragraph<'a>>,
|
|
||||||
columns: u16,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Minibuffer<'_> {
|
|
||||||
fn paragraphs(state: &AppPublicState) -> Self {
|
|
||||||
let columns = 3;
|
|
||||||
let mut mb = match state {
|
|
||||||
AppState::Browse(_) => Minibuffer {
|
|
||||||
paragraphs: vec![
|
|
||||||
Paragraph::new("m: show info overlay"),
|
|
||||||
Paragraph::new("g: show reload menu"),
|
|
||||||
Paragraph::new("ctrl+s: search artist"),
|
|
||||||
],
|
|
||||||
columns,
|
|
||||||
},
|
|
||||||
AppState::Info(_) => Minibuffer {
|
|
||||||
paragraphs: vec![Paragraph::new("m: hide info overlay")],
|
|
||||||
columns,
|
|
||||||
},
|
|
||||||
AppState::Reload(_) => Minibuffer {
|
|
||||||
paragraphs: vec![
|
|
||||||
Paragraph::new("g: hide reload menu"),
|
|
||||||
Paragraph::new("d: reload database"),
|
|
||||||
Paragraph::new("l: reload library"),
|
|
||||||
],
|
|
||||||
columns,
|
|
||||||
},
|
|
||||||
AppState::Search(ref s) => Minibuffer {
|
|
||||||
paragraphs: vec![
|
|
||||||
Paragraph::new(format!("I-search: {s}")),
|
|
||||||
Paragraph::new("ctrl+s: search next".to_string()).alignment(Alignment::Center),
|
|
||||||
Paragraph::new("ctrl+g: cancel search".to_string())
|
|
||||||
.alignment(Alignment::Center),
|
|
||||||
],
|
|
||||||
columns,
|
|
||||||
},
|
|
||||||
AppState::Error(_) => Minibuffer {
|
|
||||||
paragraphs: vec![Paragraph::new(
|
|
||||||
"Press any key to dismiss the error message...",
|
|
||||||
)],
|
|
||||||
columns: 0,
|
|
||||||
},
|
|
||||||
AppState::Critical(_) => Minibuffer {
|
|
||||||
paragraphs: vec![Paragraph::new("Press ctrl+c to terminate the program...")],
|
|
||||||
columns: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if !state.is_search() {
|
|
||||||
mb.paragraphs = mb
|
|
||||||
.paragraphs
|
|
||||||
.into_iter()
|
|
||||||
.map(|p| p.alignment(Alignment::Center))
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
mb
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ReloadMenu;
|
|
||||||
|
|
||||||
impl ReloadMenu {
|
|
||||||
fn paragraph<'a>() -> Paragraph<'a> {
|
|
||||||
Paragraph::new(
|
|
||||||
"d: database\n\
|
|
||||||
l: library",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Column<'a> {
|
|
||||||
paragraph: Paragraph<'a>,
|
|
||||||
area: Rect,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Ui;
|
|
||||||
|
|
||||||
impl Ui {
|
|
||||||
fn style(_active: bool, error: bool) -> Style {
|
|
||||||
let style = Style::default().bg(Color::Black);
|
|
||||||
if error {
|
|
||||||
style.fg(Color::Red)
|
|
||||||
} else {
|
|
||||||
style.fg(Color::White)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn block_style(active: bool, error: bool) -> Style {
|
|
||||||
Self::style(active, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn highlight_style(active: bool) -> Style {
|
|
||||||
if active {
|
|
||||||
Style::default().fg(Color::White).bg(Color::DarkGray)
|
|
||||||
} else {
|
|
||||||
Self::style(false, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn block<'a>(active: bool, error: bool) -> Block<'a> {
|
|
||||||
Block::default().style(Self::block_style(active, error))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn block_with_borders<'a>(title: &str, active: bool, error: bool) -> Block<'a> {
|
|
||||||
Self::block(active, error)
|
|
||||||
.title_alignment(Alignment::Center)
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_type(BorderType::Rounded)
|
|
||||||
.title(format!(" {title} "))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_list_widget(
|
|
||||||
title: &str,
|
|
||||||
list: List,
|
|
||||||
state: &mut WidgetState,
|
|
||||||
active: bool,
|
|
||||||
area: Rect,
|
|
||||||
frame: &mut Frame,
|
|
||||||
) {
|
|
||||||
frame.render_stateful_widget(
|
|
||||||
list.highlight_style(Self::highlight_style(active))
|
|
||||||
.highlight_symbol(">> ")
|
|
||||||
.style(Self::style(active, false))
|
|
||||||
.block(Self::block_with_borders(title, active, false)),
|
|
||||||
area,
|
|
||||||
&mut state.list,
|
|
||||||
);
|
|
||||||
state.height = area.height.saturating_sub(2) as usize;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_info_widget(
|
|
||||||
title: &str,
|
|
||||||
paragraph: Paragraph,
|
|
||||||
active: bool,
|
|
||||||
area: Rect,
|
|
||||||
frame: &mut Frame,
|
|
||||||
) {
|
|
||||||
frame.render_widget(
|
|
||||||
paragraph
|
|
||||||
.style(Self::style(active, false))
|
|
||||||
.block(Self::block_with_borders(title, active, false)),
|
|
||||||
area,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_overlay_widget(
|
|
||||||
title: &str,
|
|
||||||
paragraph: Paragraph,
|
|
||||||
area: Rect,
|
|
||||||
error: bool,
|
|
||||||
frame: &mut Frame,
|
|
||||||
) {
|
|
||||||
frame.render_widget(Clear, area);
|
|
||||||
frame.render_widget(
|
|
||||||
paragraph
|
|
||||||
.style(Self::style(true, error))
|
|
||||||
.block(Self::block_with_borders(title, true, error)),
|
|
||||||
area,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn columns(paragraphs: Vec<Paragraph>, min: u16, area: Rect) -> Vec<Column> {
|
|
||||||
let mut x = area.x;
|
|
||||||
let mut width = area.width;
|
|
||||||
let mut remaining = paragraphs.len() as u16;
|
|
||||||
if remaining < min {
|
|
||||||
remaining = min;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut blocks = vec![];
|
|
||||||
for paragraph in paragraphs.into_iter() {
|
|
||||||
let block_width = width / remaining;
|
|
||||||
|
|
||||||
blocks.push(Column {
|
|
||||||
paragraph,
|
|
||||||
area: Rect {
|
|
||||||
x,
|
|
||||||
y: area.y,
|
|
||||||
width: block_width,
|
|
||||||
height: area.height,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
x = x.saturating_add(block_width);
|
|
||||||
width = width.saturating_sub(block_width);
|
|
||||||
remaining -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_columns(
|
|
||||||
paragraphs: Vec<Paragraph>,
|
|
||||||
min: u16,
|
|
||||||
active: bool,
|
|
||||||
area: Rect,
|
|
||||||
frame: &mut Frame,
|
|
||||||
) {
|
|
||||||
for column in Self::columns(paragraphs, min, area).into_iter() {
|
|
||||||
frame.render_widget(
|
|
||||||
column
|
|
||||||
.paragraph
|
|
||||||
.style(Self::style(active, false))
|
|
||||||
.block(Self::block(active, false)),
|
|
||||||
column.area,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_artist_column(st: ArtistState, ar: ArtistArea, fr: &mut Frame) {
|
|
||||||
Self::render_list_widget("Artists", st.list, st.state, st.active, ar.list, fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_album_column(st: AlbumState, ar: AlbumArea, fr: &mut Frame) {
|
|
||||||
Self::render_list_widget("Albums", st.list, st.state, st.active, ar.list, fr);
|
|
||||||
Self::render_info_widget("Album info", st.info, st.active, ar.info, fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_track_column(st: TrackState, ar: TrackArea, fr: &mut Frame) {
|
|
||||||
Self::render_list_widget("Tracks", st.list, st.state, st.active, ar.list, fr);
|
|
||||||
Self::render_info_widget("Track info", st.info, st.active, ar.info, fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_minibuffer(state: &AppPublicState, ar: Rect, fr: &mut Frame) {
|
|
||||||
let mb = Minibuffer::paragraphs(state);
|
|
||||||
|
|
||||||
let space = 3;
|
|
||||||
let area = Rect {
|
|
||||||
x: ar.x + 1 + space,
|
|
||||||
y: ar.y + 1,
|
|
||||||
width: ar.width.saturating_sub(2 + 2 * space),
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
Self::render_info_widget("Minibuffer", Paragraph::new(""), false, ar, fr);
|
|
||||||
Self::render_columns(mb.paragraphs, mb.columns, false, area, fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_main_frame(
|
|
||||||
artists: &Collection,
|
|
||||||
selection: &mut Selection,
|
|
||||||
state: &AppPublicState,
|
|
||||||
frame: &mut Frame,
|
|
||||||
) {
|
|
||||||
let active = selection.active;
|
|
||||||
let areas = FrameArea::new(frame.size());
|
|
||||||
|
|
||||||
let artist_selection = &mut selection.artist;
|
|
||||||
let artist_state = ArtistState::new(
|
|
||||||
active == Category::Artist,
|
|
||||||
artists,
|
|
||||||
&mut artist_selection.state,
|
|
||||||
);
|
|
||||||
|
|
||||||
Self::render_artist_column(artist_state, areas.artist, frame);
|
|
||||||
|
|
||||||
let no_albums: Vec<Album> = vec![];
|
|
||||||
let albums = artist_selection
|
|
||||||
.state
|
|
||||||
.list
|
|
||||||
.selected()
|
|
||||||
.map(|i| &artists[i].albums)
|
|
||||||
.unwrap_or_else(|| &no_albums);
|
|
||||||
let album_selection = &mut artist_selection.album;
|
|
||||||
let album_state = AlbumState::new(
|
|
||||||
active == Category::Album,
|
|
||||||
albums,
|
|
||||||
&mut album_selection.state,
|
|
||||||
);
|
|
||||||
|
|
||||||
Self::render_album_column(album_state, areas.album, frame);
|
|
||||||
|
|
||||||
let no_tracks: Vec<Track> = vec![];
|
|
||||||
let tracks = album_selection
|
|
||||||
.state
|
|
||||||
.list
|
|
||||||
.selected()
|
|
||||||
.map(|i| &albums[i].tracks)
|
|
||||||
.unwrap_or_else(|| &no_tracks);
|
|
||||||
let track_selection = &mut album_selection.track;
|
|
||||||
let track_state = TrackState::new(
|
|
||||||
active == Category::Track,
|
|
||||||
tracks,
|
|
||||||
&mut track_selection.state,
|
|
||||||
);
|
|
||||||
|
|
||||||
Self::render_track_column(track_state, areas.track, frame);
|
|
||||||
|
|
||||||
Self::render_minibuffer(state, areas.minibuffer, frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_info_overlay(artists: &Collection, selection: &mut Selection, frame: &mut Frame) {
|
|
||||||
let area = OverlayBuilder::default().build(frame.size());
|
|
||||||
|
|
||||||
let artist_selection = &mut selection.artist;
|
|
||||||
let artist_overlay = ArtistOverlay::new(artists, &artist_selection.state.list);
|
|
||||||
|
|
||||||
Self::render_overlay_widget("Artist", artist_overlay.properties, area, false, frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_reload_overlay(frame: &mut Frame) {
|
|
||||||
let area = OverlayBuilder::default()
|
|
||||||
.with_width(OverlaySize::Value(39))
|
|
||||||
.with_height(OverlaySize::Value(4))
|
|
||||||
.build(frame.size());
|
|
||||||
|
|
||||||
let reload_text = ReloadMenu::paragraph().alignment(Alignment::Center);
|
|
||||||
|
|
||||||
Self::render_overlay_widget("Reload", reload_text, area, false, frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_error_overlay<S: AsRef<str>>(title: S, msg: S, frame: &mut Frame) {
|
|
||||||
let area = OverlayBuilder::default()
|
|
||||||
.with_height(OverlaySize::Value(4))
|
|
||||||
.build(frame.size());
|
|
||||||
|
|
||||||
let error_text = Paragraph::new(msg.as_ref())
|
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.wrap(Wrap { trim: true });
|
|
||||||
|
|
||||||
Self::render_overlay_widget(title.as_ref(), error_text, area, true, frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IUi for Ui {
|
|
||||||
fn render<APP: IAppAccess>(app: &mut APP, frame: &mut Frame) {
|
|
||||||
let app = app.get();
|
|
||||||
|
|
||||||
let collection = app.inner.collection;
|
|
||||||
let selection = app.inner.selection;
|
|
||||||
let state = app.state;
|
|
||||||
|
|
||||||
Self::render_main_frame(collection, selection, &state, frame);
|
|
||||||
match state {
|
|
||||||
AppState::Info(_) => Self::render_info_overlay(collection, selection, frame),
|
|
||||||
AppState::Reload(_) => Self::render_reload_overlay(frame),
|
|
||||||
AppState::Error(msg) => Self::render_error_overlay("Error", msg, frame),
|
|
||||||
AppState::Critical(msg) => Self::render_error_overlay("Critical Error", msg, frame),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::tui::{
|
|
||||||
app::{AppPublic, AppPublicInner, Delta},
|
|
||||||
testmod::COLLECTION,
|
|
||||||
tests::terminal,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// Automock does not support returning types with generic lifetimes.
|
|
||||||
impl IAppAccess for AppPublic<'_> {
|
|
||||||
fn get(&mut self) -> AppPublic {
|
|
||||||
AppPublic {
|
|
||||||
inner: AppPublicInner {
|
|
||||||
collection: self.inner.collection,
|
|
||||||
selection: self.inner.selection,
|
|
||||||
},
|
|
||||||
state: match self.state {
|
|
||||||
AppState::Browse(()) => AppState::Browse(()),
|
|
||||||
AppState::Info(()) => AppState::Info(()),
|
|
||||||
AppState::Reload(()) => AppState::Reload(()),
|
|
||||||
AppState::Search(s) => AppState::Search(s),
|
|
||||||
AppState::Error(s) => AppState::Error(s),
|
|
||||||
AppState::Critical(s) => AppState::Critical(s),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_test_suite(collection: &Collection, selection: &mut Selection) {
|
|
||||||
let mut terminal = terminal();
|
|
||||||
|
|
||||||
let mut app = AppPublic {
|
|
||||||
inner: AppPublicInner {
|
|
||||||
collection,
|
|
||||||
selection,
|
|
||||||
},
|
|
||||||
state: AppState::Browse(()),
|
|
||||||
};
|
|
||||||
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
|
|
||||||
|
|
||||||
app.state = AppState::Info(());
|
|
||||||
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
|
|
||||||
|
|
||||||
app.state = AppState::Reload(());
|
|
||||||
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
|
|
||||||
|
|
||||||
app.state = AppState::Search("");
|
|
||||||
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
|
|
||||||
|
|
||||||
app.state = AppState::Error("get rekt scrub");
|
|
||||||
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
|
|
||||||
|
|
||||||
app.state = AppState::Critical("get critically rekt scrub");
|
|
||||||
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn empty() {
|
|
||||||
let artists: Vec<Artist> = vec![];
|
|
||||||
let mut selection = Selection::new(&artists);
|
|
||||||
|
|
||||||
draw_test_suite(&artists, &mut selection);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn collection() {
|
|
||||||
let artists = &COLLECTION;
|
|
||||||
let mut selection = Selection::new(artists);
|
|
||||||
|
|
||||||
draw_test_suite(artists, &mut selection);
|
|
||||||
|
|
||||||
// Change the track (which has a different track format).
|
|
||||||
selection.increment_category();
|
|
||||||
selection.increment_category();
|
|
||||||
selection.increment_selection(artists, Delta::Line);
|
|
||||||
|
|
||||||
draw_test_suite(artists, &mut selection);
|
|
||||||
|
|
||||||
// Change the artist (which has a multi-link entry).
|
|
||||||
selection.decrement_category();
|
|
||||||
selection.decrement_category();
|
|
||||||
selection.increment_selection(artists, Delta::Line);
|
|
||||||
|
|
||||||
draw_test_suite(artists, &mut selection);
|
|
||||||
}
|
|
||||||
}
|
|
243
src/tui/ui/browse_state.rs
Normal file
243
src/tui/ui/browse_state.rs
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
use musichoard::collection::{
|
||||||
|
album::{Album, AlbumStatus},
|
||||||
|
artist::Artist,
|
||||||
|
track::{Track, TrackFormat},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
text::Line,
|
||||||
|
widgets::{List, ListItem, Paragraph},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::tui::{
|
||||||
|
app::WidgetState,
|
||||||
|
ui::{display::UiDisplay, style::UiColor},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct ArtistArea {
|
||||||
|
pub list: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AlbumArea {
|
||||||
|
pub list: Rect,
|
||||||
|
pub info: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TrackArea {
|
||||||
|
pub list: Rect,
|
||||||
|
pub info: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BrowseArea {
|
||||||
|
pub artist: ArtistArea,
|
||||||
|
pub album: AlbumArea,
|
||||||
|
pub track: TrackArea,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FrameArea {
|
||||||
|
pub browse: BrowseArea,
|
||||||
|
pub minibuffer: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FrameArea {
|
||||||
|
pub fn new(frame: Rect) -> Self {
|
||||||
|
let minibuffer_height = 3;
|
||||||
|
let buffer_height = frame.height.saturating_sub(minibuffer_height);
|
||||||
|
|
||||||
|
let width_one_third = frame.width / 3;
|
||||||
|
let height_one_third = buffer_height / 3;
|
||||||
|
|
||||||
|
let panel_width = width_one_third;
|
||||||
|
let panel_width_last = frame.width.saturating_sub(2 * panel_width);
|
||||||
|
let panel_height_top = buffer_height.saturating_sub(height_one_third);
|
||||||
|
let panel_height_bottom = height_one_third;
|
||||||
|
|
||||||
|
let artist_list = Rect {
|
||||||
|
x: frame.x,
|
||||||
|
y: frame.y,
|
||||||
|
width: panel_width,
|
||||||
|
height: buffer_height,
|
||||||
|
};
|
||||||
|
|
||||||
|
let album_list = Rect {
|
||||||
|
x: artist_list.x + artist_list.width,
|
||||||
|
y: frame.y,
|
||||||
|
width: panel_width,
|
||||||
|
height: panel_height_top,
|
||||||
|
};
|
||||||
|
|
||||||
|
let album_info = Rect {
|
||||||
|
x: album_list.x,
|
||||||
|
y: album_list.y + album_list.height,
|
||||||
|
width: album_list.width,
|
||||||
|
height: panel_height_bottom,
|
||||||
|
};
|
||||||
|
|
||||||
|
let track_list = Rect {
|
||||||
|
x: album_list.x + album_list.width,
|
||||||
|
y: frame.y,
|
||||||
|
width: panel_width_last,
|
||||||
|
height: panel_height_top,
|
||||||
|
};
|
||||||
|
|
||||||
|
let track_info = Rect {
|
||||||
|
x: track_list.x,
|
||||||
|
y: track_list.y + track_list.height,
|
||||||
|
width: track_list.width,
|
||||||
|
height: panel_height_bottom,
|
||||||
|
};
|
||||||
|
|
||||||
|
let minibuffer = Rect {
|
||||||
|
x: frame.x,
|
||||||
|
y: frame.y + buffer_height,
|
||||||
|
width: frame.width,
|
||||||
|
height: minibuffer_height,
|
||||||
|
};
|
||||||
|
|
||||||
|
FrameArea {
|
||||||
|
browse: BrowseArea {
|
||||||
|
artist: ArtistArea { list: artist_list },
|
||||||
|
album: AlbumArea {
|
||||||
|
list: album_list,
|
||||||
|
info: album_info,
|
||||||
|
},
|
||||||
|
track: TrackArea {
|
||||||
|
list: track_list,
|
||||||
|
info: track_info,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
minibuffer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ArtistState<'a, 'b> {
|
||||||
|
pub active: bool,
|
||||||
|
pub list: List<'a>,
|
||||||
|
pub state: &'b mut WidgetState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> ArtistState<'a, 'b> {
|
||||||
|
pub fn new(
|
||||||
|
active: bool,
|
||||||
|
artists: &'a [Artist],
|
||||||
|
state: &'b mut WidgetState,
|
||||||
|
) -> ArtistState<'a, 'b> {
|
||||||
|
let list = List::new(
|
||||||
|
artists
|
||||||
|
.iter()
|
||||||
|
.map(|a| ListItem::new(a.meta.id.name.as_str()))
|
||||||
|
.collect::<Vec<ListItem>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
ArtistState {
|
||||||
|
active,
|
||||||
|
list,
|
||||||
|
state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AlbumState<'a, 'b> {
|
||||||
|
pub active: bool,
|
||||||
|
pub list: List<'a>,
|
||||||
|
pub state: &'b mut WidgetState,
|
||||||
|
pub info: Paragraph<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> AlbumState<'a, 'b> {
|
||||||
|
pub fn new(
|
||||||
|
active: bool,
|
||||||
|
albums: &'a [Album],
|
||||||
|
state: &'b mut WidgetState,
|
||||||
|
) -> AlbumState<'a, 'b> {
|
||||||
|
let list = List::new(
|
||||||
|
albums
|
||||||
|
.iter()
|
||||||
|
.map(Self::to_list_item)
|
||||||
|
.collect::<Vec<ListItem>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let album = state.list.selected().map(|i| &albums[i]);
|
||||||
|
let info = Paragraph::new(format!(
|
||||||
|
"Title: {}\n\
|
||||||
|
Date: {}\n\
|
||||||
|
Type: {}\n\
|
||||||
|
Status: {}",
|
||||||
|
album.map(|a| a.meta.id.title.as_str()).unwrap_or(""),
|
||||||
|
album
|
||||||
|
.map(|a| UiDisplay::display_date(&a.meta.date, &a.meta.seq))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
album
|
||||||
|
.map(|a| UiDisplay::display_type(
|
||||||
|
&a.meta.info.primary_type,
|
||||||
|
&a.meta.info.secondary_types
|
||||||
|
))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
album
|
||||||
|
.map(|a| UiDisplay::display_album_status(&a.get_status()))
|
||||||
|
.unwrap_or("")
|
||||||
|
));
|
||||||
|
|
||||||
|
AlbumState {
|
||||||
|
active,
|
||||||
|
list,
|
||||||
|
state,
|
||||||
|
info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_list_item(album: &Album) -> ListItem {
|
||||||
|
let line = match album.get_status() {
|
||||||
|
AlbumStatus::None => Line::raw(album.meta.id.title.as_str()),
|
||||||
|
AlbumStatus::Owned(format) => match format {
|
||||||
|
TrackFormat::Mp3 => Line::styled(album.meta.id.title.as_str(), UiColor::FG_WARN),
|
||||||
|
TrackFormat::Flac => Line::styled(album.meta.id.title.as_str(), UiColor::FG_GOOD),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ListItem::new(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TrackState<'a, 'b> {
|
||||||
|
pub active: bool,
|
||||||
|
pub list: List<'a>,
|
||||||
|
pub state: &'b mut WidgetState,
|
||||||
|
pub info: Paragraph<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, 'b> TrackState<'a, 'b> {
|
||||||
|
pub fn new(
|
||||||
|
active: bool,
|
||||||
|
tracks: &'a [Track],
|
||||||
|
state: &'b mut WidgetState,
|
||||||
|
) -> TrackState<'a, 'b> {
|
||||||
|
let list = List::new(
|
||||||
|
tracks
|
||||||
|
.iter()
|
||||||
|
.map(|tr| ListItem::new(tr.id.title.as_str()))
|
||||||
|
.collect::<Vec<ListItem>>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let track = state.list.selected().map(|i| &tracks[i]);
|
||||||
|
let info = Paragraph::new(format!(
|
||||||
|
"Track: {}\n\
|
||||||
|
Title: {}\n\
|
||||||
|
Artist: {}\n\
|
||||||
|
Quality: {}",
|
||||||
|
track.map(|t| t.number.0.to_string()).unwrap_or_default(),
|
||||||
|
track.map(|t| t.id.title.as_str()).unwrap_or(""),
|
||||||
|
track.map(|t| t.artist.join("; ")).unwrap_or_default(),
|
||||||
|
track
|
||||||
|
.map(|t| UiDisplay::display_track_quality(&t.quality))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
));
|
||||||
|
|
||||||
|
TrackState {
|
||||||
|
active,
|
||||||
|
list,
|
||||||
|
state,
|
||||||
|
info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user