Compare commits

..

41 Commits
v0.2.0 ... main

Author SHA1 Message Date
5d510ff787 Integrate browse API into TUI MB daemon (#230)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m7s
Part 2 of #160

Reviewed-on: #230
2024-10-06 15:32:46 +02:00
4db09667fd Add support for MusicBrainz's Browse API (#228)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m58s
Cargo CI / Lint (push) Successful in 1m8s
Reviewed-on: #228
2024-09-29 21:33:42 +02:00
e5a367aa90 Upgrade the lookup example (#227)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m1s
Cargo CI / Lint (push) Successful in 1m8s
Reviewed-on: #227
2024-09-29 15:23:31 +02:00
0d7e6bb555 Allow fetching of a single album (#226)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m6s
Closes #225

Reviewed-on: #226
2024-09-29 12:38:38 +02:00
e22068e461 Gracefully handle case of nothing being there to match (#222)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m59s
Cargo CI / Lint (push) Successful in 1m7s
Closes #203

Reviewed-on: #222
2024-09-29 11:33:38 +02:00
0b0599318e Enable fetch to apply modifications to the database (#221)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m1s
Cargo CI / Lint (push) Successful in 1m8s
Closes #189

Reviewed-on: #221
2024-09-29 10:44:37 +02:00
dbaef0422f Implement cannot have MBID in core (#220)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m5s
Part 1 of #189

Reviewed-on: #220
2024-09-24 22:38:40 +02:00
90db5faae7 Add option for manual input during fetch (#219)
All checks were successful
Cargo CI / Lint (push) Successful in 1m5s
Cargo CI / Build and Test (push) Successful in 1m57s
Closes #188

Reviewed-on: #219
2024-09-23 22:40:25 +02:00
d6f4b2b6b7 Daemonize the musicbrainz thread (#217)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m56s
Cargo CI / Lint (push) Successful in 1m6s
Part 3 of #188

Reviewed-on: #217
2024-09-21 23:03:47 +02:00
38517caf4e Add manual input elements to the app an ui (#216)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m56s
Cargo CI / Lint (push) Successful in 1m6s
Part 2 of #188

Reviewed-on: #216
2024-09-15 15:20:11 +02:00
8b008292cb Use more verbose type names for clarity (#214)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m6s
PR 1 for #188

Reviewed-on: #214
2024-09-13 21:28:12 +02:00
9d1caffd9c Handle idle time between fetch results (#212)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m52s
Cargo CI / Lint (push) Successful in 1m7s
Closes #211

Reviewed-on: #212
2024-09-08 23:23:53 +02:00
8e48412282 Make fetch asynchronous (#210)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m51s
Cargo CI / Lint (push) Successful in 1m5s
Closes #187

Reviewed-on: #210
2024-09-01 17:47:39 +02:00
fd9d3677ec Separate metadata from collections (#209)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m59s
Cargo CI / Lint (push) Successful in 1m6s
Part 1 of #187

Reviewed-on: #209
2024-08-31 22:55:25 +02:00
ebd63cc80b Add a "cannot-have-an-mbid" entry to possible matches (#208)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m6s
Closes #190

Reviewed-on: #208
2024-08-31 16:29:36 +02:00
6333b7a131 Use a queue to communicate matches from browse to matches (#207)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m0s
Cargo CI / Lint (push) Successful in 1m10s
Closes #202

Reviewed-on: #207
2024-08-31 14:42:46 +02:00
cda1487734 The tui feature is missing a dependence on musicbrainz (#206)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m4s
Cargo CI / Lint (push) Successful in 1m9s
Closes #205

Reviewed-on: #206
2024-08-31 13:19:28 +02:00
398963b9fd Make fetch also fetch artist MBID if it is missing (#201)
All checks were successful
Cargo CI / Build and Test (push) Successful in 2m4s
Cargo CI / Lint (push) Successful in 1m6s
Closes #191

Reviewed-on: #201
2024-08-30 17:58:44 +02:00
c38961c3c1 Split ui.rs into modules based on UI element (#200)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m59s
Cargo CI / Lint (push) Successful in 1m5s
Closes #135

Reviewed-on: #200
2024-08-29 17:21:52 +02:00
0fefc52603 For the database serde implementation use Mbid rather than MbRef (#199)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m54s
Cargo CI / Lint (push) Successful in 1m7s
Closes #198

Reviewed-on: #199
2024-08-29 13:37:47 +02:00
f82a6376e0 Use the Deserialize trait for JSON just like for MusicBrainz (#197)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m53s
Cargo CI / Lint (push) Successful in 1m5s
Closes #195

Reviewed-on: #197
2024-08-28 23:02:14 +02:00
43961b3ea1 Decide carefully where external::musicbrainz belongs (#196)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m5s
Closes #193

Reviewed-on: #196
2024-08-28 18:21:13 +02:00
b70499d8de Replace MH: IMusicHoard generic with a trait object (#194)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m56s
Cargo CI / Lint (push) Successful in 1m8s
Closes #192

Reviewed-on: #194
2024-08-27 18:45:03 +02:00
cf7e23c38c Provide a keyboard shortcut to sync all existing albums with MusicBrainz (#167)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m56s
Cargo CI / Lint (push) Successful in 1m5s
Cargo CI / Lint (pull_request) Successful in 1m4s
Cargo CI / Build and Test (pull_request) Successful in 1m58s
Closes #166

Reviewed-on: #167
2024-08-27 17:55:52 +02:00
d8fd952456 Add CLI option for setting binary (#184)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m3s
Cargo CI / Build and Test (pull_request) Successful in 1m54s
Cargo CI / Lint (pull_request) Successful in 1m3s
Closes #183

Reviewed-on: #184
2024-08-24 23:10:59 +02:00
871aeb8436 Update beets to 2.0.0 in CI (#182)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m3s
Cargo CI / Build and Test (pull_request) Successful in 1m53s
Cargo CI / Lint (pull_request) Successful in 1m6s
Closes #181

Reviewed-on: #182
2024-08-24 15:43:48 +02:00
8ff09e66ba Update rust toolchain to 1.80 (#180)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m55s
Cargo CI / Lint (push) Successful in 1m5s
Closes #179

Reviewed-on: #180
2024-08-24 15:10:54 +02:00
f395433343 Update rust toolchain to 1.79 (#176)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m49s
Cargo CI / Build and Test (pull_request) Successful in 1m51s
Cargo CI / Lint (push) Successful in 1m5s
Cargo CI / Lint (pull_request) Successful in 1m6s
Closes #174

Reviewed-on: #176
2024-06-16 16:38:35 +02:00
d9d5945422 Display all the extra album info (#173)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m58s
Cargo CI / Lint (push) Successful in 1m3s
Closes #172

Reviewed-on: #173
2024-03-17 20:17:41 +01:00
a062817ae7 The MusicBrainz API search call should use the MBID if available (#171)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m3s
Cargo CI / Build and Test (pull_request) Successful in 1m59s
Cargo CI / Lint (pull_request) Successful in 1m4s
Closes #169

Reviewed-on: #171
2024-03-17 17:18:06 +01:00
3ed13ca0e9 Create examples of using the MusicBrainz API (#170)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m57s
Cargo CI / Lint (push) Successful in 1m7s
Cargo CI / Build and Test (pull_request) Successful in 1m58s
Cargo CI / Lint (pull_request) Successful in 1m4s
Closes #168

Reviewed-on: #170
2024-03-17 14:19:30 +01:00
a75dd46a40 Add a MusicBrainz API (#163)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m48s
Cargo CI / Lint (push) Successful in 1m8s
Cargo CI / Build and Test (pull_request) Successful in 1m56s
Cargo CI / Lint (pull_request) Successful in 1m5s
Closes #158

Reviewed-on: #163
2024-03-16 16:57:50 +01:00
c53ba8f35f Break down the musichoard files (#165)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m45s
Cargo CI / Lint (push) Successful in 1m14s
Cargo CI / Build and Test (pull_request) Successful in 1m46s
Cargo CI / Lint (pull_request) Successful in 1m16s
Closes #164

Reviewed-on: #165
2024-03-09 22:52:03 +01:00
8550f7d6da Move database and library implementations out of core (#162)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m43s
Cargo CI / Lint (push) Successful in 1m14s
Cargo CI / Build and Test (pull_request) Successful in 1m40s
Cargo CI / Lint (pull_request) Successful in 1m17s
Closes #159

Reviewed-on: #162
2024-03-09 19:11:59 +01:00
bd7e9ceb4d Connect release groups to musicbrainz id (#157)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m42s
Cargo CI / Lint (push) Successful in 1m15s
Cargo CI / Build and Test (pull_request) Successful in 3m9s
Cargo CI / Lint (pull_request) Successful in 1m15s
Closes #46

Reviewed-on: #157
2024-03-08 23:28:52 +01:00
b70711d886 Add a field that indicates album ownership (#156)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m41s
Cargo CI / Lint (push) Successful in 1m14s
Cargo CI / Build and Test (pull_request) Successful in 3m2s
Cargo CI / Lint (pull_request) Successful in 1m14s
Closes #47

Reviewed-on: #156
2024-03-07 23:12:41 +01:00
c015f4c112 Sort albums by month if two releases of the same artist happen in the same year (#155)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m41s
Cargo CI / Lint (push) Successful in 1m13s
Cargo CI / Build and Test (pull_request) Successful in 3m8s
Cargo CI / Lint (pull_request) Successful in 1m15s
Closes #106

Reviewed-on: #155
2024-03-05 23:24:18 +01:00
4dc56f66c6 Make Selection fields private (#154)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m39s
Cargo CI / Lint (push) Successful in 1m13s
Cargo CI / Build and Test (pull_request) Successful in 1m40s
Cargo CI / Lint (pull_request) Successful in 1m15s
Closes #153

Reviewed-on: #154
2024-03-01 22:31:12 +01:00
42d1edb69c Extend incremental search to albums and tracks (#152)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m39s
Cargo CI / Lint (push) Successful in 1m14s
Closes #145

Reviewed-on: #152
2024-03-01 22:04:26 +01:00
fd19ea3eb3 Rescanning library does not update the database (#151)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m40s
Cargo CI / Lint (push) Successful in 1m13s
Cargo CI / Build and Test (pull_request) Successful in 1m38s
Cargo CI / Lint (pull_request) Successful in 1m14s
Closes #150

Reviewed-on: #151
2024-03-01 15:34:20 +01:00
4d2ea77da9 Ensure consistency between in-memory and database state (#146)
All checks were successful
Cargo CI / Build and Test (push) Successful in 1m41s
Cargo CI / Lint (push) Successful in 1m13s
Closes #120

Reviewed-on: #146
2024-03-01 09:00:52 +01:00
119 changed files with 15166 additions and 5440 deletions

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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/

View File

@ -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");
} }
} }

View 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>(),
);
}
},
}
}

View 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:#?}");
}
}
}

View 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:#?}");
}
}
}

View File

@ -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)); }
}
}
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(artist_value) => { ArtistCommand::Remove => {
music_hoard.remove_artist(ArtistId::new(artist_value.artist)); 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");
} }

View File

@ -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();

View File

@ -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 { impl PartialOrd for Artist {
self.sort.as_ref().unwrap_or(&self.id) 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 set_sort_key<SORT: Into<ArtistId>>(&mut self, sort: SORT) { 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> { impl Default for ArtistInfo {
let url: MusicBrainz = url.as_ref().try_into()?; fn default() -> Self {
Self::new(MbRefOption::None)
}
}
match &self.musicbrainz { impl ArtistInfo {
Some(current) => { pub fn new(musicbrainz: MbRefOption<MbArtistRef>) -> Self {
if current != &url { ArtistInfo {
return Err(Error::UrlError(format!( musicbrainz,
"artist already has a different URL: {current}" properties: HashMap::new(),
)));
}
}
None => {
_ = self.musicbrainz.insert(url);
}
} }
Ok(())
} }
pub fn remove_musicbrainz_url<S: AsRef<str>>(&mut self, url: S) -> Result<(), Error> { pub fn set_musicbrainz_ref(&mut self, mbref: MbRefOption<MbArtistRef>) {
let url = url.as_ref().try_into()?; self.musicbrainz = mbref
if self.musicbrainz == Some(url) {
_ = self.musicbrainz.take();
}
Ok(())
} }
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();

View File

@ -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
}
}

View File

@ -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())
} }
} }

View 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());
}
}

View File

@ -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,
}, },
}; };

View File

@ -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\":{}\
}\
]\
}";

View File

@ -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![],
})
}
}

View File

@ -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>),
}

View File

@ -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(),
}
}
}

View File

@ -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());
} }

View File

@ -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());
} }

View File

@ -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,
}, },
] ]

View File

@ -0,0 +1,2 @@
pub mod database;
pub mod library;

View File

@ -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")
]
});

View File

@ -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
View 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);
}
}

View 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());
}
}

View 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());
}
}

View 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());
}
}

View File

@ -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())
} }
} }

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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!());

View File

@ -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 {

View File

@ -5,13 +5,14 @@ pub mod backend;
#[cfg(test)] #[cfg(test)]
use mockall::automock; use mockall::automock;
use crate::core::{ use crate::{
collection::Collection, core::{
database::{IDatabase, LoadError, SaveError}, collection::Collection,
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
View 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
View 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
View 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)
}
}

View 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
View 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
View 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(),
}
}
}

View File

@ -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.

View File

@ -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
View 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
View File

@ -0,0 +1,2 @@
#[cfg(feature = "library-beets")]
pub mod beets;

4
src/external/mod.rs vendored Normal file
View 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
View 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
View 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
View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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\""
);
}
}

View 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
View 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
View 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),
}

View File

@ -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;

View File

@ -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 {
.expect("failed to initialise beets") Some(beets_bin) => {
.config(beets_config_file_path); let bin = beets_bin.into_string().expect("invalid beets binary path");
BeetsLibrarySshExecutor::bin(uri, bin)
}
None => BeetsLibrarySshExecutor::new(uri),
}
.expect("failed to initialise beets")
.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
View 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;

View File

@ -1,94 +1,107 @@
#[allow(unused_macros)]
macro_rules! library_collection { macro_rules! library_collection {
() => { () => {
vec![ vec![
Artist { Artist {
id: ArtistId { meta: ArtistMeta {
name: "Album_Artist A".to_string(), id: ArtistId {
name: "Album_Artist A".to_string(),
},
sort: None,
info: ArtistInfo {
musicbrainz: MbRefOption::None,
properties: HashMap::new(),
},
}, },
sort: None,
musicbrainz: None,
properties: HashMap::new(),
albums: vec![ albums: vec![
Album { Album {
id: AlbumId { meta: AlbumMeta {
year: 1998, id: AlbumId {
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 {
id: AlbumId { meta: AlbumMeta {
year: 2015, id: AlbumId {
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 {
id: ArtistId { meta: ArtistMeta {
name: "Album_Artist B".to_string(), id: ArtistId {
name: "Album_Artist B".to_string(),
},
sort: None,
info: ArtistInfo {
musicbrainz: MbRefOption::None,
properties: HashMap::new(),
},
}, },
sort: None,
musicbrainz: None,
properties: HashMap::new(),
albums: vec![ albums: vec![
Album { Album {
id: AlbumId { meta: AlbumMeta {
year: 2003, id: AlbumId {
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 {
id: AlbumId { meta: AlbumMeta {
year: 2008, id: AlbumId {
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 {
id: AlbumId { meta: AlbumMeta {
year: 2009, id: AlbumId {
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 {
id: AlbumId { meta: AlbumMeta {
year: 2015, id: AlbumId {
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 {
id: ArtistId { meta: ArtistMeta {
name: "The Album_Artist C".to_string(), id: ArtistId {
name: "The Album_Artist C".to_string(),
},
sort: Some("Album_Artist C, The".to_string()),
info: ArtistInfo {
musicbrainz: MbRefOption::None,
properties: HashMap::new(),
},
}, },
sort: Some(ArtistId {
name: "Album_Artist C, The".to_string(),
}),
musicbrainz: None,
properties: HashMap::new(),
albums: vec![ albums: vec![
Album { Album {
id: AlbumId { meta: AlbumMeta {
year: 1985, id: AlbumId {
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 {
id: AlbumId { meta: AlbumMeta {
year: 2018, id: AlbumId {
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 {
id: ArtistId { meta: ArtistMeta {
name: "Album_Artist D".to_string(), id: ArtistId {
name: "Album_Artist D".to_string(),
},
sort: None,
info: ArtistInfo {
musicbrainz: MbRefOption::None,
properties: HashMap::new(),
},
}, },
sort: None,
musicbrainz: None,
properties: HashMap::new(),
albums: vec![ albums: vec![
Album { Album {
id: AlbumId { meta: AlbumMeta {
year: 1995, id: AlbumId {
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 {
id: AlbumId { meta: AlbumMeta {
year: 2028, id: AlbumId {
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
View File

@ -0,0 +1,2 @@
pub mod full;
pub mod library;

View File

@ -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();
}
}

View 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(_))
);
}
}

View File

@ -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();
}
}

View 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)
}
}

View File

@ -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();
}
}

View 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();
}
}

View 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(_)));
}
}

View File

@ -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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View File

@ -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,
AppState::Search(search) => &search.inner,
AppState::Error(error) => &error.inner,
AppState::Critical(critical) => &critical.inner,
}
} }
fn inner_mut(&mut self) -> &mut AppInner<MH> { fn inner_mut(&mut self) -> &mut AppInner {
match self { app_field_mut!(self, inner)
AppState::Browse(browse) => &mut browse.inner, }
AppState::Info(info) => &mut info.inner,
AppState::Reload(reload) => &mut reload.inner, #[cfg(test)]
AppState::Search(search) => &mut search.inner, fn input_ref(&self) -> &Option<Input> {
AppState::Error(error) => &mut error.inner, app_field_ref!(self, input)
AppState::Critical(critical) => &mut critical.inner, }
}
fn input_mut(&mut self) -> &mut Option<Input> {
app_field_mut!(self, input)
} }
} }
impl<MH: IMusicHoard> IAppInteract for App<MH> { impl IApp for App {
type BS = AppMachine<MH, AppBrowse>; type BrowseState = AppMachine<BrowseState>;
type IS = AppMachine<MH, AppInfo>; type InfoState = AppMachine<InfoState>;
type RS = AppMachine<MH, AppReload>; type ReloadState = AppMachine<ReloadState>;
type SS = AppMachine<MH, AppSearch>; type SearchState = AppMachine<SearchState>;
type ES = AppMachine<MH, AppError>; type FetchState = AppMachine<FetchState>;
type CS = AppMachine<MH, AppCritical>; type MatchState = AppMachine<MatchState>;
type ErrorState = AppMachine<ErrorState>;
type CriticalState = AppMachine<CriticalState>;
type InputMode = AppInputMode;
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();
} }

View File

@ -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();
}
}

View 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();
}
}

View File

@ -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)))
}
}

View 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)))
}
}

View File

@ -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(_))
} }

View File

@ -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));
}
}

View 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);
}
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View File

@ -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 {

View File

@ -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) => {
AppState::Info(info) => { Self::handle_browse_key_event(browse_state, key_event)
<Self as IEventHandlerPrivate<APP>>::handle_info_key_event(info, key_event) }
} AppState::Info(info_state) => Self::handle_info_key_event(info_state, key_event),
AppState::Reload(reload) => { AppState::Reload(reload_state) => {
<Self as IEventHandlerPrivate<APP>>::handle_reload_key_event(reload, key_event) Self::handle_reload_key_event(reload_state, key_event)
} }
AppState::Search(search) => { AppState::Search(search_state) => {
<Self as IEventHandlerPrivate<APP>>::handle_search_key_event(search, key_event) Self::handle_search_key_event(search_state, key_event)
} }
AppState::Error(error) => { AppState::Fetch(fetch_state) => {
<Self as IEventHandlerPrivate<APP>>::handle_error_key_event(error, key_event) Self::handle_fetch_key_event(fetch_state, key_event)
} }
AppState::Critical(critical) => { AppState::Match(match_state) => {
<Self as IEventHandlerPrivate<APP>>::handle_critical_key_event(critical, key_event) Self::handle_match_key_event(match_state, key_event)
} }
AppState::Error(error_state) => {
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

View File

@ -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
View File

@ -0,0 +1 @@
pub mod musicbrainz;

View 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

View 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(&params.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());
}
}

View File

@ -0,0 +1,2 @@
pub mod api;
pub mod daemon;

View File

@ -0,0 +1 @@
pub mod musicbrainz;

View 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;

View 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());
}
}

View File

@ -0,0 +1,2 @@
pub mod api;
pub mod daemon;

67
src/tui/lib/mod.rs Normal file
View 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

View File

@ -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;
} }

View File

@ -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]

View File

@ -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!());

View File

@ -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
View 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