Compare commits
79 Commits
Author | SHA1 | Date | |
---|---|---|---|
5d510ff787 | |||
4db09667fd | |||
e5a367aa90 | |||
0d7e6bb555 | |||
e22068e461 | |||
0b0599318e | |||
dbaef0422f | |||
90db5faae7 | |||
d6f4b2b6b7 | |||
38517caf4e | |||
8b008292cb | |||
9d1caffd9c | |||
8e48412282 | |||
fd9d3677ec | |||
ebd63cc80b | |||
6333b7a131 | |||
cda1487734 | |||
398963b9fd | |||
c38961c3c1 | |||
0fefc52603 | |||
f82a6376e0 | |||
43961b3ea1 | |||
b70499d8de | |||
cf7e23c38c | |||
d8fd952456 | |||
871aeb8436 | |||
8ff09e66ba | |||
f395433343 | |||
d9d5945422 | |||
a062817ae7 | |||
3ed13ca0e9 | |||
a75dd46a40 | |||
c53ba8f35f | |||
8550f7d6da | |||
bd7e9ceb4d | |||
b70711d886 | |||
c015f4c112 | |||
4dc56f66c6 | |||
42d1edb69c | |||
fd19ea3eb3 | |||
4d2ea77da9 | |||
3bb8fb03ab | |||
dcc33d62b1 | |||
84a2cc83ca | |||
c4dc0d173b | |||
6a18c5d9cc | |||
de564eb1a0 | |||
fad49a48b8 | |||
36b4918a44 | |||
87de8d2b4e | |||
c2506657c3 | |||
9e9c6a1a4b | |||
e7413ed885 | |||
ba85505c9a | |||
6e9249e265 | |||
267f4a5461 | |||
d876b75d14 | |||
3109e576e3 | |||
83675c25e6 | |||
95ee681229 | |||
a315bf4229 | |||
845e9b09f4 | |||
d528511249 | |||
0c48673032 | |||
395cc57b9c | |||
36b82049f2 | |||
1bc612dc45 | |||
d7384476d4 | |||
26f0ccd842 | |||
b1cf5d621d | |||
74f7da20e6 | |||
62d6c43e3c | |||
3cd0cfde18 | |||
fd775372cd | |||
bf5bf9d8ae | |||
d20a9a9dec | |||
d51f9c138b | |||
282e0e6f19 | |||
c6ed827984 |
18
.gitea/images/Dockerfile
Normal file
18
.gitea/images/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
||||
FROM docker.io/library/rust:1.80
|
||||
|
||||
RUN rustup component add \
|
||||
clippy \
|
||||
llvm-tools-preview \
|
||||
rustfmt
|
||||
|
||||
RUN cargo install \
|
||||
grcov
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
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
|
28
.gitea/scripts/coverage.py
Normal file
28
.gitea/scripts/coverage.py
Normal file
@ -0,0 +1,28 @@
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def main(coverage_file, fail_under):
|
||||
with open(coverage_file, encoding="utf-8") as f:
|
||||
coverage_json = json.load(f)
|
||||
coverage = float(coverage_json["message"][:-1])
|
||||
print(f"Code coverage: {coverage:.2f}%; Threshold: {fail_under:.2f}%")
|
||||
success = coverage >= fail_under
|
||||
if coverage < fail_under:
|
||||
print("Insufficient code coverage", file=sys.stderr)
|
||||
return success
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Check coverage output by grcov")
|
||||
|
||||
parser.add_argument("--coverage-file", type=str, required=True,
|
||||
help="Path to the coverage.json file output by grcov")
|
||||
parser.add_argument("--fail-under", type=float, default=100.,
|
||||
help="Threshold under which coverage is insufficient")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not main(args.coverage_file, args.fail_under):
|
||||
exit(2)
|
57
.gitea/workflows/gitea-ci.yaml
Normal file
57
.gitea/workflows/gitea-ci.yaml
Normal file
@ -0,0 +1,57 @@
|
||||
name: Cargo CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_TERM_VERBOSE: true
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
name: Build and Test
|
||||
container: docker.io/drrobot/musichoard-ci:20240824-1
|
||||
env:
|
||||
BEETSDIR: ./
|
||||
LLVM_PROFILE_FILE: target/debug/profraw/musichoard-%p-%m.profraw
|
||||
RUSTFLAGS: -C instrument-coverage
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: cargo build --no-default-features --all-targets
|
||||
- run: cargo test --no-default-features --all-targets --no-fail-fast
|
||||
- run: cargo build --all-targets
|
||||
- run: cargo test --all-targets --no-fail-fast
|
||||
- run: cargo build --all-features --all-targets
|
||||
- run: cargo test --all-features --all-targets --no-fail-fast
|
||||
- run: >-
|
||||
grcov target/debug/profraw
|
||||
--binary-path target/debug/
|
||||
--output-types html
|
||||
--source-dir .
|
||||
--ignore-not-existing
|
||||
--ignore "build.rs"
|
||||
--ignore "examples/*"
|
||||
--ignore "tests/*"
|
||||
--ignore "src/main.rs"
|
||||
--ignore "src/bin/musichoard-edit.rs"
|
||||
--excl-line "^#\[derive|unimplemented\!\(\)"
|
||||
--excl-start "GRCOV_EXCL_START|mod tests \{"
|
||||
--excl-stop "GRCOV_EXCL_STOP"
|
||||
--output-path ./target/debug/coverage/
|
||||
- run: >-
|
||||
python3 .gitea/scripts/coverage.py
|
||||
--coverage-file ./target/debug/coverage/coverage.json
|
||||
--fail-under 100.00
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
container: docker.io/drrobot/musichoard-ci:20240824-1
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- run: cargo clippy --no-default-features --all-targets -- -D warnings
|
||||
- run: cargo clippy --all-targets -- -D warnings
|
||||
- run: cargo clippy --all-features --all-targets -- -D warnings
|
||||
- run: cargo fmt -- --check
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,4 @@
|
||||
/target
|
||||
/codecov
|
||||
database.json
|
||||
library.db
|
||||
|
1409
Cargo.lock
generated
1409
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
62
Cargo.toml
@ -1,34 +1,66 @@
|
||||
[package]
|
||||
name = "musichoard"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
crossterm = { version = "0.26.1", optional = true}
|
||||
openssh = { version = "0.9.9", features = ["native-mux"], default-features = false, optional = true}
|
||||
ratatui = { version = "0.20.1", optional = true}
|
||||
serde = { version = "1.0.159", features = ["derive"] }
|
||||
serde_json = { version = "1.0.95", optional = true}
|
||||
aho-corasick = { version = "1.1.2", optional = true }
|
||||
crossterm = { version = "0.28.1", optional = true}
|
||||
once_cell = { version = "1.19.0", optional = true}
|
||||
openssh = { version = "0.10.3", features = ["native-mux"], default-features = false, 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_json = { version = "1.0.113", optional = true}
|
||||
structopt = { version = "0.3.26", optional = true}
|
||||
tokio = { version = "1.27.0", features = ["rt"], optional = true}
|
||||
uuid = { version = "1.3.0", features = ["serde"] }
|
||||
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" }
|
||||
uuid = { version = "1.7.0" }
|
||||
|
||||
[build-dependencies]
|
||||
version_check = "0.9.4"
|
||||
|
||||
[dev-dependencies]
|
||||
mockall = "0.11.4"
|
||||
once_cell = "1.17.1"
|
||||
tempfile = "3.5.0"
|
||||
mockall = "0.12.1"
|
||||
once_cell = "1.19.0"
|
||||
tempfile = "3.10.0"
|
||||
|
||||
[features]
|
||||
default = ["database-json", "library-beets"]
|
||||
bin = ["structopt"]
|
||||
database-json = ["serde_json"]
|
||||
library-ssh = ["openssh", "tokio"]
|
||||
tui = ["crossterm", "ratatui"]
|
||||
database-json = ["serde", "serde_json"]
|
||||
library-beets = []
|
||||
library-beets-ssh = ["openssh", "tokio"]
|
||||
musicbrainz = ["paste", "reqwest", "serde", "serde_json"]
|
||||
tui = ["aho-corasick", "crossterm", "once_cell", "ratatui", "tui-input"]
|
||||
|
||||
[[bin]]
|
||||
name = "musichoard"
|
||||
required-features = ["bin", "database-json", "library-ssh", "tui"]
|
||||
required-features = ["bin", "database-json", "library-beets", "library-beets-ssh", "musicbrainz", "tui"]
|
||||
|
||||
[[bin]]
|
||||
name = "musichoard-edit"
|
||||
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]
|
||||
all-features = true
|
||||
|
46
README.md
46
README.md
@ -1,5 +1,24 @@
|
||||
# Music Hoard
|
||||
|
||||
## Developing
|
||||
|
||||
### Pre-requisites
|
||||
|
||||
#### musicbrainz-api
|
||||
|
||||
This feature requires the `openssl` system library.
|
||||
|
||||
On Fedora:
|
||||
``` sh
|
||||
sudo dnf install openssl-devel
|
||||
```
|
||||
|
||||
## Usage notes
|
||||
|
||||
### Text selection
|
||||
|
||||
To select and copy text use the terminal-specific modifier key (on Linux this is usually the Shift key).
|
||||
|
||||
## Code Coverage
|
||||
|
||||
### Pre-requisites
|
||||
@ -13,19 +32,26 @@ cargo install grcov
|
||||
|
||||
```sh
|
||||
env CARGO_TARGET_DIR=codecov \
|
||||
cargo clean
|
||||
rm -rf ./codecov/debug/{coverage,profraw}
|
||||
env CARGO_TARGET_DIR=codecov \
|
||||
cargo clean -p musichoard
|
||||
env RUSTFLAGS="-C instrument-coverage" \
|
||||
LLVM_PROFILE_FILE="codecov/debug/profraw/musichoard-%p-%m.profraw" \
|
||||
CARGO_TARGET_DIR=codecov \
|
||||
cargo test --all-features
|
||||
BEETSDIR=./ \
|
||||
cargo test --all-features --all-targets
|
||||
grcov codecov/debug/profraw \
|
||||
--binary-path ./codecov/debug/ \
|
||||
--output-types html \
|
||||
--source-dir . \
|
||||
--ignore-not-existing \
|
||||
--ignore "build.rs" \
|
||||
--ignore "examples/*" \
|
||||
--ignore "tests/*" \
|
||||
--ignore "src/main.rs" \
|
||||
--excl-start "mod tests \{|GRCOV_EXCL_START" \
|
||||
--ignore "src/bin/musichoard-edit.rs" \
|
||||
--excl-line "^#\[derive|unimplemented\!\(\)" \
|
||||
--excl-start "GRCOV_EXCL_START|mod tests \{" \
|
||||
--excl-stop "GRCOV_EXCL_STOP" \
|
||||
--output-path ./codecov/debug/coverage/
|
||||
xdg-open codecov/debug/coverage/index.html
|
||||
@ -35,3 +61,17 @@ Note that some changes may not be visible until `codecov/debug/coverage` is remo
|
||||
command is rerun.
|
||||
|
||||
For most cases `cargo clean` can be replaced with `rm -rf ./codecov/debug/{coverage,profraw}`.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
### Pre-requisites
|
||||
|
||||
``` sh
|
||||
rustup toolchain install nightly
|
||||
```
|
||||
|
||||
### Running benchmarks
|
||||
|
||||
``` sh
|
||||
env BEETSDIR=./ rustup run nightly cargo bench --all-features --all-targets
|
||||
```
|
||||
|
6
build.rs
Normal file
6
build.rs
Normal file
@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
println!("cargo::rustc-check-cfg=cfg(nightly)");
|
||||
if let Some(true) = version_check::is_feature_flaggable() {
|
||||
println!("cargo::rustc-cfg=nightly");
|
||||
}
|
||||
}
|
94
examples/musicbrainz_api/browse.rs
Normal file
94
examples/musicbrainz_api/browse.rs
Normal file
@ -0,0 +1,94 @@
|
||||
use std::{thread, time};
|
||||
|
||||
use musichoard::{
|
||||
collection::musicbrainz::Mbid,
|
||||
external::musicbrainz::{
|
||||
api::{browse::BrowseReleaseGroupRequest, MusicBrainzClient, PageSettings},
|
||||
http::MusicBrainzHttp,
|
||||
},
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const USER_AGENT: &str = concat!(
|
||||
"MusicHoard---examples---musicbrainz-api---browse/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" ( musichoard@thenineworlds.net )"
|
||||
);
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct Opt {
|
||||
#[structopt(subcommand)]
|
||||
entity: OptEntity,
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
enum OptEntity {
|
||||
#[structopt(about = "Browse release groups")]
|
||||
ReleaseGroup(OptReleaseGroup),
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
enum OptReleaseGroup {
|
||||
#[structopt(about = "Browse release groups of an artist")]
|
||||
Artist(OptMbid),
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct OptMbid {
|
||||
#[structopt(help = "MBID of the entity")]
|
||||
mbid: Uuid,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let opt = Opt::from_args();
|
||||
|
||||
println!("USER_AGENT: {USER_AGENT}");
|
||||
|
||||
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
match opt.entity {
|
||||
OptEntity::ReleaseGroup(opt_release_group) => match opt_release_group {
|
||||
OptReleaseGroup::Artist(opt_mbid) => {
|
||||
let mbid: Mbid = opt_mbid.mbid.into();
|
||||
let request = BrowseReleaseGroupRequest::artist(&mbid);
|
||||
let mut paging = PageSettings::with_max_limit();
|
||||
|
||||
let mut response_counts: Vec<usize> = Vec::new();
|
||||
|
||||
loop {
|
||||
let response = client
|
||||
.browse_release_group(&request, &paging)
|
||||
.expect("failed to make API call");
|
||||
|
||||
for rg in response.release_groups.iter() {
|
||||
println!("{rg:?}\n");
|
||||
}
|
||||
|
||||
let count = response.release_groups.len();
|
||||
response_counts.push(count);
|
||||
|
||||
println!("Release groups in this response: {count}");
|
||||
|
||||
paging = match response.page.next_page(paging, count) {
|
||||
Some(paging) => paging,
|
||||
None => break,
|
||||
};
|
||||
|
||||
thread::sleep(time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
println!(
|
||||
"Total: {}={} release groups",
|
||||
response_counts
|
||||
.iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("+"),
|
||||
response_counts.iter().sum::<usize>(),
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
81
examples/musicbrainz_api/lookup.rs
Normal file
81
examples/musicbrainz_api/lookup.rs
Normal file
@ -0,0 +1,81 @@
|
||||
use musichoard::{
|
||||
collection::musicbrainz::Mbid,
|
||||
external::musicbrainz::{
|
||||
api::{
|
||||
lookup::{LookupArtistRequest, LookupReleaseGroupRequest},
|
||||
MusicBrainzClient,
|
||||
},
|
||||
http::MusicBrainzHttp,
|
||||
},
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const USER_AGENT: &str = concat!(
|
||||
"MusicHoard---examples---musicbrainz-api---lookup/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" ( musichoard@thenineworlds.net )"
|
||||
);
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct Opt {
|
||||
#[structopt(subcommand)]
|
||||
entity: OptEntity,
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
enum OptEntity {
|
||||
#[structopt(about = "Lookup artist")]
|
||||
Artist(OptArtist),
|
||||
#[structopt(about = "Lookup release group")]
|
||||
ReleaseGroup(OptReleaseGroup),
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct OptArtist {
|
||||
#[structopt(help = "Artist MBID to lookup")]
|
||||
mbid: Uuid,
|
||||
#[structopt(long, help = "Include release groups in lookup")]
|
||||
release_groups: bool,
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct OptReleaseGroup {
|
||||
#[structopt(help = "Release group MBID to lookup")]
|
||||
mbid: Uuid,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let opt = Opt::from_args();
|
||||
|
||||
println!("USER_AGENT: {USER_AGENT}");
|
||||
|
||||
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
match opt.entity {
|
||||
OptEntity::Artist(opt_artist) => {
|
||||
let mbid: Mbid = opt_artist.mbid.into();
|
||||
let mut request = LookupArtistRequest::new(&mbid);
|
||||
if opt_artist.release_groups {
|
||||
request.include_release_groups();
|
||||
}
|
||||
|
||||
let response = client
|
||||
.lookup_artist(&request)
|
||||
.expect("failed to make API call");
|
||||
|
||||
println!("{response:#?}");
|
||||
}
|
||||
OptEntity::ReleaseGroup(opt_release_group) => {
|
||||
let mbid: Mbid = opt_release_group.mbid.into();
|
||||
let request = LookupReleaseGroupRequest::new(&mbid);
|
||||
|
||||
let response = client
|
||||
.lookup_release_group(&request)
|
||||
.expect("failed to make API call");
|
||||
|
||||
println!("{response:#?}");
|
||||
}
|
||||
}
|
||||
}
|
150
examples/musicbrainz_api/search.rs
Normal file
150
examples/musicbrainz_api/search.rs
Normal file
@ -0,0 +1,150 @@
|
||||
use std::{num::ParseIntError, str::FromStr};
|
||||
|
||||
use musichoard::{
|
||||
collection::{album::AlbumDate, musicbrainz::Mbid},
|
||||
external::musicbrainz::{
|
||||
api::{
|
||||
search::{SearchArtistRequest, SearchReleaseGroupRequest},
|
||||
MusicBrainzClient, PageSettings,
|
||||
},
|
||||
http::MusicBrainzHttp,
|
||||
},
|
||||
};
|
||||
use structopt::StructOpt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const USER_AGENT: &str = concat!(
|
||||
"MusicHoard---examples---musicbrainz-api---search/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" ( musichoard@thenineworlds.net )"
|
||||
);
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct Opt {
|
||||
#[structopt(subcommand)]
|
||||
entity: OptEntity,
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
enum OptEntity {
|
||||
#[structopt(about = "Search artist")]
|
||||
Artist(OptArtist),
|
||||
#[structopt(about = "Search release group")]
|
||||
ReleaseGroup(OptReleaseGroup),
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct OptArtist {
|
||||
#[structopt(help = "Artist search string")]
|
||||
string: String,
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
enum OptReleaseGroup {
|
||||
#[structopt(about = "Search by artist MBID, title(, and date)")]
|
||||
Title(OptReleaseGroupTitle),
|
||||
#[structopt(about = "Search by release group MBID")]
|
||||
Rgid(OptReleaseGroupRgid),
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct OptReleaseGroupTitle {
|
||||
#[structopt(help = "Release group's artist MBID")]
|
||||
arid: Uuid,
|
||||
|
||||
#[structopt(help = "Release group title")]
|
||||
title: String,
|
||||
|
||||
#[structopt(help = "Release group release date")]
|
||||
date: Option<Date>,
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct OptReleaseGroupRgid {
|
||||
#[structopt(help = "Release group MBID")]
|
||||
rgid: Uuid,
|
||||
}
|
||||
|
||||
struct Date(AlbumDate);
|
||||
|
||||
impl FromStr for Date {
|
||||
type Err = ParseIntError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let mut elems = s.split('-');
|
||||
|
||||
let elem = elems.next();
|
||||
let year = elem.map(|s| s.parse()).transpose()?;
|
||||
|
||||
let elem = elems.next();
|
||||
let month = elem.map(|s| s.parse()).transpose()?;
|
||||
|
||||
let elem = elems.next();
|
||||
let day = elem.map(|s| s.parse()).transpose()?;
|
||||
|
||||
Ok(Date(AlbumDate::new(year, month, day)))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Date> for AlbumDate {
|
||||
fn from(value: Date) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let opt = Opt::from_args();
|
||||
|
||||
println!("USER_AGENT: {USER_AGENT}");
|
||||
|
||||
let http = MusicBrainzHttp::new(USER_AGENT).expect("failed to create API client");
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
match opt.entity {
|
||||
OptEntity::Artist(opt_artist) => {
|
||||
let query = SearchArtistRequest::new().string(&opt_artist.string);
|
||||
|
||||
println!("Query: {query}");
|
||||
|
||||
let paging = PageSettings::default();
|
||||
let matches = client
|
||||
.search_artist(&query, &paging)
|
||||
.expect("failed to make API call");
|
||||
|
||||
println!("{matches:#?}");
|
||||
}
|
||||
OptEntity::ReleaseGroup(opt_release_group) => {
|
||||
let arid: Mbid;
|
||||
let date: AlbumDate;
|
||||
let title: String;
|
||||
let rgid: Mbid;
|
||||
|
||||
let query = match opt_release_group {
|
||||
OptReleaseGroup::Title(opt_title) => {
|
||||
arid = opt_title.arid.into();
|
||||
date = opt_title.date.map(Into::into).unwrap_or_default();
|
||||
title = opt_title.title;
|
||||
SearchReleaseGroupRequest::new()
|
||||
.arid(&arid)
|
||||
.and()
|
||||
.release_group(&title)
|
||||
.and()
|
||||
.first_release_date(&date)
|
||||
}
|
||||
OptReleaseGroup::Rgid(opt_rgid) => {
|
||||
rgid = opt_rgid.rgid.into();
|
||||
SearchReleaseGroupRequest::new().rgid(&rgid)
|
||||
}
|
||||
};
|
||||
|
||||
println!("Query: {query}");
|
||||
|
||||
let paging = PageSettings::default();
|
||||
let matches = client
|
||||
.search_release_group(&query, &paging)
|
||||
.expect("failed to make API call");
|
||||
|
||||
println!("{matches:#?}");
|
||||
}
|
||||
}
|
||||
}
|
257
src/bin/musichoard-edit.rs
Normal file
257
src/bin/musichoard-edit.rs
Normal file
@ -0,0 +1,257 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use structopt::{clap::AppSettings, StructOpt};
|
||||
|
||||
use musichoard::{
|
||||
collection::{album::AlbumId, artist::ArtistId},
|
||||
external::database::json::{backend::JsonDatabaseFileBackend, JsonDatabase},
|
||||
IMusicHoardDatabase, MusicHoard, MusicHoardBuilder, NoLibrary,
|
||||
};
|
||||
|
||||
type MH = MusicHoard<JsonDatabase<JsonDatabaseFileBackend>, NoLibrary>;
|
||||
|
||||
#[derive(StructOpt, Debug)]
|
||||
#[structopt(about = "musichoard-edit: edit the MusicHoard database",
|
||||
global_settings=&[AppSettings::DeriveDisplayOrder])]
|
||||
struct Opt {
|
||||
#[structopt(
|
||||
long = "database",
|
||||
help = "Database file path",
|
||||
default_value = "database.json"
|
||||
)]
|
||||
database_file_path: PathBuf,
|
||||
|
||||
#[structopt(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(StructOpt, Debug)]
|
||||
enum Command {
|
||||
#[structopt(about = "Modify artist information")]
|
||||
Artist(ArtistOpt),
|
||||
}
|
||||
|
||||
#[derive(StructOpt, Debug)]
|
||||
struct ArtistOpt {
|
||||
// For some reason, not specyfing the artist name with the `long` name makes StructOpt failed
|
||||
// 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)]
|
||||
enum ArtistCommand {
|
||||
#[structopt(about = "Add a new artist to the collection")]
|
||||
Add,
|
||||
#[structopt(about = "Remove an artist from the collection")]
|
||||
Remove,
|
||||
#[structopt(about = "Edit the artist's sort name")]
|
||||
Sort(SortCommand),
|
||||
#[structopt(about = "Edit a property of an artist")]
|
||||
Property(PropertyCommand),
|
||||
#[structopt(about = "Modify the artist's album information")]
|
||||
Album(AlbumOpt),
|
||||
}
|
||||
|
||||
#[derive(StructOpt, Debug)]
|
||||
enum SortCommand {
|
||||
#[structopt(about = "Set the provided name as the artist's sort name")]
|
||||
Set(SortValue),
|
||||
#[structopt(about = "Clear the artist's sort name")]
|
||||
Clear,
|
||||
}
|
||||
|
||||
#[derive(StructOpt, Debug)]
|
||||
struct SortValue {
|
||||
#[structopt(help = "The sort name")]
|
||||
sort: String,
|
||||
}
|
||||
|
||||
#[derive(StructOpt, Debug)]
|
||||
enum PropertyCommand {
|
||||
#[structopt(about = "Add values to the property without overwriting existing values")]
|
||||
Add(PropertyValue),
|
||||
#[structopt(about = "Remove values from the property")]
|
||||
Remove(PropertyValue),
|
||||
#[structopt(about = "Set the property's values overwriting any existing values")]
|
||||
Set(PropertyValue),
|
||||
#[structopt(about = "Clear all values of a property")]
|
||||
Clear(PropertyName),
|
||||
}
|
||||
|
||||
#[derive(StructOpt, Debug)]
|
||||
struct PropertyValue {
|
||||
#[structopt(help = "The name of the property")]
|
||||
property: String,
|
||||
#[structopt(help = "The list of values")]
|
||||
values: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(StructOpt, Debug)]
|
||||
struct PropertyName {
|
||||
#[structopt(help = "The name of the property")]
|
||||
property: String,
|
||||
}
|
||||
|
||||
#[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) {
|
||||
match self {
|
||||
Command::Artist(artist_opt) => artist_opt.handle(music_hoard),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtistOpt {
|
||||
fn handle(self, music_hoard: &mut MH) {
|
||||
self.command.handle(music_hoard, &self.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtistCommand {
|
||||
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
|
||||
match self {
|
||||
ArtistCommand::Add => {
|
||||
music_hoard
|
||||
.add_artist(ArtistId::new(artist_name))
|
||||
.expect("failed to add artist");
|
||||
}
|
||||
ArtistCommand::Remove => {
|
||||
music_hoard
|
||||
.remove_artist(ArtistId::new(artist_name))
|
||||
.expect("failed to remove artist");
|
||||
}
|
||||
ArtistCommand::Sort(sort_command) => {
|
||||
sort_command.handle(music_hoard, artist_name);
|
||||
}
|
||||
ArtistCommand::Property(property_command) => {
|
||||
property_command.handle(music_hoard, artist_name);
|
||||
}
|
||||
ArtistCommand::Album(album_opt) => {
|
||||
album_opt.handle(music_hoard, artist_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SortCommand {
|
||||
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
|
||||
match self {
|
||||
SortCommand::Set(artist_sort_value) => music_hoard
|
||||
.set_artist_sort(ArtistId::new(artist_name), artist_sort_value.sort)
|
||||
.expect("faild to set artist sort name"),
|
||||
SortCommand::Clear => music_hoard
|
||||
.clear_artist_sort(ArtistId::new(artist_name))
|
||||
.expect("failed to clear artist sort name"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PropertyCommand {
|
||||
fn handle(self, music_hoard: &mut MH, artist_name: &str) {
|
||||
match self {
|
||||
PropertyCommand::Add(property_value) => music_hoard
|
||||
.add_to_artist_property(
|
||||
ArtistId::new(artist_name),
|
||||
property_value.property,
|
||||
property_value.values,
|
||||
)
|
||||
.expect("failed to add values to property"),
|
||||
PropertyCommand::Remove(property_value) => music_hoard
|
||||
.remove_from_artist_property(
|
||||
ArtistId::new(artist_name),
|
||||
property_value.property,
|
||||
property_value.values,
|
||||
)
|
||||
.expect("failed to remove values from property"),
|
||||
PropertyCommand::Set(property_value) => music_hoard
|
||||
.set_artist_property(
|
||||
ArtistId::new(artist_name),
|
||||
property_value.property,
|
||||
property_value.values,
|
||||
)
|
||||
.expect("failed to set property"),
|
||||
PropertyCommand::Clear(property_name) => music_hoard
|
||||
.clear_artist_property(ArtistId::new(artist_name), property_name.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() {
|
||||
let opt = Opt::from_args();
|
||||
|
||||
let db = JsonDatabase::new(JsonDatabaseFileBackend::new(&opt.database_file_path));
|
||||
|
||||
let mut music_hoard = MusicHoardBuilder::default()
|
||||
.set_database(db)
|
||||
.build()
|
||||
.expect("failed to initialise MusicHoard");
|
||||
opt.command.handle(&mut music_hoard);
|
||||
}
|
@ -1,178 +0,0 @@
|
||||
//! Module for managing the music collection, i.e. "The Music Hoard".
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::{
|
||||
database::{self, Database},
|
||||
library::{self, Library, Query},
|
||||
Artist,
|
||||
};
|
||||
|
||||
/// The collection type.
|
||||
pub type Collection = Vec<Artist>;
|
||||
|
||||
/// Error type for collection manager.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// The [`CollectionManager`] failed to read/write from/to the library.
|
||||
LibraryError(String),
|
||||
/// The [`CollectionManager`] failed to read/write from/to the database.
|
||||
DatabaseError(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match *self {
|
||||
Self::LibraryError(ref s) => write!(f, "failed to read/write from/to the library: {s}"),
|
||||
Self::DatabaseError(ref s) => {
|
||||
write!(f, "failed to read/write from/to the database: {s}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<library::Error> for Error {
|
||||
fn from(err: library::Error) -> Error {
|
||||
Error::LibraryError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<database::Error> for Error {
|
||||
fn from(err: database::Error) -> Error {
|
||||
Error::DatabaseError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait CollectionManager {
|
||||
/// Rescan the library and integrate any updates into the collection.
|
||||
fn rescan_library(&mut self) -> Result<(), Error>;
|
||||
|
||||
/// Save the collection state to the database.
|
||||
fn save_to_database(&mut self) -> Result<(), Error>;
|
||||
|
||||
/// Get the current collection.
|
||||
fn get_collection(&self) -> &Collection;
|
||||
}
|
||||
|
||||
/// The collection manager. It is responsible for pulling information from both the library and the
|
||||
/// database, ensuring its consistent and writing back any changes.
|
||||
pub struct MhCollectionManager<LIB, DB> {
|
||||
library: LIB,
|
||||
database: DB,
|
||||
collection: Collection,
|
||||
}
|
||||
|
||||
impl<LIB: Library, DB: Database> MhCollectionManager<LIB, DB> {
|
||||
/// Create a new [`CollectionManager`] with the provided [`Library`] and [`Database`].
|
||||
pub fn new(library: LIB, database: DB) -> Self {
|
||||
MhCollectionManager {
|
||||
library,
|
||||
database,
|
||||
collection: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<LIB: Library, DB: Database> CollectionManager for MhCollectionManager<LIB, DB> {
|
||||
fn rescan_library(&mut self) -> Result<(), Error> {
|
||||
self.collection = self.library.list(&Query::new())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_to_database(&mut self) -> Result<(), Error> {
|
||||
self.database.write(&self.collection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_collection(&self) -> &Collection {
|
||||
&self.collection
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::predicate;
|
||||
|
||||
use crate::{
|
||||
collection::Collection,
|
||||
database::{self, MockDatabase},
|
||||
library::{self, MockLibrary, Query},
|
||||
tests::COLLECTION,
|
||||
};
|
||||
|
||||
use super::{CollectionManager, Error, MhCollectionManager};
|
||||
|
||||
#[test]
|
||||
fn read_get_write() {
|
||||
let mut library = MockLibrary::new();
|
||||
let mut database = MockDatabase::new();
|
||||
|
||||
let library_input = Query::new();
|
||||
let library_result = Ok(COLLECTION.to_owned());
|
||||
|
||||
let database_input = COLLECTION.to_owned();
|
||||
let database_result = Ok(());
|
||||
|
||||
library
|
||||
.expect_list()
|
||||
.with(predicate::eq(library_input))
|
||||
.times(1)
|
||||
.return_once(|_| library_result);
|
||||
|
||||
database
|
||||
.expect_write()
|
||||
.with(predicate::eq(database_input))
|
||||
.times(1)
|
||||
.return_once(|_: &Collection| database_result);
|
||||
|
||||
let mut collection_manager = MhCollectionManager::new(library, database);
|
||||
|
||||
collection_manager.rescan_library().unwrap();
|
||||
assert_eq!(collection_manager.get_collection(), &*COLLECTION);
|
||||
collection_manager.save_to_database().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_error() {
|
||||
let mut library = MockLibrary::new();
|
||||
let database = MockDatabase::new();
|
||||
|
||||
let library_result = Err(library::Error::Invalid(String::from("invalid data")));
|
||||
|
||||
library
|
||||
.expect_list()
|
||||
.times(1)
|
||||
.return_once(|_| library_result);
|
||||
|
||||
let mut collection_manager = MhCollectionManager::new(library, database);
|
||||
|
||||
let actual_err = collection_manager.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_error() {
|
||||
let library = MockLibrary::new();
|
||||
let mut database = MockDatabase::new();
|
||||
|
||||
let database_result = Err(database::Error::IoError(String::from("I/O error")));
|
||||
|
||||
database
|
||||
.expect_write()
|
||||
.times(1)
|
||||
.return_once(|_: &Collection| database_result);
|
||||
|
||||
let mut collection_manager = MhCollectionManager::new(library, database);
|
||||
|
||||
let actual_err = collection_manager.save_to_database().unwrap_err();
|
||||
let expected_err =
|
||||
Error::DatabaseError(database::Error::IoError(String::from("I/O error")).to_string());
|
||||
|
||||
assert_eq!(actual_err, expected_err);
|
||||
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
||||
}
|
||||
}
|
383
src/core/collection/album.rs
Normal file
383
src/core/collection/album.rs
Normal file
@ -0,0 +1,383 @@
|
||||
use std::{
|
||||
fmt::{self, Display},
|
||||
mem,
|
||||
};
|
||||
|
||||
use crate::core::collection::{
|
||||
merge::{Merge, MergeSorted, WithId},
|
||||
musicbrainz::{MbAlbumRef, MbRefOption},
|
||||
track::{Track, TrackFormat},
|
||||
};
|
||||
|
||||
/// An album is a collection of tracks that were released together.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Album {
|
||||
pub meta: AlbumMeta,
|
||||
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.
|
||||
#[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq, Hash)]
|
||||
pub struct AlbumId {
|
||||
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 {
|
||||
pub fn new<Id: Into<AlbumId>, Date: Into<AlbumDate>>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Album {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Album {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.meta.cmp(&other.meta)
|
||||
}
|
||||
}
|
||||
|
||||
impl Merge for Album {
|
||||
fn merge_in_place(&mut self, other: Self) {
|
||||
self.meta.merge_in_place(other.meta);
|
||||
let tracks = mem::take(&mut self.tracks);
|
||||
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)]
|
||||
mod tests {
|
||||
use crate::core::testmod::FULL_COLLECTION;
|
||||
|
||||
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]
|
||||
fn merge_album_no_overlap() {
|
||||
let left = FULL_COLLECTION[0].albums[0].to_owned();
|
||||
let mut right = FULL_COLLECTION[0].albums[1].to_owned();
|
||||
right.meta.id = left.meta.id.clone();
|
||||
|
||||
let mut expected = left.clone();
|
||||
expected.tracks.append(&mut right.tracks.clone());
|
||||
expected.tracks.sort_unstable();
|
||||
|
||||
let merged = left.clone().merge(right.clone());
|
||||
assert_eq!(expected, merged);
|
||||
|
||||
// Non-overlapping merge should be commutative in the tracks.
|
||||
let merged = right.clone().merge(left.clone());
|
||||
assert_eq!(expected.tracks, merged.tracks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_album_overlap() {
|
||||
let mut left = FULL_COLLECTION[0].albums[0].to_owned();
|
||||
let mut right = FULL_COLLECTION[0].albums[1].to_owned();
|
||||
right.meta.id = left.meta.id.clone();
|
||||
left.tracks.push(right.tracks[0].clone());
|
||||
left.tracks.sort_unstable();
|
||||
|
||||
let mut expected = left.clone();
|
||||
expected.tracks.append(&mut right.tracks.clone());
|
||||
expected.tracks.sort_unstable();
|
||||
expected.tracks.dedup();
|
||||
|
||||
let merged = left.clone().merge(right);
|
||||
assert_eq!(expected, merged);
|
||||
}
|
||||
}
|
459
src/core/collection/artist.rs
Normal file
459
src/core/collection/artist.rs
Normal file
@ -0,0 +1,459 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::{self, Debug, Display},
|
||||
mem,
|
||||
};
|
||||
|
||||
use crate::core::collection::{
|
||||
album::Album,
|
||||
merge::{Merge, MergeCollections, WithId},
|
||||
musicbrainz::{MbArtistRef, MbRefOption},
|
||||
};
|
||||
|
||||
/// An artist.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Artist {
|
||||
pub meta: ArtistMeta,
|
||||
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.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ArtistId {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Artist {
|
||||
/// Create new [`Artist`] with the given [`ArtistId`].
|
||||
pub fn new<Id: Into<ArtistId>>(id: Id) -> Self {
|
||||
Artist {
|
||||
meta: ArtistMeta::new(id),
|
||||
albums: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Artist {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Artist {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.meta.cmp(&other.meta)
|
||||
}
|
||||
}
|
||||
|
||||
impl Merge for Artist {
|
||||
fn merge_in_place(&mut self, other: Self) {
|
||||
self.meta.merge_in_place(other.meta);
|
||||
let albums = mem::take(&mut self.albums);
|
||||
self.albums = MergeCollections::merge_iter(albums, other.albums);
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtistMeta {
|
||||
pub fn new<Id: Into<ArtistId>>(id: Id) -> Self {
|
||||
ArtistMeta {
|
||||
id: id.into(),
|
||||
sort: None,
|
||||
info: ArtistInfo::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_sort_key(&self) -> (&str,) {
|
||||
(self.sort.as_ref().unwrap_or(&self.id.name),)
|
||||
}
|
||||
|
||||
pub fn set_sort_key<S: Into<String>>(&mut self, sort: S) {
|
||||
self.sort = Some(sort.into());
|
||||
}
|
||||
|
||||
pub fn clear_sort_key(&mut self) {
|
||||
self.sort.take();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ArtistInfo {
|
||||
fn default() -> Self {
|
||||
Self::new(MbRefOption::None)
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtistInfo {
|
||||
pub fn new(musicbrainz: MbRefOption<MbArtistRef>) -> Self {
|
||||
ArtistInfo {
|
||||
musicbrainz,
|
||||
properties: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_musicbrainz_ref(&mut self, mbref: MbRefOption<MbArtistRef>) {
|
||||
self.musicbrainz = mbref
|
||||
}
|
||||
|
||||
pub fn clear_musicbrainz_ref(&mut self) {
|
||||
self.musicbrainz.take();
|
||||
}
|
||||
|
||||
// In the functions below, it would be better to use `contains` instead of `iter().any`, but for
|
||||
// type reasons that does not work:
|
||||
// https://stackoverflow.com/questions/48985924/why-does-a-str-not-coerce-to-a-string-when-using-veccontains
|
||||
|
||||
pub fn add_to_property<S: AsRef<str> + Into<String>>(&mut self, property: S, values: Vec<S>) {
|
||||
match self.properties.get_mut(property.as_ref()) {
|
||||
Some(container) => {
|
||||
container.append(
|
||||
&mut values
|
||||
.into_iter()
|
||||
.filter(|val| !container.iter().any(|x| x == val.as_ref()))
|
||||
.map(|val| val.into())
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
self.properties.insert(
|
||||
property.into(),
|
||||
values.into_iter().map(|s| s.into()).collect(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_from_property<S: AsRef<str>>(&mut self, property: S, values: Vec<S>) {
|
||||
if let Some(container) = self.properties.get_mut(property.as_ref()) {
|
||||
container.retain(|val| !values.iter().any(|x| x.as_ref() == val));
|
||||
if container.is_empty() {
|
||||
self.properties.remove(property.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_property<S: AsRef<str> + Into<String>>(&mut self, property: S, values: Vec<S>) {
|
||||
self.properties.insert(
|
||||
property.into(),
|
||||
values.into_iter().map(|s| s.into()).collect(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn clear_property<S: AsRef<str>>(&mut self, property: S) {
|
||||
self.properties.remove(property.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for ArtistMeta {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for ArtistMeta {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.get_sort_key().cmp(&other.get_sort_key())
|
||||
}
|
||||
}
|
||||
|
||||
impl Merge for ArtistMeta {
|
||||
fn merge_in_place(&mut self, other: Self) {
|
||||
assert_eq!(self.id, other.id);
|
||||
|
||||
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.properties.merge_in_place(other.properties);
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Into<String>> From<S> for ArtistId {
|
||||
fn from(value: S) -> Self {
|
||||
ArtistId::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<ArtistId> for ArtistId {
|
||||
fn as_ref(&self) -> &ArtistId {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtistId {
|
||||
pub fn new<S: Into<String>>(name: S) -> ArtistId {
|
||||
ArtistId { name: name.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ArtistId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::core::testmod::FULL_COLLECTION;
|
||||
|
||||
use super::*;
|
||||
|
||||
static MUSICBRAINZ: &str =
|
||||
"https://musicbrainz.org/artist/d368baa8-21ca-4759-9731-0b2753071ad8";
|
||||
static MUSICBRAINZ_2: &str =
|
||||
"https://musicbrainz.org/artist/823869a5-5ded-4f6b-9fb7-2a9344d83c6b";
|
||||
static MUSICBUTLER: &str = "https://www.musicbutler.io/artist-page/483340948";
|
||||
static MUSICBUTLER_2: &str = "https://www.musicbutler.io/artist-page/658903042/";
|
||||
|
||||
#[test]
|
||||
fn artist_sort_set_clear() {
|
||||
let artist_id = ArtistId::new("an artist");
|
||||
let sort_id_1 = String::from("sort id 1");
|
||||
let sort_id_2 = String::from("sort id 2");
|
||||
|
||||
let mut artist = Artist::new(&artist_id.name);
|
||||
|
||||
assert_eq!(artist.meta.id, artist_id);
|
||||
assert_eq!(artist.meta.sort, None);
|
||||
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_2.clone()));
|
||||
|
||||
artist.meta.set_sort_key(sort_id_1.clone());
|
||||
|
||||
assert_eq!(artist.meta.id, artist_id);
|
||||
assert_eq!(artist.meta.sort.as_ref(), Some(&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(sort_id_2.clone()));
|
||||
|
||||
artist.meta.set_sort_key(sort_id_2.clone());
|
||||
|
||||
assert_eq!(artist.meta.id, artist_id);
|
||||
assert_eq!(artist.meta.sort.as_ref(), Some(&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(sort_id_1.clone()));
|
||||
|
||||
artist.meta.clear_sort_key();
|
||||
|
||||
assert_eq!(artist.meta.id, artist_id);
|
||||
assert_eq!(artist.meta.sort, None);
|
||||
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_2.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_clear_musicbrainz_url() {
|
||||
let mut artist = Artist::new(ArtistId::new("an artist"));
|
||||
|
||||
let mut expected: MbRefOption<MbArtistRef> = MbRefOption::None;
|
||||
assert_eq!(artist.meta.info.musicbrainz, expected);
|
||||
|
||||
// Setting a URL on an artist.
|
||||
artist.meta.info.set_musicbrainz_ref(MbRefOption::Some(
|
||||
MbArtistRef::from_url_str(MUSICBRAINZ).unwrap(),
|
||||
));
|
||||
expected.replace(MbArtistRef::from_url_str(MUSICBRAINZ).unwrap());
|
||||
assert_eq!(artist.meta.info.musicbrainz, expected);
|
||||
|
||||
artist.meta.info.set_musicbrainz_ref(MbRefOption::Some(
|
||||
MbArtistRef::from_url_str(MUSICBRAINZ).unwrap(),
|
||||
));
|
||||
assert_eq!(artist.meta.info.musicbrainz, expected);
|
||||
|
||||
artist.meta.info.set_musicbrainz_ref(MbRefOption::Some(
|
||||
MbArtistRef::from_url_str(MUSICBRAINZ_2).unwrap(),
|
||||
));
|
||||
expected.replace(MbArtistRef::from_url_str(MUSICBRAINZ_2).unwrap());
|
||||
assert_eq!(artist.meta.info.musicbrainz, expected);
|
||||
|
||||
// Clearing URLs.
|
||||
artist.meta.info.clear_musicbrainz_ref();
|
||||
expected.take();
|
||||
assert_eq!(artist.meta.info.musicbrainz, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_remove_from_property() {
|
||||
let mut artist = Artist::new(ArtistId::new("an artist"));
|
||||
|
||||
let info = &mut artist.meta.info;
|
||||
let mut expected: Vec<String> = vec![];
|
||||
assert!(info.properties.is_empty());
|
||||
|
||||
// Adding a single URL.
|
||||
info.add_to_property("MusicButler", vec![MUSICBUTLER]);
|
||||
expected.push(MUSICBUTLER.to_owned());
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Adding a URL that already exists is ok, but does not do anything.
|
||||
info.add_to_property("MusicButler", vec![MUSICBUTLER]);
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Adding another single URL.
|
||||
info.add_to_property("MusicButler", vec![MUSICBUTLER_2]);
|
||||
expected.push(MUSICBUTLER_2.to_owned());
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
info.add_to_property("MusicButler", vec![MUSICBUTLER_2]);
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Removing a URL.
|
||||
info.remove_from_property("MusicButler", vec![MUSICBUTLER]);
|
||||
expected.retain(|url| url != MUSICBUTLER);
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Removing URls that do not exist is okay, they will be ignored.
|
||||
info.remove_from_property("MusicButler", vec![MUSICBUTLER]);
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Removing a URL.
|
||||
info.remove_from_property("MusicButler", vec![MUSICBUTLER_2]);
|
||||
expected.retain(|url| url.as_str() != MUSICBUTLER_2);
|
||||
assert!(info.properties.is_empty());
|
||||
|
||||
info.remove_from_property("MusicButler", vec![MUSICBUTLER_2]);
|
||||
assert!(info.properties.is_empty());
|
||||
|
||||
// Adding URLs if some exist is okay, they will be ignored.
|
||||
info.add_to_property("MusicButler", vec![MUSICBUTLER]);
|
||||
expected.push(MUSICBUTLER.to_owned());
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
info.add_to_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
||||
expected.push(MUSICBUTLER_2.to_owned());
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Removing URLs if some do not exist is okay, they will be ignored.
|
||||
info.remove_from_property("MusicButler", vec![MUSICBUTLER]);
|
||||
expected.retain(|url| url.as_str() != MUSICBUTLER);
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
info.remove_from_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
||||
expected.retain(|url| url.as_str() != MUSICBUTLER_2);
|
||||
assert!(info.properties.is_empty());
|
||||
|
||||
// Adding mutliple URLs without clashes.
|
||||
info.add_to_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
||||
expected.push(MUSICBUTLER.to_owned());
|
||||
expected.push(MUSICBUTLER_2.to_owned());
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Removing multiple URLs without clashes.
|
||||
info.remove_from_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
||||
expected.clear();
|
||||
assert!(info.properties.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_clear_musicbutler_urls() {
|
||||
let mut artist = Artist::new(ArtistId::new("an artist"));
|
||||
|
||||
let info = &mut artist.meta.info;
|
||||
let mut expected: Vec<String> = vec![];
|
||||
assert!(info.properties.is_empty());
|
||||
|
||||
// Set URLs.
|
||||
info.set_property("MusicButler", vec![MUSICBUTLER]);
|
||||
expected.push(MUSICBUTLER.to_owned());
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
info.set_property("MusicButler", vec![MUSICBUTLER_2]);
|
||||
expected.clear();
|
||||
expected.push(MUSICBUTLER_2.to_owned());
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
info.set_property("MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2]);
|
||||
expected.clear();
|
||||
expected.push(MUSICBUTLER.to_owned());
|
||||
expected.push(MUSICBUTLER_2.to_owned());
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Clear URLs.
|
||||
info.clear_property("MusicButler");
|
||||
expected.clear();
|
||||
assert!(info.properties.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_artist_no_overlap() {
|
||||
let left = FULL_COLLECTION[0].to_owned();
|
||||
let mut right = FULL_COLLECTION[1].to_owned();
|
||||
right.meta.id = left.meta.id.clone();
|
||||
right.meta.info.musicbrainz = MbRefOption::None;
|
||||
right.meta.info.properties = HashMap::new();
|
||||
|
||||
let mut expected = left.clone();
|
||||
expected.meta.info.properties = expected
|
||||
.meta
|
||||
.info
|
||||
.properties
|
||||
.merge(right.clone().meta.info.properties);
|
||||
expected.albums.append(&mut right.albums.clone());
|
||||
expected.albums.sort_unstable();
|
||||
|
||||
let merged = left.clone().merge(right.clone());
|
||||
assert_eq!(expected, merged);
|
||||
|
||||
// Non-overlapping merge should be commutative.
|
||||
let merged = right.clone().merge(left.clone());
|
||||
assert_eq!(expected, merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_artist_overlap() {
|
||||
let mut left = FULL_COLLECTION[0].to_owned();
|
||||
let mut right = FULL_COLLECTION[1].to_owned();
|
||||
right.meta.id = left.meta.id.clone();
|
||||
left.albums.push(right.albums[0].clone());
|
||||
left.albums.sort_unstable();
|
||||
|
||||
let mut expected = left.clone();
|
||||
expected.meta.info.properties = expected
|
||||
.meta
|
||||
.info
|
||||
.properties
|
||||
.merge(right.clone().meta.info.properties);
|
||||
expected.albums.append(&mut right.albums.clone());
|
||||
expected.albums.sort_unstable();
|
||||
expected.albums.dedup();
|
||||
|
||||
let merged = left.clone().merge(right);
|
||||
assert_eq!(expected, merged);
|
||||
}
|
||||
}
|
123
src/core/collection/merge.rs
Normal file
123
src/core/collection/merge.rs
Normal file
@ -0,0 +1,123 @@
|
||||
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
|
||||
/// the primary whose properties are to be kept in case of collisions.
|
||||
pub trait Merge {
|
||||
fn merge_in_place(&mut self, other: Self);
|
||||
|
||||
fn merge(mut self, other: Self) -> Self
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
self.merge_in_place(other);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Ord> Merge for Vec<T> {
|
||||
fn merge_in_place(&mut self, mut other: Self) {
|
||||
self.append(&mut other);
|
||||
self.sort_unstable();
|
||||
self.dedup();
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Hash + PartialEq + Eq, T: Ord> Merge for HashMap<K, Vec<T>> {
|
||||
fn merge_in_place(&mut self, mut other: Self) {
|
||||
for (other_key, other_value) in other.drain() {
|
||||
if let Some(ref mut value) = self.get_mut(&other_key) {
|
||||
value.merge_in_place(other_value)
|
||||
} else {
|
||||
self.insert(other_key, other_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MergeSorted<L, R>
|
||||
where
|
||||
L: Iterator<Item = R::Item>,
|
||||
R: Iterator,
|
||||
{
|
||||
left: Peekable<L>,
|
||||
right: Peekable<R>,
|
||||
}
|
||||
|
||||
impl<L, R> MergeSorted<L, R>
|
||||
where
|
||||
L: Iterator<Item = R::Item>,
|
||||
R: Iterator,
|
||||
{
|
||||
pub fn new(left: L, right: R) -> MergeSorted<L, R> {
|
||||
MergeSorted {
|
||||
left: left.peekable(),
|
||||
right: right.peekable(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<L, R> Iterator for MergeSorted<L, R>
|
||||
where
|
||||
L: Iterator<Item = R::Item>,
|
||||
R: Iterator,
|
||||
L::Item: Ord + Merge,
|
||||
{
|
||||
type Item = L::Item;
|
||||
|
||||
fn next(&mut self) -> Option<L::Item> {
|
||||
let which = match (self.left.peek(), self.right.peek()) {
|
||||
(Some(l), Some(r)) => l.cmp(r),
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(None, None) => return None,
|
||||
};
|
||||
|
||||
match which {
|
||||
Ordering::Less => self.left.next(),
|
||||
Ordering::Equal => Some(self.left.next().unwrap().merge(self.right.next().unwrap())),
|
||||
Ordering::Greater => self.right.next(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
42
src/core/collection/mod.rs
Normal file
42
src/core/collection/mod.rs
Normal file
@ -0,0 +1,42 @@
|
||||
//! The collection module defines the core data types and their relations.
|
||||
|
||||
pub mod album;
|
||||
pub mod artist;
|
||||
pub mod merge;
|
||||
pub mod musicbrainz;
|
||||
pub mod track;
|
||||
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
/// The [`Collection`] alias type for convenience.
|
||||
pub type Collection = Vec<artist::Artist>;
|
||||
|
||||
/// Error type for the [`collection`] module.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// An error occurred when processing an MBID.
|
||||
MbidError(String),
|
||||
/// An error occurred when processing a URL.
|
||||
UrlError(String),
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for Error {
|
||||
fn from(err: url::ParseError) -> Error {
|
||||
Error::UrlError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<uuid::Error> for Error {
|
||||
fn from(err: uuid::Error) -> Error {
|
||||
Error::MbidError(err.to_string())
|
||||
}
|
||||
}
|
289
src/core/collection/musicbrainz.rs
Normal file
289
src/core/collection/musicbrainz.rs
Normal file
@ -0,0 +1,289 @@
|
||||
use std::{fmt, mem};
|
||||
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::collection::Error;
|
||||
|
||||
const MB_DOMAIN: &str = "musicbrainz.org";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Mbid(Uuid);
|
||||
|
||||
impl Mbid {
|
||||
pub fn uuid(&self) -> &Uuid {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Uuid> for Mbid {
|
||||
fn from(value: Uuid) -> Self {
|
||||
Mbid(value)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! try_from_impl_for_mbid {
|
||||
($from:ty) => {
|
||||
impl TryFrom<$from> for Mbid {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: $from) -> Result<Self, Self::Error> {
|
||||
Ok(Uuid::parse_str(value.as_ref())?.into())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
try_from_impl_for_mbid!(&str);
|
||||
try_from_impl_for_mbid!(&String);
|
||||
try_from_impl_for_mbid!(String);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MbRefOption<T> {
|
||||
Some(T),
|
||||
CannotHaveMbid,
|
||||
None,
|
||||
}
|
||||
|
||||
impl<T> MbRefOption<T> {
|
||||
pub fn or(self, optb: MbRefOption<T>) -> MbRefOption<T> {
|
||||
match (&self, &optb) {
|
||||
(MbRefOption::Some(_), _) | (MbRefOption::CannotHaveMbid, MbRefOption::None) => self,
|
||||
_ => optb,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace(&mut self, value: T) -> MbRefOption<T> {
|
||||
mem::replace(self, MbRefOption::Some(value))
|
||||
}
|
||||
|
||||
pub fn take(&mut self) -> MbRefOption<T> {
|
||||
mem::replace(self, MbRefOption::None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct MusicBrainzRef {
|
||||
mbid: Mbid,
|
||||
url: Url,
|
||||
}
|
||||
|
||||
pub trait IMusicBrainzRef {
|
||||
fn mbid(&self) -> &Mbid;
|
||||
fn url(&self) -> &Url;
|
||||
fn entity() -> &'static str;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MbArtistRef(MusicBrainzRef);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MbAlbumRef(MusicBrainzRef);
|
||||
|
||||
macro_rules! impl_imusicbrainzref {
|
||||
($mbref:ident, $entity:literal) => {
|
||||
impl IMusicBrainzRef for $mbref {
|
||||
fn mbid(&self) -> &Mbid {
|
||||
&self.0.mbid
|
||||
}
|
||||
|
||||
fn url(&self) -> &Url {
|
||||
&self.0.url
|
||||
}
|
||||
|
||||
fn entity() -> &'static str {
|
||||
$entity
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Url> for $mbref {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(url: Url) -> Result<Self, Self::Error> {
|
||||
Ok($mbref(MusicBrainzRef::from_url(url, $mbref::entity())?))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Uuid> for $mbref {
|
||||
fn from(uuid: Uuid) -> Self {
|
||||
$mbref(MusicBrainzRef::from_mbid(uuid, $mbref::entity()))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Mbid> for $mbref {
|
||||
fn from(mbid: Mbid) -> Self {
|
||||
$mbref(MusicBrainzRef::from_mbid(mbid, $mbref::entity()))
|
||||
}
|
||||
}
|
||||
|
||||
impl $mbref {
|
||||
pub fn from_url_str<S: AsRef<str>>(url: S) -> Result<Self, Error> {
|
||||
let url: Url = url.as_ref().try_into()?;
|
||||
url.try_into()
|
||||
}
|
||||
}
|
||||
|
||||
impl $mbref {
|
||||
pub fn from_uuid_str<S: AsRef<str>>(uuid: S) -> Result<Self, Error> {
|
||||
let uuid: Uuid = uuid.as_ref().try_into()?;
|
||||
Ok(uuid.into())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_imusicbrainzref!(MbArtistRef, "artist");
|
||||
impl_imusicbrainzref!(MbAlbumRef, "release-group");
|
||||
|
||||
impl MusicBrainzRef {
|
||||
fn from_url(url: Url, entity: &'static str) -> Result<Self, Error> {
|
||||
if !url
|
||||
.domain()
|
||||
.map(|u| u.ends_with(MB_DOMAIN))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(Self::invalid_url_error(url, entity));
|
||||
}
|
||||
|
||||
// path_segments only returns an empty iterator if the URL cannot-be-a-base. However, if the
|
||||
// URL cannot-be-a-base then it will fail the check above already as it won't have a domain.
|
||||
if url.path_segments().and_then(|mut ps| ps.nth(0)).unwrap() != entity {
|
||||
return Err(Self::invalid_url_error(url, entity));
|
||||
}
|
||||
|
||||
let mbid = match url.path_segments().and_then(|mut ps| ps.nth(1)) {
|
||||
Some(segment) => Uuid::try_parse(segment)?.into(),
|
||||
None => return Err(Self::invalid_url_error(url, entity)),
|
||||
};
|
||||
|
||||
Ok(MusicBrainzRef { mbid, url })
|
||||
}
|
||||
|
||||
fn from_mbid<ID: Into<Mbid>>(id: ID, entity: &'static str) -> Self {
|
||||
let mbid = id.into();
|
||||
let uuid_str = mbid.uuid().to_string();
|
||||
let url = Url::parse(&format!("https://{MB_DOMAIN}/{entity}/{uuid_str}")).unwrap();
|
||||
MusicBrainzRef { mbid, url }
|
||||
}
|
||||
|
||||
fn invalid_url_error<U: fmt::Display>(url: U, entity: &'static str) -> Error {
|
||||
Error::UrlError(format!("invalid {entity} MusicBrainz URL: {url}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artist() {
|
||||
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
|
||||
let url_str = format!("https://musicbrainz.org/artist/{uuid}");
|
||||
|
||||
let mb = MbArtistRef::from_url_str(&url_str).unwrap();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
|
||||
let mb = MbArtistRef::from_uuid_str(uuid).unwrap();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
|
||||
let mbid: Mbid = TryInto::<Uuid>::try_into(uuid).unwrap().into();
|
||||
let mb: MbArtistRef = mbid.into();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
|
||||
let url: Url = url_str.as_str().try_into().unwrap();
|
||||
let mb: MbArtistRef = url.try_into().unwrap();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album() {
|
||||
let uuid = "d368baa8-21ca-4759-9731-0b2753071ad8";
|
||||
let url_str = format!("https://musicbrainz.org/release-group/{uuid}");
|
||||
|
||||
let mb = MbAlbumRef::from_url_str(&url_str).unwrap();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
|
||||
let mb = MbAlbumRef::from_uuid_str(uuid).unwrap();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
|
||||
let mbid: Mbid = TryInto::<Uuid>::try_into(uuid).unwrap().into();
|
||||
let mb: MbAlbumRef = mbid.into();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
|
||||
let url: Url = url_str.as_str().try_into().unwrap();
|
||||
let mb: MbAlbumRef = url.try_into().unwrap();
|
||||
assert_eq!(url_str, mb.url().as_ref());
|
||||
assert_eq!(uuid, mb.mbid().uuid().to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_a_url() {
|
||||
let url = "not a url at all";
|
||||
let expected_error: Error = url::ParseError::RelativeUrlWithoutBase.into();
|
||||
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||
assert_eq!(actual_error, expected_error);
|
||||
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_url() {
|
||||
let url = "https://www.musicbutler.io/artist-page/483340948";
|
||||
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}"));
|
||||
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||
assert_eq!(actual_error, expected_error);
|
||||
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_invalid_type() {
|
||||
let url = "https://musicbrainz.org/release-group/i-am-not-a-uuid";
|
||||
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}"));
|
||||
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||
assert_eq!(actual_error, expected_error);
|
||||
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_invalid_type() {
|
||||
let url = "https://musicbrainz.org/artist/i-am-not-a-uuid";
|
||||
let expected_error =
|
||||
Error::UrlError(format!("invalid release-group MusicBrainz URL: {url}"));
|
||||
let actual_error = MbAlbumRef::from_url_str(url).unwrap_err();
|
||||
assert_eq!(actual_error, expected_error);
|
||||
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_uuid() {
|
||||
let url = "https://musicbrainz.org/artist/i-am-not-a-uuid";
|
||||
let expected_error: Error = Uuid::try_parse("i-am-not-a-uuid").unwrap_err().into();
|
||||
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||
assert_eq!(actual_error, expected_error);
|
||||
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_type() {
|
||||
let url = "https://musicbrainz.org";
|
||||
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}/"));
|
||||
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||
assert_eq!(actual_error, expected_error);
|
||||
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_uuid() {
|
||||
let url = "https://musicbrainz.org/artist";
|
||||
let expected_error = Error::UrlError(format!("invalid artist MusicBrainz URL: {url}"));
|
||||
let actual_error = MbArtistRef::from_url_str(url).unwrap_err();
|
||||
assert_eq!(actual_error, expected_error);
|
||||
assert_eq!(actual_error.to_string(), expected_error.to_string());
|
||||
}
|
||||
}
|
96
src/core/collection/track.rs
Normal file
96
src/core/collection/track.rs
Normal file
@ -0,0 +1,96 @@
|
||||
use crate::core::collection::merge::Merge;
|
||||
|
||||
/// A single track on an album.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Track {
|
||||
pub id: TrackId,
|
||||
pub number: TrackNum,
|
||||
pub artist: Vec<String>,
|
||||
pub quality: TrackQuality,
|
||||
}
|
||||
|
||||
/// The track identifier.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct TrackId {
|
||||
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.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct TrackQuality {
|
||||
pub format: TrackFormat,
|
||||
pub bitrate: u32,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn get_sort_key(&self) -> (&TrackNum, &TrackId) {
|
||||
(&self.number, &self.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The track file format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum TrackFormat {
|
||||
Mp3,
|
||||
Flac,
|
||||
}
|
||||
|
||||
impl PartialOrd for Track {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Track {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.get_sort_key().cmp(&other.get_sort_key())
|
||||
}
|
||||
}
|
||||
|
||||
impl Merge for Track {
|
||||
fn merge_in_place(&mut self, other: Self) {
|
||||
assert_eq!(self.id, other.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn track_ord() {
|
||||
assert!(TrackFormat::Mp3 < TrackFormat::Flac);
|
||||
assert!(TrackFormat::Flac > TrackFormat::Mp3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_track() {
|
||||
let left = Track {
|
||||
id: TrackId {
|
||||
title: String::from("a title"),
|
||||
},
|
||||
number: TrackNum(4),
|
||||
artist: vec![String::from("left artist")],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 1411,
|
||||
},
|
||||
};
|
||||
let right = Track {
|
||||
id: left.id.clone(),
|
||||
number: left.number,
|
||||
artist: vec![String::from("right artist")],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 320,
|
||||
},
|
||||
};
|
||||
|
||||
let merged = left.clone().merge(right);
|
||||
assert_eq!(left, merged);
|
||||
}
|
||||
}
|
127
src/core/interface/database/mod.rs
Normal file
127
src/core/interface/database/mod.rs
Normal file
@ -0,0 +1,127 @@
|
||||
//! Module for storing MusicHoard data in a database.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use crate::core::collection::{self, Collection};
|
||||
|
||||
/// Trait for interacting with the database.
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait IDatabase {
|
||||
/// Load collection from the database.
|
||||
fn load(&self) -> Result<Collection, LoadError>;
|
||||
|
||||
/// Save collection to the database.
|
||||
fn save(&mut self, collection: &Collection) -> Result<(), SaveError>;
|
||||
}
|
||||
|
||||
/// Null database implementation of [`IDatabase`].
|
||||
pub struct NullDatabase;
|
||||
|
||||
impl IDatabase for NullDatabase {
|
||||
fn load(&self) -> Result<Collection, LoadError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
fn save(&mut self, _collection: &Collection) -> Result<(), SaveError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type for database calls.
|
||||
#[derive(Debug)]
|
||||
pub enum LoadError {
|
||||
/// The database experienced an I/O read error.
|
||||
IoError(String),
|
||||
/// The database experienced a deserialisation error.
|
||||
SerDeError(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for LoadError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match *self {
|
||||
Self::IoError(ref s) => write!(f, "the database experienced an I/O read error: {s}"),
|
||||
Self::SerDeError(ref s) => {
|
||||
write!(f, "the database experienced a deserialisation error: {s}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for LoadError {
|
||||
fn from(err: std::io::Error) -> LoadError {
|
||||
LoadError::IoError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<collection::Error> for LoadError {
|
||||
fn from(err: collection::Error) -> Self {
|
||||
match err {
|
||||
collection::Error::UrlError(e) | collection::Error::MbidError(e) => {
|
||||
LoadError::SerDeError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type for database calls.
|
||||
#[derive(Debug)]
|
||||
pub enum SaveError {
|
||||
/// The database experienced an I/O write error.
|
||||
IoError(String),
|
||||
/// The database experienced a serialisation error.
|
||||
SerDeError(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for SaveError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match *self {
|
||||
Self::IoError(ref s) => write!(f, "the database experienced an I/O write error: {s}"),
|
||||
Self::SerDeError(ref s) => {
|
||||
write!(f, "the database experienced a serialisation error: {s}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for SaveError {
|
||||
fn from(err: std::io::Error) -> SaveError {
|
||||
SaveError::IoError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn null_database_load() {
|
||||
let database = NullDatabase;
|
||||
assert!(database.load().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_database_save() {
|
||||
let mut database = NullDatabase;
|
||||
assert!(database.save(&vec![]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
let io_err: LoadError = io::Error::new(io::ErrorKind::Interrupted, "error").into();
|
||||
assert!(!io_err.to_string().is_empty());
|
||||
assert!(!format!("{:?}", io_err).is_empty());
|
||||
|
||||
let col_err: LoadError = collection::Error::UrlError(String::from("get rekt")).into();
|
||||
assert!(!col_err.to_string().is_empty());
|
||||
assert!(!format!("{:?}", col_err).is_empty());
|
||||
|
||||
let io_err: SaveError = io::Error::new(io::ErrorKind::Interrupted, "error").into();
|
||||
assert!(!io_err.to_string().is_empty());
|
||||
assert!(!format!("{:?}", io_err).is_empty());
|
||||
}
|
||||
}
|
@ -5,43 +5,67 @@ use std::{collections::HashSet, fmt, num::ParseIntError, str::Utf8Error};
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use crate::Artist;
|
||||
use crate::core::collection::track::TrackFormat;
|
||||
|
||||
pub mod beets;
|
||||
/// Trait for interacting with the music library.
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait ILibrary {
|
||||
/// List library items that match the a specific query.
|
||||
fn list(&mut self, query: &Query) -> Result<Vec<Item>, Error>;
|
||||
}
|
||||
|
||||
/// Null library implementation for [`ILibrary`].
|
||||
pub struct NullLibrary;
|
||||
|
||||
impl ILibrary for NullLibrary {
|
||||
fn list(&mut self, _query: &Query) -> Result<Vec<Item>, Error> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
/// An item from the library. An item corresponds to an individual file (usually a single track).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Item {
|
||||
pub album_artist: String,
|
||||
pub album_artist_sort: Option<String>,
|
||||
pub album_year: u32,
|
||||
pub album_month: u8,
|
||||
pub album_day: u8,
|
||||
pub album_title: String,
|
||||
pub track_number: u32,
|
||||
pub track_title: String,
|
||||
pub track_artist: Vec<String>,
|
||||
pub track_format: TrackFormat,
|
||||
pub track_bitrate: u32,
|
||||
}
|
||||
|
||||
/// Individual fields that can be queried on.
|
||||
#[derive(Debug, Hash, PartialEq, Eq)]
|
||||
pub enum Field {
|
||||
AlbumArtist(String),
|
||||
AlbumArtistSort(String),
|
||||
AlbumYear(u32),
|
||||
AlbumMonth(u8),
|
||||
AlbumDay(u8),
|
||||
AlbumTitle(String),
|
||||
TrackNumber(u32),
|
||||
TrackTitle(String),
|
||||
TrackArtist(Vec<String>),
|
||||
TrackFormat(TrackFormat),
|
||||
All(String),
|
||||
}
|
||||
|
||||
/// A library query. Can include or exclude particular fields.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct Query {
|
||||
include: HashSet<Field>,
|
||||
exclude: HashSet<Field>,
|
||||
}
|
||||
|
||||
impl Default for Query {
|
||||
/// Create an empty query.
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
pub include: HashSet<Field>,
|
||||
pub exclude: HashSet<Field>,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
/// Create an empty query.
|
||||
pub fn new() -> Self {
|
||||
Query {
|
||||
include: HashSet::new(),
|
||||
exclude: HashSet::new(),
|
||||
}
|
||||
Query::default()
|
||||
}
|
||||
|
||||
/// Refine the query to include a particular search term.
|
||||
@ -102,18 +126,20 @@ impl From<Utf8Error> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for interacting with the music library.
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait Library {
|
||||
/// List lirbary items that match the a specific query.
|
||||
fn list(&mut self, query: &Query) -> Result<Vec<Artist>, Error>;
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub mod testmod;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
|
||||
use super::{Error, Field, Query};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn null_library_list() {
|
||||
let mut library = NullLibrary;
|
||||
assert!(library.list(&Query::default()).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query() {
|
||||
@ -137,6 +163,7 @@ mod tests {
|
||||
let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "Interrupted").into();
|
||||
let inv_err = Error::Invalid(String::from("Invalid"));
|
||||
let int_err: Error = "five".parse::<u32>().unwrap_err().into();
|
||||
#[allow(invalid_from_utf8)]
|
||||
let utf_err: Error = std::str::from_utf8(b"\xe2\x28\xa1").unwrap_err().into();
|
||||
|
||||
assert!(!exe_err.to_string().is_empty());
|
321
src/core/interface/library/testmod.rs
Normal file
321
src/core/interface/library/testmod.rs
Normal file
@ -0,0 +1,321 @@
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use crate::core::{collection::track::TrackFormat, interface::library::Item};
|
||||
|
||||
pub static LIBRARY_ITEMS: Lazy<Vec<Item>> = Lazy::new(|| -> Vec<Item> {
|
||||
vec![
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘A’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 1998,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title a.a"),
|
||||
track_number: 1,
|
||||
track_title: String::from("track a.a.1"),
|
||||
track_artist: vec![String::from("artist a.a.1")],
|
||||
track_format: TrackFormat::Flac,
|
||||
track_bitrate: 992,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘A’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 1998,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title a.a"),
|
||||
track_number: 2,
|
||||
track_title: String::from("track a.a.2"),
|
||||
track_artist: vec![
|
||||
String::from("artist a.a.2.1"),
|
||||
String::from("artist a.a.2.2"),
|
||||
],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 320,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘A’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 1998,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title a.a"),
|
||||
track_number: 3,
|
||||
track_title: String::from("track a.a.3"),
|
||||
track_artist: vec![String::from("artist a.a.3")],
|
||||
track_format: TrackFormat::Flac,
|
||||
track_bitrate: 1061,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘A’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 1998,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title a.a"),
|
||||
track_number: 4,
|
||||
track_title: String::from("track a.a.4"),
|
||||
track_artist: vec![String::from("artist a.a.4")],
|
||||
track_format: TrackFormat::Flac,
|
||||
track_bitrate: 1042,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘A’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2015,
|
||||
album_month: 4,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title a.b"),
|
||||
track_number: 1,
|
||||
track_title: String::from("track a.b.1"),
|
||||
track_artist: vec![String::from("artist a.b.1")],
|
||||
track_format: TrackFormat::Flac,
|
||||
track_bitrate: 1004,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘A’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2015,
|
||||
album_month: 4,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title a.b"),
|
||||
track_number: 2,
|
||||
track_title: String::from("track a.b.2"),
|
||||
track_artist: vec![String::from("artist a.b.2")],
|
||||
track_format: TrackFormat::Flac,
|
||||
track_bitrate: 1077,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘B’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2003,
|
||||
album_month: 6,
|
||||
album_day: 6,
|
||||
album_title: String::from("album_title b.a"),
|
||||
track_number: 1,
|
||||
track_title: String::from("track b.a.1"),
|
||||
track_artist: vec![String::from("artist b.a.1")],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 190,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘B’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2003,
|
||||
album_month: 6,
|
||||
album_day: 6,
|
||||
album_title: String::from("album_title b.a"),
|
||||
track_number: 2,
|
||||
track_title: String::from("track b.a.2"),
|
||||
track_artist: vec![
|
||||
String::from("artist b.a.2.1"),
|
||||
String::from("artist b.a.2.2"),
|
||||
],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 120,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘B’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2008,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title b.b"),
|
||||
track_number: 1,
|
||||
track_title: String::from("track b.b.1"),
|
||||
track_artist: vec![String::from("artist b.b.1")],
|
||||
track_format: TrackFormat::Flac,
|
||||
track_bitrate: 1077,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘B’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2008,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title b.b"),
|
||||
track_number: 2,
|
||||
track_title: String::from("track b.b.2"),
|
||||
track_artist: vec![
|
||||
String::from("artist b.b.2.1"),
|
||||
String::from("artist b.b.2.2"),
|
||||
],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 320,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘B’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2009,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title b.c"),
|
||||
track_number: 1,
|
||||
track_title: String::from("track b.c.1"),
|
||||
track_artist: vec![String::from("artist b.c.1")],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 190,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘B’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2009,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title b.c"),
|
||||
track_number: 2,
|
||||
track_title: String::from("track b.c.2"),
|
||||
track_artist: vec![
|
||||
String::from("artist b.c.2.1"),
|
||||
String::from("artist b.c.2.2"),
|
||||
],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 120,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘B’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2015,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title b.d"),
|
||||
track_number: 1,
|
||||
track_title: String::from("track b.d.1"),
|
||||
track_artist: vec![String::from("artist b.d.1")],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 190,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘B’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2015,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title b.d"),
|
||||
track_number: 2,
|
||||
track_title: String::from("track b.d.2"),
|
||||
track_artist: vec![
|
||||
String::from("artist b.d.2.1"),
|
||||
String::from("artist b.d.2.2"),
|
||||
],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 120,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("The Album_Artist ‘C’"),
|
||||
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
||||
album_year: 1985,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title c.a"),
|
||||
track_number: 1,
|
||||
track_title: String::from("track c.a.1"),
|
||||
track_artist: vec![String::from("artist c.a.1")],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 320,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("The Album_Artist ‘C’"),
|
||||
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
||||
album_year: 1985,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title c.a"),
|
||||
track_number: 2,
|
||||
track_title: String::from("track c.a.2"),
|
||||
track_artist: vec![
|
||||
String::from("artist c.a.2.1"),
|
||||
String::from("artist c.a.2.2"),
|
||||
],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 120,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("The Album_Artist ‘C’"),
|
||||
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
||||
album_year: 2018,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title c.b"),
|
||||
track_number: 1,
|
||||
track_title: String::from("track c.b.1"),
|
||||
track_artist: vec![String::from("artist c.b.1")],
|
||||
track_format: TrackFormat::Flac,
|
||||
track_bitrate: 1041,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("The Album_Artist ‘C’"),
|
||||
album_artist_sort: Some(String::from("Album_Artist ‘C’, The")),
|
||||
album_year: 2018,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title c.b"),
|
||||
track_number: 2,
|
||||
track_title: String::from("track c.b.2"),
|
||||
track_artist: vec![
|
||||
String::from("artist c.b.2.1"),
|
||||
String::from("artist c.b.2.2"),
|
||||
],
|
||||
track_format: TrackFormat::Flac,
|
||||
track_bitrate: 756,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘D’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 1995,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title d.a"),
|
||||
track_number: 1,
|
||||
track_title: String::from("track d.a.1"),
|
||||
track_artist: vec![String::from("artist d.a.1")],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 120,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘D’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 1995,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title d.a"),
|
||||
track_number: 2,
|
||||
track_title: String::from("track d.a.2"),
|
||||
track_artist: vec![
|
||||
String::from("artist d.a.2.1"),
|
||||
String::from("artist d.a.2.2"),
|
||||
],
|
||||
track_format: TrackFormat::Mp3,
|
||||
track_bitrate: 120,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘D’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2028,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title d.b"),
|
||||
track_number: 1,
|
||||
track_title: String::from("track d.b.1"),
|
||||
track_artist: vec![String::from("artist d.b.1")],
|
||||
track_format: TrackFormat::Flac,
|
||||
track_bitrate: 841,
|
||||
},
|
||||
Item {
|
||||
album_artist: String::from("Album_Artist ‘D’"),
|
||||
album_artist_sort: None,
|
||||
album_year: 2028,
|
||||
album_month: 0,
|
||||
album_day: 0,
|
||||
album_title: String::from("album_title d.b"),
|
||||
track_number: 2,
|
||||
track_title: String::from("track d.b.2"),
|
||||
track_artist: vec![
|
||||
String::from("artist d.b.2.1"),
|
||||
String::from("artist d.b.2.2"),
|
||||
],
|
||||
track_format: TrackFormat::Flac,
|
||||
track_bitrate: 756,
|
||||
},
|
||||
]
|
||||
});
|
2
src/core/interface/mod.rs
Normal file
2
src/core/interface/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod database;
|
||||
pub mod library;
|
6
src/core/mod.rs
Normal file
6
src/core/mod.rs
Normal file
@ -0,0 +1,6 @@
|
||||
pub mod collection;
|
||||
pub mod interface;
|
||||
pub mod musichoard;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod testmod;
|
231
src/core/musichoard/base.rs
Normal file
231
src/core/musichoard/base.rs
Normal file
@ -0,0 +1,231 @@
|
||||
use crate::core::{
|
||||
collection::{
|
||||
album::{Album, AlbumId},
|
||||
artist::{Artist, ArtistId},
|
||||
merge::MergeCollections,
|
||||
Collection,
|
||||
},
|
||||
musichoard::{Error, MusicHoard},
|
||||
};
|
||||
|
||||
pub trait IMusicHoardBase {
|
||||
fn get_collection(&self) -> &Collection;
|
||||
}
|
||||
|
||||
impl<Database, Library> IMusicHoardBase for MusicHoard<Database, Library> {
|
||||
fn get_collection(&self) -> &Collection {
|
||||
&self.collection
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IMusicHoardBasePrivate {
|
||||
fn sort_artists(collection: &mut [Artist]);
|
||||
fn sort_albums_and_tracks<'a, C: Iterator<Item = &'a mut Artist>>(collection: C);
|
||||
|
||||
fn merge_collections(&self) -> Collection;
|
||||
|
||||
fn get_artist<'a>(collection: &'a Collection, artist_id: &ArtistId) -> Option<&'a Artist>;
|
||||
fn get_artist_mut<'a>(
|
||||
collection: &'a mut Collection,
|
||||
artist_id: &ArtistId,
|
||||
) -> Option<&'a mut Artist>;
|
||||
fn get_artist_mut_or_err<'a>(
|
||||
collection: &'a mut Collection,
|
||||
artist_id: &ArtistId,
|
||||
) -> Result<&'a mut Artist, Error>;
|
||||
|
||||
fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album>;
|
||||
fn get_album_mut_or_err<'a>(
|
||||
artist: &'a mut Artist,
|
||||
album_id: &AlbumId,
|
||||
) -> Result<&'a mut Album, Error>;
|
||||
}
|
||||
|
||||
impl<Database, Library> IMusicHoardBasePrivate for MusicHoard<Database, Library> {
|
||||
fn sort_artists(collection: &mut [Artist]) {
|
||||
collection.sort_unstable();
|
||||
}
|
||||
|
||||
fn sort_albums_and_tracks<'a, COL: Iterator<Item = &'a mut Artist>>(collection: COL) {
|
||||
for artist in collection {
|
||||
artist.albums.sort_unstable();
|
||||
for album in artist.albums.iter_mut() {
|
||||
album.tracks.sort_unstable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_collections(&self) -> Collection {
|
||||
MergeCollections::merge(self.library_cache.clone(), self.database_cache.clone())
|
||||
}
|
||||
|
||||
fn get_artist<'a>(collection: &'a Collection, artist_id: &ArtistId) -> Option<&'a Artist> {
|
||||
collection.iter().find(|a| &a.meta.id == artist_id)
|
||||
}
|
||||
|
||||
fn get_artist_mut<'a>(
|
||||
collection: &'a mut Collection,
|
||||
artist_id: &ArtistId,
|
||||
) -> Option<&'a mut Artist> {
|
||||
collection.iter_mut().find(|a| &a.meta.id == artist_id)
|
||||
}
|
||||
|
||||
fn get_artist_mut_or_err<'a>(
|
||||
collection: &'a mut Collection,
|
||||
artist_id: &ArtistId,
|
||||
) -> Result<&'a mut Artist, Error> {
|
||||
Self::get_artist_mut(collection, artist_id).ok_or_else(|| {
|
||||
Error::CollectionError(format!("artist '{}' is not in the collection", artist_id))
|
||||
})
|
||||
}
|
||||
|
||||
fn get_album_mut<'a>(artist: &'a mut Artist, album_id: &AlbumId) -> Option<&'a mut Album> {
|
||||
artist.albums.iter_mut().find(|a| &a.meta.id == album_id)
|
||||
}
|
||||
|
||||
fn get_album_mut_or_err<'a>(
|
||||
artist: &'a mut Artist,
|
||||
album_id: &AlbumId,
|
||||
) -> Result<&'a mut Album, Error> {
|
||||
Self::get_album_mut(artist, album_id).ok_or_else(|| {
|
||||
Error::CollectionError(format!(
|
||||
"album '{}' does not belong to the artist",
|
||||
album_id
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::core::testmod::FULL_COLLECTION;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_collection_no_overlap() {
|
||||
let half: usize = FULL_COLLECTION.len() / 2;
|
||||
|
||||
let left = FULL_COLLECTION[..half].to_owned();
|
||||
let right = FULL_COLLECTION[half..].to_owned();
|
||||
|
||||
let mut expected = FULL_COLLECTION.to_owned();
|
||||
expected.sort_unstable();
|
||||
|
||||
let mut mh = MusicHoard {
|
||||
library_cache: left
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|a| (a.meta.id.clone(), a))
|
||||
.collect(),
|
||||
database_cache: right.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
mh.collection = mh.merge_collections();
|
||||
assert_eq!(expected, mh.collection);
|
||||
|
||||
// The merge is completely non-overlapping so it should be commutative.
|
||||
let mut mh = MusicHoard {
|
||||
library_cache: right
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|a| (a.meta.id.clone(), a))
|
||||
.collect(),
|
||||
database_cache: left.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
mh.collection = mh.merge_collections();
|
||||
assert_eq!(expected, mh.collection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_collection_overlap() {
|
||||
let half: usize = FULL_COLLECTION.len() / 2;
|
||||
|
||||
let left = FULL_COLLECTION[..(half + 1)].to_owned();
|
||||
let right = FULL_COLLECTION[half..].to_owned();
|
||||
|
||||
let mut expected = FULL_COLLECTION.to_owned();
|
||||
expected.sort_unstable();
|
||||
|
||||
let mut mh = MusicHoard {
|
||||
library_cache: left
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|a| (a.meta.id.clone(), a))
|
||||
.collect(),
|
||||
database_cache: right.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
mh.collection = mh.merge_collections();
|
||||
assert_eq!(expected, mh.collection);
|
||||
|
||||
// The merge does not overwrite any data so it should be commutative.
|
||||
let mut mh = MusicHoard {
|
||||
library_cache: right
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|a| (a.meta.id.clone(), a))
|
||||
.collect(),
|
||||
database_cache: left.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
mh.collection = mh.merge_collections();
|
||||
assert_eq!(expected, mh.collection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_collection_incompatible_sorting() {
|
||||
// It may be that the same artist in one collection has a "sort" field defined while the
|
||||
// same artist in the other collection does not. This means that the two collections are not
|
||||
// sorted consistently. If the merge assumes they are sorted consistently this will lead to
|
||||
// the same artist appearing twice in the final list. This should not be the case.
|
||||
|
||||
// We will mimic this situation by taking the last artist from FULL_COLLECTION and giving it
|
||||
// a sorting name that would place it in the beginning.
|
||||
let left = FULL_COLLECTION.to_owned();
|
||||
let mut right: Vec<Artist> = vec![left.last().unwrap().clone()];
|
||||
|
||||
assert!(right.first().unwrap() > left.first().unwrap());
|
||||
let artist_sort = Some(String::from("Album_Artist 0"));
|
||||
right[0].meta.sort = artist_sort.clone();
|
||||
assert!(right.first().unwrap() < left.first().unwrap());
|
||||
|
||||
// The result of the merge should be the same list of artists, but with the last artist now
|
||||
// in first place.
|
||||
let mut expected = left.to_owned();
|
||||
expected.last_mut().as_mut().unwrap().meta.sort = artist_sort.clone();
|
||||
expected.rotate_right(1);
|
||||
|
||||
let mut mh = MusicHoard {
|
||||
library_cache: left
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|a| (a.meta.id.clone(), a))
|
||||
.collect(),
|
||||
database_cache: right.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
mh.collection = mh.merge_collections();
|
||||
assert_eq!(expected, mh.collection);
|
||||
|
||||
// The merge overwrites the sort data, but no data is erased so it should be commutative.
|
||||
let mut mh = MusicHoard {
|
||||
library_cache: right
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|a| (a.meta.id.clone(), a))
|
||||
.collect(),
|
||||
database_cache: left.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
mh.collection = mh.merge_collections();
|
||||
assert_eq!(expected, mh.collection);
|
||||
}
|
||||
}
|
191
src/core/musichoard/builder.rs
Normal file
191
src/core/musichoard/builder.rs
Normal file
@ -0,0 +1,191 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::core::{
|
||||
interface::{database::IDatabase, library::ILibrary},
|
||||
musichoard::{database::IMusicHoardDatabase, Error, MusicHoard, NoDatabase, NoLibrary},
|
||||
};
|
||||
|
||||
/// Builder for [`MusicHoard`]. Its purpose is to make it easier to set various combinations of
|
||||
/// library/database or their absence.
|
||||
pub struct MusicHoardBuilder<Database, Library> {
|
||||
database: Database,
|
||||
library: Library,
|
||||
}
|
||||
|
||||
impl Default for MusicHoardBuilder<NoDatabase, NoLibrary> {
|
||||
/// Create a [`MusicHoardBuilder`].
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl MusicHoardBuilder<NoDatabase, NoLibrary> {
|
||||
/// Create a [`MusicHoardBuilder`].
|
||||
pub fn new() -> Self {
|
||||
MusicHoardBuilder {
|
||||
database: NoDatabase,
|
||||
library: NoLibrary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Database, Library> MusicHoardBuilder<Database, Library> {
|
||||
/// Set a library for [`MusicHoard`].
|
||||
pub fn set_library<NewLibrary: ILibrary>(
|
||||
self,
|
||||
library: NewLibrary,
|
||||
) -> MusicHoardBuilder<Database, NewLibrary> {
|
||||
MusicHoardBuilder {
|
||||
database: self.database,
|
||||
library,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a database for [`MusicHoard`].
|
||||
pub fn set_database<NewDatabase: IDatabase>(
|
||||
self,
|
||||
database: NewDatabase,
|
||||
) -> MusicHoardBuilder<NewDatabase, Library> {
|
||||
MusicHoardBuilder {
|
||||
database,
|
||||
library: self.library,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MusicHoardBuilder<NoDatabase, NoLibrary> {
|
||||
/// Build [`MusicHoard`] with the currently set library and database.
|
||||
pub fn build(self) -> MusicHoard<NoDatabase, NoLibrary> {
|
||||
MusicHoard::empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MusicHoard<NoDatabase, NoLibrary> {
|
||||
/// Create a new [`MusicHoard`] without any library or database.
|
||||
pub fn empty() -> Self {
|
||||
MusicHoard {
|
||||
collection: vec![],
|
||||
pre_commit: vec![],
|
||||
database: NoDatabase,
|
||||
database_cache: vec![],
|
||||
library: NoLibrary,
|
||||
library_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Library: ILibrary> MusicHoardBuilder<NoDatabase, Library> {
|
||||
/// Build [`MusicHoard`] with the currently set library and database.
|
||||
pub fn build(self) -> MusicHoard<NoDatabase, Library> {
|
||||
MusicHoard::library(self.library)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Library: ILibrary> MusicHoard<NoDatabase, Library> {
|
||||
/// Create a new [`MusicHoard`] with the provided [`ILibrary`] and no database.
|
||||
pub fn library(library: Library) -> Self {
|
||||
MusicHoard {
|
||||
collection: vec![],
|
||||
pre_commit: vec![],
|
||||
database: NoDatabase,
|
||||
database_cache: vec![],
|
||||
library,
|
||||
library_cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Database: IDatabase> MusicHoardBuilder<Database, NoLibrary> {
|
||||
/// Build [`MusicHoard`] with the currently set library and database.
|
||||
pub fn build(self) -> Result<MusicHoard<Database, NoLibrary>, Error> {
|
||||
MusicHoard::database(self.database)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Database: IDatabase> MusicHoard<Database, NoLibrary> {
|
||||
/// Create a new [`MusicHoard`] with the provided [`IDatabase`] and no library.
|
||||
pub fn database(database: Database) -> Result<Self, Error> {
|
||||
let mut mh = MusicHoard {
|
||||
collection: vec![],
|
||||
pre_commit: vec![],
|
||||
database,
|
||||
database_cache: vec![],
|
||||
library: NoLibrary,
|
||||
library_cache: HashMap::new(),
|
||||
};
|
||||
mh.reload_database()?;
|
||||
Ok(mh)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Database: IDatabase, Library: ILibrary> MusicHoardBuilder<Database, Library> {
|
||||
/// Build [`MusicHoard`] with the currently set library and database.
|
||||
pub fn build(self) -> Result<MusicHoard<Database, Library>, Error> {
|
||||
MusicHoard::new(self.database, self.library)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Database: IDatabase, Library: ILibrary> MusicHoard<Database, Library> {
|
||||
/// Create a new [`MusicHoard`] with the provided [`ILibrary`] and [`IDatabase`].
|
||||
pub fn new(database: Database, library: Library) -> Result<Self, Error> {
|
||||
let mut mh = MusicHoard {
|
||||
collection: vec![],
|
||||
pre_commit: vec![],
|
||||
database,
|
||||
database_cache: vec![],
|
||||
library,
|
||||
library_cache: HashMap::new(),
|
||||
};
|
||||
mh.reload_database()?;
|
||||
Ok(mh)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::core::{
|
||||
interface::{database::NullDatabase, library::NullLibrary},
|
||||
musichoard::library::IMusicHoardLibrary,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_library_no_database() {
|
||||
MusicHoardBuilder::default().build();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty() {
|
||||
let music_hoard = MusicHoard::empty();
|
||||
assert!(!format!("{music_hoard:?}").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_library_no_database() {
|
||||
let mut mh = MusicHoardBuilder::default()
|
||||
.set_library(NullLibrary)
|
||||
.build();
|
||||
assert!(mh.rescan_library().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_library_with_database() {
|
||||
let mut mh = MusicHoardBuilder::default()
|
||||
.set_database(NullDatabase)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!(mh.reload_database().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_library_with_database() {
|
||||
let mut mh = MusicHoardBuilder::default()
|
||||
.set_library(NullLibrary)
|
||||
.set_database(NullDatabase)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!(mh.rescan_library().is_ok());
|
||||
assert!(mh.reload_database().is_ok());
|
||||
}
|
||||
}
|
769
src/core/musichoard/database.rs
Normal file
769
src/core/musichoard/database.rs
Normal file
@ -0,0 +1,769 @@
|
||||
use std::mem;
|
||||
|
||||
use crate::{
|
||||
collection::{album::AlbumInfo, artist::ArtistInfo, merge::Merge},
|
||||
core::{
|
||||
collection::{
|
||||
album::{Album, AlbumId, AlbumSeq},
|
||||
artist::{Artist, ArtistId},
|
||||
Collection,
|
||||
},
|
||||
interface::database::IDatabase,
|
||||
musichoard::{base::IMusicHoardBasePrivate, Error, MusicHoard, NoDatabase},
|
||||
},
|
||||
};
|
||||
|
||||
pub trait IMusicHoardDatabase {
|
||||
fn reload_database(&mut self) -> Result<(), Error>;
|
||||
|
||||
fn add_artist<IntoId: Into<ArtistId>>(&mut self, artist_id: IntoId) -> Result<(), Error>;
|
||||
fn remove_artist<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
|
||||
|
||||
fn set_artist_sort<Id: AsRef<ArtistId>, S: Into<String>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
artist_sort: S,
|
||||
) -> Result<(), Error>;
|
||||
fn clear_artist_sort<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
|
||||
|
||||
fn merge_artist_info<Id: AsRef<ArtistId>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
info: ArtistInfo,
|
||||
) -> Result<(), Error>;
|
||||
fn clear_artist_info<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error>;
|
||||
|
||||
fn add_to_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
property: S,
|
||||
values: Vec<S>,
|
||||
) -> Result<(), Error>;
|
||||
fn remove_from_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
property: S,
|
||||
values: Vec<S>,
|
||||
) -> Result<(), Error>;
|
||||
fn set_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
property: S,
|
||||
values: Vec<S>,
|
||||
) -> Result<(), Error>;
|
||||
fn clear_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
property: S,
|
||||
) -> Result<(), Error>;
|
||||
|
||||
fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||
&mut self,
|
||||
artist_id: ArtistIdRef,
|
||||
album_id: AlbumIdRef,
|
||||
seq: u8,
|
||||
) -> Result<(), Error>;
|
||||
fn clear_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||
&mut self,
|
||||
artist_id: ArtistIdRef,
|
||||
album_id: AlbumIdRef,
|
||||
) -> Result<(), Error>;
|
||||
fn merge_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
album_id: AlbumIdRef,
|
||||
info: AlbumInfo,
|
||||
) -> Result<(), Error>;
|
||||
fn clear_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
album_id: AlbumIdRef,
|
||||
) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
impl<Database: IDatabase, Library> IMusicHoardDatabase for MusicHoard<Database, Library> {
|
||||
fn reload_database(&mut self) -> Result<(), Error> {
|
||||
self.database_cache = self.database.load()?;
|
||||
Self::sort_albums_and_tracks(self.database_cache.iter_mut());
|
||||
|
||||
self.collection = self.merge_collections();
|
||||
self.pre_commit = self.collection.clone();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_artist<IntoId: Into<ArtistId>>(&mut self, artist_id: IntoId) -> Result<(), Error> {
|
||||
let artist_id: ArtistId = artist_id.into();
|
||||
|
||||
self.update_collection(|collection| {
|
||||
if Self::get_artist(collection, &artist_id).is_none() {
|
||||
collection.push(Artist::new(artist_id));
|
||||
Self::sort_artists(collection);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_artist<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
|
||||
self.update_collection(|collection| {
|
||||
let index_opt = collection
|
||||
.iter()
|
||||
.position(|a| &a.meta.id == artist_id.as_ref());
|
||||
if let Some(index) = index_opt {
|
||||
collection.remove(index);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn set_artist_sort<Id: AsRef<ArtistId>, S: Into<String>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
artist_sort: S,
|
||||
) -> Result<(), Error> {
|
||||
self.update_artist_and(
|
||||
artist_id.as_ref(),
|
||||
|artist| artist.meta.set_sort_key(artist_sort),
|
||||
|collection| Self::sort_artists(collection),
|
||||
)
|
||||
}
|
||||
|
||||
fn clear_artist_sort<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
|
||||
self.update_artist_and(
|
||||
artist_id.as_ref(),
|
||||
|artist| artist.meta.clear_sort_key(),
|
||||
|collection| Self::sort_artists(collection),
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_artist_info<Id: AsRef<ArtistId>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
mut info: ArtistInfo,
|
||||
) -> Result<(), Error> {
|
||||
self.update_artist(artist_id.as_ref(), |artist| {
|
||||
mem::swap(&mut artist.meta.info, &mut info);
|
||||
artist.meta.info.merge_in_place(info);
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_artist_info<Id: AsRef<ArtistId>>(&mut self, artist_id: Id) -> Result<(), Error> {
|
||||
self.update_artist(artist_id.as_ref(), |artist| {
|
||||
artist.meta.info = ArtistInfo::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn add_to_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
property: S,
|
||||
values: Vec<S>,
|
||||
) -> Result<(), Error> {
|
||||
self.update_artist(artist_id.as_ref(), |artist| {
|
||||
artist.meta.info.add_to_property(property, values)
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_from_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
property: S,
|
||||
values: Vec<S>,
|
||||
) -> Result<(), Error> {
|
||||
self.update_artist(artist_id.as_ref(), |artist| {
|
||||
artist.meta.info.remove_from_property(property, values)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_artist_property<Id: AsRef<ArtistId>, S: AsRef<str> + Into<String>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
property: S,
|
||||
values: Vec<S>,
|
||||
) -> Result<(), Error> {
|
||||
self.update_artist(artist_id.as_ref(), |artist| {
|
||||
artist.meta.info.set_property(property, values)
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_artist_property<Id: AsRef<ArtistId>, S: AsRef<str>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
property: S,
|
||||
) -> Result<(), Error> {
|
||||
self.update_artist(artist_id.as_ref(), |artist| {
|
||||
artist.meta.info.clear_property(property)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||
&mut self,
|
||||
artist_id: ArtistIdRef,
|
||||
album_id: AlbumIdRef,
|
||||
seq: u8,
|
||||
) -> Result<(), Error> {
|
||||
self.update_album_and(
|
||||
artist_id.as_ref(),
|
||||
album_id.as_ref(),
|
||||
|album| album.meta.set_seq(AlbumSeq(seq)),
|
||||
|artist| artist.albums.sort_unstable(),
|
||||
)
|
||||
}
|
||||
|
||||
fn clear_album_seq<ArtistIdRef: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||
&mut self,
|
||||
artist_id: ArtistIdRef,
|
||||
album_id: AlbumIdRef,
|
||||
) -> Result<(), Error> {
|
||||
self.update_album_and(
|
||||
artist_id.as_ref(),
|
||||
album_id.as_ref(),
|
||||
|album| album.meta.clear_seq(),
|
||||
|artist| artist.albums.sort_unstable(),
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
album_id: AlbumIdRef,
|
||||
mut info: AlbumInfo,
|
||||
) -> Result<(), Error> {
|
||||
self.update_album(artist_id.as_ref(), album_id.as_ref(), |album| {
|
||||
mem::swap(&mut album.meta.info, &mut info);
|
||||
album.meta.info.merge_in_place(info);
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_album_info<Id: AsRef<ArtistId>, AlbumIdRef: AsRef<AlbumId>>(
|
||||
&mut self,
|
||||
artist_id: Id,
|
||||
album_id: AlbumIdRef,
|
||||
) -> Result<(), Error> {
|
||||
self.update_album(artist_id.as_ref(), album_id.as_ref(), |album| {
|
||||
album.meta.info = AlbumInfo::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub trait IMusicHoardDatabasePrivate {
|
||||
fn commit(&mut self) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
impl<Library> IMusicHoardDatabasePrivate for MusicHoard<NoDatabase, Library> {
|
||||
fn commit(&mut self) -> Result<(), Error> {
|
||||
self.collection = self.pre_commit.clone();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<Database: IDatabase, Library> IMusicHoardDatabasePrivate for MusicHoard<Database, Library> {
|
||||
fn commit(&mut self) -> Result<(), Error> {
|
||||
if self.collection != self.pre_commit {
|
||||
if let Err(err) = self.database.save(&self.pre_commit) {
|
||||
self.pre_commit = self.collection.clone();
|
||||
return Err(err.into());
|
||||
}
|
||||
self.collection = self.pre_commit.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<Database: IDatabase, Library> MusicHoard<Database, Library> {
|
||||
fn update_collection<FnColl>(&mut self, fn_coll: FnColl) -> Result<(), Error>
|
||||
where
|
||||
FnColl: FnOnce(&mut Collection),
|
||||
{
|
||||
fn_coll(&mut self.pre_commit);
|
||||
self.commit()
|
||||
}
|
||||
|
||||
fn update_artist_and<FnArtist, FnColl>(
|
||||
&mut self,
|
||||
artist_id: &ArtistId,
|
||||
fn_artist: FnArtist,
|
||||
fn_coll: FnColl,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
FnArtist: FnOnce(&mut Artist),
|
||||
FnColl: FnOnce(&mut Collection),
|
||||
{
|
||||
let artist = Self::get_artist_mut_or_err(&mut self.pre_commit, artist_id)?;
|
||||
fn_artist(artist);
|
||||
self.update_collection(fn_coll)
|
||||
}
|
||||
|
||||
fn update_artist<FnArtist>(
|
||||
&mut self,
|
||||
artist_id: &ArtistId,
|
||||
fn_artist: FnArtist,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
FnArtist: FnOnce(&mut Artist),
|
||||
{
|
||||
self.update_artist_and(artist_id, fn_artist, |_| {})
|
||||
}
|
||||
|
||||
fn update_album_and<FnAlbum, FnArtist>(
|
||||
&mut self,
|
||||
artist_id: &ArtistId,
|
||||
album_id: &AlbumId,
|
||||
fn_album: FnAlbum,
|
||||
fn_artist: FnArtist,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
FnAlbum: FnOnce(&mut Album),
|
||||
FnArtist: FnOnce(&mut Artist),
|
||||
{
|
||||
let artist = Self::get_artist_mut_or_err(&mut self.pre_commit, artist_id)?;
|
||||
let album = Self::get_album_mut_or_err(artist, album_id)?;
|
||||
fn_album(album);
|
||||
fn_artist(artist);
|
||||
self.update_collection(|_| {})
|
||||
}
|
||||
|
||||
fn update_album<FnAlbum>(
|
||||
&mut self,
|
||||
artist_id: &ArtistId,
|
||||
album_id: &AlbumId,
|
||||
fn_album: FnAlbum,
|
||||
) -> Result<(), Error>
|
||||
where
|
||||
FnAlbum: FnOnce(&mut Album),
|
||||
{
|
||||
self.update_album_and(artist_id, album_id, fn_album, |_| {})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::{predicate, Sequence};
|
||||
|
||||
use crate::{
|
||||
collection::{
|
||||
album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||
musicbrainz::{MbArtistRef, MbRefOption},
|
||||
},
|
||||
core::{
|
||||
collection::{album::AlbumDate, artist::ArtistId},
|
||||
interface::database::{self, MockIDatabase},
|
||||
musichoard::{base::IMusicHoardBase, NoLibrary},
|
||||
testmod::FULL_COLLECTION,
|
||||
},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
static MBID: &str = "d368baa8-21ca-4759-9731-0b2753071ad8";
|
||||
static MUSICBUTLER: &str = "https://www.musicbutler.io/artist-page/483340948";
|
||||
static MUSICBUTLER_2: &str = "https://www.musicbutler.io/artist-page/658903042/";
|
||||
|
||||
#[test]
|
||||
fn artist_new_delete() {
|
||||
let artist_id = ArtistId::new("an artist");
|
||||
let artist_id_2 = ArtistId::new("another artist");
|
||||
|
||||
let collection = FULL_COLLECTION.to_owned();
|
||||
let mut with_artist = collection.clone();
|
||||
with_artist.push(Artist::new(artist_id.clone()));
|
||||
|
||||
let mut database = MockIDatabase::new();
|
||||
let mut seq = Sequence::new();
|
||||
database
|
||||
.expect_load()
|
||||
.times(1)
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.returning(|| Ok(FULL_COLLECTION.to_owned()));
|
||||
database
|
||||
.expect_save()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.with(predicate::eq(with_artist.clone()))
|
||||
.returning(|_| Ok(()));
|
||||
database
|
||||
.expect_save()
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.with(predicate::eq(collection.clone()))
|
||||
.returning(|_| Ok(()));
|
||||
|
||||
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||
assert_eq!(music_hoard.collection, collection);
|
||||
|
||||
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
|
||||
assert_eq!(music_hoard.collection, with_artist);
|
||||
|
||||
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
|
||||
assert_eq!(music_hoard.collection, with_artist);
|
||||
|
||||
assert!(music_hoard.remove_artist(&artist_id_2).is_ok());
|
||||
assert_eq!(music_hoard.collection, with_artist);
|
||||
|
||||
assert!(music_hoard.remove_artist(&artist_id).is_ok());
|
||||
assert_eq!(music_hoard.collection, collection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_sort_set_clear() {
|
||||
let mut database = MockIDatabase::new();
|
||||
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||
database.expect_save().times(4).returning(|_| Ok(()));
|
||||
|
||||
type MH = MusicHoard<MockIDatabase, NoLibrary>;
|
||||
let mut music_hoard: MH = MusicHoard::database(database).unwrap();
|
||||
|
||||
let artist_1_id = ArtistId::new("the artist");
|
||||
let artist_1_sort = String::from("artist, the");
|
||||
|
||||
// Must be after "artist, the", but before "the artist"
|
||||
let artist_2_id = ArtistId::new("b-artist");
|
||||
|
||||
assert!(artist_1_sort < artist_2_id.name);
|
||||
assert!(artist_2_id < artist_1_id);
|
||||
|
||||
assert!(music_hoard.add_artist(artist_1_id.clone()).is_ok());
|
||||
assert!(music_hoard.add_artist(artist_2_id.clone()).is_ok());
|
||||
|
||||
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
|
||||
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
|
||||
|
||||
assert!(artist_2 < artist_1);
|
||||
|
||||
assert_eq!(artist_1, &music_hoard.collection[1]);
|
||||
assert_eq!(artist_2, &music_hoard.collection[0]);
|
||||
|
||||
music_hoard
|
||||
.set_artist_sort(artist_1_id.as_ref(), artist_1_sort.clone())
|
||||
.unwrap();
|
||||
|
||||
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
|
||||
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
|
||||
|
||||
assert!(artist_1 < artist_2);
|
||||
|
||||
assert_eq!(artist_1, &music_hoard.collection[0]);
|
||||
assert_eq!(artist_2, &music_hoard.collection[1]);
|
||||
|
||||
music_hoard.clear_artist_sort(artist_1_id.as_ref()).unwrap();
|
||||
|
||||
let artist_1: &Artist = MH::get_artist(&music_hoard.collection, &artist_1_id).unwrap();
|
||||
let artist_2: &Artist = MH::get_artist(&music_hoard.collection, &artist_2_id).unwrap();
|
||||
|
||||
assert!(artist_2 < artist_1);
|
||||
|
||||
assert_eq!(artist_1, &music_hoard.collection[1]);
|
||||
assert_eq!(artist_2, &music_hoard.collection[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_error() {
|
||||
let mut database = MockIDatabase::new();
|
||||
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||
|
||||
let artist_id = ArtistId::new("an artist");
|
||||
let actual_err = music_hoard
|
||||
.merge_artist_info(&artist_id, ArtistInfo::default())
|
||||
.unwrap_err();
|
||||
let expected_err =
|
||||
Error::CollectionError(format!("artist '{artist_id}' is not in the collection"));
|
||||
assert_eq!(actual_err, expected_err);
|
||||
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_clear_artist_info() {
|
||||
let mut database = MockIDatabase::new();
|
||||
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||
database.expect_save().times(3).returning(|_| Ok(()));
|
||||
|
||||
let artist_id = ArtistId::new("an artist");
|
||||
let artist_id_2 = ArtistId::new("another artist");
|
||||
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||
|
||||
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
|
||||
|
||||
let mut expected: MbRefOption<MbArtistRef> = MbRefOption::None;
|
||||
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
|
||||
|
||||
let info = ArtistInfo::new(MbRefOption::Some(MbArtistRef::from_uuid_str(MBID).unwrap()));
|
||||
|
||||
// Setting a URL on an artist not in the collection is an error.
|
||||
assert!(music_hoard
|
||||
.merge_artist_info(&artist_id_2, info.clone())
|
||||
.is_err());
|
||||
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
|
||||
|
||||
// Setting a URL on an artist.
|
||||
assert!(music_hoard
|
||||
.merge_artist_info(&artist_id, info.clone())
|
||||
.is_ok());
|
||||
expected.replace(MbArtistRef::from_uuid_str(MBID).unwrap());
|
||||
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
|
||||
|
||||
// Clearing URLs on an artist that does not exist is an error.
|
||||
assert!(music_hoard.clear_artist_info(&artist_id_2).is_err());
|
||||
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
|
||||
|
||||
// Clearing URLs.
|
||||
assert!(music_hoard.clear_artist_info(&artist_id).is_ok());
|
||||
expected.take();
|
||||
assert_eq!(music_hoard.collection[0].meta.info.musicbrainz, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_remove_from_property() {
|
||||
let mut database = MockIDatabase::new();
|
||||
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||
database.expect_save().times(3).returning(|_| Ok(()));
|
||||
|
||||
let artist_id = ArtistId::new("an artist");
|
||||
let artist_id_2 = ArtistId::new("another artist");
|
||||
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||
|
||||
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
|
||||
|
||||
let mut expected: Vec<String> = vec![];
|
||||
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||
|
||||
// Adding URLs to an artist not in the collection is an error.
|
||||
assert!(music_hoard
|
||||
.add_to_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
|
||||
.is_err());
|
||||
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||
|
||||
// Adding mutliple URLs without clashes.
|
||||
assert!(music_hoard
|
||||
.add_to_artist_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
|
||||
.is_ok());
|
||||
expected.push(MUSICBUTLER.to_owned());
|
||||
expected.push(MUSICBUTLER_2.to_owned());
|
||||
let info = &music_hoard.collection[0].meta.info;
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Removing URLs from an artist not in the collection is an error.
|
||||
assert!(music_hoard
|
||||
.remove_from_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
|
||||
.is_err());
|
||||
let info = &music_hoard.collection[0].meta.info;
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Removing multiple URLs without clashes.
|
||||
assert!(music_hoard
|
||||
.remove_from_artist_property(
|
||||
&artist_id,
|
||||
"MusicButler",
|
||||
vec![MUSICBUTLER, MUSICBUTLER_2]
|
||||
)
|
||||
.is_ok());
|
||||
expected.clear();
|
||||
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_clear_property() {
|
||||
let mut database = MockIDatabase::new();
|
||||
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||
database.expect_save().times(3).returning(|_| Ok(()));
|
||||
|
||||
let artist_id = ArtistId::new("an artist");
|
||||
let artist_id_2 = ArtistId::new("another artist");
|
||||
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||
|
||||
assert!(music_hoard.add_artist(artist_id.clone()).is_ok());
|
||||
|
||||
let mut expected: Vec<String> = vec![];
|
||||
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||
|
||||
// Seting URL on an artist not in the collection is an error.
|
||||
assert!(music_hoard
|
||||
.set_artist_property(&artist_id_2, "MusicButler", vec![MUSICBUTLER])
|
||||
.is_err());
|
||||
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||
|
||||
// Set URLs.
|
||||
assert!(music_hoard
|
||||
.set_artist_property(&artist_id, "MusicButler", vec![MUSICBUTLER, MUSICBUTLER_2])
|
||||
.is_ok());
|
||||
expected.clear();
|
||||
expected.push(MUSICBUTLER.to_owned());
|
||||
expected.push(MUSICBUTLER_2.to_owned());
|
||||
let info = &music_hoard.collection[0].meta.info;
|
||||
assert_eq!(info.properties.get("MusicButler"), Some(&expected));
|
||||
|
||||
// Clearing URLs on an artist that does not exist is an error.
|
||||
assert!(music_hoard
|
||||
.clear_artist_property(&artist_id_2, "MusicButler")
|
||||
.is_err());
|
||||
|
||||
// Clear URLs.
|
||||
assert!(music_hoard
|
||||
.clear_artist_property(&artist_id, "MusicButler")
|
||||
.is_ok());
|
||||
expected.clear();
|
||||
assert!(music_hoard.collection[0].meta.info.properties.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_clear_album_seq() {
|
||||
let mut database = MockIDatabase::new();
|
||||
|
||||
let artist_id = ArtistId::new("an artist");
|
||||
let album_id = AlbumId::new("an album");
|
||||
let album_id_2 = AlbumId::new("another album");
|
||||
|
||||
let mut database_result = vec![Artist::new(artist_id.clone())];
|
||||
database_result[0].albums.push(Album::new(
|
||||
album_id.clone(),
|
||||
AlbumDate::default(),
|
||||
None,
|
||||
vec![],
|
||||
));
|
||||
|
||||
database
|
||||
.expect_load()
|
||||
.times(1)
|
||||
.return_once(|| Ok(database_result));
|
||||
database.expect_save().times(2).returning(|_| Ok(()));
|
||||
|
||||
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(0));
|
||||
|
||||
// Seting seq on an album not belonging to the artist is an error.
|
||||
assert!(music_hoard
|
||||
.set_album_seq(&artist_id, &album_id_2, 6)
|
||||
.is_err());
|
||||
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(0));
|
||||
|
||||
// Set seq.
|
||||
assert!(music_hoard.set_album_seq(&artist_id, &album_id, 6).is_ok());
|
||||
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(6));
|
||||
|
||||
// Clearing seq on an album that does not exist is an error.
|
||||
assert!(music_hoard
|
||||
.clear_album_seq(&artist_id, &album_id_2)
|
||||
.is_err());
|
||||
|
||||
// Clear seq.
|
||||
assert!(music_hoard.clear_album_seq(&artist_id, &album_id).is_ok());
|
||||
assert_eq!(music_hoard.collection[0].albums[0].meta.seq, AlbumSeq(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_clear_album_info() {
|
||||
let mut database = MockIDatabase::new();
|
||||
|
||||
let artist_id = ArtistId::new("an artist");
|
||||
let album_id = AlbumId::new("an album");
|
||||
let album_id_2 = AlbumId::new("another album");
|
||||
|
||||
let mut database_result = vec![Artist::new(artist_id.clone())];
|
||||
database_result[0].albums.push(Album::new(
|
||||
album_id.clone(),
|
||||
AlbumDate::default(),
|
||||
None,
|
||||
vec![],
|
||||
));
|
||||
|
||||
database
|
||||
.expect_load()
|
||||
.times(1)
|
||||
.return_once(|| Ok(database_result));
|
||||
database.expect_save().times(2).returning(|_| Ok(()));
|
||||
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||
|
||||
let meta = &music_hoard.collection[0].albums[0].meta;
|
||||
assert_eq!(meta.info.musicbrainz, MbRefOption::None);
|
||||
assert_eq!(meta.info.primary_type, None);
|
||||
assert_eq!(meta.info.secondary_types, Vec::new());
|
||||
|
||||
let info = AlbumInfo::new(
|
||||
MbRefOption::CannotHaveMbid,
|
||||
Some(AlbumPrimaryType::Album),
|
||||
vec![AlbumSecondaryType::Live],
|
||||
);
|
||||
|
||||
// Seting info on an album not belonging to the artist is an error.
|
||||
assert!(music_hoard
|
||||
.merge_album_info(&artist_id, &album_id_2, info.clone())
|
||||
.is_err());
|
||||
let meta = &music_hoard.collection[0].albums[0].meta;
|
||||
assert_eq!(meta.info, AlbumInfo::default());
|
||||
|
||||
// Set info.
|
||||
assert!(music_hoard
|
||||
.merge_album_info(&artist_id, &album_id, info.clone())
|
||||
.is_ok());
|
||||
let meta = &music_hoard.collection[0].albums[0].meta;
|
||||
assert_eq!(meta.info, info);
|
||||
|
||||
// Clearing info on an album that does not exist is an error.
|
||||
assert!(music_hoard
|
||||
.clear_album_info(&artist_id, &album_id_2)
|
||||
.is_err());
|
||||
|
||||
// Clear info.
|
||||
assert!(music_hoard.clear_album_info(&artist_id, &album_id).is_ok());
|
||||
let meta = &music_hoard.collection[0].albums[0].meta;
|
||||
assert_eq!(meta.info, AlbumInfo::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_database() {
|
||||
let mut database = MockIDatabase::new();
|
||||
|
||||
database
|
||||
.expect_load()
|
||||
.times(1)
|
||||
.return_once(|| Ok(FULL_COLLECTION.to_owned()));
|
||||
|
||||
let music_hoard = MusicHoard::database(database).unwrap();
|
||||
|
||||
assert_eq!(music_hoard.get_collection(), &*FULL_COLLECTION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_load_error() {
|
||||
let mut database = MockIDatabase::new();
|
||||
|
||||
let database_result = Err(database::LoadError::IoError(String::from("I/O error")));
|
||||
|
||||
database
|
||||
.expect_load()
|
||||
.times(1)
|
||||
.return_once(|| database_result);
|
||||
|
||||
let actual_err = MusicHoard::database(database).unwrap_err();
|
||||
let expected_err = Error::DatabaseError(
|
||||
database::LoadError::IoError(String::from("I/O error")).to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(actual_err, expected_err);
|
||||
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_save_error() {
|
||||
let mut database = MockIDatabase::new();
|
||||
|
||||
let database_result = Err(database::SaveError::IoError(String::from("I/O error")));
|
||||
|
||||
database.expect_load().return_once(|| Ok(vec![]));
|
||||
database
|
||||
.expect_save()
|
||||
.times(1)
|
||||
.return_once(|_: &Collection| database_result);
|
||||
|
||||
let mut music_hoard = MusicHoard::database(database).unwrap();
|
||||
|
||||
let actual_err = music_hoard
|
||||
.add_artist(ArtistId::new("an artist"))
|
||||
.unwrap_err();
|
||||
let expected_err = Error::DatabaseError(
|
||||
database::SaveError::IoError(String::from("I/O error")).to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(actual_err, expected_err);
|
||||
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
||||
}
|
||||
}
|
317
src/core/musichoard/library.rs
Normal file
317
src/core/musichoard/library.rs
Normal file
@ -0,0 +1,317 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::core::{
|
||||
collection::{
|
||||
album::{Album, AlbumDate, AlbumId},
|
||||
artist::{Artist, ArtistId},
|
||||
track::{Track, TrackId, TrackNum, TrackQuality},
|
||||
Collection,
|
||||
},
|
||||
interface::{
|
||||
database::IDatabase,
|
||||
library::{ILibrary, Item, Query},
|
||||
},
|
||||
musichoard::{
|
||||
base::IMusicHoardBasePrivate, database::IMusicHoardDatabasePrivate, Error, MusicHoard,
|
||||
NoDatabase,
|
||||
},
|
||||
};
|
||||
|
||||
pub trait IMusicHoardLibrary {
|
||||
fn rescan_library(&mut self) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
impl<Library: ILibrary> IMusicHoardLibrary for MusicHoard<NoDatabase, Library> {
|
||||
fn rescan_library(&mut self) -> Result<(), Error> {
|
||||
self.pre_commit = self.rescan_library_inner()?;
|
||||
self.commit()
|
||||
}
|
||||
}
|
||||
|
||||
impl<Database: IDatabase, Library: ILibrary> IMusicHoardLibrary for MusicHoard<Database, Library> {
|
||||
fn rescan_library(&mut self) -> Result<(), Error> {
|
||||
self.pre_commit = self.rescan_library_inner()?;
|
||||
self.commit()
|
||||
}
|
||||
}
|
||||
|
||||
impl<Database, Library: ILibrary> MusicHoard<Database, Library> {
|
||||
fn rescan_library_inner(&mut self) -> Result<Collection, Error> {
|
||||
let items = self.library.list(&Query::new())?;
|
||||
self.library_cache = Self::items_to_artists(items)?;
|
||||
Self::sort_albums_and_tracks(self.library_cache.values_mut());
|
||||
|
||||
Ok(self.merge_collections())
|
||||
}
|
||||
|
||||
fn items_to_artists(items: Vec<Item>) -> Result<HashMap<ArtistId, Artist>, Error> {
|
||||
let mut collection = HashMap::<ArtistId, Artist>::new();
|
||||
|
||||
for item in items.into_iter() {
|
||||
let artist_id = ArtistId {
|
||||
name: item.album_artist,
|
||||
};
|
||||
|
||||
let artist_sort = item.album_artist_sort;
|
||||
|
||||
let album_id = AlbumId {
|
||||
title: item.album_title,
|
||||
};
|
||||
|
||||
let album_date = AlbumDate {
|
||||
year: Some(item.album_year).filter(|y| y > &0),
|
||||
month: Some(item.album_month).filter(|m| m > &0),
|
||||
day: Some(item.album_day).filter(|d| d > &0),
|
||||
};
|
||||
|
||||
let track = Track {
|
||||
id: TrackId {
|
||||
title: item.track_title,
|
||||
},
|
||||
number: TrackNum(item.track_number),
|
||||
artist: item.track_artist,
|
||||
quality: TrackQuality {
|
||||
format: item.track_format,
|
||||
bitrate: item.track_bitrate,
|
||||
},
|
||||
};
|
||||
|
||||
// There are usually many entries per artist. Therefore, we avoid simply calling
|
||||
// .entry(artist_id.clone()).or_insert_with(..), because of the clone. The flipside is
|
||||
// that insertions will thus do an additional lookup.
|
||||
let artist = match collection.get_mut(&artist_id) {
|
||||
Some(artist) => artist,
|
||||
None => collection
|
||||
.entry(artist_id.clone())
|
||||
.or_insert_with(|| Artist::new(artist_id)),
|
||||
};
|
||||
|
||||
if artist.meta.sort.is_some() {
|
||||
if artist_sort.is_some() && (artist.meta.sort != artist_sort) {
|
||||
return Err(Error::CollectionError(format!(
|
||||
"multiple album_artist_sort found for artist '{}': '{}' != '{}'",
|
||||
artist.meta.id,
|
||||
artist.meta.sort.as_ref().unwrap(),
|
||||
artist_sort.as_ref().unwrap()
|
||||
)));
|
||||
}
|
||||
} else if artist_sort.is_some() {
|
||||
artist.meta.sort = artist_sort;
|
||||
}
|
||||
|
||||
// Do a linear search as few artists have more than a handful of albums. Search from the
|
||||
// back as the original items vector is usually already sorted.
|
||||
match artist
|
||||
.albums
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|album| album.meta.id == album_id)
|
||||
{
|
||||
Some(album) => album.tracks.push(track),
|
||||
None => {
|
||||
let mut album = Album::new(album_id, album_date, None, vec![]);
|
||||
album.tracks.push(track);
|
||||
artist.albums.push(album);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(collection)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::{predicate, Sequence};
|
||||
|
||||
use crate::core::{
|
||||
interface::{
|
||||
database::MockIDatabase,
|
||||
library::{self, testmod::LIBRARY_ITEMS, MockILibrary},
|
||||
},
|
||||
musichoard::base::IMusicHoardBase,
|
||||
testmod::LIBRARY_COLLECTION,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rescan_library_ordered() {
|
||||
let mut library = MockILibrary::new();
|
||||
let mut database = MockIDatabase::new();
|
||||
|
||||
let library_input = Query::new();
|
||||
let library_result = Ok(LIBRARY_ITEMS.to_owned());
|
||||
|
||||
library
|
||||
.expect_list()
|
||||
.with(predicate::eq(library_input))
|
||||
.times(1)
|
||||
.return_once(|_| library_result);
|
||||
|
||||
database.expect_load().times(1).returning(|| Ok(vec![]));
|
||||
database
|
||||
.expect_save()
|
||||
.with(predicate::eq(&*LIBRARY_COLLECTION))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
|
||||
let mut music_hoard = MusicHoard::new(database, library).unwrap();
|
||||
|
||||
music_hoard.rescan_library().unwrap();
|
||||
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rescan_library_changed() {
|
||||
let mut library = MockILibrary::new();
|
||||
let mut seq = Sequence::new();
|
||||
|
||||
let library_input = Query::new();
|
||||
let library_result = Ok(LIBRARY_ITEMS.to_owned());
|
||||
|
||||
library
|
||||
.expect_list()
|
||||
.with(predicate::eq(library_input))
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|_| library_result);
|
||||
|
||||
let library_input = Query::new();
|
||||
let library_result = Ok(LIBRARY_ITEMS
|
||||
.iter()
|
||||
.filter(|item| item.album_title != "album_title a.a")
|
||||
.cloned()
|
||||
.collect());
|
||||
|
||||
library
|
||||
.expect_list()
|
||||
.with(predicate::eq(library_input))
|
||||
.times(1)
|
||||
.in_sequence(&mut seq)
|
||||
.return_once(|_| library_result);
|
||||
|
||||
let mut music_hoard = MusicHoard::library(library);
|
||||
|
||||
music_hoard.rescan_library().unwrap();
|
||||
assert!(music_hoard.get_collection()[0]
|
||||
.albums
|
||||
.iter()
|
||||
.any(|album| album.meta.id.title == "album_title a.a"));
|
||||
|
||||
music_hoard.rescan_library().unwrap();
|
||||
assert!(!music_hoard.get_collection()[0]
|
||||
.albums
|
||||
.iter()
|
||||
.any(|album| album.meta.id.title == "album_title a.a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rescan_library_unordered() {
|
||||
let mut library = MockILibrary::new();
|
||||
|
||||
let library_input = Query::new();
|
||||
let mut library_result = Ok(LIBRARY_ITEMS.to_owned());
|
||||
|
||||
// Swap the last item with the first.
|
||||
let last = library_result.as_ref().unwrap().len() - 1;
|
||||
library_result.as_mut().unwrap().swap(0, last);
|
||||
|
||||
library
|
||||
.expect_list()
|
||||
.with(predicate::eq(library_input))
|
||||
.times(1)
|
||||
.return_once(|_| library_result);
|
||||
|
||||
let mut music_hoard = MusicHoard::library(library);
|
||||
|
||||
music_hoard.rescan_library().unwrap();
|
||||
assert_eq!(music_hoard.get_collection(), &*LIBRARY_COLLECTION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rescan_library_album_id_clash() {
|
||||
let mut library = MockILibrary::new();
|
||||
|
||||
let mut expected = LIBRARY_COLLECTION.to_owned();
|
||||
let removed_album_id = expected[0].albums[0].meta.id.clone();
|
||||
let clashed_album_id = &expected[1].albums[0].meta.id;
|
||||
|
||||
let mut items = LIBRARY_ITEMS.to_owned();
|
||||
for item in items
|
||||
.iter_mut()
|
||||
.filter(|it| it.album_title == removed_album_id.title)
|
||||
{
|
||||
item.album_title = clashed_album_id.title.clone();
|
||||
}
|
||||
|
||||
expected[0].albums[0].meta.id = clashed_album_id.clone();
|
||||
|
||||
let library_input = Query::new();
|
||||
let library_result = Ok(items);
|
||||
|
||||
library
|
||||
.expect_list()
|
||||
.with(predicate::eq(library_input))
|
||||
.times(1)
|
||||
.return_once(|_| library_result);
|
||||
|
||||
let mut music_hoard = MusicHoard::library(library);
|
||||
|
||||
music_hoard.rescan_library().unwrap();
|
||||
assert_eq!(music_hoard.get_collection()[0], expected[0]);
|
||||
assert_eq!(music_hoard.get_collection(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rescan_library_album_artist_sort_clash() {
|
||||
let mut library = MockILibrary::new();
|
||||
|
||||
let library_input = Query::new();
|
||||
let mut library_items = LIBRARY_ITEMS.to_owned();
|
||||
|
||||
assert_eq!(library_items[0].album_artist, library_items[1].album_artist);
|
||||
library_items[0].album_artist_sort = Some(library_items[0].album_artist.clone());
|
||||
library_items[1].album_artist_sort = Some(
|
||||
library_items[1]
|
||||
.album_artist
|
||||
.clone()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect(),
|
||||
);
|
||||
|
||||
let library_result = Ok(library_items);
|
||||
|
||||
library
|
||||
.expect_list()
|
||||
.with(predicate::eq(library_input))
|
||||
.times(1)
|
||||
.return_once(|_| library_result);
|
||||
|
||||
let mut music_hoard = MusicHoard::library(library);
|
||||
|
||||
assert!(music_hoard.rescan_library().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_error() {
|
||||
let mut library = MockILibrary::new();
|
||||
|
||||
let library_result = Err(library::Error::Invalid(String::from("invalid data")));
|
||||
|
||||
library
|
||||
.expect_list()
|
||||
.times(1)
|
||||
.return_once(|_| library_result);
|
||||
|
||||
let mut music_hoard = MusicHoard::library(library);
|
||||
|
||||
let actual_err = music_hoard.rescan_library().unwrap_err();
|
||||
let expected_err =
|
||||
Error::LibraryError(library::Error::Invalid(String::from("invalid data")).to_string());
|
||||
|
||||
assert_eq!(actual_err, expected_err);
|
||||
assert_eq!(actual_err.to_string(), expected_err.to_string());
|
||||
}
|
||||
}
|
95
src/core/musichoard/mod.rs
Normal file
95
src/core/musichoard/mod.rs
Normal file
@ -0,0 +1,95 @@
|
||||
//! The core MusicHoard module. Serves as the main entry-point into the library.
|
||||
|
||||
mod base;
|
||||
mod database;
|
||||
mod library;
|
||||
|
||||
pub mod builder;
|
||||
|
||||
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`.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// The [`MusicHoard`] is not able to read/write its in-memory collection.
|
||||
CollectionError(String),
|
||||
/// The [`MusicHoard`] failed to read/write from/to the library.
|
||||
LibraryError(String),
|
||||
/// The [`MusicHoard`] failed to read/write from/to the database.
|
||||
DatabaseError(String),
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match *self {
|
||||
Self::CollectionError(ref s) => write!(f, "failed to read/write the collection: {s}"),
|
||||
Self::LibraryError(ref s) => write!(f, "failed to read/write from/to the library: {s}"),
|
||||
Self::DatabaseError(ref s) => {
|
||||
write!(f, "failed to read/write from/to the database: {s}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LibraryError> for Error {
|
||||
fn from(err: LibraryError) -> Error {
|
||||
Error::LibraryError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DatabaseLoadError> for Error {
|
||||
fn from(err: DatabaseLoadError) -> Error {
|
||||
Error::DatabaseError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DatabaseSaveError> for Error {
|
||||
fn from(err: DatabaseSaveError) -> Error {
|
||||
Error::DatabaseError(err.to_string())
|
||||
}
|
||||
}
|
13
src/core/testmod.rs
Normal file
13
src/core/testmod.rs
Normal file
@ -0,0 +1,13 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::core::collection::{
|
||||
album::{Album, AlbumId, AlbumInfo, AlbumMeta, AlbumPrimaryType, AlbumSeq},
|
||||
artist::{Artist, ArtistId, ArtistInfo, ArtistMeta},
|
||||
musicbrainz::{MbAlbumRef, MbArtistRef, MbRefOption},
|
||||
track::{Track, TrackFormat, TrackId, TrackNum, TrackQuality},
|
||||
};
|
||||
use crate::testmod::*;
|
||||
|
||||
pub static LIBRARY_COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| library::library_collection!());
|
||||
pub static FULL_COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| full::full_collection!());
|
@ -1,199 +0,0 @@
|
||||
//! Module for storing MusicHoard data in a JSON file database.
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use super::{Database, Error};
|
||||
|
||||
pub mod backend;
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(err: serde_json::Error) -> Error {
|
||||
Error::SerDeError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for the JSON database backend.
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait JsonDatabaseBackend {
|
||||
/// Read the JSON string from the backend.
|
||||
fn read(&self) -> Result<String, std::io::Error>;
|
||||
|
||||
/// Write the JSON string to the backend.
|
||||
fn write(&mut self, json: &str) -> Result<(), std::io::Error>;
|
||||
}
|
||||
|
||||
/// JSON database.
|
||||
pub struct JsonDatabase<JDB> {
|
||||
backend: JDB,
|
||||
}
|
||||
|
||||
impl<JDB: JsonDatabaseBackend> JsonDatabase<JDB> {
|
||||
/// Create a new JSON database with the provided backend, e.g.
|
||||
/// [`backend::JsonDatabaseFileBackend`].
|
||||
pub fn new(backend: JDB) -> Self {
|
||||
JsonDatabase { backend }
|
||||
}
|
||||
}
|
||||
|
||||
impl<JDB: JsonDatabaseBackend> Database for JsonDatabase<JDB> {
|
||||
fn read<D: DeserializeOwned>(&self, collection: &mut D) -> Result<(), Error> {
|
||||
let serialized = self.backend.read()?;
|
||||
*collection = serde_json::from_str(&serialized)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write<S: Serialize>(&mut self, collection: &S) -> Result<(), Error> {
|
||||
let serialized = serde_json::to_string(&collection)?;
|
||||
self.backend.write(&serialized)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mockall::predicate;
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::{tests::COLLECTION, Artist, ArtistId, TrackFormat};
|
||||
|
||||
fn artist_to_json(artist: &Artist) -> String {
|
||||
let album_artist = &artist.id.name;
|
||||
|
||||
let mut albums: Vec<String> = vec![];
|
||||
for album in artist.albums.iter() {
|
||||
let album_year = album.id.year;
|
||||
let album_title = &album.id.title;
|
||||
|
||||
let mut tracks: Vec<String> = vec![];
|
||||
for track in album.tracks.iter() {
|
||||
let track_number = track.number;
|
||||
let track_title = &track.title;
|
||||
|
||||
let mut track_artist: Vec<String> = vec![];
|
||||
for artist in track.artist.iter() {
|
||||
track_artist.push(format!("\"{artist}\""))
|
||||
}
|
||||
let track_artist = track_artist.join(",");
|
||||
|
||||
let track_format: &'static str = match track.format {
|
||||
TrackFormat::Flac => stringify!(Flac),
|
||||
TrackFormat::Mp3 => stringify!(Mp3),
|
||||
};
|
||||
|
||||
tracks.push(format!(
|
||||
"{{\
|
||||
\"number\":{track_number},\
|
||||
\"title\":\"{track_title}\",\
|
||||
\"artist\":[{track_artist}],\
|
||||
\"format\":\"{track_format}\"\
|
||||
}}"
|
||||
));
|
||||
}
|
||||
let tracks = tracks.join(",");
|
||||
|
||||
albums.push(format!(
|
||||
"{{\
|
||||
\"id\":{{\
|
||||
\"year\":{album_year},\
|
||||
\"title\":\"{album_title}\"\
|
||||
}},\"tracks\":[{tracks}]\
|
||||
}}"
|
||||
));
|
||||
}
|
||||
let albums = albums.join(",");
|
||||
|
||||
format!(
|
||||
"{{\
|
||||
\"id\":{{\
|
||||
\"name\":\"{album_artist}\"\
|
||||
}},\"albums\":[{albums}]\
|
||||
}}"
|
||||
)
|
||||
}
|
||||
|
||||
fn artists_to_json(artists: &[Artist]) -> String {
|
||||
let mut artists_strings: Vec<String> = vec![];
|
||||
for artist in artists.iter() {
|
||||
artists_strings.push(artist_to_json(artist));
|
||||
}
|
||||
let artists_json = artists_strings.join(",");
|
||||
format!("[{artists_json}]")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write() {
|
||||
let write_data = COLLECTION.to_owned();
|
||||
let input = artists_to_json(&write_data);
|
||||
|
||||
let mut backend = MockJsonDatabaseBackend::new();
|
||||
backend
|
||||
.expect_write()
|
||||
.with(predicate::eq(input))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
|
||||
JsonDatabase::new(backend).write(&write_data).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read() {
|
||||
let expected = COLLECTION.to_owned();
|
||||
let result = Ok(artists_to_json(&expected));
|
||||
|
||||
let mut backend = MockJsonDatabaseBackend::new();
|
||||
backend.expect_read().times(1).return_once(|| result);
|
||||
|
||||
let mut read_data: Vec<Artist> = vec![];
|
||||
JsonDatabase::new(backend).read(&mut read_data).unwrap();
|
||||
|
||||
assert_eq!(read_data, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse() {
|
||||
let expected = COLLECTION.to_owned();
|
||||
let input = artists_to_json(&expected);
|
||||
let result = Ok(input.clone());
|
||||
|
||||
let mut backend = MockJsonDatabaseBackend::new();
|
||||
backend
|
||||
.expect_write()
|
||||
.with(predicate::eq(input))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
backend.expect_read().times(1).return_once(|| result);
|
||||
|
||||
let mut database = JsonDatabase::new(backend);
|
||||
|
||||
let write_data = COLLECTION.to_owned();
|
||||
let mut read_data: Vec<Artist> = vec![];
|
||||
database.write(&write_data).unwrap();
|
||||
database.read(&mut read_data).unwrap();
|
||||
|
||||
assert_eq!(write_data, read_data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
let mut object = HashMap::<ArtistId, String>::new();
|
||||
object.insert(
|
||||
ArtistId {
|
||||
name: String::from("artist"),
|
||||
},
|
||||
String::from("string"),
|
||||
);
|
||||
let serde_err = serde_json::to_string(&object);
|
||||
assert!(serde_err.is_err());
|
||||
|
||||
let serde_err: Error = serde_err.unwrap_err().into();
|
||||
assert!(!serde_err.to_string().is_empty());
|
||||
assert!(!format!("{:?}", serde_err).is_empty());
|
||||
}
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
//! Module for storing MusicHoard data in a database.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
#[cfg(feature = "database-json")]
|
||||
pub mod json;
|
||||
|
||||
/// Error type for database calls.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// The database experienced an I/O error.
|
||||
IoError(String),
|
||||
/// The database experienced a (de)serialisation error.
|
||||
SerDeError(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match *self {
|
||||
Self::IoError(ref s) => write!(f, "the database experienced an I/O error: {s}"),
|
||||
Self::SerDeError(ref s) => {
|
||||
write!(f, "the database experienced a (de)serialisation error: {s}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(err: std::io::Error) -> Error {
|
||||
Error::IoError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for interacting with the database.
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait Database {
|
||||
/// Read collection from the database.
|
||||
fn read<D: DeserializeOwned + 'static>(&self, collection: &mut D) -> Result<(), Error>;
|
||||
|
||||
/// Write collection to the database.
|
||||
fn write<S: Serialize + 'static>(&mut self, collection: &S) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io;
|
||||
|
||||
use super::Error;
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "error").into();
|
||||
assert!(!io_err.to_string().is_empty());
|
||||
assert!(!format!("{:?}", io_err).is_empty());
|
||||
}
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::JsonDatabaseBackend;
|
||||
use crate::external::database::json::IJsonDatabaseBackend;
|
||||
|
||||
/// JSON database backend that uses a local file for persistent storage.
|
||||
pub struct JsonDatabaseFileBackend {
|
||||
@ -17,7 +17,7 @@ impl JsonDatabaseFileBackend {
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonDatabaseBackend for JsonDatabaseFileBackend {
|
||||
impl IJsonDatabaseBackend for JsonDatabaseFileBackend {
|
||||
fn read(&self) -> Result<String, std::io::Error> {
|
||||
// Read entire file to memory as for now this is faster than a buffered read from disk:
|
||||
// https://github.com/serde-rs/json/issues/160
|
169
src/external/database/json/mod.rs
vendored
Normal file
169
src/external/database/json/mod.rs
vendored
Normal file
@ -0,0 +1,169 @@
|
||||
//! Module for storing MusicHoard data in a JSON file database.
|
||||
|
||||
pub mod backend;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use crate::{
|
||||
core::{
|
||||
collection::Collection,
|
||||
interface::database::{IDatabase, LoadError, SaveError},
|
||||
},
|
||||
external::database::serde::{deserialize::DeserializeDatabase, serialize::SerializeDatabase},
|
||||
};
|
||||
|
||||
impl From<serde_json::Error> for LoadError {
|
||||
fn from(err: serde_json::Error) -> LoadError {
|
||||
LoadError::SerDeError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for SaveError {
|
||||
fn from(err: serde_json::Error) -> SaveError {
|
||||
SaveError::SerDeError(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for the JSON database backend.
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait IJsonDatabaseBackend {
|
||||
/// Read the JSON string from the backend.
|
||||
fn read(&self) -> Result<String, std::io::Error>;
|
||||
|
||||
/// Write the JSON string to the backend.
|
||||
fn write(&mut self, json: &str) -> Result<(), std::io::Error>;
|
||||
}
|
||||
|
||||
/// JSON database.
|
||||
pub struct JsonDatabase<JDB> {
|
||||
backend: JDB,
|
||||
}
|
||||
|
||||
impl<JDB: IJsonDatabaseBackend> JsonDatabase<JDB> {
|
||||
/// Create a new JSON database with the provided backend, e.g.
|
||||
/// [`backend::JsonDatabaseFileBackend`].
|
||||
pub fn new(backend: JDB) -> Self {
|
||||
JsonDatabase { backend }
|
||||
}
|
||||
}
|
||||
|
||||
impl<JDB: IJsonDatabaseBackend> IDatabase for JsonDatabase<JDB> {
|
||||
fn load(&self) -> Result<Collection, LoadError> {
|
||||
let serialized = self.backend.read()?;
|
||||
let database: DeserializeDatabase = serde_json::from_str(&serialized)?;
|
||||
Ok(database.into())
|
||||
}
|
||||
|
||||
fn save(&mut self, collection: &Collection) -> Result<(), SaveError> {
|
||||
let database: SerializeDatabase = collection.into();
|
||||
let serialized = serde_json::to_string(&database)?;
|
||||
self.backend.write(&serialized)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod testmod;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mockall::predicate;
|
||||
|
||||
use crate::core::{
|
||||
collection::{album::AlbumDate, artist::Artist, Collection},
|
||||
testmod::FULL_COLLECTION,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use testmod::DATABASE_JSON;
|
||||
|
||||
fn expected() -> Collection {
|
||||
let mut expected = FULL_COLLECTION.to_owned();
|
||||
for artist in expected.iter_mut() {
|
||||
for album in artist.albums.iter_mut() {
|
||||
album.meta.date = AlbumDate::default();
|
||||
album.tracks.clear();
|
||||
}
|
||||
}
|
||||
expected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save() {
|
||||
let write_data = FULL_COLLECTION.to_owned();
|
||||
let input = DATABASE_JSON.to_owned();
|
||||
|
||||
let mut backend = MockIJsonDatabaseBackend::new();
|
||||
backend
|
||||
.expect_write()
|
||||
.with(predicate::eq(input))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
|
||||
JsonDatabase::new(backend).save(&write_data).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load() {
|
||||
let expected = expected();
|
||||
let result = Ok(DATABASE_JSON.to_owned());
|
||||
eprintln!("{DATABASE_JSON}");
|
||||
|
||||
let mut backend = MockIJsonDatabaseBackend::new();
|
||||
backend.expect_read().times(1).return_once(|| result);
|
||||
|
||||
let read_data: Vec<Artist> = JsonDatabase::new(backend).load().unwrap();
|
||||
|
||||
assert_eq!(read_data, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse() {
|
||||
let input = DATABASE_JSON.to_owned();
|
||||
let result = Ok(input.clone());
|
||||
|
||||
let mut backend = MockIJsonDatabaseBackend::new();
|
||||
backend
|
||||
.expect_write()
|
||||
.with(predicate::eq(input))
|
||||
.times(1)
|
||||
.return_once(|_| Ok(()));
|
||||
backend.expect_read().times(1).return_once(|| result);
|
||||
let mut database = JsonDatabase::new(backend);
|
||||
|
||||
let write_data = FULL_COLLECTION.to_owned();
|
||||
database.save(&write_data).unwrap();
|
||||
let read_data: Vec<Artist> = database.load().unwrap();
|
||||
|
||||
// Album information is not saved to disk.
|
||||
let expected = expected();
|
||||
assert_eq!(read_data, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_errors() {
|
||||
let json = String::from("");
|
||||
let serde_err = serde_json::from_str::<DeserializeDatabase>(&json);
|
||||
assert!(serde_err.is_err());
|
||||
|
||||
let serde_err: LoadError = serde_err.unwrap_err().into();
|
||||
assert!(!serde_err.to_string().is_empty());
|
||||
assert!(!format!("{:?}", serde_err).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_errors() {
|
||||
// serde_json will raise an error as it has certain requirements on keys.
|
||||
let mut object = HashMap::<Result<(), ()>, String>::new();
|
||||
object.insert(Ok(()), String::from("string"));
|
||||
let serde_err = serde_json::to_string(&object);
|
||||
assert!(serde_err.is_err());
|
||||
|
||||
let serde_err: SaveError = serde_err.unwrap_err().into();
|
||||
assert!(!serde_err.to_string().is_empty());
|
||||
assert!(!format!("{:?}", serde_err).is_empty());
|
||||
}
|
||||
}
|
90
src/external/database/json/testmod.rs
vendored
Normal file
90
src/external/database/json/testmod.rs
vendored
Normal file
@ -0,0 +1,90 @@
|
||||
pub static DATABASE_JSON: &str = "{\
|
||||
\"V20240924\":\
|
||||
[\
|
||||
{\
|
||||
\"name\":\"Album_Artist ‘A’\",\
|
||||
\"sort\":null,\
|
||||
\"musicbrainz\":{\"Some\":\"00000000-0000-0000-0000-000000000000\"},\
|
||||
\"properties\":{\
|
||||
\"MusicButler\":[\"https://www.musicbutler.io/artist-page/000000000\"],\
|
||||
\"Qobuz\":[\"https://www.qobuz.com/nl-nl/interpreter/artist-a/download-streaming-albums\"]\
|
||||
},\
|
||||
\"albums\":[\
|
||||
{\
|
||||
\"title\":\"album_title a.a\",\"seq\":1,\
|
||||
\"musicbrainz\":{\"Some\":\"00000000-0000-0000-0000-000000000000\"},\
|
||||
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||
},\
|
||||
{\
|
||||
\"title\":\"album_title a.b\",\"seq\":1,\"musicbrainz\":\"None\",\
|
||||
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||
}\
|
||||
]\
|
||||
},\
|
||||
{\
|
||||
\"name\":\"Album_Artist ‘B’\",\
|
||||
\"sort\":null,\
|
||||
\"musicbrainz\":{\"Some\":\"11111111-1111-1111-1111-111111111111\"},\
|
||||
\"properties\":{\
|
||||
\"Bandcamp\":[\"https://artist-b.bandcamp.com/\"],\
|
||||
\"MusicButler\":[\
|
||||
\"https://www.musicbutler.io/artist-page/111111111\",\
|
||||
\"https://www.musicbutler.io/artist-page/111111112\"\
|
||||
],\
|
||||
\"Qobuz\":[\"https://www.qobuz.com/nl-nl/interpreter/artist-b/download-streaming-albums\"]\
|
||||
},\
|
||||
\"albums\":[\
|
||||
{\
|
||||
\"title\":\"album_title b.a\",\"seq\":1,\"musicbrainz\":\"None\",\
|
||||
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||
},\
|
||||
{\
|
||||
\"title\":\"album_title b.b\",\"seq\":3,\
|
||||
\"musicbrainz\":{\"Some\":\"11111111-1111-1111-1111-111111111111\"},\
|
||||
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||
},\
|
||||
{\
|
||||
\"title\":\"album_title b.c\",\"seq\":2,\
|
||||
\"musicbrainz\":{\"Some\":\"11111111-1111-1111-1111-111111111112\"},\
|
||||
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||
},\
|
||||
{\
|
||||
\"title\":\"album_title b.d\",\"seq\":4,\"musicbrainz\":\"None\",\
|
||||
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||
}\
|
||||
]\
|
||||
},\
|
||||
{\
|
||||
\"name\":\"The Album_Artist ‘C’\",\
|
||||
\"sort\":\"Album_Artist ‘C’, The\",\
|
||||
\"musicbrainz\":\"CannotHaveMbid\",\
|
||||
\"properties\":{},\
|
||||
\"albums\":[\
|
||||
{\
|
||||
\"title\":\"album_title c.a\",\"seq\":0,\"musicbrainz\":\"None\",\
|
||||
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||
},\
|
||||
{\
|
||||
\"title\":\"album_title c.b\",\"seq\":0,\"musicbrainz\":\"None\",\
|
||||
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||
}\
|
||||
]\
|
||||
},\
|
||||
{\
|
||||
\"name\":\"Album_Artist ‘D’\",\
|
||||
\"sort\":null,\
|
||||
\"musicbrainz\":\"None\",\
|
||||
\"properties\":{},\
|
||||
\"albums\":[\
|
||||
{\
|
||||
\"title\":\"album_title d.a\",\"seq\":0,\"musicbrainz\":\"None\",\
|
||||
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||
},\
|
||||
{\
|
||||
\"title\":\"album_title d.b\",\"seq\":0,\"musicbrainz\":\"None\",\
|
||||
\"primary_type\":\"Album\",\"secondary_types\":[]\
|
||||
}\
|
||||
]\
|
||||
}\
|
||||
]\
|
||||
}";
|
4
src/external/database/mod.rs
vendored
Normal file
4
src/external/database/mod.rs
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
#[cfg(feature = "database-json")]
|
||||
pub mod json;
|
||||
#[cfg(feature = "database-json")]
|
||||
mod serde;
|
71
src/external/database/serde/common.rs
vendored
Normal file
71
src/external/database/serde/common.rs
vendored
Normal file
@ -0,0 +1,71 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
collection::musicbrainz::MbRefOption,
|
||||
core::collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(remote = "MbRefOption")]
|
||||
pub enum MbRefOptionDef<T> {
|
||||
Some(T),
|
||||
CannotHaveMbid,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(remote = "AlbumPrimaryType")]
|
||||
pub enum AlbumPrimaryTypeDef {
|
||||
Album,
|
||||
Single,
|
||||
Ep,
|
||||
Broadcast,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
|
||||
|
||||
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
||||
fn from(value: SerdeAlbumPrimaryType) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AlbumPrimaryType> for SerdeAlbumPrimaryType {
|
||||
fn from(value: AlbumPrimaryType) -> Self {
|
||||
SerdeAlbumPrimaryType(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(remote = "AlbumSecondaryType")]
|
||||
pub enum AlbumSecondaryTypeDef {
|
||||
Compilation,
|
||||
Soundtrack,
|
||||
Spokenword,
|
||||
Interview,
|
||||
Audiobook,
|
||||
AudioDrama,
|
||||
Live,
|
||||
Remix,
|
||||
DjMix,
|
||||
MixtapeStreet,
|
||||
Demo,
|
||||
FieldRecording,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
|
||||
|
||||
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
||||
fn from(value: SerdeAlbumSecondaryType) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AlbumSecondaryType> for SerdeAlbumSecondaryType {
|
||||
fn from(value: AlbumSecondaryType) -> Self {
|
||||
SerdeAlbumSecondaryType(value)
|
||||
}
|
||||
}
|
167
src/external/database/serde/deserialize.rs
vendored
Normal file
167
src/external/database/serde/deserialize.rs
vendored
Normal file
@ -0,0 +1,167 @@
|
||||
use std::{collections::HashMap, fmt};
|
||||
|
||||
use serde::{de::Visitor, Deserialize, Deserializer};
|
||||
|
||||
use crate::{
|
||||
collection::{
|
||||
album::{AlbumInfo, AlbumMeta},
|
||||
artist::{ArtistInfo, ArtistMeta},
|
||||
musicbrainz::{MbAlbumRef, MbArtistRef, MbRefOption, Mbid},
|
||||
},
|
||||
core::collection::{
|
||||
album::{Album, AlbumDate, AlbumId, AlbumSeq},
|
||||
artist::{Artist, ArtistId},
|
||||
Collection, Error as CollectionError,
|
||||
},
|
||||
external::database::serde::common::{SerdeAlbumPrimaryType, SerdeAlbumSecondaryType},
|
||||
};
|
||||
|
||||
use super::common::MbRefOptionDef;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub enum DeserializeDatabase {
|
||||
V20240924(Vec<DeserializeArtist>),
|
||||
}
|
||||
|
||||
impl From<DeserializeDatabase> for Collection {
|
||||
fn from(database: DeserializeDatabase) -> Self {
|
||||
match database {
|
||||
DeserializeDatabase::V20240924(collection) => {
|
||||
collection.into_iter().map(Into::into).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeserializeArtist {
|
||||
name: String,
|
||||
sort: Option<String>,
|
||||
musicbrainz: DeserializeMbRefOption,
|
||||
properties: HashMap<String, Vec<String>>,
|
||||
albums: Vec<DeserializeAlbum>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeserializeAlbum {
|
||||
title: String,
|
||||
seq: u8,
|
||||
musicbrainz: DeserializeMbRefOption,
|
||||
primary_type: Option<SerdeAlbumPrimaryType>,
|
||||
secondary_types: Vec<SerdeAlbumSecondaryType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeserializeMbRefOption(#[serde(with = "MbRefOptionDef")] MbRefOption<DeserializeMbid>);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DeserializeMbid(Mbid);
|
||||
|
||||
macro_rules! impl_from_for_mb_ref_option {
|
||||
($ref:ty) => {
|
||||
impl From<DeserializeMbRefOption> for MbRefOption<$ref> {
|
||||
fn from(value: DeserializeMbRefOption) -> Self {
|
||||
match value.0 {
|
||||
MbRefOption::Some(val) => MbRefOption::Some(val.0.into()),
|
||||
MbRefOption::CannotHaveMbid => MbRefOption::CannotHaveMbid,
|
||||
MbRefOption::None => MbRefOption::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_from_for_mb_ref_option!(MbArtistRef);
|
||||
impl_from_for_mb_ref_option!(MbAlbumRef);
|
||||
|
||||
impl From<DeserializeMbid> for Mbid {
|
||||
fn from(value: DeserializeMbid) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
struct DeserializeMbidVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for DeserializeMbidVisitor {
|
||||
type Value = DeserializeMbid;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a valid MusicBrainz identifier")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(DeserializeMbid(
|
||||
v.try_into()
|
||||
.map_err(|e: CollectionError| E::custom(e.to_string()))?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DeserializeMbid {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_str(DeserializeMbidVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeserializeArtist> for Artist {
|
||||
fn from(artist: DeserializeArtist) -> Self {
|
||||
Artist {
|
||||
meta: ArtistMeta {
|
||||
id: ArtistId::new(artist.name),
|
||||
sort: artist.sort,
|
||||
info: ArtistInfo {
|
||||
musicbrainz: artist.musicbrainz.into(),
|
||||
properties: artist.properties,
|
||||
},
|
||||
},
|
||||
albums: artist.albums.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeserializeAlbum> for Album {
|
||||
fn from(album: DeserializeAlbum) -> Self {
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId { title: album.title },
|
||||
date: AlbumDate::default(),
|
||||
seq: AlbumSeq(album.seq),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: album.musicbrainz.into(),
|
||||
primary_type: album.primary_type.map(Into::into),
|
||||
secondary_types: album.secondary_types.into_iter().map(Into::into).collect(),
|
||||
},
|
||||
},
|
||||
tracks: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_mbid() {
|
||||
let mbid = "\"d368baa8-21ca-4759-9731-0b2753071ad8\"";
|
||||
let mbid: DeserializeMbid = serde_json::from_str(mbid).unwrap();
|
||||
let mbid: Mbid = mbid.into();
|
||||
assert_eq!(
|
||||
mbid,
|
||||
"d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap()
|
||||
);
|
||||
|
||||
let mbid = "null";
|
||||
let result: Result<DeserializeMbid, _> = serde_json::from_str(mbid);
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("a valid MusicBrainz identifier"));
|
||||
}
|
||||
}
|
5
src/external/database/serde/mod.rs
vendored
Normal file
5
src/external/database/serde/mod.rs
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
//! Helper module for backends that can use serde for (de)serialisation.
|
||||
|
||||
mod common;
|
||||
pub mod deserialize;
|
||||
pub mod serialize;
|
106
src/external/database/serde/serialize.rs
vendored
Normal file
106
src/external/database/serde/serialize.rs
vendored
Normal file
@ -0,0 +1,106 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
collection::musicbrainz::{MbRefOption, Mbid},
|
||||
core::collection::{album::Album, artist::Artist, musicbrainz::IMusicBrainzRef, Collection},
|
||||
external::database::serde::common::{
|
||||
MbRefOptionDef, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub enum SerializeDatabase<'a> {
|
||||
V20240924(Vec<SerializeArtist<'a>>),
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Collection> for SerializeDatabase<'a> {
|
||||
fn from(collection: &'a Collection) -> Self {
|
||||
SerializeDatabase::V20240924(collection.iter().map(Into::into).collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SerializeArtist<'a> {
|
||||
name: &'a str,
|
||||
sort: Option<&'a str>,
|
||||
musicbrainz: SerializeMbRefOption<'a>,
|
||||
properties: BTreeMap<&'a str, &'a Vec<String>>,
|
||||
albums: Vec<SerializeAlbum<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SerializeAlbum<'a> {
|
||||
title: &'a str,
|
||||
seq: u8,
|
||||
musicbrainz: SerializeMbRefOption<'a>,
|
||||
primary_type: Option<SerdeAlbumPrimaryType>,
|
||||
secondary_types: Vec<SerdeAlbumSecondaryType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SerializeMbRefOption<'a>(
|
||||
#[serde(with = "MbRefOptionDef")] MbRefOption<SerializeMbid<'a>>,
|
||||
);
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SerializeMbid<'a>(&'a Mbid);
|
||||
|
||||
impl<'a, T: IMusicBrainzRef> From<&'a MbRefOption<T>> for SerializeMbRefOption<'a> {
|
||||
fn from(value: &'a MbRefOption<T>) -> Self {
|
||||
match value {
|
||||
MbRefOption::Some(val) => {
|
||||
SerializeMbRefOption(MbRefOption::Some(SerializeMbid(val.mbid())))
|
||||
}
|
||||
MbRefOption::CannotHaveMbid => SerializeMbRefOption(MbRefOption::CannotHaveMbid),
|
||||
MbRefOption::None => SerializeMbRefOption(MbRefOption::None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Serialize for SerializeMbid<'a> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.0.uuid().as_hyphenated().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Artist> for SerializeArtist<'a> {
|
||||
fn from(artist: &'a Artist) -> Self {
|
||||
SerializeArtist {
|
||||
name: &artist.meta.id.name,
|
||||
sort: artist.meta.sort.as_deref(),
|
||||
musicbrainz: (&artist.meta.info.musicbrainz).into(),
|
||||
properties: artist
|
||||
.meta
|
||||
.info
|
||||
.properties
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_ref(), v))
|
||||
.collect(),
|
||||
albums: artist.albums.iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Album> for SerializeAlbum<'a> {
|
||||
fn from(album: &'a Album) -> Self {
|
||||
SerializeAlbum {
|
||||
title: &album.meta.id.title,
|
||||
seq: album.meta.seq.0,
|
||||
musicbrainz: (&album.meta.info.musicbrainz).into(),
|
||||
primary_type: album.meta.info.primary_type.map(Into::into),
|
||||
secondary_types: album
|
||||
.meta
|
||||
.info
|
||||
.secondary_types
|
||||
.iter()
|
||||
.copied()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
@ -8,11 +8,11 @@ use std::{
|
||||
str,
|
||||
};
|
||||
|
||||
use super::{BeetsLibraryExecutor, Error};
|
||||
use crate::{core::interface::library::Error, external::library::beets::IBeetsLibraryExecutor};
|
||||
|
||||
const BEET_DEFAULT: &str = "beet";
|
||||
|
||||
trait BeetsLibraryExecutorPrivate {
|
||||
trait IBeetsLibraryExecutorPrivate {
|
||||
fn output(output: Output) -> Result<Vec<String>, Error> {
|
||||
if !output.status.success() {
|
||||
return Err(Error::Executor(
|
||||
@ -59,7 +59,7 @@ impl Default for BeetsLibraryProcessExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
impl BeetsLibraryExecutor for BeetsLibraryProcessExecutor {
|
||||
impl IBeetsLibraryExecutor for BeetsLibraryProcessExecutor {
|
||||
fn exec<S: AsRef<str> + 'static>(&mut self, arguments: &[S]) -> Result<Vec<String>, Error> {
|
||||
let mut cmd = Command::new(&self.bin);
|
||||
if let Some(ref path) = self.config {
|
||||
@ -71,11 +71,13 @@ impl BeetsLibraryExecutor for BeetsLibraryProcessExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
impl BeetsLibraryExecutorPrivate for BeetsLibraryProcessExecutor {}
|
||||
impl IBeetsLibraryExecutorPrivate for BeetsLibraryProcessExecutor {}
|
||||
|
||||
// GRCOV_EXCL_START
|
||||
#[cfg(feature = "library-ssh")]
|
||||
#[cfg(feature = "library-beets-ssh")]
|
||||
pub mod ssh {
|
||||
//! Module for interacting with the music library via
|
||||
//! [beets](https://beets.readthedocs.io/en/stable/) over SSH.
|
||||
|
||||
use openssh::{KnownHosts, Session};
|
||||
use tokio::runtime::{self, Runtime};
|
||||
@ -128,7 +130,7 @@ pub mod ssh {
|
||||
}
|
||||
}
|
||||
|
||||
impl BeetsLibraryExecutor for BeetsLibrarySshExecutor {
|
||||
impl IBeetsLibraryExecutor for BeetsLibrarySshExecutor {
|
||||
fn exec<S: AsRef<str> + 'static>(&mut self, arguments: &[S]) -> Result<Vec<String>, Error> {
|
||||
let mut cmd = self.session.command(&self.bin);
|
||||
if let Some(ref path) = self.config {
|
||||
@ -141,6 +143,6 @@ pub mod ssh {
|
||||
}
|
||||
}
|
||||
|
||||
impl BeetsLibraryExecutorPrivate for BeetsLibrarySshExecutor {}
|
||||
impl IBeetsLibraryExecutorPrivate for BeetsLibrarySshExecutor {}
|
||||
}
|
||||
// GRCOV_EXCL_STOP
|
@ -1,19 +1,15 @@
|
||||
//! Module for interacting with the music library via
|
||||
//! [beets](https://beets.readthedocs.io/en/stable/).
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
str,
|
||||
};
|
||||
pub mod executor;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use crate::{Album, AlbumId, Artist, ArtistId, Track, TrackFormat};
|
||||
|
||||
use super::{Error, Field, Library, Query};
|
||||
|
||||
pub mod executor;
|
||||
use crate::core::{
|
||||
collection::track::TrackFormat,
|
||||
interface::library::{Error, Field, ILibrary, Item, Query},
|
||||
};
|
||||
|
||||
macro_rules! list_format_separator {
|
||||
() => {
|
||||
@ -27,8 +23,14 @@ const LIST_FORMAT_ARG: &str = concat!(
|
||||
"--format=",
|
||||
"$albumartist",
|
||||
list_format_separator!(),
|
||||
"$albumartist_sort",
|
||||
list_format_separator!(),
|
||||
"$year",
|
||||
list_format_separator!(),
|
||||
"$month",
|
||||
list_format_separator!(),
|
||||
"$day",
|
||||
list_format_separator!(),
|
||||
"$album",
|
||||
list_format_separator!(),
|
||||
"$track",
|
||||
@ -37,11 +39,28 @@ const LIST_FORMAT_ARG: &str = concat!(
|
||||
list_format_separator!(),
|
||||
"$artist",
|
||||
list_format_separator!(),
|
||||
"$format"
|
||||
"$format",
|
||||
list_format_separator!(),
|
||||
"$bitrate"
|
||||
);
|
||||
const TRACK_FORMAT_FLAC: &str = "FLAC";
|
||||
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 {
|
||||
fn to_arg(&self, include: bool) -> String;
|
||||
}
|
||||
@ -55,11 +74,15 @@ impl ToBeetsArg for Field {
|
||||
let negate = if include { "" } else { "^" };
|
||||
match self {
|
||||
Field::AlbumArtist(ref s) => format!("{negate}albumartist:{s}"),
|
||||
Field::AlbumArtistSort(ref s) => format!("{negate}albumartist_sort:{s}"),
|
||||
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::TrackNumber(ref u) => format!("{negate}track:{u}"),
|
||||
Field::TrackTitle(ref s) => format!("{negate}title:{s}"),
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
@ -78,7 +101,7 @@ impl ToBeetsArgs for Query {
|
||||
|
||||
/// Trait for invoking beets commands.
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait BeetsLibraryExecutor {
|
||||
pub trait IBeetsLibraryExecutor {
|
||||
/// Invoke beets with the provided arguments.
|
||||
fn exec<S: AsRef<str> + 'static>(&mut self, arguments: &[S]) -> Result<Vec<String>, Error>;
|
||||
}
|
||||
@ -88,12 +111,7 @@ pub struct BeetsLibrary<BLE> {
|
||||
executor: BLE,
|
||||
}
|
||||
|
||||
trait LibraryPrivate {
|
||||
fn list_cmd_and_args(query: &Query) -> Vec<String>;
|
||||
fn list_to_artists<S: AsRef<str>>(list_output: &[S]) -> Result<Vec<Artist>, Error>;
|
||||
}
|
||||
|
||||
impl<BLE: BeetsLibraryExecutor> BeetsLibrary<BLE> {
|
||||
impl<BLE: IBeetsLibraryExecutor> BeetsLibrary<BLE> {
|
||||
/// Create a new beets library with the provided executor, e.g.
|
||||
/// [`executor::BeetsLibraryProcessExecutor`].
|
||||
pub fn new(executor: BLE) -> Self {
|
||||
@ -101,15 +119,15 @@ impl<BLE: BeetsLibraryExecutor> BeetsLibrary<BLE> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<BLE: BeetsLibraryExecutor> Library for BeetsLibrary<BLE> {
|
||||
fn list(&mut self, query: &Query) -> Result<Vec<Artist>, Error> {
|
||||
impl<BLE: IBeetsLibraryExecutor> ILibrary for BeetsLibrary<BLE> {
|
||||
fn list(&mut self, query: &Query) -> Result<Vec<Item>, Error> {
|
||||
let cmd = Self::list_cmd_and_args(query);
|
||||
let output = self.executor.exec(&cmd)?;
|
||||
Self::list_to_artists(&output)
|
||||
Self::list_to_items(&output)
|
||||
}
|
||||
}
|
||||
|
||||
impl<BLE: BeetsLibraryExecutor> LibraryPrivate for BeetsLibrary<BLE> {
|
||||
impl<BLE: IBeetsLibraryExecutor> BeetsLibrary<BLE> {
|
||||
fn list_cmd_and_args(query: &Query) -> Vec<String> {
|
||||
let mut cmd: Vec<String> = vec![String::from(CMD_LIST)];
|
||||
cmd.push(LIST_FORMAT_ARG.to_string());
|
||||
@ -117,9 +135,8 @@ impl<BLE: BeetsLibraryExecutor> LibraryPrivate for BeetsLibrary<BLE> {
|
||||
cmd
|
||||
}
|
||||
|
||||
fn list_to_artists<S: AsRef<str>>(list_output: &[S]) -> Result<Vec<Artist>, Error> {
|
||||
let mut artists: Vec<Artist> = vec![];
|
||||
let mut album_ids = HashMap::<ArtistId, HashSet<AlbumId>>::new();
|
||||
fn list_to_items<S: AsRef<str>>(list_output: &[S]) -> Result<Vec<Item>, Error> {
|
||||
let mut items: Vec<Item> = vec![];
|
||||
|
||||
for line in list_output.iter().map(|s| s.as_ref()) {
|
||||
if line.is_empty() {
|
||||
@ -127,121 +144,58 @@ impl<BLE: BeetsLibraryExecutor> LibraryPrivate for BeetsLibrary<BLE> {
|
||||
}
|
||||
|
||||
let split: Vec<&str> = line.split(LIST_FORMAT_SEPARATOR).collect();
|
||||
if split.len() != 7 {
|
||||
if split.len() != 11 {
|
||||
return Err(Error::Invalid(line.to_string()));
|
||||
}
|
||||
|
||||
let album_artist = split[0].to_string();
|
||||
let album_year = split[1].parse::<u32>()?;
|
||||
let album_title = split[2].to_string();
|
||||
let track_number = split[3].parse::<u32>()?;
|
||||
let track_title = split[4].to_string();
|
||||
let track_artist = split[5].to_string();
|
||||
let track_format = split[6].to_string();
|
||||
|
||||
let artist_id = ArtistId { name: album_artist };
|
||||
|
||||
let album_id = AlbumId {
|
||||
year: album_year,
|
||||
title: album_title,
|
||||
let album_artist_sort = match !split[1].is_empty() {
|
||||
true => Some(split[1].to_string()),
|
||||
false => None,
|
||||
};
|
||||
|
||||
let track = Track {
|
||||
number: track_number,
|
||||
title: track_title,
|
||||
artist: track_artist.split("; ").map(|s| s.to_owned()).collect(),
|
||||
format: match track_format.as_ref() {
|
||||
TRACK_FORMAT_FLAC => TrackFormat::Flac,
|
||||
TRACK_FORMAT_MP3 => TrackFormat::Mp3,
|
||||
_ => return Err(Error::Invalid(line.to_string())),
|
||||
},
|
||||
let album_year = split[2].parse::<u32>()?;
|
||||
let album_month = split[3].parse::<u8>()?;
|
||||
let album_day = split[4].parse::<u8>()?;
|
||||
let album_title = split[5].to_string();
|
||||
let track_number = split[6].parse::<u32>()?;
|
||||
let track_title = split[7].to_string();
|
||||
let track_artist = split[8].split("; ").map(|s| s.to_owned()).collect();
|
||||
let track_format = match str_to_format(split[9].to_string().as_str()) {
|
||||
Some(format) => format,
|
||||
None => return Err(Error::Invalid(line.to_string())),
|
||||
};
|
||||
let track_bitrate = split[10].trim_end_matches("kbps").parse::<u32>()?;
|
||||
|
||||
let artist = if album_ids.contains_key(&artist_id) {
|
||||
// Beets returns results in order so we look from the back.
|
||||
artists
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|a| a.id == artist_id)
|
||||
.unwrap()
|
||||
} else {
|
||||
album_ids.insert(artist_id.clone(), HashSet::<AlbumId>::new());
|
||||
artists.push(Artist {
|
||||
id: artist_id.clone(),
|
||||
albums: vec![],
|
||||
});
|
||||
artists.last_mut().unwrap()
|
||||
};
|
||||
|
||||
if album_ids[&artist_id].contains(&album_id) {
|
||||
// Beets returns results in order so we look from the back.
|
||||
let album = artist
|
||||
.albums
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|a| a.id == album_id)
|
||||
.unwrap();
|
||||
album.tracks.push(track);
|
||||
} else {
|
||||
album_ids
|
||||
.get_mut(&artist_id)
|
||||
.unwrap()
|
||||
.insert(album_id.clone());
|
||||
artist.albums.push(Album {
|
||||
id: album_id,
|
||||
tracks: vec![track],
|
||||
items.push(Item {
|
||||
album_artist,
|
||||
album_artist_sort,
|
||||
album_year,
|
||||
album_month,
|
||||
album_day,
|
||||
album_title,
|
||||
track_number,
|
||||
track_title,
|
||||
track_artist,
|
||||
track_format,
|
||||
track_bitrate,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
|
||||
Ok(artists)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod testmod;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::predicate;
|
||||
|
||||
use crate::tests::COLLECTION;
|
||||
use crate::core::interface::library::testmod::LIBRARY_ITEMS;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn artist_to_beets_string(artist: &Artist) -> Vec<String> {
|
||||
let mut strings = vec![];
|
||||
|
||||
let album_artist = &artist.id.name;
|
||||
|
||||
for album in artist.albums.iter() {
|
||||
let album_year = &album.id.year;
|
||||
let album_title = &album.id.title;
|
||||
|
||||
for track in album.tracks.iter() {
|
||||
let track_number = &track.number;
|
||||
let track_title = &track.title;
|
||||
let track_artist = &track.artist.join("; ");
|
||||
let track_format = match track.format {
|
||||
TrackFormat::Flac => TRACK_FORMAT_FLAC,
|
||||
TrackFormat::Mp3 => TRACK_FORMAT_MP3,
|
||||
};
|
||||
|
||||
strings.push(format!(
|
||||
"{album_artist}{0}{album_year}{0}{album_title}{0}\
|
||||
{track_number}{0}{track_title}{0}{track_artist}{0}{track_format}",
|
||||
LIST_FORMAT_SEPARATOR,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
strings
|
||||
}
|
||||
|
||||
fn artists_to_beets_string(artists: &[Artist]) -> Vec<String> {
|
||||
let mut strings = vec![];
|
||||
for artist in artists.iter() {
|
||||
strings.append(&mut artist_to_beets_string(artist));
|
||||
}
|
||||
strings
|
||||
}
|
||||
use testmod::LIBRARY_BEETS;
|
||||
|
||||
#[test]
|
||||
fn test_query() {
|
||||
@ -252,6 +206,7 @@ mod tests {
|
||||
String::from("some.artist.1"),
|
||||
String::from("some.artist.2"),
|
||||
]))
|
||||
.exclude(Field::TrackFormat(TrackFormat::Mp3))
|
||||
.exclude(Field::All(String::from("some.all")))
|
||||
.to_args();
|
||||
query.sort();
|
||||
@ -260,6 +215,7 @@ mod tests {
|
||||
query,
|
||||
vec![
|
||||
String::from("^album:some.album"),
|
||||
String::from("^format:MP3"),
|
||||
String::from("^some.all"),
|
||||
String::from("artist:some.artist.1; some.artist.2"),
|
||||
String::from("track:5"),
|
||||
@ -268,8 +224,12 @@ mod tests {
|
||||
|
||||
let mut query = Query::default()
|
||||
.exclude(Field::AlbumArtist(String::from("some.albumartist")))
|
||||
.exclude(Field::AlbumArtistSort(String::from("some.albumartist")))
|
||||
.include(Field::AlbumYear(3030))
|
||||
.include(Field::AlbumMonth(4))
|
||||
.include(Field::AlbumDay(6))
|
||||
.include(Field::TrackTitle(String::from("some.track")))
|
||||
.include(Field::TrackFormat(TrackFormat::Flac))
|
||||
.exclude(Field::TrackArtist(vec![
|
||||
String::from("some.artist.1"),
|
||||
String::from("some.artist.2"),
|
||||
@ -281,7 +241,11 @@ mod tests {
|
||||
query,
|
||||
vec![
|
||||
String::from("^albumartist:some.albumartist"),
|
||||
String::from("^albumartist_sort:some.albumartist"),
|
||||
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("year:3030"),
|
||||
]
|
||||
@ -293,7 +257,7 @@ mod tests {
|
||||
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
|
||||
let result = Ok(vec![]);
|
||||
|
||||
let mut executor = MockBeetsLibraryExecutor::new();
|
||||
let mut executor = MockIBeetsLibraryExecutor::new();
|
||||
executor
|
||||
.expect_exec()
|
||||
.with(predicate::eq(arguments))
|
||||
@ -303,17 +267,17 @@ mod tests {
|
||||
let mut beets = BeetsLibrary::new(executor);
|
||||
let output = beets.list(&Query::new()).unwrap();
|
||||
|
||||
let expected: Vec<Artist> = vec![];
|
||||
let expected: Vec<Item> = vec![];
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_ordered() {
|
||||
fn test_list() {
|
||||
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
|
||||
let expected = COLLECTION.to_owned();
|
||||
let result = Ok(artists_to_beets_string(&expected));
|
||||
let expected: &Vec<Item> = LIBRARY_ITEMS.as_ref();
|
||||
let result = Ok(LIBRARY_BEETS.to_owned());
|
||||
|
||||
let mut executor = MockBeetsLibraryExecutor::new();
|
||||
let mut executor = MockIBeetsLibraryExecutor::new();
|
||||
executor
|
||||
.expect_exec()
|
||||
.with(predicate::eq(arguments))
|
||||
@ -323,64 +287,7 @@ mod tests {
|
||||
let mut beets = BeetsLibrary::new(executor);
|
||||
let output = beets.list(&Query::new()).unwrap();
|
||||
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_unordered() {
|
||||
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
|
||||
let mut expected = COLLECTION.to_owned();
|
||||
let mut output = artists_to_beets_string(&expected);
|
||||
let last = output.len() - 1;
|
||||
output.swap(0, last);
|
||||
let result = Ok(output);
|
||||
|
||||
// Putting the last track first will make the entire artist come first in the output.
|
||||
expected.rotate_right(1);
|
||||
|
||||
// Same applies to that artists' albums.
|
||||
expected[0].albums.rotate_right(1);
|
||||
|
||||
// Same applies to that album's tracks.
|
||||
expected[0].albums[0].tracks.rotate_right(1);
|
||||
|
||||
// And the original first album's (now the first album of the second artist) tracks first
|
||||
// track comes last.
|
||||
expected[1].albums[0].tracks.rotate_left(1);
|
||||
|
||||
let mut executor = MockBeetsLibraryExecutor::new();
|
||||
executor
|
||||
.expect_exec()
|
||||
.with(predicate::eq(arguments))
|
||||
.times(1)
|
||||
.return_once(|_| result);
|
||||
|
||||
let mut beets = BeetsLibrary::new(executor);
|
||||
let output = beets.list(&Query::new()).unwrap();
|
||||
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_album_title_year_clash() {
|
||||
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
|
||||
let mut expected = COLLECTION.to_owned();
|
||||
expected[0].albums[0].id.year = expected[1].albums[0].id.year;
|
||||
expected[0].albums[0].id.title = expected[1].albums[0].id.title.clone();
|
||||
let output = artists_to_beets_string(&expected);
|
||||
let result = Ok(output);
|
||||
|
||||
let mut executor = MockBeetsLibraryExecutor::new();
|
||||
executor
|
||||
.expect_exec()
|
||||
.with(predicate::eq(arguments))
|
||||
.times(1)
|
||||
.return_once(|_| result);
|
||||
|
||||
let mut beets = BeetsLibrary::new(executor);
|
||||
let output = beets.list(&Query::new()).unwrap();
|
||||
|
||||
assert_eq!(output, expected);
|
||||
assert_eq!(&output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -400,7 +307,7 @@ mod tests {
|
||||
];
|
||||
let result = Ok(vec![]);
|
||||
|
||||
let mut executor = MockBeetsLibraryExecutor::new();
|
||||
let mut executor = MockIBeetsLibraryExecutor::new();
|
||||
executor
|
||||
.expect_exec()
|
||||
.with(predicate::function(move |x: &[String]| {
|
||||
@ -414,15 +321,14 @@ mod tests {
|
||||
let mut beets = BeetsLibrary::new(executor);
|
||||
let output = beets.list(&query).unwrap();
|
||||
|
||||
let expected: Vec<Artist> = vec![];
|
||||
let expected: Vec<Item> = vec![];
|
||||
assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_data_split() {
|
||||
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
|
||||
let expected = COLLECTION.to_owned();
|
||||
let mut output = artists_to_beets_string(&expected);
|
||||
let mut output: Vec<String> = LIBRARY_BEETS.to_owned();
|
||||
let invalid_string = output[2]
|
||||
.split(LIST_FORMAT_SEPARATOR)
|
||||
.map(|s| s.to_owned())
|
||||
@ -431,7 +337,7 @@ mod tests {
|
||||
output[2] = invalid_string.clone();
|
||||
let result = Ok(output);
|
||||
|
||||
let mut executor = MockBeetsLibraryExecutor::new();
|
||||
let mut executor = MockIBeetsLibraryExecutor::new();
|
||||
executor
|
||||
.expect_exec()
|
||||
.with(predicate::eq(arguments))
|
||||
@ -447,22 +353,18 @@ mod tests {
|
||||
#[test]
|
||||
fn invalid_data_format() {
|
||||
let arguments = vec!["ls".to_string(), LIST_FORMAT_ARG.to_string()];
|
||||
let expected = COLLECTION.to_owned();
|
||||
let mut output = artists_to_beets_string(&expected);
|
||||
let mut output: Vec<String> = LIBRARY_BEETS.to_owned();
|
||||
let mut invalid_string = output[2]
|
||||
.split(LIST_FORMAT_SEPARATOR)
|
||||
.map(|s| s.to_owned())
|
||||
.collect::<Vec<String>>();
|
||||
invalid_string.last_mut().unwrap().clear();
|
||||
invalid_string
|
||||
.last_mut()
|
||||
.unwrap()
|
||||
.push_str("invalid format");
|
||||
invalid_string[9].clear();
|
||||
invalid_string[9].push_str("invalid format");
|
||||
let invalid_string = invalid_string.join(LIST_FORMAT_SEPARATOR);
|
||||
output[2] = invalid_string.clone();
|
||||
let result = Ok(output);
|
||||
|
||||
let mut executor = MockBeetsLibraryExecutor::new();
|
||||
let mut executor = MockIBeetsLibraryExecutor::new();
|
||||
executor
|
||||
.expect_exec()
|
||||
.with(predicate::eq(arguments))
|
116
src/external/library/beets/testmod.rs
vendored
Normal file
116
src/external/library/beets/testmod.rs
vendored
Normal file
@ -0,0 +1,116 @@
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub static LIBRARY_BEETS: Lazy<Vec<String>> = Lazy::new(|| -> Vec<String> {
|
||||
vec![
|
||||
String::from(
|
||||
"Album_Artist ‘A’ -*^- -*^- \
|
||||
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
|
||||
1 -*^- track a.a.1 -*^- artist a.a.1 -*^- FLAC -*^- 992",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘A’ -*^- -*^- \
|
||||
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
|
||||
2 -*^- track a.a.2 -*^- artist a.a.2.1; artist a.a.2.2 -*^- MP3 -*^- 320",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘A’ -*^- -*^- \
|
||||
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
|
||||
3 -*^- track a.a.3 -*^- artist a.a.3 -*^- FLAC -*^- 1061",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘A’ -*^- -*^- \
|
||||
1998 -*^- 00 -*^- 00 -*^- album_title a.a -*^- \
|
||||
4 -*^- track a.a.4 -*^- artist a.a.4 -*^- FLAC -*^- 1042",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘A’ -*^- -*^- \
|
||||
2015 -*^- 04 -*^- 00 -*^- album_title a.b -*^- \
|
||||
1 -*^- track a.b.1 -*^- artist a.b.1 -*^- FLAC -*^- 1004",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘A’ -*^- -*^- \
|
||||
2015 -*^- 04 -*^- 00 -*^- album_title a.b -*^- \
|
||||
2 -*^- track a.b.2 -*^- artist a.b.2 -*^- FLAC -*^- 1077",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘B’ -*^- -*^- \
|
||||
2003 -*^- 06 -*^- 06 -*^- album_title b.a -*^- \
|
||||
1 -*^- track b.a.1 -*^- artist b.a.1 -*^- MP3 -*^- 190",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘B’ -*^- -*^- \
|
||||
2003 -*^- 06 -*^- 06 -*^- album_title b.a -*^- \
|
||||
2 -*^- track b.a.2 -*^- artist b.a.2.1; artist b.a.2.2 -*^- MP3 -*^- 120",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘B’ -*^- -*^- \
|
||||
2008 -*^- 00 -*^- 00 -*^- album_title b.b -*^- \
|
||||
1 -*^- track b.b.1 -*^- artist b.b.1 -*^- FLAC -*^- 1077",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘B’ -*^- -*^- \
|
||||
2008 -*^- 00 -*^- 00 -*^- album_title b.b -*^- \
|
||||
2 -*^- track b.b.2 -*^- artist b.b.2.1; artist b.b.2.2 -*^- MP3 -*^- 320",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘B’ -*^- -*^- \
|
||||
2009 -*^- 00 -*^- 00 -*^- album_title b.c -*^- \
|
||||
1 -*^- track b.c.1 -*^- artist b.c.1 -*^- MP3 -*^- 190",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘B’ -*^- -*^- \
|
||||
2009 -*^- 00 -*^- 00 -*^- album_title b.c -*^- \
|
||||
2 -*^- track b.c.2 -*^- artist b.c.2.1; artist b.c.2.2 -*^- MP3 -*^- 120",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘B’ -*^- -*^- \
|
||||
2015 -*^- 00 -*^- 00 -*^- album_title b.d -*^- \
|
||||
1 -*^- track b.d.1 -*^- artist b.d.1 -*^- MP3 -*^- 190",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘B’ -*^- -*^- \
|
||||
2015 -*^- 00 -*^- 00 -*^- album_title b.d -*^- \
|
||||
2 -*^- track b.d.2 -*^- artist b.d.2.1; artist b.d.2.2 -*^- MP3 -*^- 120",
|
||||
),
|
||||
String::from(
|
||||
"The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- \
|
||||
1985 -*^- 00 -*^- 00 -*^- album_title c.a -*^- \
|
||||
1 -*^- track c.a.1 -*^- artist c.a.1 -*^- MP3 -*^- 320",
|
||||
),
|
||||
String::from(
|
||||
"The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- \
|
||||
1985 -*^- 00 -*^- 00 -*^- album_title c.a -*^- \
|
||||
2 -*^- track c.a.2 -*^- artist c.a.2.1; artist c.a.2.2 -*^- MP3 -*^- 120",
|
||||
),
|
||||
String::from(
|
||||
"The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- \
|
||||
2018 -*^- 00 -*^- 00 -*^- album_title c.b -*^- \
|
||||
1 -*^- track c.b.1 -*^- artist c.b.1 -*^- FLAC -*^- 1041",
|
||||
),
|
||||
String::from(
|
||||
"The Album_Artist ‘C’ -*^- Album_Artist ‘C’, The -*^- \
|
||||
2018 -*^- 00 -*^- 00 -*^- album_title c.b -*^- \
|
||||
2 -*^- track c.b.2 -*^- artist c.b.2.1; artist c.b.2.2 -*^- FLAC -*^- 756",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘D’ -*^- -*^- \
|
||||
1995 -*^- 00 -*^- 00 -*^- album_title d.a -*^- \
|
||||
1 -*^- track d.a.1 -*^- artist d.a.1 -*^- MP3 -*^- 120",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘D’ -*^- -*^- \
|
||||
1995 -*^- 00 -*^- 00 -*^- album_title d.a -*^- \
|
||||
2 -*^- track d.a.2 -*^- artist d.a.2.1; artist d.a.2.2 -*^- MP3 -*^- 120",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘D’ -*^- -*^- \
|
||||
2028 -*^- 00 -*^- 00 -*^- album_title d.b -*^- \
|
||||
1 -*^- track d.b.1 -*^- artist d.b.1 -*^- FLAC -*^- 841",
|
||||
),
|
||||
String::from(
|
||||
"Album_Artist ‘D’ -*^- -*^- \
|
||||
2028 -*^- 00 -*^- 00 -*^- album_title d.b -*^- \
|
||||
2 -*^- track d.b.2 -*^- artist d.b.2.1; artist d.b.2.2 -*^- FLAC -*^- 756",
|
||||
),
|
||||
]
|
||||
});
|
2
src/external/library/mod.rs
vendored
Normal file
2
src/external/library/mod.rs
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
#[cfg(feature = "library-beets")]
|
||||
pub mod beets;
|
4
src/external/mod.rs
vendored
Normal file
4
src/external/mod.rs
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
pub mod database;
|
||||
pub mod library;
|
||||
#[cfg(feature = "musicbrainz")]
|
||||
pub mod musicbrainz;
|
178
src/external/musicbrainz/api/browse.rs
vendored
Normal file
178
src/external/musicbrainz/api/browse.rs
vendored
Normal file
@ -0,0 +1,178 @@
|
||||
use std::fmt;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
collection::musicbrainz::Mbid,
|
||||
external::musicbrainz::{
|
||||
api::{
|
||||
ApiDisplay, Error, MbReleaseGroupMeta, MusicBrainzClient, PageSettings,
|
||||
SerdeMbReleaseGroupMeta, MB_BASE_URL,
|
||||
},
|
||||
IMusicBrainzHttp,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
pub struct BrowseReleaseGroupPage {
|
||||
release_group_offset: usize,
|
||||
release_group_count: usize,
|
||||
}
|
||||
|
||||
impl BrowseReleaseGroupPage {
|
||||
pub fn next_page(&self, settings: PageSettings, page_count: usize) -> Option<PageSettings> {
|
||||
settings.next_page(
|
||||
self.release_group_offset,
|
||||
self.release_group_count,
|
||||
page_count,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SerdeBrowseReleaseGroupPage = BrowseReleaseGroupPage;
|
||||
|
||||
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||
pub fn browse_release_group(
|
||||
&mut self,
|
||||
request: &BrowseReleaseGroupRequest,
|
||||
paging: &PageSettings,
|
||||
) -> Result<BrowseReleaseGroupResponse, Error> {
|
||||
let entity = &request.entity;
|
||||
let mbid = request.mbid.uuid().as_hyphenated();
|
||||
let page = ApiDisplay::format_page_settings(paging);
|
||||
|
||||
let url = format!("{MB_BASE_URL}/release-group?{entity}={mbid}{page}");
|
||||
|
||||
let response: DeserializeBrowseReleaseGroupResponse = self.http.get(&url)?;
|
||||
Ok(response.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BrowseReleaseGroupRequest<'a> {
|
||||
entity: BrowseReleaseGroupRequestEntity,
|
||||
mbid: &'a Mbid,
|
||||
}
|
||||
|
||||
enum BrowseReleaseGroupRequestEntity {
|
||||
Artist,
|
||||
}
|
||||
|
||||
impl fmt::Display for BrowseReleaseGroupRequestEntity {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
BrowseReleaseGroupRequestEntity::Artist => write!(f, "artist"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BrowseReleaseGroupRequest<'a> {
|
||||
pub fn artist(mbid: &'a Mbid) -> Self {
|
||||
BrowseReleaseGroupRequest {
|
||||
entity: BrowseReleaseGroupRequestEntity::Artist,
|
||||
mbid,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct BrowseReleaseGroupResponse {
|
||||
pub release_groups: Vec<MbReleaseGroupMeta>,
|
||||
pub page: BrowseReleaseGroupPage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct DeserializeBrowseReleaseGroupResponse {
|
||||
release_groups: Option<Vec<SerdeMbReleaseGroupMeta>>,
|
||||
#[serde(flatten)]
|
||||
page: SerdeBrowseReleaseGroupPage,
|
||||
}
|
||||
|
||||
impl From<DeserializeBrowseReleaseGroupResponse> for BrowseReleaseGroupResponse {
|
||||
fn from(value: DeserializeBrowseReleaseGroupResponse) -> Self {
|
||||
BrowseReleaseGroupResponse {
|
||||
page: value.page,
|
||||
release_groups: value
|
||||
.release_groups
|
||||
.map(|rgs| rgs.into_iter().map(Into::into).collect())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::predicate;
|
||||
|
||||
use crate::{
|
||||
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||
external::musicbrainz::{
|
||||
api::{
|
||||
tests::next_page_test, SerdeAlbumDate, SerdeAlbumPrimaryType,
|
||||
SerdeAlbumSecondaryType, SerdeMbid, MB_MAX_PAGE_LIMIT,
|
||||
},
|
||||
MockIMusicBrainzHttp,
|
||||
},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn browse_release_group_next_page() {
|
||||
let page = BrowseReleaseGroupPage {
|
||||
release_group_offset: 5,
|
||||
release_group_count: 45,
|
||||
};
|
||||
|
||||
next_page_test(|val| page.next_page(PageSettings::default(), val));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browse_release_group() {
|
||||
let mbid = "00000000-0000-0000-0000-000000000000";
|
||||
let mut http = MockIMusicBrainzHttp::new();
|
||||
|
||||
let de_release_group_offset = 24;
|
||||
let de_release_group_count = 302;
|
||||
let de_meta = SerdeMbReleaseGroupMeta {
|
||||
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||
title: String::from("an album"),
|
||||
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
||||
AlbumSecondaryType::Compilation,
|
||||
)]),
|
||||
};
|
||||
let de_response = DeserializeBrowseReleaseGroupResponse {
|
||||
page: SerdeBrowseReleaseGroupPage {
|
||||
release_group_offset: de_release_group_offset,
|
||||
release_group_count: de_release_group_count,
|
||||
},
|
||||
release_groups: Some(vec![de_meta.clone()]),
|
||||
};
|
||||
|
||||
let response = BrowseReleaseGroupResponse {
|
||||
page: de_response.page,
|
||||
release_groups: vec![de_meta.clone().into()],
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"https://musicbrainz.org/ws/2/release-group?artist={mbid}&limit={MB_MAX_PAGE_LIMIT}",
|
||||
);
|
||||
let expect_response = de_response.clone();
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url))
|
||||
.return_once(|_| Ok(expect_response));
|
||||
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let mbid: Mbid = mbid.try_into().unwrap();
|
||||
|
||||
let request = BrowseReleaseGroupRequest::artist(&mbid);
|
||||
let paging = PageSettings::with_max_limit();
|
||||
let result = client.browse_release_group(&request, &paging).unwrap();
|
||||
assert_eq!(result, response);
|
||||
}
|
||||
}
|
221
src/external/musicbrainz/api/lookup.rs
vendored
Normal file
221
src/external/musicbrainz/api/lookup.rs
vendored
Normal file
@ -0,0 +1,221 @@
|
||||
use serde::Deserialize;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::{
|
||||
collection::musicbrainz::Mbid,
|
||||
external::musicbrainz::{
|
||||
api::{Error, MusicBrainzClient, MB_BASE_URL},
|
||||
IMusicBrainzHttp,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{MbArtistMeta, MbReleaseGroupMeta, SerdeMbArtistMeta, SerdeMbReleaseGroupMeta};
|
||||
|
||||
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||
pub fn lookup_artist(
|
||||
&mut self,
|
||||
request: &LookupArtistRequest,
|
||||
) -> Result<LookupArtistResponse, Error> {
|
||||
let mut include: Vec<String> = vec![];
|
||||
|
||||
if request.release_groups {
|
||||
include.push(String::from("release-groups"));
|
||||
}
|
||||
|
||||
let include: String =
|
||||
form_urlencoded::byte_serialize(include.join("+").as_bytes()).collect();
|
||||
let url = format!(
|
||||
"{MB_BASE_URL}/artist/{mbid}?inc={include}",
|
||||
mbid = request.mbid.uuid().as_hyphenated()
|
||||
);
|
||||
|
||||
let response: DeserializeLookupArtistResponse = self.http.get(&url)?;
|
||||
Ok(response.into())
|
||||
}
|
||||
|
||||
pub fn lookup_release_group(
|
||||
&mut self,
|
||||
request: &LookupReleaseGroupRequest,
|
||||
) -> Result<LookupReleaseGroupResponse, Error> {
|
||||
let url = format!(
|
||||
"{MB_BASE_URL}/release-group/{mbid}",
|
||||
mbid = request.mbid.uuid().as_hyphenated()
|
||||
);
|
||||
|
||||
let response: DeserializeLookupReleaseGroupResponse = self.http.get(&url)?;
|
||||
Ok(response.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LookupArtistRequest<'a> {
|
||||
mbid: &'a Mbid,
|
||||
release_groups: bool,
|
||||
}
|
||||
|
||||
impl<'a> LookupArtistRequest<'a> {
|
||||
pub fn new(mbid: &'a Mbid) -> Self {
|
||||
LookupArtistRequest {
|
||||
mbid,
|
||||
release_groups: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn include_release_groups(&mut self) -> &mut Self {
|
||||
self.release_groups = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct LookupArtistResponse {
|
||||
pub meta: MbArtistMeta,
|
||||
pub release_groups: Vec<MbReleaseGroupMeta>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct DeserializeLookupArtistResponse {
|
||||
#[serde(flatten)]
|
||||
meta: SerdeMbArtistMeta,
|
||||
release_groups: Option<Vec<SerdeMbReleaseGroupMeta>>,
|
||||
}
|
||||
|
||||
impl From<DeserializeLookupArtistResponse> for LookupArtistResponse {
|
||||
fn from(value: DeserializeLookupArtistResponse) -> Self {
|
||||
LookupArtistResponse {
|
||||
meta: value.meta.into(),
|
||||
release_groups: value
|
||||
.release_groups
|
||||
.map(|rgs| rgs.into_iter().map(Into::into).collect())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LookupReleaseGroupRequest<'a> {
|
||||
mbid: &'a Mbid,
|
||||
}
|
||||
|
||||
impl<'a> LookupReleaseGroupRequest<'a> {
|
||||
pub fn new(mbid: &'a Mbid) -> Self {
|
||||
LookupReleaseGroupRequest { mbid }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct LookupReleaseGroupResponse {
|
||||
pub meta: MbReleaseGroupMeta,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct DeserializeLookupReleaseGroupResponse {
|
||||
#[serde(flatten)]
|
||||
meta: SerdeMbReleaseGroupMeta,
|
||||
}
|
||||
|
||||
impl From<DeserializeLookupReleaseGroupResponse> for LookupReleaseGroupResponse {
|
||||
fn from(value: DeserializeLookupReleaseGroupResponse) -> Self {
|
||||
LookupReleaseGroupResponse {
|
||||
meta: value.meta.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::predicate;
|
||||
|
||||
use crate::{
|
||||
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||
external::musicbrainz::{
|
||||
api::{SerdeAlbumDate, SerdeAlbumPrimaryType, SerdeAlbumSecondaryType, SerdeMbid},
|
||||
MockIMusicBrainzHttp,
|
||||
},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lookup_artist() {
|
||||
let mbid = "00000000-0000-0000-0000-000000000000";
|
||||
let mut http = MockIMusicBrainzHttp::new();
|
||||
let url = format!("https://musicbrainz.org/ws/2/artist/{mbid}?inc=release-groups",);
|
||||
|
||||
let de_meta = SerdeMbArtistMeta {
|
||||
id: SerdeMbid(mbid.try_into().unwrap()),
|
||||
name: String::from("the artist"),
|
||||
sort_name: String::from("artist, the"),
|
||||
disambiguation: Some(String::from("disambig")),
|
||||
};
|
||||
let de_release_group = SerdeMbReleaseGroupMeta {
|
||||
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||
title: String::from("an album"),
|
||||
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
||||
AlbumSecondaryType::Compilation,
|
||||
)]),
|
||||
};
|
||||
let de_response = DeserializeLookupArtistResponse {
|
||||
meta: de_meta.clone(),
|
||||
release_groups: Some(vec![de_release_group.clone()]),
|
||||
};
|
||||
|
||||
let response = LookupArtistResponse {
|
||||
meta: de_meta.into(),
|
||||
release_groups: vec![de_release_group.into()],
|
||||
};
|
||||
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url))
|
||||
.return_once(|_| Ok(de_response));
|
||||
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||
let mut request = LookupArtistRequest::new(&mbid);
|
||||
request.include_release_groups();
|
||||
let result = client.lookup_artist(&request).unwrap();
|
||||
|
||||
assert_eq!(result, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_release_group() {
|
||||
let mbid = "00000000-0000-0000-0000-000000000000";
|
||||
let mut http = MockIMusicBrainzHttp::new();
|
||||
let url = format!("https://musicbrainz.org/ws/2/release-group/{mbid}",);
|
||||
|
||||
let de_meta = SerdeMbReleaseGroupMeta {
|
||||
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||
title: String::from("an album"),
|
||||
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||
secondary_types: Some(vec![SerdeAlbumSecondaryType(
|
||||
AlbumSecondaryType::Compilation,
|
||||
)]),
|
||||
};
|
||||
let de_response = DeserializeLookupReleaseGroupResponse {
|
||||
meta: de_meta.clone(),
|
||||
};
|
||||
|
||||
let response = LookupReleaseGroupResponse {
|
||||
meta: de_meta.into(),
|
||||
};
|
||||
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url))
|
||||
.return_once(|_| Ok(de_response));
|
||||
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let mbid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||
let request = LookupReleaseGroupRequest::new(&mbid);
|
||||
let result = client.lookup_release_group(&request).unwrap();
|
||||
|
||||
assert_eq!(result, response);
|
||||
}
|
||||
}
|
461
src/external/musicbrainz/api/mod.rs
vendored
Normal file
461
src/external/musicbrainz/api/mod.rs
vendored
Normal file
@ -0,0 +1,461 @@
|
||||
use std::{fmt, num};
|
||||
|
||||
use serde::{de::Visitor, Deserialize, Deserializer};
|
||||
|
||||
use crate::{
|
||||
collection::{
|
||||
album::{AlbumDate, AlbumPrimaryType, AlbumSecondaryType},
|
||||
musicbrainz::Mbid,
|
||||
Error as CollectionError,
|
||||
},
|
||||
external::musicbrainz::HttpError,
|
||||
};
|
||||
|
||||
pub mod browse;
|
||||
pub mod lookup;
|
||||
pub mod search;
|
||||
|
||||
const MB_BASE_URL: &str = "https://musicbrainz.org/ws/2";
|
||||
const MB_RATE_LIMIT_CODE: u16 = 503;
|
||||
const MB_MAX_PAGE_LIMIT: usize = 100;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// The HTTP client failed.
|
||||
Http(String),
|
||||
/// The client reached the API rate limit.
|
||||
RateLimit,
|
||||
/// The API response could not be understood.
|
||||
Unknown(u16),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Error::Http(s) => write!(f, "the HTTP client failed: {s}"),
|
||||
Error::RateLimit => write!(f, "the API rate limit has been reached"),
|
||||
Error::Unknown(u) => write!(f, "the API response could not be understood: status {u}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HttpError> for Error {
|
||||
fn from(err: HttpError) -> Self {
|
||||
match err {
|
||||
HttpError::Client(s) => Error::Http(s),
|
||||
HttpError::Status(status) => match status {
|
||||
MB_RATE_LIMIT_CODE => Error::RateLimit,
|
||||
_ => Error::Unknown(status),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MusicBrainzClient<Http> {
|
||||
http: Http,
|
||||
}
|
||||
|
||||
impl<Http> MusicBrainzClient<Http> {
|
||||
pub fn new(http: Http) -> Self {
|
||||
MusicBrainzClient { http }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PageSettings {
|
||||
limit: Option<usize>,
|
||||
offset: Option<usize>,
|
||||
}
|
||||
|
||||
impl PageSettings {
|
||||
pub fn with_limit(limit: usize) -> Self {
|
||||
PageSettings {
|
||||
limit: Some(limit),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_max_limit() -> Self {
|
||||
Self::with_limit(MB_MAX_PAGE_LIMIT)
|
||||
}
|
||||
|
||||
pub fn with_offset(mut self, offset: usize) -> Self {
|
||||
self.set_offset(offset);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_offset(&mut self, offset: usize) {
|
||||
self.offset = Some(offset);
|
||||
}
|
||||
|
||||
pub fn next_page(self, offset: usize, total_count: usize, page_count: usize) -> Option<Self> {
|
||||
let next_offset = offset + page_count;
|
||||
if next_offset < total_count {
|
||||
Some(self.with_offset(next_offset))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MbArtistMeta {
|
||||
pub id: Mbid,
|
||||
pub name: String,
|
||||
pub sort_name: String,
|
||||
pub disambiguation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct SerdeMbArtistMeta {
|
||||
id: SerdeMbid,
|
||||
name: String,
|
||||
sort_name: String,
|
||||
disambiguation: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SerdeMbArtistMeta> for MbArtistMeta {
|
||||
fn from(value: SerdeMbArtistMeta) -> Self {
|
||||
MbArtistMeta {
|
||||
id: value.id.into(),
|
||||
name: value.name,
|
||||
sort_name: value.sort_name,
|
||||
disambiguation: value.disambiguation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MbReleaseGroupMeta {
|
||||
pub id: Mbid,
|
||||
pub title: String,
|
||||
pub first_release_date: AlbumDate,
|
||||
pub primary_type: Option<AlbumPrimaryType>,
|
||||
pub secondary_types: Option<Vec<AlbumSecondaryType>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
pub struct SerdeMbReleaseGroupMeta {
|
||||
id: SerdeMbid,
|
||||
title: String,
|
||||
first_release_date: SerdeAlbumDate,
|
||||
primary_type: Option<SerdeAlbumPrimaryType>,
|
||||
secondary_types: Option<Vec<SerdeAlbumSecondaryType>>,
|
||||
}
|
||||
|
||||
impl From<SerdeMbReleaseGroupMeta> for MbReleaseGroupMeta {
|
||||
fn from(value: SerdeMbReleaseGroupMeta) -> Self {
|
||||
MbReleaseGroupMeta {
|
||||
id: value.id.into(),
|
||||
title: value.title,
|
||||
first_release_date: value.first_release_date.into(),
|
||||
primary_type: value.primary_type.map(Into::into),
|
||||
secondary_types: value
|
||||
.secondary_types
|
||||
.map(|v| v.into_iter().map(Into::into).collect()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApiDisplay;
|
||||
|
||||
impl ApiDisplay {
|
||||
fn format_page_settings(paging: &PageSettings) -> String {
|
||||
let limit = paging
|
||||
.limit
|
||||
.map(|l| format!("&limit={l}"))
|
||||
.unwrap_or_default();
|
||||
let offset = paging
|
||||
.offset
|
||||
.map(|o| format!("&offset={o}"))
|
||||
.unwrap_or_default();
|
||||
format!("{limit}{offset}")
|
||||
}
|
||||
|
||||
fn format_album_date(date: &AlbumDate) -> String {
|
||||
match date.year {
|
||||
Some(year) => match date.month {
|
||||
Some(month) => match date.day {
|
||||
Some(day) => format!("{year}-{month:02}-{day:02}"),
|
||||
None => format!("{year}-{month:02}"),
|
||||
},
|
||||
None => format!("{year}"),
|
||||
},
|
||||
None => String::from("*"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SerdeMbid(Mbid);
|
||||
|
||||
impl From<SerdeMbid> for Mbid {
|
||||
fn from(value: SerdeMbid) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
struct SerdeMbidVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for SerdeMbidVisitor {
|
||||
type Value = SerdeMbid;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a valid MusicBrainz identifier")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
Ok(SerdeMbid(
|
||||
v.try_into()
|
||||
.map_err(|e: CollectionError| E::custom(e.to_string()))?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SerdeMbid {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_str(SerdeMbidVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SerdeAlbumDate(AlbumDate);
|
||||
|
||||
impl From<SerdeAlbumDate> for AlbumDate {
|
||||
fn from(value: SerdeAlbumDate) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
struct SerdeAlbumDateVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for SerdeAlbumDateVisitor {
|
||||
type Value = SerdeAlbumDate;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a valid YYYY(-MM-(-DD)) date")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: serde::de::Error,
|
||||
{
|
||||
let mut elems = v.split('-');
|
||||
|
||||
let elem = elems.next();
|
||||
let year = elem
|
||||
.and_then(|s| if s.is_empty() { None } else { Some(s.parse()) })
|
||||
.transpose()
|
||||
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||
|
||||
let elem = elems.next();
|
||||
let month = elem
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||
|
||||
let elem = elems.next();
|
||||
let day = elem
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e: num::ParseIntError| E::custom(e.to_string()))?;
|
||||
|
||||
Ok(SerdeAlbumDate(AlbumDate::new(year, month, day)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SerdeAlbumDate {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_str(SerdeAlbumDateVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(remote = "AlbumPrimaryType")]
|
||||
pub enum AlbumPrimaryTypeDef {
|
||||
Album,
|
||||
Single,
|
||||
#[serde(rename = "EP")]
|
||||
Ep,
|
||||
Broadcast,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SerdeAlbumPrimaryType(#[serde(with = "AlbumPrimaryTypeDef")] AlbumPrimaryType);
|
||||
|
||||
impl From<SerdeAlbumPrimaryType> for AlbumPrimaryType {
|
||||
fn from(value: SerdeAlbumPrimaryType) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(remote = "AlbumSecondaryType")]
|
||||
pub enum AlbumSecondaryTypeDef {
|
||||
Compilation,
|
||||
Soundtrack,
|
||||
Spokenword,
|
||||
Interview,
|
||||
Audiobook,
|
||||
#[serde(rename = "Audio drama")]
|
||||
AudioDrama,
|
||||
Live,
|
||||
Remix,
|
||||
#[serde(rename = "DJ-mix")]
|
||||
DjMix,
|
||||
#[serde(rename = "Mixtape/Street")]
|
||||
MixtapeStreet,
|
||||
Demo,
|
||||
#[serde(rename = "Field recording")]
|
||||
FieldRecording,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SerdeAlbumSecondaryType(#[serde(with = "AlbumSecondaryTypeDef")] AlbumSecondaryType);
|
||||
|
||||
impl From<SerdeAlbumSecondaryType> for AlbumSecondaryType {
|
||||
fn from(value: SerdeAlbumSecondaryType) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
let http_err = HttpError::Client(String::from("a http error"));
|
||||
let http_err: Error = http_err.into();
|
||||
assert!(matches!(http_err, Error::Http(_)));
|
||||
assert!(!http_err.to_string().is_empty());
|
||||
assert!(!format!("{http_err:?}").is_empty());
|
||||
|
||||
let rate_err = HttpError::Status(MB_RATE_LIMIT_CODE);
|
||||
let rate_err: Error = rate_err.into();
|
||||
assert!(matches!(rate_err, Error::RateLimit));
|
||||
assert!(!rate_err.to_string().is_empty());
|
||||
assert!(!format!("{rate_err:?}").is_empty());
|
||||
|
||||
let unk_err = HttpError::Status(404);
|
||||
let unk_err: Error = unk_err.into();
|
||||
assert!(matches!(unk_err, Error::Unknown(_)));
|
||||
assert!(!unk_err.to_string().is_empty());
|
||||
assert!(!format!("{unk_err:?}").is_empty());
|
||||
}
|
||||
|
||||
pub fn next_page_test<Fn>(mut f: Fn)
|
||||
where
|
||||
Fn: FnMut(usize) -> Option<PageSettings>,
|
||||
{
|
||||
let next = f(20);
|
||||
assert_eq!(next.unwrap().offset, Some(25));
|
||||
|
||||
let next = f(40);
|
||||
assert!(next.is_none());
|
||||
|
||||
let next = f(100);
|
||||
assert!(next.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_page() {
|
||||
next_page_test(|val| PageSettings::default().next_page(5, 45, val));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_page_settings() {
|
||||
let paging = PageSettings::default();
|
||||
assert_eq!(ApiDisplay::format_page_settings(&paging), "");
|
||||
|
||||
let paging = PageSettings::with_max_limit();
|
||||
assert_eq!(ApiDisplay::format_page_settings(&paging), "&limit=100");
|
||||
|
||||
let mut paging = PageSettings::with_limit(45);
|
||||
paging.set_offset(145);
|
||||
assert_eq!(
|
||||
ApiDisplay::format_page_settings(&paging),
|
||||
"&limit=45&offset=145"
|
||||
);
|
||||
|
||||
let mut paging = PageSettings::default();
|
||||
paging.set_offset(26);
|
||||
assert_eq!(ApiDisplay::format_page_settings(&paging), "&offset=26");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_album_date() {
|
||||
assert_eq!(
|
||||
ApiDisplay::format_album_date(&AlbumDate::new(None, None, None)),
|
||||
"*"
|
||||
);
|
||||
assert_eq!(ApiDisplay::format_album_date(&(1986).into()), "1986");
|
||||
assert_eq!(ApiDisplay::format_album_date(&(1986, 4).into()), "1986-04");
|
||||
assert_eq!(
|
||||
ApiDisplay::format_album_date(&(1986, 4, 21).into()),
|
||||
"1986-04-21"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde() {
|
||||
let mbid = "\"d368baa8-21ca-4759-9731-0b2753071ad8\"";
|
||||
let mbid: SerdeMbid = serde_json::from_str(mbid).unwrap();
|
||||
let mbid: Mbid = mbid.into();
|
||||
assert_eq!(
|
||||
mbid,
|
||||
"d368baa8-21ca-4759-9731-0b2753071ad8".try_into().unwrap()
|
||||
);
|
||||
|
||||
let mbid = "0";
|
||||
let result: Result<SerdeMbid, _> = serde_json::from_str(mbid);
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("a valid MusicBrainz identifier"));
|
||||
|
||||
let album_date = "\"1986-04-21\"";
|
||||
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
|
||||
let album_date: AlbumDate = album_date.into();
|
||||
assert_eq!(album_date, AlbumDate::new(Some(1986), Some(4), Some(21)));
|
||||
|
||||
let album_date = "\"1986-04\"";
|
||||
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
|
||||
let album_date: AlbumDate = album_date.into();
|
||||
assert_eq!(album_date, AlbumDate::new(Some(1986), Some(4), None));
|
||||
|
||||
let album_date = "\"1986\"";
|
||||
let album_date: SerdeAlbumDate = serde_json::from_str(album_date).unwrap();
|
||||
let album_date: AlbumDate = album_date.into();
|
||||
assert_eq!(album_date, AlbumDate::new(Some(1986), None, None));
|
||||
|
||||
let album_date = "0";
|
||||
let result: Result<SerdeAlbumDate, _> = serde_json::from_str(album_date);
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("a valid YYYY(-MM-(-DD)) date"));
|
||||
|
||||
let primary_type = "\"EP\"";
|
||||
let primary_type: SerdeAlbumPrimaryType = serde_json::from_str(primary_type).unwrap();
|
||||
let primary_type: AlbumPrimaryType = primary_type.into();
|
||||
assert_eq!(primary_type, AlbumPrimaryType::Ep);
|
||||
|
||||
let secondary_type = "\"Field recording\"";
|
||||
let secondary_type: SerdeAlbumSecondaryType = serde_json::from_str(secondary_type).unwrap();
|
||||
let secondary_type: AlbumSecondaryType = secondary_type.into();
|
||||
assert_eq!(secondary_type, AlbumSecondaryType::FieldRecording);
|
||||
}
|
||||
}
|
149
src/external/musicbrainz/api/search/artist.rs
vendored
Normal file
149
src/external/musicbrainz/api/search/artist.rs
vendored
Normal file
@ -0,0 +1,149 @@
|
||||
use std::fmt;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::external::musicbrainz::api::{
|
||||
search::{
|
||||
query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
|
||||
SearchPage, SerdeSearchPage,
|
||||
},
|
||||
MbArtistMeta, SerdeMbArtistMeta,
|
||||
};
|
||||
|
||||
pub enum SearchArtist<'a> {
|
||||
String(&'a str),
|
||||
}
|
||||
|
||||
impl<'a> fmt::Display for SearchArtist<'a> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::String(s) => write!(f, "\"{s}\""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type SearchArtistRequest<'a> = Query<SearchArtist<'a>>;
|
||||
|
||||
impl_term!(string, SearchArtist<'a>, String, &'a str);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct SearchArtistResponse {
|
||||
pub artists: Vec<SearchArtistResponseArtist>,
|
||||
pub page: SearchPage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
pub struct DeserializeSearchArtistResponse {
|
||||
artists: Vec<DeserializeSearchArtistResponseArtist>,
|
||||
#[serde(flatten)]
|
||||
page: SerdeSearchPage,
|
||||
}
|
||||
|
||||
impl From<DeserializeSearchArtistResponse> for SearchArtistResponse {
|
||||
fn from(value: DeserializeSearchArtistResponse) -> Self {
|
||||
SearchArtistResponse {
|
||||
artists: value.artists.into_iter().map(Into::into).collect(),
|
||||
page: value.page,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SearchArtistResponseArtist {
|
||||
pub score: u8,
|
||||
pub meta: MbArtistMeta,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
struct DeserializeSearchArtistResponseArtist {
|
||||
score: u8,
|
||||
#[serde(flatten)]
|
||||
meta: SerdeMbArtistMeta,
|
||||
}
|
||||
|
||||
impl From<DeserializeSearchArtistResponseArtist> for SearchArtistResponseArtist {
|
||||
fn from(value: DeserializeSearchArtistResponseArtist) -> Self {
|
||||
SearchArtistResponseArtist {
|
||||
score: value.score,
|
||||
meta: value.meta.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::predicate;
|
||||
|
||||
use crate::external::musicbrainz::{
|
||||
api::{MusicBrainzClient, PageSettings, SerdeMbid},
|
||||
MockIMusicBrainzHttp,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn de_response() -> DeserializeSearchArtistResponse {
|
||||
let de_offset = 24;
|
||||
let de_count = 124;
|
||||
let de_artist = DeserializeSearchArtistResponseArtist {
|
||||
score: 67,
|
||||
meta: SerdeMbArtistMeta {
|
||||
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||
name: String::from("an artist"),
|
||||
sort_name: String::from("artist, an"),
|
||||
disambiguation: None,
|
||||
},
|
||||
};
|
||||
DeserializeSearchArtistResponse {
|
||||
artists: vec![de_artist.clone()],
|
||||
page: SerdeSearchPage {
|
||||
offset: de_offset,
|
||||
count: de_count,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn response(de_response: DeserializeSearchArtistResponse) -> SearchArtistResponse {
|
||||
SearchArtistResponse {
|
||||
artists: de_response
|
||||
.artists
|
||||
.into_iter()
|
||||
.map(|a| SearchArtistResponseArtist {
|
||||
score: 67,
|
||||
meta: a.meta.into(),
|
||||
})
|
||||
.collect(),
|
||||
page: de_response.page,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_string() {
|
||||
let mut http = MockIMusicBrainzHttp::new();
|
||||
let url = format!(
|
||||
"https://musicbrainz.org/ws/2\
|
||||
/artist\
|
||||
?query=%22{no_field}%22",
|
||||
no_field = "an+artist",
|
||||
);
|
||||
|
||||
let de_response = de_response();
|
||||
let response = response(de_response.clone());
|
||||
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url))
|
||||
.return_once(|_| Ok(de_response));
|
||||
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let name = "an artist";
|
||||
|
||||
let query = SearchArtistRequest::new().string(name);
|
||||
|
||||
let paging = PageSettings::default();
|
||||
let matches = client.search_artist(&query, &paging).unwrap();
|
||||
assert_eq!(matches, response);
|
||||
}
|
||||
}
|
81
src/external/musicbrainz/api/search/mod.rs
vendored
Normal file
81
src/external/musicbrainz/api/search/mod.rs
vendored
Normal file
@ -0,0 +1,81 @@
|
||||
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||
mod artist;
|
||||
mod query;
|
||||
mod release_group;
|
||||
|
||||
pub use artist::{SearchArtistRequest, SearchArtistResponse, SearchArtistResponseArtist};
|
||||
pub use release_group::{
|
||||
SearchReleaseGroupRequest, SearchReleaseGroupResponse, SearchReleaseGroupResponseReleaseGroup,
|
||||
};
|
||||
|
||||
use paste::paste;
|
||||
use serde::Deserialize;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::external::musicbrainz::{
|
||||
api::{
|
||||
search::{
|
||||
artist::DeserializeSearchArtistResponse,
|
||||
release_group::DeserializeSearchReleaseGroupResponse,
|
||||
},
|
||||
ApiDisplay, Error, MusicBrainzClient, PageSettings, MB_BASE_URL,
|
||||
},
|
||||
IMusicBrainzHttp,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
pub struct SearchPage {
|
||||
offset: usize,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl SearchPage {
|
||||
pub fn next_page(&self, settings: PageSettings, page_count: usize) -> Option<PageSettings> {
|
||||
settings.next_page(self.offset, self.count, page_count)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SerdeSearchPage = SearchPage;
|
||||
|
||||
macro_rules! impl_search_entity {
|
||||
($name:ident, $entity:literal) => {
|
||||
paste! {
|
||||
pub fn [<search_ $name:snake>](
|
||||
&mut self,
|
||||
query: &[<Search $name Request>],
|
||||
paging: &PageSettings,
|
||||
) -> Result<[<Search $name Response>], Error> {
|
||||
let query: String =
|
||||
form_urlencoded::byte_serialize(format!("{query}").as_bytes()).collect();
|
||||
let page = ApiDisplay::format_page_settings(paging);
|
||||
let url = format!("{MB_BASE_URL}/{entity}?query={query}{page}", entity = $entity);
|
||||
|
||||
let response: [<DeserializeSearch $name Response>] = self.http.get(&url)?;
|
||||
Ok(response.into())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl<Http: IMusicBrainzHttp> MusicBrainzClient<Http> {
|
||||
impl_search_entity!(Artist, "artist");
|
||||
impl_search_entity!(ReleaseGroup, "release-group");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::external::musicbrainz::api::tests::next_page_test;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn search_next_page() {
|
||||
let page = SearchPage {
|
||||
offset: 5,
|
||||
count: 45,
|
||||
};
|
||||
|
||||
next_page_test(|val| page.next_page(PageSettings::default(), val));
|
||||
}
|
||||
}
|
312
src/external/musicbrainz/api/search/query.rs
vendored
Normal file
312
src/external/musicbrainz/api/search/query.rs
vendored
Normal file
@ -0,0 +1,312 @@
|
||||
use std::{fmt, marker::PhantomData};
|
||||
|
||||
pub enum Logical {
|
||||
Unary(Unary),
|
||||
Binary(Boolean),
|
||||
}
|
||||
|
||||
impl fmt::Display for Logical {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Logical::Unary(u) => write!(f, "{u}"),
|
||||
Logical::Binary(b) => write!(f, "{b}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Unary {
|
||||
Require,
|
||||
Prohibit,
|
||||
}
|
||||
|
||||
impl fmt::Display for Unary {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Unary::Require => write!(f, "+"),
|
||||
Unary::Prohibit => write!(f, "-"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Boolean {
|
||||
And,
|
||||
Or,
|
||||
Not,
|
||||
}
|
||||
|
||||
impl fmt::Display for Boolean {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Boolean::And => write!(f, "AND "),
|
||||
Boolean::Or => write!(f, "OR "),
|
||||
Boolean::Not => write!(f, "NOT "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Expression<Entity> {
|
||||
Term(Entity),
|
||||
Expr(Query<Entity>),
|
||||
}
|
||||
|
||||
impl<Entity> From<Entity> for Expression<Entity> {
|
||||
fn from(value: Entity) -> Self {
|
||||
Expression::Term(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Entity> From<Query<Entity>> for Expression<Entity> {
|
||||
fn from(value: Query<Entity>) -> Self {
|
||||
Expression::Expr(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Entity: fmt::Display> fmt::Display for Expression<Entity> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Expression::Term(t) => write!(f, "{t}"),
|
||||
Expression::Expr(q) => write!(f, "({q})"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EmptyQuery<Entity> {
|
||||
_marker: PhantomData<Entity>,
|
||||
}
|
||||
|
||||
impl<Entity> Default for EmptyQuery<Entity> {
|
||||
fn default() -> Self {
|
||||
EmptyQuery {
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Entity> EmptyQuery<Entity> {
|
||||
pub fn expression<Expr: Into<Expression<Entity>>>(self, expr: Expr) -> Query<Entity> {
|
||||
Query {
|
||||
left: (None, Box::new(expr.into())),
|
||||
right: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn require(self) -> EmptyQueryJoin<Entity> {
|
||||
EmptyQueryJoin {
|
||||
unary: Unary::Require,
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prohibit(self) -> EmptyQueryJoin<Entity> {
|
||||
EmptyQueryJoin {
|
||||
unary: Unary::Prohibit,
|
||||
_marker: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EmptyQueryJoin<Entity> {
|
||||
unary: Unary,
|
||||
_marker: PhantomData<Entity>,
|
||||
}
|
||||
|
||||
impl<Entity> EmptyQueryJoin<Entity> {
|
||||
pub fn expression<Expr: Into<Expression<Entity>>>(self, expr: Expr) -> Query<Entity> {
|
||||
Query {
|
||||
left: (Some(self.unary), Box::new(expr.into())),
|
||||
right: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Query<Entity> {
|
||||
left: (Option<Unary>, Box<Expression<Entity>>),
|
||||
right: Vec<(Logical, Box<Expression<Entity>>)>,
|
||||
}
|
||||
|
||||
impl<Entity: fmt::Display> fmt::Display for Query<Entity> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if let Some(u) = &self.left.0 {
|
||||
write!(f, "{u}")?;
|
||||
}
|
||||
|
||||
write!(f, "{}", self.left.1)?;
|
||||
|
||||
for (logical, expr) in self.right.iter() {
|
||||
write!(f, " {logical}{expr}")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<Entity> Query<Entity> {
|
||||
#[allow(clippy::new_ret_no_self)]
|
||||
pub fn new() -> EmptyQuery<Entity> {
|
||||
EmptyQuery::default()
|
||||
}
|
||||
|
||||
pub fn require(self) -> QueryJoin<Entity> {
|
||||
QueryJoin {
|
||||
logical: Logical::Unary(Unary::Require),
|
||||
query: self,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prohibit(self) -> QueryJoin<Entity> {
|
||||
QueryJoin {
|
||||
logical: Logical::Unary(Unary::Prohibit),
|
||||
query: self,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn and(self) -> QueryJoin<Entity> {
|
||||
QueryJoin {
|
||||
logical: Logical::Binary(Boolean::And),
|
||||
query: self,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn or(self) -> QueryJoin<Entity> {
|
||||
QueryJoin {
|
||||
logical: Logical::Binary(Boolean::Or),
|
||||
query: self,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn not(self) -> QueryJoin<Entity> {
|
||||
QueryJoin {
|
||||
logical: Logical::Binary(Boolean::Not),
|
||||
query: self,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct QueryJoin<Entity> {
|
||||
logical: Logical,
|
||||
query: Query<Entity>,
|
||||
}
|
||||
|
||||
impl<Entity> QueryJoin<Entity> {
|
||||
pub fn expression<Expr: Into<Expression<Entity>>>(mut self, expr: Expr) -> Query<Entity> {
|
||||
self.query.right.push((self.logical, Box::new(expr.into())));
|
||||
self.query
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_term {
|
||||
($name:ident, $enum:ty, $variant:ident, $type:ty) => {
|
||||
impl<'a> EmptyQuery<$enum> {
|
||||
pub fn $name(self, $name: $type) -> Query<$enum> {
|
||||
self.expression(<$enum>::$variant($name))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> EmptyQueryJoin<$enum> {
|
||||
pub fn $name(self, $name: $type) -> Query<$enum> {
|
||||
self.expression(<$enum>::$variant($name))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> QueryJoin<$enum> {
|
||||
pub fn $name(self, $name: $type) -> Query<$enum> {
|
||||
self.expression(<$enum>::$variant($name))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use impl_term;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fmt;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub enum TestEntity<'a> {
|
||||
String(&'a str),
|
||||
}
|
||||
|
||||
impl<'a> fmt::Display for TestEntity<'a> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::String(s) => write!(f, "\"{s}\""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type TestEntityRequest<'a> = Query<TestEntity<'a>>;
|
||||
|
||||
impl_term!(string, TestEntity<'a>, String, &'a str);
|
||||
|
||||
#[test]
|
||||
fn lucene_logical() {
|
||||
let query = TestEntityRequest::new()
|
||||
.string("jakarta apache")
|
||||
.or()
|
||||
.string("jakarta");
|
||||
assert_eq!(format!("{query}"), "\"jakarta apache\" OR \"jakarta\"");
|
||||
|
||||
let query = TestEntityRequest::new()
|
||||
.string("jakarta apache")
|
||||
.and()
|
||||
.string("jakarta");
|
||||
assert_eq!(format!("{query}"), "\"jakarta apache\" AND \"jakarta\"");
|
||||
|
||||
let query = TestEntityRequest::new()
|
||||
.require()
|
||||
.string("jakarta")
|
||||
.or()
|
||||
.string("lucene");
|
||||
assert_eq!(format!("{query}"), "+\"jakarta\" OR \"lucene\"");
|
||||
|
||||
let query = TestEntityRequest::new()
|
||||
.string("lucene")
|
||||
.require()
|
||||
.string("jakarta");
|
||||
assert_eq!(format!("{query}"), "\"lucene\" +\"jakarta\"");
|
||||
|
||||
let query = TestEntityRequest::new()
|
||||
.string("jakarta apache")
|
||||
.not()
|
||||
.string("Apache Lucene");
|
||||
assert_eq!(
|
||||
format!("{query}"),
|
||||
"\"jakarta apache\" NOT \"Apache Lucene\""
|
||||
);
|
||||
|
||||
let query = TestEntityRequest::new()
|
||||
.prohibit()
|
||||
.string("Apache Lucene")
|
||||
.or()
|
||||
.string("jakarta apache");
|
||||
assert_eq!(
|
||||
format!("{query}"),
|
||||
"-\"Apache Lucene\" OR \"jakarta apache\""
|
||||
);
|
||||
|
||||
let query = TestEntityRequest::new()
|
||||
.string("jakarta apache")
|
||||
.prohibit()
|
||||
.string("Apache Lucene");
|
||||
assert_eq!(format!("{query}"), "\"jakarta apache\" -\"Apache Lucene\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lucene_grouping() {
|
||||
let query = TestEntityRequest::new()
|
||||
.expression(
|
||||
TestEntityRequest::new()
|
||||
.string("jakarta")
|
||||
.or()
|
||||
.string("apache"),
|
||||
)
|
||||
.and()
|
||||
.string("website");
|
||||
assert_eq!(
|
||||
format!("{query}"),
|
||||
"(\"jakarta\" OR \"apache\") AND \"website\""
|
||||
);
|
||||
}
|
||||
}
|
249
src/external/musicbrainz/api/search/release_group.rs
vendored
Normal file
249
src/external/musicbrainz/api/search/release_group.rs
vendored
Normal file
@ -0,0 +1,249 @@
|
||||
use std::fmt;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
collection::{album::AlbumDate, musicbrainz::Mbid},
|
||||
external::musicbrainz::api::{
|
||||
search::{
|
||||
query::{impl_term, EmptyQuery, EmptyQueryJoin, Query, QueryJoin},
|
||||
SearchPage, SerdeSearchPage,
|
||||
},
|
||||
ApiDisplay, MbReleaseGroupMeta, SerdeMbReleaseGroupMeta,
|
||||
},
|
||||
};
|
||||
|
||||
pub enum SearchReleaseGroup<'a> {
|
||||
String(&'a str),
|
||||
Arid(&'a Mbid),
|
||||
FirstReleaseDate(&'a AlbumDate),
|
||||
ReleaseGroup(&'a str),
|
||||
Rgid(&'a Mbid),
|
||||
}
|
||||
|
||||
impl<'a> fmt::Display for SearchReleaseGroup<'a> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::String(s) => write!(f, "\"{s}\""),
|
||||
Self::Arid(arid) => write!(f, "arid:{}", arid.uuid().as_hyphenated()),
|
||||
Self::FirstReleaseDate(date) => write!(
|
||||
f,
|
||||
"firstreleasedate:{}",
|
||||
ApiDisplay::format_album_date(date)
|
||||
),
|
||||
Self::ReleaseGroup(release_group) => write!(f, "releasegroup:\"{release_group}\""),
|
||||
Self::Rgid(rgid) => write!(f, "rgid:{}", rgid.uuid().as_hyphenated()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type SearchReleaseGroupRequest<'a> = Query<SearchReleaseGroup<'a>>;
|
||||
|
||||
impl_term!(string, SearchReleaseGroup<'a>, String, &'a str);
|
||||
impl_term!(arid, SearchReleaseGroup<'a>, Arid, &'a Mbid);
|
||||
impl_term!(
|
||||
first_release_date,
|
||||
SearchReleaseGroup<'a>,
|
||||
FirstReleaseDate,
|
||||
&'a AlbumDate
|
||||
);
|
||||
impl_term!(release_group, SearchReleaseGroup<'a>, ReleaseGroup, &'a str);
|
||||
impl_term!(rgid, SearchReleaseGroup<'a>, Rgid, &'a Mbid);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct SearchReleaseGroupResponse {
|
||||
pub release_groups: Vec<SearchReleaseGroupResponseReleaseGroup>,
|
||||
pub page: SearchPage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
pub struct DeserializeSearchReleaseGroupResponse {
|
||||
release_groups: Vec<DeserializeSearchReleaseGroupResponseReleaseGroup>,
|
||||
#[serde(flatten)]
|
||||
page: SerdeSearchPage,
|
||||
}
|
||||
|
||||
impl From<DeserializeSearchReleaseGroupResponse> for SearchReleaseGroupResponse {
|
||||
fn from(value: DeserializeSearchReleaseGroupResponse) -> Self {
|
||||
SearchReleaseGroupResponse {
|
||||
release_groups: value.release_groups.into_iter().map(Into::into).collect(),
|
||||
page: value.page,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SearchReleaseGroupResponseReleaseGroup {
|
||||
pub score: u8,
|
||||
pub meta: MbReleaseGroupMeta,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all(deserialize = "kebab-case"))]
|
||||
pub struct DeserializeSearchReleaseGroupResponseReleaseGroup {
|
||||
score: u8,
|
||||
#[serde(flatten)]
|
||||
meta: SerdeMbReleaseGroupMeta,
|
||||
}
|
||||
|
||||
impl From<DeserializeSearchReleaseGroupResponseReleaseGroup>
|
||||
for SearchReleaseGroupResponseReleaseGroup
|
||||
{
|
||||
fn from(value: DeserializeSearchReleaseGroupResponseReleaseGroup) -> Self {
|
||||
SearchReleaseGroupResponseReleaseGroup {
|
||||
score: value.score,
|
||||
meta: value.meta.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::predicate;
|
||||
|
||||
use crate::{
|
||||
collection::album::{AlbumPrimaryType, AlbumSecondaryType},
|
||||
external::musicbrainz::{
|
||||
api::{
|
||||
MusicBrainzClient, PageSettings, SerdeAlbumDate, SerdeAlbumPrimaryType,
|
||||
SerdeAlbumSecondaryType, SerdeMbid,
|
||||
},
|
||||
MockIMusicBrainzHttp,
|
||||
},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn de_response() -> DeserializeSearchReleaseGroupResponse {
|
||||
let de_offset = 26;
|
||||
let de_count = 126;
|
||||
let de_release_group = DeserializeSearchReleaseGroupResponseReleaseGroup {
|
||||
score: 67,
|
||||
meta: SerdeMbReleaseGroupMeta {
|
||||
id: SerdeMbid("11111111-1111-1111-1111-111111111111".try_into().unwrap()),
|
||||
title: String::from("an album"),
|
||||
first_release_date: SerdeAlbumDate((1986, 4).into()),
|
||||
primary_type: Some(SerdeAlbumPrimaryType(AlbumPrimaryType::Album)),
|
||||
secondary_types: Some(vec![SerdeAlbumSecondaryType(AlbumSecondaryType::Live)]),
|
||||
},
|
||||
};
|
||||
DeserializeSearchReleaseGroupResponse {
|
||||
release_groups: vec![de_release_group.clone()],
|
||||
page: SerdeSearchPage {
|
||||
offset: de_offset,
|
||||
count: de_count,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn response(de_response: DeserializeSearchReleaseGroupResponse) -> SearchReleaseGroupResponse {
|
||||
SearchReleaseGroupResponse {
|
||||
release_groups: de_response
|
||||
.release_groups
|
||||
.into_iter()
|
||||
.map(|rg| SearchReleaseGroupResponseReleaseGroup {
|
||||
score: 67,
|
||||
meta: rg.meta.into(),
|
||||
})
|
||||
.collect(),
|
||||
page: de_response.page,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_string() {
|
||||
let mut http = MockIMusicBrainzHttp::new();
|
||||
let url = format!(
|
||||
"https://musicbrainz.org/ws/2\
|
||||
/release-group\
|
||||
?query=%22{title}%22",
|
||||
title = "an+album",
|
||||
);
|
||||
|
||||
let de_response = de_response();
|
||||
let response = response(de_response.clone());
|
||||
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url))
|
||||
.return_once(|_| Ok(de_response));
|
||||
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let title = "an album";
|
||||
|
||||
let query = SearchReleaseGroupRequest::new().string(title);
|
||||
|
||||
let paging = PageSettings::default();
|
||||
let matches = client.search_release_group(&query, &paging).unwrap();
|
||||
assert_eq!(matches, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_arid_album_date_release_group() {
|
||||
let mut http = MockIMusicBrainzHttp::new();
|
||||
let url = format!(
|
||||
"https://musicbrainz.org/ws/2\
|
||||
/release-group\
|
||||
?query=arid%3A{arid}+AND+releasegroup%3A%22{title}%22+AND+firstreleasedate%3A{date}",
|
||||
arid = "00000000-0000-0000-0000-000000000000",
|
||||
date = "1986-04",
|
||||
title = "an+album",
|
||||
);
|
||||
|
||||
let de_response = de_response();
|
||||
let response = response(de_response.clone());
|
||||
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url))
|
||||
.return_once(|_| Ok(de_response));
|
||||
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let arid: Mbid = "00000000-0000-0000-0000-000000000000".try_into().unwrap();
|
||||
let title = "an album";
|
||||
let date = (1986, 4).into();
|
||||
|
||||
let query = SearchReleaseGroupRequest::new()
|
||||
.arid(&arid)
|
||||
.and()
|
||||
.release_group(title)
|
||||
.and()
|
||||
.first_release_date(&date);
|
||||
|
||||
let paging = PageSettings::default();
|
||||
let matches = client.search_release_group(&query, &paging).unwrap();
|
||||
assert_eq!(matches, response);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_rgid() {
|
||||
let mut http = MockIMusicBrainzHttp::new();
|
||||
let url = format!(
|
||||
"https://musicbrainz.org/ws/2\
|
||||
/release-group\
|
||||
?query=rgid%3A{rgid}",
|
||||
rgid = "11111111-1111-1111-1111-111111111111",
|
||||
);
|
||||
|
||||
let de_response = de_response();
|
||||
let response = response(de_response.clone());
|
||||
|
||||
http.expect_get()
|
||||
.times(1)
|
||||
.with(predicate::eq(url))
|
||||
.return_once(|_| Ok(de_response));
|
||||
|
||||
let mut client = MusicBrainzClient::new(http);
|
||||
|
||||
let rgid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
|
||||
|
||||
let query = SearchReleaseGroupRequest::new().rgid(&rgid);
|
||||
|
||||
let paging = PageSettings::default();
|
||||
let matches = client.search_release_group(&query, &paging).unwrap();
|
||||
assert_eq!(matches, response);
|
||||
}
|
||||
}
|
46
src/external/musicbrainz/http.rs
vendored
Normal file
46
src/external/musicbrainz/http.rs
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
//! Module for interacting with the MusicBrainz API via an HTTP client.
|
||||
|
||||
use reqwest::{self, blocking::Client, header};
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::external::musicbrainz::{HttpError, IMusicBrainzHttp};
|
||||
|
||||
// GRCOV_EXCL_START
|
||||
pub struct MusicBrainzHttp(Client);
|
||||
|
||||
impl MusicBrainzHttp {
|
||||
pub fn new(user_agent: &'static str) -> Result<Self, HttpError> {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert(
|
||||
header::USER_AGENT,
|
||||
header::HeaderValue::from_static(user_agent),
|
||||
);
|
||||
headers.insert(
|
||||
header::ACCEPT,
|
||||
header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
Ok(MusicBrainzHttp(
|
||||
Client::builder().default_headers(headers).build()?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl IMusicBrainzHttp for MusicBrainzHttp {
|
||||
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, HttpError> {
|
||||
let response = self.0.get(url).send()?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.json()?)
|
||||
} else {
|
||||
Err(HttpError::Status(response.status().as_u16()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for HttpError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
HttpError::Client(err.to_string())
|
||||
}
|
||||
}
|
||||
// GRCOV_EXCL_STOP
|
19
src/external/musicbrainz/mod.rs
vendored
Normal file
19
src/external/musicbrainz/mod.rs
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||
|
||||
pub mod api;
|
||||
pub mod http;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait IMusicBrainzHttp {
|
||||
fn get<D: DeserializeOwned + 'static>(&mut self, url: &str) -> Result<D, HttpError>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum HttpError {
|
||||
Client(String),
|
||||
Status(u16),
|
||||
}
|
69
src/lib.rs
69
src/lib.rs
@ -1,67 +1,16 @@
|
||||
//! MusicHoard - a music collection manager.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
mod core;
|
||||
pub mod external;
|
||||
|
||||
pub mod collection;
|
||||
pub mod database;
|
||||
pub mod library;
|
||||
pub use core::collection;
|
||||
pub use core::interface;
|
||||
|
||||
/// [MusicBrainz Identifier](https://musicbrainz.org/doc/MusicBrainz_Identifier) (MBID).
|
||||
pub type Mbid = Uuid;
|
||||
|
||||
/// The track file format.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub enum TrackFormat {
|
||||
Flac,
|
||||
Mp3,
|
||||
}
|
||||
|
||||
/// A single track on an album.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Track {
|
||||
pub number: u32,
|
||||
pub title: String,
|
||||
pub artist: Vec<String>,
|
||||
pub format: TrackFormat,
|
||||
}
|
||||
|
||||
/// The album identifier.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
|
||||
pub struct AlbumId {
|
||||
pub year: u32,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// An album is a collection of tracks that were released together.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Album {
|
||||
pub id: AlbumId,
|
||||
pub tracks: Vec<Track>,
|
||||
}
|
||||
|
||||
/// The artist identifier.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, Hash)]
|
||||
pub struct ArtistId {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// An artist.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct Artist {
|
||||
pub id: ArtistId,
|
||||
pub albums: Vec<Album>,
|
||||
}
|
||||
pub use core::musichoard::{
|
||||
builder::MusicHoardBuilder, Error, IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary,
|
||||
MusicHoard, NoDatabase, NoLibrary,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
mod testlib;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use super::*;
|
||||
|
||||
pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| collection!());
|
||||
}
|
||||
mod testmod;
|
||||
|
182
src/main.rs
182
src/main.rs
@ -1,105 +1,175 @@
|
||||
use std::path::PathBuf;
|
||||
use std::{ffi::OsString, io};
|
||||
#![cfg_attr(nightly, feature(test))]
|
||||
#[cfg(nightly)]
|
||||
extern crate test;
|
||||
|
||||
mod tui;
|
||||
|
||||
use std::{ffi::OsString, fs::OpenOptions, io, path::PathBuf, thread};
|
||||
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use structopt::StructOpt;
|
||||
|
||||
use musichoard::{
|
||||
collection::MhCollectionManager,
|
||||
database::{
|
||||
json::{backend::JsonDatabaseFileBackend, JsonDatabase},
|
||||
Database,
|
||||
},
|
||||
library::{
|
||||
beets::{
|
||||
external::{
|
||||
database::json::{backend::JsonDatabaseFileBackend, JsonDatabase},
|
||||
library::beets::{
|
||||
executor::{ssh::BeetsLibrarySshExecutor, BeetsLibraryProcessExecutor},
|
||||
BeetsLibrary,
|
||||
},
|
||||
Library,
|
||||
musicbrainz::{api::MusicBrainzClient, http::MusicBrainzHttp},
|
||||
},
|
||||
interface::{
|
||||
database::{IDatabase, NullDatabase},
|
||||
library::{ILibrary, NullLibrary},
|
||||
},
|
||||
MusicHoardBuilder, NoDatabase, NoLibrary,
|
||||
};
|
||||
|
||||
mod tui;
|
||||
use tui::ui::MhUi;
|
||||
use tui::{event::EventChannel, handler::TuiEventHandler, listener::TuiEventListener, Tui};
|
||||
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)]
|
||||
struct Opt {
|
||||
#[structopt(long = "ssh", name = "beets SSH URI")]
|
||||
#[structopt(flatten)]
|
||||
lib_opt: LibOpt,
|
||||
|
||||
#[structopt(flatten)]
|
||||
db_opt: DbOpt,
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct LibOpt {
|
||||
#[structopt(long = "ssh", help = "Beets SSH URI")]
|
||||
beets_ssh_uri: Option<OsString>,
|
||||
|
||||
#[structopt(long = "beets", name = "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>,
|
||||
|
||||
#[structopt(long = "no-library", help = "Do not connect to the library")]
|
||||
no_library: bool,
|
||||
}
|
||||
|
||||
#[derive(StructOpt)]
|
||||
struct DbOpt {
|
||||
#[structopt(
|
||||
long = "database",
|
||||
name = "database file path",
|
||||
help = "Database file path",
|
||||
default_value = "database.json"
|
||||
)]
|
||||
database_file_path: PathBuf,
|
||||
|
||||
#[structopt(long = "no-database", help = "Do not read from/write to the database")]
|
||||
no_database: bool,
|
||||
}
|
||||
|
||||
fn with<LIB: Library, DB: Database>(lib: LIB, db: DB) {
|
||||
let collection_manager = MhCollectionManager::new(lib, db);
|
||||
fn with<Database: IDatabase + 'static, Library: ILibrary + 'static>(
|
||||
builder: MusicHoardBuilder<Database, Library>,
|
||||
) {
|
||||
let music_hoard = builder.build().expect("failed to initialise MusicHoard");
|
||||
|
||||
// Initialize the terminal user interface.
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
let terminal = Terminal::new(backend).expect("failed to initialise terminal");
|
||||
|
||||
let channel = EventChannel::new();
|
||||
let listener = TuiEventListener::new(channel.sender());
|
||||
let handler = TuiEventHandler::new(channel.receiver());
|
||||
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 ui = MhUi::new(collection_manager).expect("failed to initialise ui");
|
||||
let channel = EventChannel::new();
|
||||
let listener_sender = channel.sender();
|
||||
let app_sender = channel.sender();
|
||||
|
||||
let listener = EventListener::new(listener_sender);
|
||||
let handler = EventHandler::new(channel.receiver());
|
||||
|
||||
let mb_job_channel = JobChannel::new();
|
||||
|
||||
let app = App::new(music_hoard, mb_job_channel.sender());
|
||||
let ui = Ui;
|
||||
|
||||
// Run the TUI application.
|
||||
Tui::run(terminal, ui, handler, listener).expect("failed to run tui");
|
||||
thread::spawn(|| MusicBrainzDaemon::run(musicbrainz, mb_job_channel.receiver(), app_sender));
|
||||
Tui::run(terminal, app, ui, handler, listener).expect("failed to run tui");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Create the application.
|
||||
let opt = Opt::from_args();
|
||||
fn with_database<Library: ILibrary + 'static>(
|
||||
db_opt: DbOpt,
|
||||
builder: MusicHoardBuilder<NoDatabase, Library>,
|
||||
) {
|
||||
if db_opt.no_database {
|
||||
with(builder.set_database(NullDatabase));
|
||||
} else {
|
||||
// Create an empty database file if it does not exist.
|
||||
match OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&db_opt.database_file_path)
|
||||
{
|
||||
Ok(f) => {
|
||||
drop(f);
|
||||
JsonDatabase::new(JsonDatabaseFileBackend::new(&db_opt.database_file_path))
|
||||
.save(&vec![])
|
||||
.expect("failed to create empty database");
|
||||
}
|
||||
Err(e) => match e.kind() {
|
||||
io::ErrorKind::AlreadyExists => {}
|
||||
_ => panic!("failed to access database file"),
|
||||
},
|
||||
}
|
||||
|
||||
if let Some(uri) = opt.beets_ssh_uri {
|
||||
let db_exec = JsonDatabaseFileBackend::new(&db_opt.database_file_path);
|
||||
with(builder.set_database(JsonDatabase::new(db_exec)));
|
||||
};
|
||||
}
|
||||
|
||||
fn with_library(lib_opt: LibOpt, db_opt: DbOpt, builder: MusicHoardBuilder<NoDatabase, NoLibrary>) {
|
||||
if lib_opt.no_library {
|
||||
with_database(db_opt, builder.set_library(NullLibrary));
|
||||
} else if let Some(uri) = lib_opt.beets_ssh_uri {
|
||||
let uri = uri.into_string().expect("invalid SSH URI");
|
||||
let beets_config_file_path = opt
|
||||
let beets_config_file_path = lib_opt
|
||||
.beets_config_file_path
|
||||
.map(|s| s.into_string())
|
||||
.transpose()
|
||||
.expect("failed to extract beets config file path");
|
||||
let lib_exec = BeetsLibrarySshExecutor::new(uri)
|
||||
let lib_exec = match lib_opt.beets_bin_path {
|
||||
Some(beets_bin) => {
|
||||
let bin = beets_bin.into_string().expect("invalid beets binary path");
|
||||
BeetsLibrarySshExecutor::bin(uri, bin)
|
||||
}
|
||||
None => BeetsLibrarySshExecutor::new(uri),
|
||||
}
|
||||
.expect("failed to initialise beets")
|
||||
.config(beets_config_file_path);
|
||||
let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path);
|
||||
with(BeetsLibrary::new(lib_exec), JsonDatabase::new(db_exec));
|
||||
with_database(db_opt, builder.set_library(BeetsLibrary::new(lib_exec)));
|
||||
} else {
|
||||
let lib_exec = BeetsLibraryProcessExecutor::default().config(opt.beets_config_file_path);
|
||||
let db_exec = JsonDatabaseFileBackend::new(&opt.database_file_path);
|
||||
with(BeetsLibrary::new(lib_exec), JsonDatabase::new(db_exec));
|
||||
let lib_exec = match lib_opt.beets_bin_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)));
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let opt = Opt::from_args();
|
||||
let builder = MusicHoardBuilder::default();
|
||||
with_library(opt.lib_opt, opt.db_opt, builder);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_use]
|
||||
mod testlib;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::mock;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use musichoard::collection::{self, Collection, CollectionManager};
|
||||
use musichoard::*;
|
||||
|
||||
pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| collection!());
|
||||
|
||||
mock! {
|
||||
pub CollectionManager {}
|
||||
|
||||
impl CollectionManager for CollectionManager {
|
||||
fn rescan_library(&mut self) -> Result<(), collection::Error>;
|
||||
fn save_to_database(&mut self) -> Result<(), collection::Error>;
|
||||
fn get_collection(&self) -> &Collection;
|
||||
}
|
||||
}
|
||||
}
|
||||
mod testmod;
|
||||
|
168
src/testlib.rs
168
src/testlib.rs
@ -1,168 +0,0 @@
|
||||
macro_rules! collection {
|
||||
() => {
|
||||
vec![
|
||||
Artist {
|
||||
id: ArtistId {
|
||||
name: "album_artist a".to_string(),
|
||||
},
|
||||
albums: vec![
|
||||
Album {
|
||||
id: AlbumId {
|
||||
year: 1998,
|
||||
title: "album_title a.a".to_string(),
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
number: 1,
|
||||
title: "track a.a.1".to_string(),
|
||||
artist: vec!["artist a.a.1".to_string()],
|
||||
format: TrackFormat::Flac,
|
||||
},
|
||||
Track {
|
||||
number: 2,
|
||||
title: "track a.a.2".to_string(),
|
||||
artist: vec![
|
||||
"artist a.a.2.1".to_string(),
|
||||
"artist a.a.2.2".to_string(),
|
||||
],
|
||||
format: TrackFormat::Mp3,
|
||||
},
|
||||
Track {
|
||||
number: 3,
|
||||
title: "track a.a.3".to_string(),
|
||||
artist: vec!["artist a.a.3".to_string()],
|
||||
format: TrackFormat::Flac,
|
||||
},
|
||||
],
|
||||
},
|
||||
Album {
|
||||
id: AlbumId {
|
||||
year: 2015,
|
||||
title: "album_title a.b".to_string(),
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
number: 1,
|
||||
title: "track a.b.1".to_string(),
|
||||
artist: vec!["artist a.b.1".to_string()],
|
||||
format: TrackFormat::Flac,
|
||||
},
|
||||
Track {
|
||||
number: 2,
|
||||
title: "track a.b.2".to_string(),
|
||||
artist: vec!["artist a.b.2".to_string()],
|
||||
format: TrackFormat::Flac,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: ArtistId {
|
||||
name: "album_artist b".to_string(),
|
||||
},
|
||||
albums: vec![
|
||||
Album {
|
||||
id: AlbumId {
|
||||
year: 2003,
|
||||
title: "album_title b.a".to_string(),
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
number: 1,
|
||||
title: "track b.a.1".to_string(),
|
||||
artist: vec!["artist b.a.1".to_string()],
|
||||
format: TrackFormat::Mp3,
|
||||
},
|
||||
Track {
|
||||
number: 2,
|
||||
title: "track b.a.2".to_string(),
|
||||
artist: vec![
|
||||
"artist b.a.2.1".to_string(),
|
||||
"artist b.a.2.2".to_string(),
|
||||
],
|
||||
format: TrackFormat::Mp3,
|
||||
},
|
||||
],
|
||||
},
|
||||
Album {
|
||||
id: AlbumId {
|
||||
year: 2008,
|
||||
title: "album_title b.b".to_string(),
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
number: 1,
|
||||
title: "track b.b.1".to_string(),
|
||||
artist: vec!["artist b.b.1".to_string()],
|
||||
format: TrackFormat::Flac,
|
||||
},
|
||||
Track {
|
||||
number: 2,
|
||||
title: "track b.b.2".to_string(),
|
||||
artist: vec![
|
||||
"artist b.b.2.1".to_string(),
|
||||
"artist b.b.2.2".to_string(),
|
||||
],
|
||||
format: TrackFormat::Mp3,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
id: ArtistId {
|
||||
name: "album_artist c".to_string(),
|
||||
},
|
||||
albums: vec![
|
||||
Album {
|
||||
id: AlbumId {
|
||||
year: 1985,
|
||||
title: "album_title c.a".to_string(),
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
number: 1,
|
||||
title: "track c.a.1".to_string(),
|
||||
artist: vec!["artist c.a.1".to_string()],
|
||||
format: TrackFormat::Mp3,
|
||||
},
|
||||
Track {
|
||||
number: 2,
|
||||
title: "track c.a.2".to_string(),
|
||||
artist: vec![
|
||||
"artist c.a.2.1".to_string(),
|
||||
"artist c.a.2.2".to_string(),
|
||||
],
|
||||
format: TrackFormat::Mp3,
|
||||
},
|
||||
],
|
||||
},
|
||||
Album {
|
||||
id: AlbumId {
|
||||
year: 2018,
|
||||
title: "album_title c.b".to_string(),
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
number: 1,
|
||||
title: "track c.b.1".to_string(),
|
||||
artist: vec!["artist c.b.1".to_string()],
|
||||
format: TrackFormat::Flac,
|
||||
},
|
||||
Track {
|
||||
number: 2,
|
||||
title: "track c.b.2".to_string(),
|
||||
artist: vec![
|
||||
"artist c.b.2.1".to_string(),
|
||||
"artist c.b.2.2".to_string(),
|
||||
],
|
||||
format: TrackFormat::Flac,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
};
|
||||
}
|
525
src/testmod/full.rs
Normal file
525
src/testmod/full.rs
Normal file
@ -0,0 +1,525 @@
|
||||
macro_rules! full_collection {
|
||||
() => {
|
||||
vec![
|
||||
Artist {
|
||||
meta: ArtistMeta {
|
||||
id: ArtistId {
|
||||
name: "Album_Artist ‘A’".to_string(),
|
||||
},
|
||||
sort: None,
|
||||
info: ArtistInfo {
|
||||
musicbrainz: MbRefOption::Some(MbArtistRef::from_url_str(
|
||||
"https://musicbrainz.org/artist/00000000-0000-0000-0000-000000000000"
|
||||
).unwrap()),
|
||||
properties: HashMap::from([
|
||||
(String::from("MusicButler"), vec![
|
||||
String::from("https://www.musicbutler.io/artist-page/000000000"),
|
||||
]),
|
||||
(String::from("Qobuz"), vec![
|
||||
String::from(
|
||||
"https://www.qobuz.com/nl-nl/interpreter/artist-a/download-streaming-albums",
|
||||
)
|
||||
]),
|
||||
]),
|
||||
},
|
||||
},
|
||||
albums: vec![
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title a.a".to_string(),
|
||||
},
|
||||
date: 1998.into(),
|
||||
seq: AlbumSeq(1),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::Some(MbAlbumRef::from_url_str(
|
||||
"https://musicbrainz.org/release-group/00000000-0000-0000-0000-000000000000"
|
||||
).unwrap()),
|
||||
primary_type: Some(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![],
|
||||
},
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track a.a.1".to_string(),
|
||||
},
|
||||
number: TrackNum(1),
|
||||
artist: vec!["artist a.a.1".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 992,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track a.a.2".to_string(),
|
||||
},
|
||||
number: TrackNum(2),
|
||||
artist: vec![
|
||||
"artist a.a.2.1".to_string(),
|
||||
"artist a.a.2.2".to_string(),
|
||||
],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 320,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track a.a.3".to_string(),
|
||||
},
|
||||
number: TrackNum(3),
|
||||
artist: vec!["artist a.a.3".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 1061,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track a.a.4".to_string(),
|
||||
},
|
||||
number: TrackNum(4),
|
||||
artist: vec!["artist a.a.4".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 1042,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title a.b".to_string(),
|
||||
},
|
||||
date: (2015, 4).into(),
|
||||
seq: AlbumSeq(1),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::None,
|
||||
primary_type: Some(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![],
|
||||
},
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track a.b.1".to_string(),
|
||||
},
|
||||
number: TrackNum(1),
|
||||
artist: vec!["artist a.b.1".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 1004,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track a.b.2".to_string(),
|
||||
},
|
||||
number: TrackNum(2),
|
||||
artist: vec!["artist a.b.2".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 1077,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
meta: ArtistMeta {
|
||||
id: ArtistId {
|
||||
name: "Album_Artist ‘B’".to_string(),
|
||||
},
|
||||
sort: None,
|
||||
info: ArtistInfo {
|
||||
musicbrainz: MbRefOption::Some(MbArtistRef::from_url_str(
|
||||
"https://musicbrainz.org/artist/11111111-1111-1111-1111-111111111111"
|
||||
).unwrap()),
|
||||
properties: HashMap::from([
|
||||
(String::from("MusicButler"), vec![
|
||||
String::from("https://www.musicbutler.io/artist-page/111111111"),
|
||||
String::from("https://www.musicbutler.io/artist-page/111111112"),
|
||||
]),
|
||||
(String::from("Bandcamp"), vec![
|
||||
String::from("https://artist-b.bandcamp.com/")
|
||||
]),
|
||||
(String::from("Qobuz"), vec![
|
||||
String::from(
|
||||
"https://www.qobuz.com/nl-nl/interpreter/artist-b/download-streaming-albums",
|
||||
)
|
||||
]),
|
||||
]),
|
||||
},
|
||||
},
|
||||
albums: vec![
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title b.a".to_string(),
|
||||
},
|
||||
date: (2003, 6, 6).into(),
|
||||
seq: AlbumSeq(1),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::None,
|
||||
primary_type: Some(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![],
|
||||
},
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track b.a.1".to_string(),
|
||||
},
|
||||
number: TrackNum(1),
|
||||
artist: vec!["artist b.a.1".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 190,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track b.a.2".to_string(),
|
||||
},
|
||||
number: TrackNum(2),
|
||||
artist: vec![
|
||||
"artist b.a.2.1".to_string(),
|
||||
"artist b.a.2.2".to_string(),
|
||||
],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 120,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title b.b".to_string(),
|
||||
},
|
||||
date: 2008.into(),
|
||||
seq: AlbumSeq(3),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::Some(MbAlbumRef::from_url_str(
|
||||
"https://musicbrainz.org/release-group/11111111-1111-1111-1111-111111111111"
|
||||
).unwrap()),
|
||||
primary_type: Some(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![],
|
||||
},
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track b.b.1".to_string(),
|
||||
},
|
||||
number: TrackNum(1),
|
||||
artist: vec!["artist b.b.1".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 1077,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track b.b.2".to_string(),
|
||||
},
|
||||
number: TrackNum(2),
|
||||
artist: vec![
|
||||
"artist b.b.2.1".to_string(),
|
||||
"artist b.b.2.2".to_string(),
|
||||
],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 320,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title b.c".to_string(),
|
||||
},
|
||||
date: 2009.into(),
|
||||
seq: AlbumSeq(2),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::Some(MbAlbumRef::from_url_str(
|
||||
"https://musicbrainz.org/release-group/11111111-1111-1111-1111-111111111112"
|
||||
).unwrap()),
|
||||
primary_type: Some(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![],
|
||||
},
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track b.c.1".to_string(),
|
||||
},
|
||||
number: TrackNum(1),
|
||||
artist: vec!["artist b.c.1".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 190,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track b.c.2".to_string(),
|
||||
},
|
||||
number: TrackNum(2),
|
||||
artist: vec![
|
||||
"artist b.c.2.1".to_string(),
|
||||
"artist b.c.2.2".to_string(),
|
||||
],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 120,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title b.d".to_string(),
|
||||
},
|
||||
date: 2015.into(),
|
||||
seq: AlbumSeq(4),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::None,
|
||||
primary_type: Some(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![],
|
||||
},
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track b.d.1".to_string(),
|
||||
},
|
||||
number: TrackNum(1),
|
||||
artist: vec!["artist b.d.1".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 190,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track b.d.2".to_string(),
|
||||
},
|
||||
number: TrackNum(2),
|
||||
artist: vec![
|
||||
"artist b.d.2.1".to_string(),
|
||||
"artist b.d.2.2".to_string(),
|
||||
],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 120,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
meta: ArtistMeta {
|
||||
id: ArtistId {
|
||||
name: "The Album_Artist ‘C’".to_string(),
|
||||
},
|
||||
sort: Some("Album_Artist ‘C’, The".to_string()),
|
||||
info: ArtistInfo {
|
||||
musicbrainz: MbRefOption::CannotHaveMbid,
|
||||
properties: HashMap::new(),
|
||||
},
|
||||
},
|
||||
albums: vec![
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title c.a".to_string(),
|
||||
},
|
||||
date: 1985.into(),
|
||||
seq: AlbumSeq(0),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::None,
|
||||
primary_type: Some(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![],
|
||||
},
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track c.a.1".to_string(),
|
||||
},
|
||||
number: TrackNum(1),
|
||||
artist: vec!["artist c.a.1".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 320,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track c.a.2".to_string(),
|
||||
},
|
||||
number: TrackNum(2),
|
||||
artist: vec![
|
||||
"artist c.a.2.1".to_string(),
|
||||
"artist c.a.2.2".to_string(),
|
||||
],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 120,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title c.b".to_string(),
|
||||
},
|
||||
date: 2018.into(),
|
||||
seq: AlbumSeq(0),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::None,
|
||||
primary_type: Some(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![],
|
||||
},
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track c.b.1".to_string(),
|
||||
},
|
||||
number: TrackNum(1),
|
||||
artist: vec!["artist c.b.1".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 1041,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track c.b.2".to_string(),
|
||||
},
|
||||
number: TrackNum(2),
|
||||
artist: vec![
|
||||
"artist c.b.2.1".to_string(),
|
||||
"artist c.b.2.2".to_string(),
|
||||
],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 756,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
Artist {
|
||||
meta: ArtistMeta {
|
||||
id: ArtistId {
|
||||
name: "Album_Artist ‘D’".to_string(),
|
||||
},
|
||||
sort: None,
|
||||
info: ArtistInfo {
|
||||
musicbrainz: MbRefOption::None,
|
||||
properties: HashMap::new(),
|
||||
},
|
||||
},
|
||||
albums: vec![
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title d.a".to_string(),
|
||||
},
|
||||
date: 1995.into(),
|
||||
seq: AlbumSeq(0),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::None,
|
||||
primary_type: Some(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![],
|
||||
},
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track d.a.1".to_string(),
|
||||
},
|
||||
number: TrackNum(1),
|
||||
artist: vec!["artist d.a.1".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 120,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track d.a.2".to_string(),
|
||||
},
|
||||
number: TrackNum(2),
|
||||
artist: vec![
|
||||
"artist d.a.2.1".to_string(),
|
||||
"artist d.a.2.2".to_string(),
|
||||
],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 120,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title d.b".to_string(),
|
||||
},
|
||||
date: 2028.into(),
|
||||
seq: AlbumSeq(0),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::None,
|
||||
primary_type: Some(AlbumPrimaryType::Album),
|
||||
secondary_types: vec![],
|
||||
},
|
||||
},
|
||||
tracks: vec![
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track d.b.1".to_string(),
|
||||
},
|
||||
number: TrackNum(1),
|
||||
artist: vec!["artist d.b.1".to_string()],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 841,
|
||||
},
|
||||
},
|
||||
Track {
|
||||
id: TrackId {
|
||||
title: "track d.b.2".to_string(),
|
||||
},
|
||||
number: TrackNum(2),
|
||||
artist: vec![
|
||||
"artist d.b.2.1".to_string(),
|
||||
"artist d.b.2.2".to_string(),
|
||||
],
|
||||
quality: TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 756,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use full_collection;
|
455
src/testmod/library.rs
Normal file
455
src/testmod/library.rs
Normal file
@ -0,0 +1,455 @@
|
||||
#[allow(unused_macros)]
|
||||
macro_rules! library_collection {
|
||||
() => {
|
||||
vec![
|
||||
Artist {
|
||||
meta: ArtistMeta {
|
||||
id: ArtistId {
|
||||
name: "Album_Artist ‘A’".to_string(),
|
||||
},
|
||||
sort: None,
|
||||
info: ArtistInfo {
|
||||
musicbrainz: MbRefOption::None,
|
||||
properties: HashMap::new(),
|
||||
},
|
||||
},
|
||||
albums: vec![
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title a.a".to_string(),
|
||||
},
|
||||
date: 1998.into(),
|
||||
seq: AlbumSeq(0),
|
||||
info: AlbumInfo::default(),
|
||||
},
|
||||
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(0),
|
||||
info: AlbumInfo::default(),
|
||||
},
|
||||
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::None,
|
||||
properties: HashMap::new(),
|
||||
},
|
||||
},
|
||||
albums: vec![
|
||||
Album {
|
||||
meta: AlbumMeta {
|
||||
id: AlbumId {
|
||||
title: "album_title b.a".to_string(),
|
||||
},
|
||||
date: (2003, 6, 6).into(),
|
||||
seq: AlbumSeq(0),
|
||||
info: AlbumInfo::default(),
|
||||
},
|
||||
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(0),
|
||||
info: AlbumInfo::default(),
|
||||
},
|
||||
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(0),
|
||||
info: AlbumInfo::default(),
|
||||
},
|
||||
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(0),
|
||||
info: AlbumInfo::default(),
|
||||
},
|
||||
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::None,
|
||||
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::default(),
|
||||
},
|
||||
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::default(),
|
||||
},
|
||||
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::default(),
|
||||
},
|
||||
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::default(),
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use library_collection;
|
2
src/testmod/mod.rs
Normal file
2
src/testmod/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod full;
|
||||
pub mod library;
|
144
src/tui/app/machine/benchmod.rs
Normal file
144
src/tui/app/machine/benchmod.rs
Normal file
@ -0,0 +1,144 @@
|
||||
// Date: 2024-02-19
|
||||
pub const ARTISTS: [&str; 141] = [
|
||||
"Abadden",
|
||||
"Acid Drinkers",
|
||||
"Adema",
|
||||
"Æther Realm",
|
||||
"Alestorm",
|
||||
"Alex Rivers",
|
||||
"Alien Weaponry",
|
||||
"Allegaeon",
|
||||
"Alter Bridge",
|
||||
"Amon Amarth",
|
||||
"Amorphis",
|
||||
"Apocalyptica",
|
||||
"Arch Enemy",
|
||||
"Аркона",
|
||||
"Artas",
|
||||
"As I Lay Dying",
|
||||
"Avenged Sevenfold",
|
||||
"Aversions Crown",
|
||||
"Aviators",
|
||||
"Azarath",
|
||||
"Baaba Kulka",
|
||||
"Battle Beast",
|
||||
"Beast in Black",
|
||||
"Behemoth",
|
||||
"Black Sabbath",
|
||||
"Blind Guardian",
|
||||
"Blind Guardian Twilight Orchestra",
|
||||
"Bloodbath",
|
||||
"Bloodbound",
|
||||
"Brothers of Metal",
|
||||
"Carnation",
|
||||
"Cellar Darling",
|
||||
"Children of Bodom",
|
||||
"Chimaira",
|
||||
"Crystalic",
|
||||
"Dark Tranquillity",
|
||||
"Dethklok",
|
||||
"DevilDriver",
|
||||
"Dismember",
|
||||
"Disturbed",
|
||||
"The Dreadnoughts",
|
||||
"Dynazty",
|
||||
"Edguy",
|
||||
"Eluveitie",
|
||||
"Eminem",
|
||||
"Enforcer",
|
||||
"Ensiferum",
|
||||
"Epica",
|
||||
"Era",
|
||||
"Evile",
|
||||
"Ex Deo",
|
||||
"Exit Eden",
|
||||
"Faithful Darkness",
|
||||
"Fear Factory",
|
||||
"Fit for an Autopsy",
|
||||
"Five Finger Death Punch",
|
||||
"Fleshgod Apocalypse",
|
||||
"Flotsam and Jetsam",
|
||||
"Frontside",
|
||||
"Furyon",
|
||||
"Godsmack",
|
||||
"Grand Magus",
|
||||
"Grave Digger",
|
||||
"Graveworm",
|
||||
"Guns N’ Roses",
|
||||
"Haggard",
|
||||
"Hate",
|
||||
"Havukruunu",
|
||||
"Heaven Shall Burn",
|
||||
"Heaven’s Basement",
|
||||
"Heavy Load",
|
||||
"Hermh",
|
||||
"Immortal",
|
||||
"In Flames",
|
||||
"Insomnium",
|
||||
"Iron Maiden",
|
||||
"Kalmah",
|
||||
"Kataklysm",
|
||||
"Kontrust",
|
||||
"Korn",
|
||||
"Korpiklaani",
|
||||
"The Last Hangmen",
|
||||
"Level 70 Elite Tauren Chieftain",
|
||||
"Linkin Park",
|
||||
"Lost Dreams",
|
||||
"Man Must Die",
|
||||
"Me and That Man",
|
||||
"Mercyful Fate",
|
||||
"Metallica",
|
||||
"Michael Jackson",
|
||||
"Miracle of Sound",
|
||||
"Misery Index",
|
||||
"Mudvayne",
|
||||
"Månegarm",
|
||||
"Nickelback",
|
||||
"Nightwish",
|
||||
"Nile",
|
||||
"Nine Treasures",
|
||||
"Obscura",
|
||||
"The Offspring",
|
||||
"Oomph!",
|
||||
"P.O.D.",
|
||||
"Paddy and the Rats",
|
||||
"Paul Stanley",
|
||||
"Persefone",
|
||||
"Peyton Parrish",
|
||||
"Powerwolf",
|
||||
"Primitai",
|
||||
"Primordial",
|
||||
"Pro‐Pain",
|
||||
"Rammstein",
|
||||
"Red Hot Chili Peppers",
|
||||
"Revocation",
|
||||
"Rob Zombie",
|
||||
"Sabaton",
|
||||
"Savatage",
|
||||
"Scars on Broadway",
|
||||
"Scorpions",
|
||||
"Silent Descent",
|
||||
"Slayer",
|
||||
"Slipknot",
|
||||
"Soilwork",
|
||||
"Sonic Syndicate",
|
||||
"Soulfallen",
|
||||
"Spiritfall",
|
||||
"Stratovarius",
|
||||
"Sylosis",
|
||||
"System of a Down",
|
||||
"Tarot",
|
||||
"Timecry",
|
||||
"Trivium",
|
||||
"Tuomas Holopainen",
|
||||
"VNV Nation",
|
||||
"Vader",
|
||||
"Vicious Crusade",
|
||||
"The Wages of Sin",
|
||||
"Whitechapel",
|
||||
"Within Temptation",
|
||||
"Woe of Tyrants",
|
||||
"Wovenwar",
|
||||
"Xandria",
|
||||
];
|
188
src/tui/app/machine/browse_state.rs
Normal file
188
src/tui/app/machine/browse_state.rs
Normal file
@ -0,0 +1,188 @@
|
||||
use crate::tui::app::{
|
||||
machine::{App, AppInner, AppMachine},
|
||||
selection::ListSelection,
|
||||
AppPublicState, AppState, Delta, IAppInteractBrowse,
|
||||
};
|
||||
|
||||
pub struct BrowseState;
|
||||
|
||||
impl AppMachine<BrowseState> {
|
||||
pub fn browse_state(inner: AppInner) -> Self {
|
||||
AppMachine::new(inner, BrowseState)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppMachine<BrowseState>> for App {
|
||||
fn from(machine: AppMachine<BrowseState>) -> Self {
|
||||
AppState::Browse(machine)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a mut BrowseState> for AppPublicState<'a> {
|
||||
fn from(_state: &'a mut BrowseState) -> Self {
|
||||
AppState::Browse(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IAppInteractBrowse for AppMachine<BrowseState> {
|
||||
type APP = App;
|
||||
|
||||
fn quit(mut self) -> Self::APP {
|
||||
self.inner.running = false;
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn increment_category(mut self) -> Self::APP {
|
||||
self.inner.selection.increment_category();
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn decrement_category(mut self) -> Self::APP {
|
||||
self.inner.selection.decrement_category();
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn increment_selection(mut self, delta: Delta) -> Self::APP {
|
||||
self.inner
|
||||
.selection
|
||||
.increment_selection(self.inner.music_hoard.get_collection(), delta);
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn decrement_selection(mut self, delta: Delta) -> Self::APP {
|
||||
self.inner
|
||||
.selection
|
||||
.decrement_selection(self.inner.music_hoard.get_collection(), delta);
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn show_info_overlay(self) -> Self::APP {
|
||||
AppMachine::info_state(self.inner).into()
|
||||
}
|
||||
|
||||
fn show_reload_menu(self) -> Self::APP {
|
||||
AppMachine::reload_state(self.inner).into()
|
||||
}
|
||||
|
||||
fn begin_search(mut self) -> Self::APP {
|
||||
let orig = ListSelection::get(&self.inner.selection);
|
||||
self.inner
|
||||
.selection
|
||||
.reset(self.inner.music_hoard.get_collection());
|
||||
AppMachine::search_state(self.inner, orig).into()
|
||||
}
|
||||
|
||||
fn fetch_musicbrainz(self) -> Self::APP {
|
||||
AppMachine::app_fetch_first(self.inner)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::{
|
||||
app::{
|
||||
machine::tests::{inner, inner_with_mb, music_hoard},
|
||||
Category, IApp, IAppAccess,
|
||||
},
|
||||
lib::interface::musicbrainz::daemon::MockIMbJobSender,
|
||||
testmod::COLLECTION,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quit() {
|
||||
let music_hoard = music_hoard(vec![]);
|
||||
|
||||
let browse = AppMachine::browse_state(inner(music_hoard));
|
||||
|
||||
let app = browse.quit();
|
||||
assert!(!app.is_running());
|
||||
app.unwrap_browse();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increment_decrement() {
|
||||
let mut browse = AppMachine::browse_state(inner(music_hoard(COLLECTION.to_owned())));
|
||||
let sel = &browse.inner.selection;
|
||||
assert_eq!(sel.category(), Category::Artist);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let sel = &browse.inner.selection;
|
||||
assert_eq!(sel.category(), Category::Artist);
|
||||
assert_eq!(sel.selected(), Some(1));
|
||||
|
||||
browse = browse.increment_category().unwrap_browse();
|
||||
let sel = &browse.inner.selection;
|
||||
assert_eq!(sel.category(), Category::Album);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let sel = &browse.inner.selection;
|
||||
assert_eq!(sel.category(), Category::Album);
|
||||
assert_eq!(sel.selected(), Some(1));
|
||||
|
||||
browse = browse.decrement_selection(Delta::Line).unwrap_browse();
|
||||
let sel = &browse.inner.selection;
|
||||
assert_eq!(sel.category(), Category::Album);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
browse = browse.decrement_category().unwrap_browse();
|
||||
let sel = &browse.inner.selection;
|
||||
assert_eq!(sel.category(), Category::Artist);
|
||||
assert_eq!(sel.selected(), Some(1));
|
||||
|
||||
browse = browse.decrement_selection(Delta::Line).unwrap_browse();
|
||||
let sel = &browse.inner.selection;
|
||||
assert_eq!(sel.category(), Category::Artist);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_info_overlay() {
|
||||
let browse = AppMachine::browse_state(inner(music_hoard(vec![])));
|
||||
let app = browse.show_info_overlay();
|
||||
app.unwrap_info();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_reload_menu() {
|
||||
let browse = AppMachine::browse_state(inner(music_hoard(vec![])));
|
||||
let app = browse.show_reload_menu();
|
||||
app.unwrap_reload();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn begin_search() {
|
||||
let browse = AppMachine::browse_state(inner(music_hoard(vec![])));
|
||||
let app = browse.begin_search();
|
||||
app.unwrap_search();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_musicbrainz() {
|
||||
let mut mb_job_sender = MockIMbJobSender::new();
|
||||
mb_job_sender
|
||||
.expect_submit_background_job()
|
||||
.times(1)
|
||||
.returning(|_, _| Ok(()));
|
||||
|
||||
let browse = AppMachine::browse_state(inner_with_mb(
|
||||
music_hoard(COLLECTION.to_owned()),
|
||||
mb_job_sender,
|
||||
));
|
||||
|
||||
// Use the second artist for this test.
|
||||
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let mut app = browse.fetch_musicbrainz();
|
||||
|
||||
let public = app.get();
|
||||
|
||||
// Because of fetch's threaded behaviour, this unit test cannot expect one or the other.
|
||||
assert!(
|
||||
matches!(public.state, AppState::Match(_))
|
||||
|| matches!(public.state, AppState::Fetch(_))
|
||||
);
|
||||
}
|
||||
}
|
34
src/tui/app/machine/critical_state.rs
Normal file
34
src/tui/app/machine/critical_state.rs
Normal file
@ -0,0 +1,34 @@
|
||||
use crate::tui::app::{
|
||||
machine::{App, AppInner, AppMachine},
|
||||
AppPublicState, AppState,
|
||||
};
|
||||
|
||||
pub struct CriticalState {
|
||||
string: String,
|
||||
}
|
||||
|
||||
impl CriticalState {
|
||||
fn new<S: Into<String>>(string: S) -> Self {
|
||||
CriticalState {
|
||||
string: string.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppMachine<CriticalState> {
|
||||
pub fn critical_state<S: Into<String>>(inner: AppInner, string: S) -> Self {
|
||||
AppMachine::new(inner, CriticalState::new(string))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppMachine<CriticalState>> for App {
|
||||
fn from(machine: AppMachine<CriticalState>) -> Self {
|
||||
AppState::Critical(machine)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a mut CriticalState> for AppPublicState<'a> {
|
||||
fn from(state: &'a mut CriticalState) -> Self {
|
||||
AppState::Critical(&state.string)
|
||||
}
|
||||
}
|
56
src/tui/app/machine/error_state.rs
Normal file
56
src/tui/app/machine/error_state.rs
Normal file
@ -0,0 +1,56 @@
|
||||
use crate::tui::app::{
|
||||
machine::{App, AppInner, AppMachine},
|
||||
AppPublicState, AppState, IAppInteractError,
|
||||
};
|
||||
|
||||
pub struct ErrorState {
|
||||
string: String,
|
||||
}
|
||||
|
||||
impl ErrorState {
|
||||
fn new<S: Into<String>>(string: S) -> Self {
|
||||
ErrorState {
|
||||
string: string.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppMachine<ErrorState> {
|
||||
pub fn error_state<S: Into<String>>(inner: AppInner, string: S) -> Self {
|
||||
AppMachine::new(inner, ErrorState::new(string))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppMachine<ErrorState>> for App {
|
||||
fn from(machine: AppMachine<ErrorState>) -> Self {
|
||||
AppState::Error(machine)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a mut ErrorState> for AppPublicState<'a> {
|
||||
fn from(state: &'a mut ErrorState) -> Self {
|
||||
AppState::Error(&state.string)
|
||||
}
|
||||
}
|
||||
|
||||
impl IAppInteractError for AppMachine<ErrorState> {
|
||||
type APP = App;
|
||||
|
||||
fn dismiss_error(self) -> Self::APP {
|
||||
AppMachine::browse_state(self.inner).into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::app::machine::tests::{inner, music_hoard};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dismiss_error() {
|
||||
let error = AppMachine::error_state(inner(music_hoard(vec![])), "get rekt");
|
||||
let app = error.dismiss_error();
|
||||
app.unwrap_browse();
|
||||
}
|
||||
}
|
705
src/tui/app/machine/fetch_state.rs
Normal file
705
src/tui/app/machine/fetch_state.rs
Normal file
@ -0,0 +1,705 @@
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
slice,
|
||||
sync::mpsc::{self, TryRecvError},
|
||||
};
|
||||
|
||||
use musichoard::collection::{
|
||||
album::{Album, AlbumId},
|
||||
artist::{Artist, ArtistId, ArtistMeta},
|
||||
musicbrainz::{IMusicBrainzRef, MbArtistRef, MbRefOption, Mbid},
|
||||
};
|
||||
|
||||
use crate::tui::{
|
||||
app::{
|
||||
machine::{match_state::MatchState, App, AppInner, AppMachine},
|
||||
AppPublicState, AppState, Category, IAppEventFetch, IAppInteractFetch,
|
||||
},
|
||||
lib::interface::musicbrainz::daemon::{
|
||||
Error as DaemonError, IMbJobSender, MbApiResult, MbParams, MbReturn, ResultSender,
|
||||
},
|
||||
};
|
||||
|
||||
pub type FetchReceiver = mpsc::Receiver<MbApiResult>;
|
||||
pub struct FetchState {
|
||||
fetch_rx: FetchReceiver,
|
||||
lookup_rx: Option<FetchReceiver>,
|
||||
}
|
||||
|
||||
impl FetchState {
|
||||
pub fn new(fetch_rx: FetchReceiver) -> Self {
|
||||
FetchState {
|
||||
fetch_rx,
|
||||
lookup_rx: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_recv(&mut self) -> Result<MbApiResult, TryRecvError> {
|
||||
if let Some(lookup_rx) = &self.lookup_rx {
|
||||
match lookup_rx.try_recv() {
|
||||
x @ Ok(_) | x @ Err(TryRecvError::Empty) => return x,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.lookup_rx.take();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.fetch_rx.try_recv()
|
||||
}
|
||||
}
|
||||
|
||||
enum FetchError {
|
||||
NothingToFetch,
|
||||
SubmitError(DaemonError),
|
||||
}
|
||||
|
||||
impl From<DaemonError> for FetchError {
|
||||
fn from(value: DaemonError) -> Self {
|
||||
FetchError::SubmitError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl AppMachine<FetchState> {
|
||||
fn fetch_state(inner: AppInner, state: FetchState) -> Self {
|
||||
AppMachine::new(inner, state)
|
||||
}
|
||||
|
||||
pub fn app_fetch_first(inner: AppInner) -> App {
|
||||
Self::app_fetch_new(inner)
|
||||
}
|
||||
|
||||
fn app_fetch_new(inner: AppInner) -> App {
|
||||
let coll = inner.music_hoard.get_collection();
|
||||
|
||||
let artist = match inner.selection.state_artist(coll) {
|
||||
Some(artist_state) => &coll[artist_state.index],
|
||||
None => {
|
||||
let err = "cannot fetch artist: no artist selected";
|
||||
return AppMachine::error_state(inner, err).into();
|
||||
}
|
||||
};
|
||||
|
||||
let (fetch_tx, fetch_rx) = mpsc::channel::<MbApiResult>();
|
||||
let fetch = FetchState::new(fetch_rx);
|
||||
|
||||
let mb = &*inner.musicbrainz;
|
||||
let result = match inner.selection.category() {
|
||||
Category::Artist => Self::submit_search_artist_job(mb, fetch_tx, artist),
|
||||
_ => {
|
||||
let arid = match artist.meta.info.musicbrainz {
|
||||
MbRefOption::Some(ref mbref) => mbref,
|
||||
_ => {
|
||||
let err = "cannot fetch album: artist has no MBID";
|
||||
return AppMachine::error_state(inner, err).into();
|
||||
}
|
||||
};
|
||||
let album = match inner.selection.state_album(coll) {
|
||||
Some(album_state) => &artist.albums[album_state.index],
|
||||
None => {
|
||||
let err = "cannot fetch album: no album selected";
|
||||
return AppMachine::error_state(inner, err).into();
|
||||
}
|
||||
};
|
||||
let artist_id = &artist.meta.id;
|
||||
Self::submit_search_release_group_job(mb, fetch_tx, artist_id, arid, album)
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => AppMachine::fetch_state(inner, fetch).into(),
|
||||
Err(FetchError::NothingToFetch) => AppMachine::browse_state(inner).into(),
|
||||
Err(FetchError::SubmitError(daemon_err)) => {
|
||||
AppMachine::error_state(inner, daemon_err.to_string()).into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn app_fetch_next(inner: AppInner, mut fetch: FetchState) -> App {
|
||||
match fetch.try_recv() {
|
||||
Ok(fetch_result) => match fetch_result {
|
||||
Ok(retval) => Self::handle_mb_api_return(inner, fetch, retval),
|
||||
Err(fetch_err) => {
|
||||
AppMachine::error_state(inner, format!("fetch failed: {fetch_err}")).into()
|
||||
}
|
||||
},
|
||||
Err(recv_err) => match recv_err {
|
||||
TryRecvError::Empty => AppMachine::fetch_state(inner, fetch).into(),
|
||||
TryRecvError::Disconnected => Self::app_fetch_new(inner),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mb_api_return(inner: AppInner, fetch: FetchState, retval: MbReturn) -> App {
|
||||
match retval {
|
||||
MbReturn::Match(next_match) => {
|
||||
AppMachine::match_state(inner, MatchState::new(next_match, fetch)).into()
|
||||
}
|
||||
_ => unimplemented!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn app_lookup_artist(
|
||||
inner: AppInner,
|
||||
fetch: FetchState,
|
||||
artist: &ArtistMeta,
|
||||
mbid: Mbid,
|
||||
) -> App {
|
||||
let f = Self::submit_lookup_artist_job;
|
||||
Self::app_lookup(f, inner, fetch, artist, mbid)
|
||||
}
|
||||
|
||||
pub fn app_lookup_album(
|
||||
inner: AppInner,
|
||||
fetch: FetchState,
|
||||
artist_id: &ArtistId,
|
||||
album_id: &AlbumId,
|
||||
mbid: Mbid,
|
||||
) -> App {
|
||||
let f = |mb: &dyn IMbJobSender, rs, album, mbid| {
|
||||
Self::submit_lookup_release_group_job(mb, rs, artist_id, album, mbid)
|
||||
};
|
||||
Self::app_lookup(f, inner, fetch, album_id, mbid)
|
||||
}
|
||||
|
||||
fn app_lookup<F, Meta>(
|
||||
submit: F,
|
||||
inner: AppInner,
|
||||
mut fetch: FetchState,
|
||||
meta: Meta,
|
||||
mbid: Mbid,
|
||||
) -> App
|
||||
where
|
||||
F: FnOnce(&dyn IMbJobSender, ResultSender, Meta, Mbid) -> Result<(), DaemonError>,
|
||||
{
|
||||
let (lookup_tx, lookup_rx) = mpsc::channel::<MbApiResult>();
|
||||
if let Err(err) = submit(&*inner.musicbrainz, lookup_tx, meta, mbid) {
|
||||
return AppMachine::error_state(inner, err.to_string()).into();
|
||||
}
|
||||
fetch.lookup_rx.replace(lookup_rx);
|
||||
Self::app_fetch_next(inner, fetch)
|
||||
}
|
||||
|
||||
fn submit_search_artist_job(
|
||||
musicbrainz: &dyn IMbJobSender,
|
||||
result_sender: ResultSender,
|
||||
artist: &Artist,
|
||||
) -> Result<(), FetchError> {
|
||||
let requests = match artist.meta.info.musicbrainz {
|
||||
MbRefOption::Some(ref arid) => {
|
||||
Self::search_albums_requests(&artist.meta.id, arid, &artist.albums)
|
||||
}
|
||||
MbRefOption::CannotHaveMbid => VecDeque::new(),
|
||||
MbRefOption::None => Self::search_artist_request(&artist.meta),
|
||||
};
|
||||
if requests.is_empty() {
|
||||
return Err(FetchError::NothingToFetch);
|
||||
}
|
||||
Ok(musicbrainz.submit_background_job(result_sender, requests)?)
|
||||
}
|
||||
|
||||
fn submit_search_release_group_job(
|
||||
musicbrainz: &dyn IMbJobSender,
|
||||
result_sender: ResultSender,
|
||||
artist_id: &ArtistId,
|
||||
artist_mbid: &MbArtistRef,
|
||||
album: &Album,
|
||||
) -> Result<(), FetchError> {
|
||||
if !matches!(album.meta.info.musicbrainz, MbRefOption::None) {
|
||||
return Err(FetchError::NothingToFetch);
|
||||
}
|
||||
let requests = Self::search_albums_requests(artist_id, artist_mbid, slice::from_ref(album));
|
||||
Ok(musicbrainz.submit_background_job(result_sender, requests)?)
|
||||
}
|
||||
|
||||
fn search_albums_requests(
|
||||
artist: &ArtistId,
|
||||
arid: &MbArtistRef,
|
||||
albums: &[Album],
|
||||
) -> VecDeque<MbParams> {
|
||||
let arid = arid.mbid();
|
||||
albums
|
||||
.iter()
|
||||
.filter(|album| matches!(album.meta.info.musicbrainz, MbRefOption::None))
|
||||
.map(|album| {
|
||||
MbParams::search_release_group(artist.clone(), arid.clone(), album.meta.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn search_artist_request(meta: &ArtistMeta) -> VecDeque<MbParams> {
|
||||
VecDeque::from([MbParams::search_artist(meta.clone())])
|
||||
}
|
||||
|
||||
fn submit_lookup_artist_job(
|
||||
musicbrainz: &dyn IMbJobSender,
|
||||
result_sender: ResultSender,
|
||||
artist: &ArtistMeta,
|
||||
mbid: Mbid,
|
||||
) -> Result<(), DaemonError> {
|
||||
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid)]);
|
||||
musicbrainz.submit_foreground_job(result_sender, requests)
|
||||
}
|
||||
|
||||
fn submit_lookup_release_group_job(
|
||||
musicbrainz: &dyn IMbJobSender,
|
||||
result_sender: ResultSender,
|
||||
artist_id: &ArtistId,
|
||||
album_id: &AlbumId,
|
||||
mbid: Mbid,
|
||||
) -> Result<(), DaemonError> {
|
||||
let requests = VecDeque::from([MbParams::lookup_release_group(
|
||||
artist_id.clone(),
|
||||
album_id.clone(),
|
||||
mbid,
|
||||
)]);
|
||||
musicbrainz.submit_foreground_job(result_sender, requests)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppMachine<FetchState>> for App {
|
||||
fn from(machine: AppMachine<FetchState>) -> Self {
|
||||
AppState::Fetch(machine)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a mut FetchState> for AppPublicState<'a> {
|
||||
fn from(_state: &'a mut FetchState) -> Self {
|
||||
AppState::Fetch(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IAppInteractFetch for AppMachine<FetchState> {
|
||||
type APP = App;
|
||||
|
||||
fn abort(self) -> Self::APP {
|
||||
AppMachine::browse_state(self.inner).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl IAppEventFetch for AppMachine<FetchState> {
|
||||
type APP = App;
|
||||
|
||||
fn fetch_result_ready(self) -> Self::APP {
|
||||
Self::app_fetch_next(self.inner, self.state)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::predicate;
|
||||
use musichoard::collection::{
|
||||
album::AlbumMeta,
|
||||
artist::{ArtistId, ArtistMeta},
|
||||
musicbrainz::Mbid,
|
||||
};
|
||||
|
||||
use crate::tui::{
|
||||
app::{
|
||||
machine::tests::{inner, music_hoard},
|
||||
Delta, EntityMatches, IApp, IAppAccess, IAppInteractBrowse, MatchOption,
|
||||
},
|
||||
lib::interface::musicbrainz::{self, api::Entity, daemon::MockIMbJobSender},
|
||||
testmod::COLLECTION,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn mbid() -> Mbid {
|
||||
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_recv() {
|
||||
let (fetch_tx, fetch_rx) = mpsc::channel();
|
||||
let (lookup_tx, lookup_rx) = mpsc::channel();
|
||||
|
||||
let mut fetch = FetchState::new(fetch_rx);
|
||||
fetch.lookup_rx.replace(lookup_rx);
|
||||
|
||||
let artist = COLLECTION[3].meta.clone();
|
||||
|
||||
let matches: Vec<Entity<ArtistMeta>> = vec![];
|
||||
let fetch_result = MbReturn::Match(EntityMatches::artist_search(artist.clone(), matches));
|
||||
fetch_tx.send(Ok(fetch_result.clone())).unwrap();
|
||||
|
||||
assert_eq!(fetch.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
let lookup = Entity::new(artist.clone());
|
||||
let lookup_result = MbReturn::Match(EntityMatches::artist_lookup(artist.clone(), lookup));
|
||||
lookup_tx.send(Ok(lookup_result.clone())).unwrap();
|
||||
|
||||
assert_eq!(fetch.try_recv(), Ok(Ok(lookup_result)));
|
||||
|
||||
assert_eq!(fetch.try_recv(), Err(TryRecvError::Empty));
|
||||
drop(lookup_tx);
|
||||
assert_eq!(fetch.try_recv(), Ok(Ok(fetch_result)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_no_artist() {
|
||||
let app = AppMachine::app_fetch_first(inner(music_hoard(vec![])));
|
||||
assert!(matches!(app.state(), AppState::Error(_)));
|
||||
}
|
||||
|
||||
fn search_release_group_expectation(
|
||||
job_sender: &mut MockIMbJobSender,
|
||||
artist_id: &ArtistId,
|
||||
artist_mbid: &Mbid,
|
||||
albums: &[AlbumMeta],
|
||||
) {
|
||||
let mut requests = VecDeque::new();
|
||||
for album in albums.iter() {
|
||||
requests.push_back(MbParams::search_release_group(
|
||||
artist_id.clone(),
|
||||
artist_mbid.clone(),
|
||||
album.clone(),
|
||||
));
|
||||
}
|
||||
job_sender
|
||||
.expect_submit_background_job()
|
||||
.with(predicate::always(), predicate::eq(requests))
|
||||
.times(1)
|
||||
.return_once(|_, _| Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_single_album() {
|
||||
let mut mb_job_sender = MockIMbJobSender::new();
|
||||
|
||||
let artist_id = COLLECTION[1].meta.id.clone();
|
||||
let artist_mbid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
|
||||
|
||||
let album_meta = COLLECTION[1].albums[0].meta.clone();
|
||||
|
||||
search_release_group_expectation(
|
||||
&mut mb_job_sender,
|
||||
&artist_id,
|
||||
&artist_mbid,
|
||||
&[album_meta],
|
||||
);
|
||||
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||
|
||||
// Use second artist and have album selected to match the expectation.
|
||||
let browse = AppMachine::browse_state(inner);
|
||||
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let app = browse.increment_category();
|
||||
|
||||
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||
assert!(matches!(app, AppState::Fetch(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_single_album_nothing_to_fetch() {
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
let inner = inner(music_hoard);
|
||||
|
||||
// Use second artist, have second album selected (has MBID) to match the expectation.
|
||||
let browse = AppMachine::browse_state(inner);
|
||||
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let browse = browse.increment_category().unwrap_browse();
|
||||
let app = browse.increment_selection(Delta::Line);
|
||||
|
||||
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||
assert!(matches!(app, AppState::Browse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_single_album_no_artist_mbid() {
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
let inner = inner(music_hoard);
|
||||
|
||||
// Use third artist and have album selected to match the expectation.
|
||||
let browse = AppMachine::browse_state(inner);
|
||||
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let app = browse.increment_category();
|
||||
|
||||
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||
assert!(matches!(app, AppState::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_single_album_no_album() {
|
||||
let mut collection = COLLECTION.to_owned();
|
||||
collection[1].albums.clear();
|
||||
|
||||
let music_hoard = music_hoard(collection);
|
||||
let inner = inner(music_hoard);
|
||||
|
||||
// Use second artist and have album selected to match the expectation.
|
||||
let browse = AppMachine::browse_state(inner);
|
||||
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let app = browse.increment_category();
|
||||
|
||||
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||
assert!(matches!(app, AppState::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_albums() {
|
||||
let mut mb_job_sender = MockIMbJobSender::new();
|
||||
|
||||
let artist_id = COLLECTION[1].meta.id.clone();
|
||||
let artist_mbid: Mbid = "11111111-1111-1111-1111-111111111111".try_into().unwrap();
|
||||
|
||||
let album_1_meta = COLLECTION[1].albums[0].meta.clone();
|
||||
let album_4_meta = COLLECTION[1].albums[3].meta.clone();
|
||||
|
||||
// Other albums have an MBID and so they will be skipped.
|
||||
search_release_group_expectation(
|
||||
&mut mb_job_sender,
|
||||
&artist_id,
|
||||
&artist_mbid,
|
||||
&[album_1_meta, album_4_meta],
|
||||
);
|
||||
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||
|
||||
// Use second artist to match the expectation.
|
||||
let browse = AppMachine::browse_state(inner);
|
||||
let app = browse.increment_selection(Delta::Line);
|
||||
|
||||
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||
assert!(matches!(app, AppState::Fetch(_)));
|
||||
}
|
||||
|
||||
fn lookup_album_expectation(
|
||||
job_sender: &mut MockIMbJobSender,
|
||||
artist_id: &ArtistId,
|
||||
album_id: &AlbumId,
|
||||
) {
|
||||
let requests = VecDeque::from([MbParams::lookup_release_group(
|
||||
artist_id.clone(),
|
||||
album_id.clone(),
|
||||
mbid(),
|
||||
)]);
|
||||
job_sender
|
||||
.expect_submit_foreground_job()
|
||||
.with(predicate::always(), predicate::eq(requests))
|
||||
.times(1)
|
||||
.return_once(|_, _| Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_album() {
|
||||
let mut mb_job_sender = MockIMbJobSender::new();
|
||||
|
||||
let artist_id = COLLECTION[1].meta.id.clone();
|
||||
let album_id = COLLECTION[1].albums[0].meta.id.clone();
|
||||
lookup_album_expectation(&mut mb_job_sender, &artist_id, &album_id);
|
||||
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||
|
||||
let (_fetch_tx, fetch_rx) = mpsc::channel();
|
||||
let fetch = FetchState::new(fetch_rx);
|
||||
|
||||
AppMachine::app_lookup_album(inner, fetch, &artist_id, &album_id, mbid());
|
||||
}
|
||||
|
||||
fn search_artist_expectation(job_sender: &mut MockIMbJobSender, artist: &ArtistMeta) {
|
||||
let requests = VecDeque::from([MbParams::search_artist(artist.clone())]);
|
||||
job_sender
|
||||
.expect_submit_background_job()
|
||||
.with(predicate::always(), predicate::eq(requests))
|
||||
.times(1)
|
||||
.return_once(|_, _| Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_artist() {
|
||||
let mut mb_job_sender = MockIMbJobSender::new();
|
||||
|
||||
let artist = COLLECTION[3].meta.clone();
|
||||
search_artist_expectation(&mut mb_job_sender, &artist);
|
||||
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||
|
||||
// Use fourth artist to match the expectation.
|
||||
let browse = AppMachine::browse_state(inner);
|
||||
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let app = browse.increment_selection(Delta::Line);
|
||||
|
||||
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||
assert!(matches!(app, AppState::Fetch(_)));
|
||||
}
|
||||
|
||||
fn lookup_artist_expectation(job_sender: &mut MockIMbJobSender, artist: &ArtistMeta) {
|
||||
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid())]);
|
||||
job_sender
|
||||
.expect_submit_foreground_job()
|
||||
.with(predicate::always(), predicate::eq(requests))
|
||||
.times(1)
|
||||
.return_once(|_, _| Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_artist() {
|
||||
let mut mb_job_sender = MockIMbJobSender::new();
|
||||
|
||||
let artist = COLLECTION[3].meta.clone();
|
||||
lookup_artist_expectation(&mut mb_job_sender, &artist);
|
||||
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||
|
||||
let (_fetch_tx, fetch_rx) = mpsc::channel();
|
||||
let fetch = FetchState::new(fetch_rx);
|
||||
|
||||
AppMachine::app_lookup_artist(inner, fetch, &artist, mbid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_artist_cannot_have_mbid() {
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
let inner = inner(music_hoard);
|
||||
|
||||
// Use third artist to match the expectation.
|
||||
let browse = AppMachine::browse_state(inner);
|
||||
let browse = browse.increment_selection(Delta::Line).unwrap_browse();
|
||||
let app = browse.increment_selection(Delta::Line);
|
||||
|
||||
let app = app.unwrap_browse().fetch_musicbrainz();
|
||||
assert!(matches!(app, AppState::Browse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_artist_job_sender_err() {
|
||||
let mut mb_job_sender = MockIMbJobSender::new();
|
||||
|
||||
mb_job_sender
|
||||
.expect_submit_background_job()
|
||||
.return_once(|_, _| Err(DaemonError::JobChannelDisconnected));
|
||||
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||
let browse = AppMachine::browse_state(inner);
|
||||
|
||||
let app = browse.fetch_musicbrainz();
|
||||
assert!(matches!(app, AppState::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_artist_job_sender_err() {
|
||||
let mut mb_job_sender = MockIMbJobSender::new();
|
||||
|
||||
mb_job_sender
|
||||
.expect_submit_foreground_job()
|
||||
.return_once(|_, _| Err(DaemonError::JobChannelDisconnected));
|
||||
|
||||
let artist = COLLECTION[3].meta.clone();
|
||||
|
||||
let music_hoard = music_hoard(COLLECTION.to_owned());
|
||||
let inner = AppInner::new(music_hoard, mb_job_sender);
|
||||
|
||||
let (_fetch_tx, fetch_rx) = mpsc::channel();
|
||||
let fetch = FetchState::new(fetch_rx);
|
||||
|
||||
let app = AppMachine::app_lookup_artist(inner, fetch, &artist, mbid());
|
||||
assert!(matches!(app, AppState::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_ok_fetch_ok() {
|
||||
let (tx, rx) = mpsc::channel::<MbApiResult>();
|
||||
|
||||
let artist = COLLECTION[3].meta.clone();
|
||||
let artist_match = Entity::with_score(COLLECTION[2].meta.clone(), 80);
|
||||
let artist_match_info =
|
||||
EntityMatches::artist_search(artist.clone(), vec![artist_match.clone()]);
|
||||
let fetch_result = Ok(MbReturn::Match(artist_match_info));
|
||||
tx.send(fetch_result).unwrap();
|
||||
|
||||
let inner = inner(music_hoard(COLLECTION.clone()));
|
||||
let fetch = FetchState::new(rx);
|
||||
let mut app = AppMachine::app_fetch_next(inner, fetch);
|
||||
assert!(matches!(app, AppState::Match(_)));
|
||||
|
||||
let public = app.get();
|
||||
let match_state = public.state.unwrap_match();
|
||||
let match_options = vec![
|
||||
artist_match.into(),
|
||||
MatchOption::CannotHaveMbid,
|
||||
MatchOption::ManualInputMbid,
|
||||
];
|
||||
let expected = EntityMatches::artist_search(artist, match_options);
|
||||
assert_eq!(match_state.matches, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_ok_fetch_err() {
|
||||
let (tx, rx) = mpsc::channel::<MbApiResult>();
|
||||
|
||||
let fetch_result = Err(musicbrainz::api::Error::RateLimit);
|
||||
tx.send(fetch_result).unwrap();
|
||||
|
||||
let inner = inner(music_hoard(COLLECTION.clone()));
|
||||
let fetch = FetchState::new(rx);
|
||||
let app = AppMachine::app_fetch_next(inner, fetch);
|
||||
assert!(matches!(app, AppState::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_err_empty() {
|
||||
let (_tx, rx) = mpsc::channel::<MbApiResult>();
|
||||
|
||||
let inner = inner(music_hoard(COLLECTION.clone()));
|
||||
let fetch = FetchState::new(rx);
|
||||
let app = AppMachine::app_fetch_next(inner, fetch);
|
||||
assert!(matches!(app, AppState::Fetch(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_err_empty_first() {
|
||||
let mut collection = COLLECTION.clone();
|
||||
collection[0].albums.clear();
|
||||
|
||||
let app = AppMachine::app_fetch_first(inner(music_hoard(collection)));
|
||||
assert!(matches!(app, AppState::Browse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recv_err_empty_next() {
|
||||
let mut collection = COLLECTION.clone();
|
||||
collection[0].albums.clear();
|
||||
|
||||
let (_, rx) = mpsc::channel::<MbApiResult>();
|
||||
let fetch = FetchState::new(rx);
|
||||
|
||||
let app = AppMachine::app_fetch_next(inner(music_hoard(collection)), fetch);
|
||||
assert!(matches!(app, AppState::Browse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_first_then_ready() {
|
||||
let (tx, rx) = mpsc::channel::<MbApiResult>();
|
||||
|
||||
let inner = inner(music_hoard(COLLECTION.clone()));
|
||||
let fetch = FetchState::new(rx);
|
||||
let app = AppMachine::app_fetch_next(inner, fetch);
|
||||
assert!(matches!(app, AppState::Fetch(_)));
|
||||
|
||||
let artist = COLLECTION[3].meta.clone();
|
||||
let match_info = EntityMatches::artist_search::<Entity<ArtistMeta>>(artist, vec![]);
|
||||
let fetch_result = Ok(MbReturn::Match(match_info));
|
||||
tx.send(fetch_result).unwrap();
|
||||
|
||||
let app = app.unwrap_fetch().fetch_result_ready();
|
||||
assert!(matches!(app, AppState::Match(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort() {
|
||||
let (_, rx) = mpsc::channel::<MbApiResult>();
|
||||
|
||||
let fetch = FetchState::new(rx);
|
||||
let app = AppMachine::fetch_state(inner(music_hoard(COLLECTION.clone())), fetch);
|
||||
|
||||
let app = app.abort();
|
||||
assert!(matches!(app, AppState::Browse(_)));
|
||||
}
|
||||
}
|
46
src/tui/app/machine/info_state.rs
Normal file
46
src/tui/app/machine/info_state.rs
Normal file
@ -0,0 +1,46 @@
|
||||
use crate::tui::app::{
|
||||
machine::{App, AppInner, AppMachine},
|
||||
AppPublicState, AppState, IAppInteractInfo,
|
||||
};
|
||||
|
||||
pub struct InfoState;
|
||||
|
||||
impl AppMachine<InfoState> {
|
||||
pub fn info_state(inner: AppInner) -> Self {
|
||||
AppMachine::new(inner, InfoState)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppMachine<InfoState>> for App {
|
||||
fn from(machine: AppMachine<InfoState>) -> Self {
|
||||
AppState::Info(machine)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a mut InfoState> for AppPublicState<'a> {
|
||||
fn from(_state: &'a mut InfoState) -> Self {
|
||||
AppState::Info(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IAppInteractInfo for AppMachine<InfoState> {
|
||||
type APP = App;
|
||||
|
||||
fn hide_info_overlay(self) -> Self::APP {
|
||||
AppMachine::browse_state(self.inner).into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::app::machine::tests::{inner, music_hoard};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hide_info_overlay() {
|
||||
let info = AppMachine::info_state(inner(music_hoard(vec![])));
|
||||
let app = info.hide_info_overlay();
|
||||
app.unwrap_browse();
|
||||
}
|
||||
}
|
97
src/tui/app/machine/input.rs
Normal file
97
src/tui/app/machine/input.rs
Normal file
@ -0,0 +1,97 @@
|
||||
use tui_input::backend::crossterm::EventHandler;
|
||||
|
||||
use crate::tui::app::{machine::App, AppMode, AppState, IAppInput, InputEvent, InputPublic};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Input(tui_input::Input);
|
||||
|
||||
impl<'app> From<&'app Input> for InputPublic<'app> {
|
||||
fn from(value: &'app Input) -> Self {
|
||||
&value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Input {
|
||||
pub fn value(&self) -> &str {
|
||||
self.0.value()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<App> for AppMode<App, AppInputMode> {
|
||||
fn from(mut app: App) -> Self {
|
||||
if let Some(input) = app.input_mut().take() {
|
||||
AppMode::Input(AppInputMode::new(input, app))
|
||||
} else {
|
||||
AppMode::State(app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppInputMode {
|
||||
input: Input,
|
||||
app: App,
|
||||
}
|
||||
|
||||
impl AppInputMode {
|
||||
pub fn new(input: Input, app: App) -> Self {
|
||||
AppInputMode { input, app }
|
||||
}
|
||||
}
|
||||
|
||||
impl IAppInput for AppInputMode {
|
||||
type APP = App;
|
||||
|
||||
fn input(mut self, input: InputEvent) -> Self::APP {
|
||||
self.input
|
||||
.0
|
||||
.handle_event(&crossterm::event::Event::Key(input.into()));
|
||||
self.app.input_mut().replace(self.input);
|
||||
self.app
|
||||
}
|
||||
|
||||
fn confirm(self) -> Self::APP {
|
||||
if let AppState::Match(state) = self.app {
|
||||
return state.submit_input(self.input);
|
||||
}
|
||||
self.app
|
||||
}
|
||||
|
||||
fn cancel(self) -> Self::APP {
|
||||
self.app
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::app::{
|
||||
machine::tests::{input_event, mb_job_sender, music_hoard_init},
|
||||
IApp,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn handle_input() {
|
||||
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||
app.input_mut().replace(Input::default());
|
||||
|
||||
let input = app.mode().unwrap_input();
|
||||
let app = input.input(input_event('H'));
|
||||
|
||||
let input = app.mode().unwrap_input();
|
||||
let app = input.input(input_event('e'));
|
||||
|
||||
let input = app.mode().unwrap_input();
|
||||
let app = input.input(input_event('l'));
|
||||
|
||||
let input = app.mode().unwrap_input();
|
||||
let app = input.input(input_event('l'));
|
||||
|
||||
let input = app.mode().unwrap_input();
|
||||
let app = input.input(input_event('o'));
|
||||
|
||||
assert_eq!(app.input_ref().as_ref().unwrap().0.value(), "Hello");
|
||||
|
||||
app.mode().unwrap_input().confirm().unwrap_browse();
|
||||
}
|
||||
}
|
657
src/tui/app/machine/match_state.rs
Normal file
657
src/tui/app/machine/match_state.rs
Normal file
@ -0,0 +1,657 @@
|
||||
use std::cmp;
|
||||
|
||||
use musichoard::collection::{
|
||||
album::{AlbumInfo, AlbumMeta},
|
||||
artist::{ArtistInfo, ArtistMeta},
|
||||
musicbrainz::{MbRefOption, Mbid},
|
||||
};
|
||||
|
||||
use crate::tui::app::{
|
||||
machine::{fetch_state::FetchState, input::Input, App, AppInner, AppMachine},
|
||||
AlbumMatches, AppPublicState, AppState, ArtistMatches, Delta, EntityMatches, IAppInteractMatch,
|
||||
MatchOption, MatchStatePublic, WidgetState,
|
||||
};
|
||||
|
||||
trait GetInfoMeta {
|
||||
type InfoType;
|
||||
}
|
||||
impl GetInfoMeta for ArtistMeta {
|
||||
type InfoType = ArtistInfo;
|
||||
}
|
||||
impl GetInfoMeta for AlbumMeta {
|
||||
type InfoType = AlbumInfo;
|
||||
}
|
||||
|
||||
trait GetInfo {
|
||||
type InfoType;
|
||||
fn get_info(&self) -> InfoOption<Self::InfoType>;
|
||||
}
|
||||
|
||||
enum InfoOption<T> {
|
||||
Info(T),
|
||||
NeedInput,
|
||||
}
|
||||
|
||||
impl GetInfo for MatchOption<ArtistMeta> {
|
||||
type InfoType = ArtistInfo;
|
||||
|
||||
fn get_info(&self) -> InfoOption<Self::InfoType> {
|
||||
let mut info = ArtistInfo::default();
|
||||
match self {
|
||||
MatchOption::Some(option) => info.musicbrainz = option.entity.info.musicbrainz.clone(),
|
||||
MatchOption::CannotHaveMbid => info.musicbrainz = MbRefOption::CannotHaveMbid,
|
||||
MatchOption::ManualInputMbid => return InfoOption::NeedInput,
|
||||
}
|
||||
InfoOption::Info(info)
|
||||
}
|
||||
}
|
||||
|
||||
impl GetInfo for MatchOption<AlbumMeta> {
|
||||
type InfoType = AlbumInfo;
|
||||
|
||||
fn get_info(&self) -> InfoOption<Self::InfoType> {
|
||||
let mut info = AlbumInfo::default();
|
||||
match self {
|
||||
MatchOption::Some(option) => info = option.entity.info.clone(),
|
||||
MatchOption::CannotHaveMbid => info.musicbrainz = MbRefOption::CannotHaveMbid,
|
||||
MatchOption::ManualInputMbid => return InfoOption::NeedInput,
|
||||
}
|
||||
InfoOption::Info(info)
|
||||
}
|
||||
}
|
||||
|
||||
trait ExtractInfo {
|
||||
type InfoType;
|
||||
fn extract_info(&self, index: usize) -> InfoOption<Self::InfoType>;
|
||||
}
|
||||
|
||||
impl<T: GetInfoMeta> ExtractInfo for Vec<MatchOption<T>>
|
||||
where
|
||||
MatchOption<T>: GetInfo<InfoType = T::InfoType>,
|
||||
MatchOption<T>: GetInfo<InfoType = T::InfoType>,
|
||||
{
|
||||
type InfoType = T::InfoType;
|
||||
|
||||
fn extract_info(&self, index: usize) -> InfoOption<Self::InfoType> {
|
||||
self.get(index).unwrap().get_info()
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtistMatches {
|
||||
fn len(&self) -> usize {
|
||||
self.list.len()
|
||||
}
|
||||
|
||||
fn push_cannot_have_mbid(&mut self) {
|
||||
self.list.push(MatchOption::CannotHaveMbid);
|
||||
}
|
||||
|
||||
fn push_manual_input_mbid(&mut self) {
|
||||
self.list.push(MatchOption::ManualInputMbid);
|
||||
}
|
||||
}
|
||||
|
||||
impl AlbumMatches {
|
||||
fn len(&self) -> usize {
|
||||
self.list.len()
|
||||
}
|
||||
|
||||
fn push_cannot_have_mbid(&mut self) {
|
||||
self.list.push(MatchOption::CannotHaveMbid);
|
||||
}
|
||||
|
||||
fn push_manual_input_mbid(&mut self) {
|
||||
self.list.push(MatchOption::ManualInputMbid);
|
||||
}
|
||||
}
|
||||
|
||||
impl EntityMatches {
|
||||
fn len(&self) -> usize {
|
||||
match self {
|
||||
Self::Artist(a) => a.len(),
|
||||
Self::Album(a) => a.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_cannot_have_mbid(&mut self) {
|
||||
match self {
|
||||
Self::Artist(a) => a.push_cannot_have_mbid(),
|
||||
Self::Album(a) => a.push_cannot_have_mbid(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_manual_input_mbid(&mut self) {
|
||||
match self {
|
||||
Self::Artist(a) => a.push_manual_input_mbid(),
|
||||
Self::Album(a) => a.push_manual_input_mbid(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MatchState {
|
||||
current: EntityMatches,
|
||||
state: WidgetState,
|
||||
fetch: FetchState,
|
||||
}
|
||||
|
||||
impl MatchState {
|
||||
pub fn new(mut current: EntityMatches, fetch: FetchState) -> Self {
|
||||
current.push_cannot_have_mbid();
|
||||
current.push_manual_input_mbid();
|
||||
|
||||
let state = WidgetState::default().with_selected(Some(0));
|
||||
|
||||
MatchState {
|
||||
current,
|
||||
state,
|
||||
fetch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppMachine<MatchState> {
|
||||
pub fn match_state(inner: AppInner, state: MatchState) -> Self {
|
||||
AppMachine::new(inner, state)
|
||||
}
|
||||
|
||||
pub fn submit_input(self, input: Input) -> App {
|
||||
let mbid: Mbid = match input.value().try_into() {
|
||||
Ok(mbid) => mbid,
|
||||
Err(err) => return AppMachine::error_state(self.inner, err.to_string()).into(),
|
||||
};
|
||||
match self.state.current {
|
||||
EntityMatches::Artist(artist_matches) => {
|
||||
let matching = &artist_matches.matching;
|
||||
AppMachine::app_lookup_artist(self.inner, self.state.fetch, matching, mbid)
|
||||
}
|
||||
EntityMatches::Album(album_matches) => {
|
||||
let artist_id = &album_matches.artist;
|
||||
let matching = &album_matches.matching;
|
||||
AppMachine::app_lookup_album(
|
||||
self.inner,
|
||||
self.state.fetch,
|
||||
artist_id,
|
||||
matching,
|
||||
mbid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_input(mut self) -> App {
|
||||
self.input.replace(Input::default());
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppMachine<MatchState>> for App {
|
||||
fn from(machine: AppMachine<MatchState>) -> Self {
|
||||
AppState::Match(machine)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a mut MatchState> for AppPublicState<'a> {
|
||||
fn from(state: &'a mut MatchState) -> Self {
|
||||
AppState::Match(MatchStatePublic {
|
||||
matches: &state.current,
|
||||
state: &mut state.state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl IAppInteractMatch for AppMachine<MatchState> {
|
||||
type APP = App;
|
||||
|
||||
fn decrement_match(mut self, delta: Delta) -> Self::APP {
|
||||
if let Some(index) = self.state.state.list.selected() {
|
||||
let result = index.saturating_sub(delta.as_usize(&self.state.state));
|
||||
self.state.state.list.select(Some(result));
|
||||
}
|
||||
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn increment_match(mut self, delta: Delta) -> Self::APP {
|
||||
let index = self.state.state.list.selected().unwrap();
|
||||
let to = cmp::min(
|
||||
index.saturating_add(delta.as_usize(&self.state.state)),
|
||||
self.state.current.len().saturating_sub(1),
|
||||
);
|
||||
self.state.state.list.select(Some(to));
|
||||
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn select(mut self) -> Self::APP {
|
||||
let index = self.state.state.list.selected().unwrap();
|
||||
|
||||
let mh = &mut self.inner.music_hoard;
|
||||
let result = match self.state.current {
|
||||
EntityMatches::Artist(ref mut matches) => match matches.list.extract_info(index) {
|
||||
InfoOption::Info(info) => mh.merge_artist_info(&matches.matching.id, info),
|
||||
InfoOption::NeedInput => return self.get_input(),
|
||||
},
|
||||
EntityMatches::Album(ref mut matches) => match matches.list.extract_info(index) {
|
||||
InfoOption::Info(info) => {
|
||||
mh.merge_album_info(&matches.artist, &matches.matching, info)
|
||||
}
|
||||
InfoOption::NeedInput => return self.get_input(),
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(err) = result {
|
||||
return AppMachine::error_state(self.inner, err.to_string()).into();
|
||||
}
|
||||
|
||||
AppMachine::app_fetch_next(self.inner, self.state.fetch)
|
||||
}
|
||||
|
||||
fn abort(self) -> Self::APP {
|
||||
AppMachine::browse_state(self.inner).into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::VecDeque, sync::mpsc};
|
||||
|
||||
use mockall::predicate::{self, eq};
|
||||
use musichoard::collection::{
|
||||
album::{AlbumDate, AlbumId, AlbumInfo, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType},
|
||||
artist::{ArtistId, ArtistMeta},
|
||||
};
|
||||
|
||||
use crate::tui::{
|
||||
app::{
|
||||
machine::tests::{inner, inner_with_mb, input_event, music_hoard},
|
||||
IApp, IAppAccess, IAppInput,
|
||||
},
|
||||
lib::interface::musicbrainz::{
|
||||
api::Entity,
|
||||
daemon::{MbParams, MockIMbJobSender},
|
||||
},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl<T> Entity<T> {
|
||||
pub fn with_score(entity: T, score: u8) -> Self {
|
||||
Entity {
|
||||
score: Some(score),
|
||||
entity,
|
||||
disambiguation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mbid() -> Mbid {
|
||||
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
|
||||
}
|
||||
|
||||
fn artist_meta() -> ArtistMeta {
|
||||
let mut meta = ArtistMeta::new(ArtistId::new("Artist"));
|
||||
meta.info.musicbrainz = MbRefOption::Some(mbid().into());
|
||||
meta
|
||||
}
|
||||
|
||||
fn artist_match() -> EntityMatches {
|
||||
let artist = artist_meta();
|
||||
|
||||
let artist_1 = artist.clone();
|
||||
let artist_match_1 = Entity::with_score(artist_1, 100);
|
||||
|
||||
let artist_2 = artist.clone();
|
||||
let mut artist_match_2 = Entity::with_score(artist_2, 100);
|
||||
artist_match_2.disambiguation = Some(String::from("some disambiguation"));
|
||||
|
||||
let list = vec![artist_match_1.clone(), artist_match_2.clone()];
|
||||
EntityMatches::artist_search(artist, list)
|
||||
}
|
||||
|
||||
fn artist_lookup() -> EntityMatches {
|
||||
let artist = artist_meta();
|
||||
let lookup = Entity::new(artist.clone());
|
||||
EntityMatches::artist_lookup(artist, lookup)
|
||||
}
|
||||
|
||||
fn album_id() -> AlbumId {
|
||||
AlbumId::new("Album")
|
||||
}
|
||||
|
||||
fn album_meta(id: AlbumId) -> AlbumMeta {
|
||||
AlbumMeta::new(
|
||||
id,
|
||||
AlbumDate::new(Some(1990), Some(5), None),
|
||||
AlbumInfo::new(
|
||||
MbRefOption::Some(mbid().into()),
|
||||
Some(AlbumPrimaryType::Album),
|
||||
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn album_match() -> EntityMatches {
|
||||
let artist_id = ArtistId::new("Artist");
|
||||
let album_id = album_id();
|
||||
let album_meta = album_meta(album_id.clone());
|
||||
|
||||
let album_1 = album_meta.clone();
|
||||
let album_match_1 = Entity::with_score(album_1, 100);
|
||||
|
||||
let mut album_2 = album_meta.clone();
|
||||
album_2.id.title.push_str(" extra title part");
|
||||
album_2.info.secondary_types.pop();
|
||||
let album_match_2 = Entity::with_score(album_2, 100);
|
||||
|
||||
let list = vec![album_match_1.clone(), album_match_2.clone()];
|
||||
EntityMatches::album_search(artist_id, album_id, list)
|
||||
}
|
||||
|
||||
fn album_lookup() -> EntityMatches {
|
||||
let artist_id = ArtistId::new("Artist");
|
||||
let album_id = album_id();
|
||||
let album_meta = album_meta(album_id.clone());
|
||||
let lookup = Entity::new(album_meta.clone());
|
||||
EntityMatches::album_lookup(artist_id, album_id, lookup)
|
||||
}
|
||||
|
||||
fn fetch_state() -> FetchState {
|
||||
let (_, rx) = mpsc::channel();
|
||||
FetchState::new(rx)
|
||||
}
|
||||
|
||||
fn match_state(match_state_info: EntityMatches) -> MatchState {
|
||||
MatchState::new(match_state_info, fetch_state())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create() {
|
||||
let mut album_match = album_match();
|
||||
let matches =
|
||||
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match.clone()));
|
||||
album_match.push_cannot_have_mbid();
|
||||
album_match.push_manual_input_mbid();
|
||||
|
||||
let widget_state = WidgetState::default().with_selected(Some(0));
|
||||
|
||||
assert_eq!(matches.state.current, album_match);
|
||||
assert_eq!(matches.state.state, widget_state);
|
||||
|
||||
let mut app: App = matches.into();
|
||||
let public = app.get();
|
||||
let public_matches = public.state.unwrap_match();
|
||||
|
||||
assert_eq!(public_matches.matches, &album_match);
|
||||
assert_eq!(public_matches.state, &widget_state);
|
||||
}
|
||||
|
||||
fn match_state_flow(mut matches_info: EntityMatches, len: usize) {
|
||||
// tx must exist for rx to return Empty rather than Disconnected.
|
||||
let (_tx, rx) = mpsc::channel();
|
||||
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
|
||||
|
||||
let mut music_hoard = music_hoard(vec![]);
|
||||
let artist_id = ArtistId::new("Artist");
|
||||
match matches_info {
|
||||
EntityMatches::Album(_) => {
|
||||
let album_id = AlbumId::new("Album");
|
||||
let info = AlbumInfo {
|
||||
musicbrainz: MbRefOption::CannotHaveMbid,
|
||||
..Default::default()
|
||||
};
|
||||
music_hoard
|
||||
.expect_merge_album_info()
|
||||
.with(eq(artist_id.clone()), eq(album_id.clone()), eq(info))
|
||||
.times(1)
|
||||
.return_once(|_, _, _| Ok(()));
|
||||
}
|
||||
EntityMatches::Artist(_) => {
|
||||
let info = ArtistInfo {
|
||||
musicbrainz: MbRefOption::CannotHaveMbid,
|
||||
..Default::default()
|
||||
};
|
||||
music_hoard
|
||||
.expect_merge_artist_info()
|
||||
.with(eq(artist_id.clone()), eq(info))
|
||||
.times(1)
|
||||
.return_once(|_, _| Ok(()));
|
||||
}
|
||||
}
|
||||
|
||||
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
|
||||
matches_info.push_cannot_have_mbid();
|
||||
matches_info.push_manual_input_mbid();
|
||||
|
||||
let widget_state = WidgetState::default().with_selected(Some(0));
|
||||
|
||||
assert_eq!(matches.state.current, matches_info);
|
||||
assert_eq!(matches.state.state, widget_state);
|
||||
|
||||
let matches = matches.decrement_match(Delta::Line).unwrap_match();
|
||||
|
||||
assert_eq!(matches.state.current, matches_info);
|
||||
assert_eq!(matches.state.state.list.selected(), Some(0));
|
||||
|
||||
let mut matches = matches;
|
||||
for ii in 1..len {
|
||||
matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
|
||||
assert_eq!(matches.state.current, matches_info);
|
||||
assert_eq!(matches.state.state.list.selected(), Some(ii));
|
||||
}
|
||||
|
||||
// Next is CannotHaveMBID
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
|
||||
assert_eq!(matches.state.current, matches_info);
|
||||
assert_eq!(matches.state.state.list.selected(), Some(len));
|
||||
|
||||
// Next is ManualInputMbid
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
|
||||
assert_eq!(matches.state.current, matches_info);
|
||||
assert_eq!(matches.state.state.list.selected(), Some(len + 1));
|
||||
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
|
||||
assert_eq!(matches.state.current, matches_info);
|
||||
assert_eq!(matches.state.state.list.selected(), Some(len + 1));
|
||||
|
||||
// Go prev_match first as selecting on manual input does not go back to fetch.
|
||||
let matches = matches.decrement_match(Delta::Line).unwrap_match();
|
||||
matches.select().unwrap_fetch();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_matches_flow() {
|
||||
match_state_flow(artist_match(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_lookup_flow() {
|
||||
match_state_flow(artist_lookup(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_matches_flow() {
|
||||
match_state_flow(album_match(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_lookup_flow() {
|
||||
match_state_flow(album_lookup(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_artist_info() {
|
||||
let matches_info = artist_match();
|
||||
|
||||
let (_tx, rx) = mpsc::channel();
|
||||
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
|
||||
|
||||
let mut music_hoard = music_hoard(vec![]);
|
||||
match matches_info {
|
||||
EntityMatches::Album(_) => panic!(),
|
||||
EntityMatches::Artist(_) => {
|
||||
let meta = artist_meta();
|
||||
music_hoard
|
||||
.expect_merge_artist_info()
|
||||
.with(eq(meta.id), eq(meta.info))
|
||||
.times(1)
|
||||
.return_once(|_, _| Ok(()));
|
||||
}
|
||||
}
|
||||
|
||||
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
|
||||
matches.select().unwrap_fetch();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_album_info() {
|
||||
let matches_info = album_match();
|
||||
|
||||
let (_tx, rx) = mpsc::channel();
|
||||
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
|
||||
|
||||
let mut music_hoard = music_hoard(vec![]);
|
||||
match matches_info {
|
||||
EntityMatches::Artist(_) => panic!(),
|
||||
EntityMatches::Album(matches) => {
|
||||
let meta = album_meta(album_id());
|
||||
music_hoard
|
||||
.expect_merge_album_info()
|
||||
.with(eq(matches.artist), eq(meta.id), eq(meta.info))
|
||||
.times(1)
|
||||
.return_once(|_, _, _| Ok(()));
|
||||
}
|
||||
}
|
||||
|
||||
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
|
||||
matches.select().unwrap_fetch();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_info_error() {
|
||||
let matches_info = artist_match();
|
||||
|
||||
let (_tx, rx) = mpsc::channel();
|
||||
let app_matches = MatchState::new(matches_info.clone(), FetchState::new(rx));
|
||||
|
||||
let mut music_hoard = music_hoard(vec![]);
|
||||
match matches_info {
|
||||
EntityMatches::Album(_) => panic!(),
|
||||
EntityMatches::Artist(_) => {
|
||||
music_hoard.expect_merge_artist_info().return_once(|_, _| {
|
||||
Err(musichoard::Error::DatabaseError(String::from("error")))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let matches = AppMachine::match_state(inner(music_hoard), app_matches);
|
||||
matches.select().unwrap_error();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort() {
|
||||
let mut album_match = album_match();
|
||||
let matches =
|
||||
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match.clone()));
|
||||
album_match.push_cannot_have_mbid();
|
||||
album_match.push_manual_input_mbid();
|
||||
|
||||
let widget_state = WidgetState::default().with_selected(Some(0));
|
||||
|
||||
assert_eq!(matches.state.current, album_match);
|
||||
assert_eq!(matches.state.state, widget_state);
|
||||
|
||||
matches.abort().unwrap_browse();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_manual_input_empty() {
|
||||
let matches =
|
||||
AppMachine::match_state(inner(music_hoard(vec![])), match_state(album_match()));
|
||||
|
||||
// album_match has two matches which means that the fourth option should be manual input.
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
|
||||
let app = matches.select();
|
||||
|
||||
let input = app.mode().unwrap_input();
|
||||
input.confirm().unwrap_error();
|
||||
}
|
||||
|
||||
fn input_mbid(mut app: App) -> App {
|
||||
let mbid = mbid().uuid().to_string();
|
||||
for c in mbid.chars() {
|
||||
let input = app.mode().unwrap_input();
|
||||
app = input.input(input_event(c));
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_manual_input_artist() {
|
||||
let mut mb_job_sender = MockIMbJobSender::new();
|
||||
let artist = ArtistMeta::new(ArtistId::new("Artist"));
|
||||
let requests = VecDeque::from([MbParams::lookup_artist(artist.clone(), mbid())]);
|
||||
mb_job_sender
|
||||
.expect_submit_foreground_job()
|
||||
.with(predicate::always(), predicate::eq(requests))
|
||||
.return_once(|_, _| Ok(()));
|
||||
|
||||
let matches_vec: Vec<Entity<ArtistMeta>> = vec![];
|
||||
let artist_match = EntityMatches::artist_search(artist.clone(), matches_vec);
|
||||
let matches = AppMachine::match_state(
|
||||
inner_with_mb(music_hoard(vec![]), mb_job_sender),
|
||||
match_state(artist_match),
|
||||
);
|
||||
|
||||
// There are no matches which means that the second option should be manual input.
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
|
||||
let mut app = matches.select();
|
||||
app = input_mbid(app);
|
||||
|
||||
let input = app.mode().unwrap_input();
|
||||
input.confirm();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_manual_input_album() {
|
||||
let mut mb_job_sender = MockIMbJobSender::new();
|
||||
let artist_id = ArtistId::new("Artist");
|
||||
let album = AlbumMeta::new("Album", 1990, AlbumInfo::default());
|
||||
let requests = VecDeque::from([MbParams::lookup_release_group(
|
||||
artist_id.clone(),
|
||||
album.id.clone(),
|
||||
mbid(),
|
||||
)]);
|
||||
mb_job_sender
|
||||
.expect_submit_foreground_job()
|
||||
.with(predicate::always(), predicate::eq(requests))
|
||||
.return_once(|_, _| Ok(()));
|
||||
|
||||
let matches_vec: Vec<Entity<AlbumMeta>> = vec![];
|
||||
let album_match =
|
||||
EntityMatches::album_search(artist_id.clone(), album.id.clone(), matches_vec);
|
||||
let matches = AppMachine::match_state(
|
||||
inner_with_mb(music_hoard(vec![]), mb_job_sender),
|
||||
match_state(album_match),
|
||||
);
|
||||
|
||||
// There are no matches which means that the second option should be manual input.
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
let matches = matches.increment_match(Delta::Line).unwrap_match();
|
||||
|
||||
let mut app = matches.select();
|
||||
app = input_mbid(app);
|
||||
|
||||
let input = app.mode().unwrap_input();
|
||||
input.confirm();
|
||||
}
|
||||
}
|
607
src/tui/app/machine/mod.rs
Normal file
607
src/tui/app/machine/mod.rs
Normal file
@ -0,0 +1,607 @@
|
||||
mod browse_state;
|
||||
mod critical_state;
|
||||
mod error_state;
|
||||
mod fetch_state;
|
||||
mod info_state;
|
||||
mod input;
|
||||
mod match_state;
|
||||
mod reload_state;
|
||||
mod search_state;
|
||||
|
||||
use crate::tui::{
|
||||
app::{
|
||||
selection::Selection, AppMode, AppPublic, AppPublicInner, AppPublicState, AppState, IApp,
|
||||
IAppAccess, IAppBase, IAppState,
|
||||
},
|
||||
lib::{interface::musicbrainz::daemon::IMbJobSender, IMusicHoard},
|
||||
};
|
||||
|
||||
use browse_state::BrowseState;
|
||||
use critical_state::CriticalState;
|
||||
use error_state::ErrorState;
|
||||
use fetch_state::FetchState;
|
||||
use info_state::InfoState;
|
||||
use input::{AppInputMode, Input};
|
||||
use match_state::MatchState;
|
||||
use reload_state::ReloadState;
|
||||
use search_state::SearchState;
|
||||
|
||||
pub type App = AppState<
|
||||
AppMachine<BrowseState>,
|
||||
AppMachine<InfoState>,
|
||||
AppMachine<ReloadState>,
|
||||
AppMachine<SearchState>,
|
||||
AppMachine<FetchState>,
|
||||
AppMachine<MatchState>,
|
||||
AppMachine<ErrorState>,
|
||||
AppMachine<CriticalState>,
|
||||
>;
|
||||
|
||||
pub struct AppMachine<STATE> {
|
||||
inner: AppInner,
|
||||
state: STATE,
|
||||
input: Option<Input>,
|
||||
}
|
||||
|
||||
pub struct AppInner {
|
||||
running: bool,
|
||||
music_hoard: Box<dyn IMusicHoard>,
|
||||
musicbrainz: Box<dyn IMbJobSender>,
|
||||
selection: Selection,
|
||||
}
|
||||
|
||||
macro_rules! app_field_ref {
|
||||
($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 inner = AppInner::new(music_hoard, musicbrainz);
|
||||
match init_result {
|
||||
Ok(()) => AppMachine::browse_state(inner).into(),
|
||||
Err(err) => AppMachine::critical_state(inner, err.to_string()).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn init<MH: IMusicHoard>(music_hoard: &mut MH) -> Result<(), musichoard::Error> {
|
||||
music_hoard.rescan_library()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inner_ref(&self) -> &AppInner {
|
||||
app_field_ref!(self, inner)
|
||||
}
|
||||
|
||||
fn inner_mut(&mut self) -> &mut AppInner {
|
||||
app_field_mut!(self, inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn input_ref(&self) -> &Option<Input> {
|
||||
app_field_ref!(self, input)
|
||||
}
|
||||
|
||||
fn input_mut(&mut self) -> &mut Option<Input> {
|
||||
app_field_mut!(self, input)
|
||||
}
|
||||
}
|
||||
|
||||
impl IApp for App {
|
||||
type BrowseState = AppMachine<BrowseState>;
|
||||
type InfoState = AppMachine<InfoState>;
|
||||
type ReloadState = AppMachine<ReloadState>;
|
||||
type SearchState = AppMachine<SearchState>;
|
||||
type FetchState = AppMachine<FetchState>;
|
||||
type MatchState = AppMachine<MatchState>;
|
||||
type ErrorState = AppMachine<ErrorState>;
|
||||
type CriticalState = AppMachine<CriticalState>;
|
||||
type InputMode = AppInputMode;
|
||||
|
||||
fn is_running(&self) -> bool {
|
||||
self.inner_ref().running
|
||||
}
|
||||
|
||||
fn force_quit(mut self) -> Self {
|
||||
self.inner_mut().running = false;
|
||||
self
|
||||
}
|
||||
|
||||
fn state(self) -> IAppState!() {
|
||||
self
|
||||
}
|
||||
|
||||
fn mode(self) -> AppMode<IAppState!(), Self::InputMode> {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
match self {
|
||||
AppState::Browse(state) => state.into(),
|
||||
AppState::Info(state) => state.into(),
|
||||
AppState::Reload(state) => state.into(),
|
||||
AppState::Search(state) => state.into(),
|
||||
AppState::Fetch(state) => state.into(),
|
||||
AppState::Match(state) => state.into(),
|
||||
AppState::Error(state) => state.into(),
|
||||
AppState::Critical(state) => state.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppInner {
|
||||
pub fn new<MH: IMusicHoard + 'static, MB: IMbJobSender + 'static>(
|
||||
music_hoard: MH,
|
||||
musicbrainz: MB,
|
||||
) -> Self {
|
||||
let selection = Selection::new(music_hoard.get_collection());
|
||||
AppInner {
|
||||
running: true,
|
||||
music_hoard: Box::new(music_hoard),
|
||||
musicbrainz: Box::new(musicbrainz),
|
||||
selection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a mut AppInner> for AppPublicInner<'a> {
|
||||
fn from(inner: &'a mut AppInner) -> Self {
|
||||
AppPublicInner {
|
||||
collection: inner.music_hoard.get_collection(),
|
||||
selection: &mut inner.selection,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use std::sync::mpsc;
|
||||
|
||||
use musichoard::collection::{
|
||||
artist::{ArtistId, ArtistMeta},
|
||||
Collection,
|
||||
};
|
||||
|
||||
use crate::tui::{
|
||||
app::{AppState, EntityMatches, IApp, IAppInput, IAppInteractBrowse, InputEvent},
|
||||
lib::{
|
||||
interface::musicbrainz::{api::Entity, daemon::MockIMbJobSender},
|
||||
MockIMusicHoard,
|
||||
},
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
impl<StateMode, InputMode> AppMode<StateMode, InputMode> {
|
||||
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 {
|
||||
AppState::Browse(browse) => browse,
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_info(self) -> InfoState {
|
||||
match self {
|
||||
AppState::Info(info) => info,
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_reload(self) -> ReloadState {
|
||||
match self {
|
||||
AppState::Reload(reload) => reload,
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_search(self) -> SearchState {
|
||||
match self {
|
||||
AppState::Search(search) => search,
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
AppState::Error(error) => error,
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unwrap_critical(self) -> CriticalState {
|
||||
match self {
|
||||
AppState::Critical(critical) => critical,
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn music_hoard(collection: Collection) -> MockIMusicHoard {
|
||||
let mut music_hoard = MockIMusicHoard::new();
|
||||
music_hoard.expect_get_collection().return_const(collection);
|
||||
|
||||
music_hoard
|
||||
}
|
||||
|
||||
pub fn music_hoard_init(collection: Collection) -> MockIMusicHoard {
|
||||
let mut music_hoard = music_hoard(collection);
|
||||
|
||||
music_hoard
|
||||
.expect_rescan_library()
|
||||
.times(1)
|
||||
.return_once(|| Ok(()));
|
||||
|
||||
music_hoard
|
||||
}
|
||||
|
||||
pub fn mb_job_sender() -> MockIMbJobSender {
|
||||
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]
|
||||
fn state_browse() {
|
||||
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||
assert!(app.is_running());
|
||||
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Browse(_)));
|
||||
app = state;
|
||||
|
||||
app = app.no_op();
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Browse(_)));
|
||||
app = state;
|
||||
|
||||
let public = app.get();
|
||||
assert!(matches!(public.state, AppState::Browse(_)));
|
||||
|
||||
let app = app.force_quit();
|
||||
assert!(!app.is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_info() {
|
||||
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||
assert!(app.is_running());
|
||||
|
||||
app = app.unwrap_browse().show_info_overlay();
|
||||
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Info(_)));
|
||||
app = state;
|
||||
|
||||
app = app.no_op();
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Info(_)));
|
||||
app = state;
|
||||
|
||||
let public = app.get();
|
||||
assert!(matches!(public.state, AppState::Info(_)));
|
||||
|
||||
let app = app.force_quit();
|
||||
assert!(!app.is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_reload() {
|
||||
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||
assert!(app.is_running());
|
||||
|
||||
app = app.unwrap_browse().show_reload_menu();
|
||||
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Reload(_)));
|
||||
app = state;
|
||||
|
||||
app = app.no_op();
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Reload(_)));
|
||||
app = state;
|
||||
|
||||
let public = app.get();
|
||||
assert!(matches!(public.state, AppState::Reload(_)));
|
||||
|
||||
let app = app.force_quit();
|
||||
assert!(!app.is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_search() {
|
||||
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||
assert!(app.is_running());
|
||||
|
||||
app = app.unwrap_browse().begin_search();
|
||||
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Search(_)));
|
||||
app = state;
|
||||
|
||||
app = app.no_op();
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Search(_)));
|
||||
app = state;
|
||||
|
||||
let public = app.get();
|
||||
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();
|
||||
assert!(!app.is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_error() {
|
||||
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||
assert!(app.is_running());
|
||||
|
||||
app = AppMachine::error_state(app.unwrap_browse().inner, "get rekt").into();
|
||||
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Error(_)));
|
||||
app = state;
|
||||
|
||||
app = app.no_op();
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Error(_)));
|
||||
app = state;
|
||||
|
||||
let public = app.get();
|
||||
assert!(matches!(public.state, AppState::Error("get rekt")));
|
||||
|
||||
app = app.force_quit();
|
||||
assert!(!app.is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_critical() {
|
||||
let mut app = App::new(music_hoard_init(vec![]), mb_job_sender());
|
||||
assert!(app.is_running());
|
||||
|
||||
app = AppMachine::critical_state(app.unwrap_browse().inner, "get rekt").into();
|
||||
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Critical(_)));
|
||||
app = state;
|
||||
|
||||
app = app.no_op();
|
||||
let state = app.state();
|
||||
assert!(matches!(state, AppState::Critical(_)));
|
||||
app = state;
|
||||
|
||||
let public = app.get();
|
||||
assert!(matches!(public.state, AppState::Critical("get rekt")));
|
||||
|
||||
app = app.force_quit();
|
||||
assert!(!app.is_running());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_error() {
|
||||
let mut music_hoard = MockIMusicHoard::new();
|
||||
|
||||
music_hoard
|
||||
.expect_rescan_library()
|
||||
.times(1)
|
||||
.return_once(|| Err(musichoard::Error::LibraryError(String::from("get rekt"))));
|
||||
music_hoard.expect_get_collection().return_const(vec![]);
|
||||
|
||||
let app = App::new(music_hoard, mb_job_sender());
|
||||
assert!(app.is_running());
|
||||
app.unwrap_critical();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(nightly)]
|
||||
#[cfg(test)]
|
||||
mod benchmod;
|
125
src/tui/app/machine/reload_state.rs
Normal file
125
src/tui/app/machine/reload_state.rs
Normal file
@ -0,0 +1,125 @@
|
||||
use crate::tui::app::{
|
||||
machine::{App, AppInner, AppMachine},
|
||||
selection::KeySelection,
|
||||
AppPublicState, AppState, IAppInteractReload,
|
||||
};
|
||||
|
||||
pub struct ReloadState;
|
||||
|
||||
impl AppMachine<ReloadState> {
|
||||
pub fn reload_state(inner: AppInner) -> Self {
|
||||
AppMachine::new(inner, ReloadState)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppMachine<ReloadState>> for App {
|
||||
fn from(machine: AppMachine<ReloadState>) -> Self {
|
||||
AppState::Reload(machine)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a mut ReloadState> for AppPublicState<'a> {
|
||||
fn from(_state: &'a mut ReloadState) -> Self {
|
||||
AppState::Reload(())
|
||||
}
|
||||
}
|
||||
|
||||
impl IAppInteractReload for AppMachine<ReloadState> {
|
||||
type APP = App;
|
||||
|
||||
fn reload_library(mut self) -> Self::APP {
|
||||
let previous = KeySelection::get(
|
||||
self.inner.music_hoard.get_collection(),
|
||||
&self.inner.selection,
|
||||
);
|
||||
let result = self.inner.music_hoard.rescan_library();
|
||||
self.refresh(previous, result)
|
||||
}
|
||||
|
||||
fn reload_database(mut self) -> Self::APP {
|
||||
let previous = KeySelection::get(
|
||||
self.inner.music_hoard.get_collection(),
|
||||
&self.inner.selection,
|
||||
);
|
||||
let result = self.inner.music_hoard.reload_database();
|
||||
self.refresh(previous, result)
|
||||
}
|
||||
|
||||
fn hide_reload_menu(self) -> Self::APP {
|
||||
AppMachine::browse_state(self.inner).into()
|
||||
}
|
||||
}
|
||||
|
||||
trait IAppInteractReloadPrivate {
|
||||
fn refresh(self, previous: KeySelection, result: Result<(), musichoard::Error>) -> App;
|
||||
}
|
||||
|
||||
impl IAppInteractReloadPrivate for AppMachine<ReloadState> {
|
||||
fn refresh(mut self, previous: KeySelection, result: Result<(), musichoard::Error>) -> App {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.inner
|
||||
.selection
|
||||
.select_by_id(self.inner.music_hoard.get_collection(), previous);
|
||||
AppMachine::browse_state(self.inner).into()
|
||||
}
|
||||
Err(err) => AppMachine::error_state(self.inner, err.to_string()).into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::app::machine::tests::{inner, music_hoard};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hide_reload_menu() {
|
||||
let reload = AppMachine::reload_state(inner(music_hoard(vec![])));
|
||||
let app = reload.hide_reload_menu();
|
||||
app.unwrap_browse();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_database() {
|
||||
let mut music_hoard = music_hoard(vec![]);
|
||||
|
||||
music_hoard
|
||||
.expect_reload_database()
|
||||
.times(1)
|
||||
.return_once(|| Ok(()));
|
||||
|
||||
let reload = AppMachine::reload_state(inner(music_hoard));
|
||||
let app = reload.reload_database();
|
||||
app.unwrap_browse();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_library() {
|
||||
let mut music_hoard = music_hoard(vec![]);
|
||||
|
||||
music_hoard
|
||||
.expect_rescan_library()
|
||||
.times(1)
|
||||
.return_once(|| Ok(()));
|
||||
|
||||
let reload = AppMachine::reload_state(inner(music_hoard));
|
||||
let app = reload.reload_library();
|
||||
app.unwrap_browse();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_error() {
|
||||
let mut music_hoard = music_hoard(vec![]);
|
||||
|
||||
music_hoard
|
||||
.expect_reload_database()
|
||||
.times(1)
|
||||
.return_once(|| Err(musichoard::Error::DatabaseError(String::from("get rekt"))));
|
||||
|
||||
let reload = AppMachine::reload_state(inner(music_hoard));
|
||||
let app = reload.reload_database();
|
||||
app.unwrap_error();
|
||||
}
|
||||
}
|
571
src/tui/app/machine/search_state.rs
Normal file
571
src/tui/app/machine/search_state.rs
Normal file
@ -0,0 +1,571 @@
|
||||
use aho_corasick::AhoCorasick;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
use musichoard::collection::{album::Album, artist::Artist, track::Track};
|
||||
|
||||
use crate::tui::app::{
|
||||
machine::{App, AppInner, AppMachine},
|
||||
selection::{ListSelection, SelectionState},
|
||||
AppPublicState, AppState, Category, IAppInteractSearch,
|
||||
};
|
||||
|
||||
// Unlikely that this covers all possible strings, but it should at least cover strings
|
||||
// relevant for music (at least in English). The list of characters handled is based on
|
||||
// https://wiki.musicbrainz.org/User:Yurim/Punctuation_and_Special_Characters.
|
||||
//
|
||||
// U+2010 hyphen, U+2012 figure dash, U+2013 en dash, U+2014 em dash, U+2015 horizontal bar, U+2018,
|
||||
// U+2019, U+201C, U+201D, U+2026, U+2212 minus sign
|
||||
const SPECIAL: [char; 11] = ['‐', '‒', '–', '—', '―', '‘', '’', '“', '”', '…', '−'];
|
||||
const REPLACE: [&str; 11] = ["-", "-", "-", "-", "-", "'", "'", "\"", "\"", "...", "-"];
|
||||
static AC: Lazy<AhoCorasick> =
|
||||
Lazy::new(|| AhoCorasick::new(SPECIAL.map(|ch| ch.to_string())).unwrap());
|
||||
|
||||
pub struct SearchState {
|
||||
string: String,
|
||||
orig: ListSelection,
|
||||
memo: Vec<SearchStateMemo>,
|
||||
}
|
||||
|
||||
struct SearchStateMemo {
|
||||
index: Option<usize>,
|
||||
char: bool,
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
fn new(orig: ListSelection) -> Self {
|
||||
SearchState {
|
||||
string: String::new(),
|
||||
orig,
|
||||
memo: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppMachine<SearchState> {
|
||||
pub fn search_state(inner: AppInner, orig: ListSelection) -> Self {
|
||||
AppMachine::new(inner, SearchState::new(orig))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppMachine<SearchState>> for App {
|
||||
fn from(machine: AppMachine<SearchState>) -> Self {
|
||||
AppState::Search(machine)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a mut SearchState> for AppPublicState<'a> {
|
||||
fn from(state: &'a mut SearchState) -> Self {
|
||||
AppState::Search(&state.string)
|
||||
}
|
||||
}
|
||||
|
||||
impl IAppInteractSearch for AppMachine<SearchState> {
|
||||
type APP = App;
|
||||
|
||||
fn append_character(mut self, ch: char) -> Self::APP {
|
||||
self.state.string.push(ch);
|
||||
let index = self.inner.selection.selected();
|
||||
self.state.memo.push(SearchStateMemo { index, char: true });
|
||||
self.incremental_search(false);
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn search_next(mut self) -> Self::APP {
|
||||
if !self.state.string.is_empty() {
|
||||
let index = self.inner.selection.selected();
|
||||
self.state.memo.push(SearchStateMemo { index, char: false });
|
||||
self.incremental_search(true);
|
||||
}
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn step_back(mut self) -> Self::APP {
|
||||
let collection = self.inner.music_hoard.get_collection();
|
||||
if let Some(memo) = self.state.memo.pop() {
|
||||
if memo.char {
|
||||
self.state.string.pop();
|
||||
}
|
||||
self.inner.selection.select(collection, memo.index);
|
||||
}
|
||||
self.into()
|
||||
}
|
||||
|
||||
fn finish_search(self) -> Self::APP {
|
||||
AppMachine::browse_state(self.inner).into()
|
||||
}
|
||||
|
||||
fn cancel_search(mut self) -> Self::APP {
|
||||
self.inner.selection.select_by_list(self.state.orig);
|
||||
AppMachine::browse_state(self.inner).into()
|
||||
}
|
||||
}
|
||||
|
||||
trait IAppInteractSearchPrivate {
|
||||
fn incremental_search(&mut self, next: bool);
|
||||
fn next<P, T>(pred: P, name: &str, next: bool, st: SelectionState<'_, T>) -> Option<usize>
|
||||
where
|
||||
P: FnMut(bool, bool, &str, &T) -> bool;
|
||||
|
||||
fn search_artists(name: &str, next: bool, st: SelectionState<'_, Artist>) -> Option<usize>;
|
||||
fn search_albums(name: &str, next: bool, st: SelectionState<'_, Album>) -> Option<usize>;
|
||||
fn search_tracks(name: &str, next: bool, st: SelectionState<'_, Track>) -> Option<usize>;
|
||||
|
||||
fn predicate_artists(case_sens: bool, char_sens: bool, search: &str, probe: &Artist) -> bool;
|
||||
fn predicate_albums(case_sens: bool, char_sens: bool, search: &str, probe: &Album) -> bool;
|
||||
fn predicate_tracks(case_sens: bool, char_sens: bool, search: &str, probe: &Track) -> bool;
|
||||
fn predicate_title(case_sens: bool, char_sens: bool, search: &str, title: &str) -> bool;
|
||||
|
||||
fn is_case_sensitive(artist_name: &str) -> bool;
|
||||
fn is_char_sensitive(artist_name: &str) -> bool;
|
||||
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String;
|
||||
}
|
||||
|
||||
impl IAppInteractSearchPrivate for AppMachine<SearchState> {
|
||||
fn incremental_search(&mut self, next: bool) {
|
||||
let collection = self.inner.music_hoard.get_collection();
|
||||
let search = &self.state.string;
|
||||
|
||||
let sel = &self.inner.selection;
|
||||
let result = match sel.category() {
|
||||
Category::Artist => sel
|
||||
.state_artist(collection)
|
||||
.and_then(|state| Self::search_artists(search, next, state)),
|
||||
Category::Album => sel
|
||||
.state_album(collection)
|
||||
.and_then(|state| Self::search_albums(search, next, state)),
|
||||
Category::Track => sel
|
||||
.state_track(collection)
|
||||
.and_then(|state| Self::search_tracks(search, next, state)),
|
||||
};
|
||||
|
||||
if result.is_some() {
|
||||
let collection = self.inner.music_hoard.get_collection();
|
||||
self.inner.selection.select(collection, result);
|
||||
}
|
||||
}
|
||||
|
||||
fn search_artists(name: &str, next: bool, st: SelectionState<'_, Artist>) -> Option<usize> {
|
||||
Self::next(Self::predicate_artists, name, next, st)
|
||||
}
|
||||
|
||||
fn search_albums(name: &str, next: bool, st: SelectionState<'_, Album>) -> Option<usize> {
|
||||
Self::next(Self::predicate_albums, name, next, st)
|
||||
}
|
||||
|
||||
fn search_tracks(name: &str, next: bool, st: SelectionState<'_, Track>) -> Option<usize> {
|
||||
Self::next(Self::predicate_tracks, name, next, st)
|
||||
}
|
||||
|
||||
fn next<P, T>(mut pred: P, name: &str, next: bool, st: SelectionState<'_, T>) -> Option<usize>
|
||||
where
|
||||
P: FnMut(bool, bool, &str, &T) -> bool,
|
||||
{
|
||||
let case_sens = Self::is_case_sensitive(name);
|
||||
let char_sens = Self::is_char_sensitive(name);
|
||||
let search = Self::normalize_search(name, !case_sens, !char_sens);
|
||||
|
||||
let mut index = st.index;
|
||||
if next && ((index + 1) < st.list.len()) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
let slice = &st.list[index..];
|
||||
slice
|
||||
.iter()
|
||||
.position(|probe| pred(case_sens, char_sens, &search, probe))
|
||||
.map(|slice_index| index + slice_index)
|
||||
}
|
||||
|
||||
fn predicate_artists(case_sens: bool, char_sens: bool, search: &str, probe: &Artist) -> bool {
|
||||
let name = Self::normalize_search(&probe.meta.id.name, !case_sens, !char_sens);
|
||||
let mut result = name.starts_with(search);
|
||||
|
||||
if let Some(ref probe_sort) = probe.meta.sort {
|
||||
if !result {
|
||||
let name = Self::normalize_search(probe_sort, !case_sens, !char_sens);
|
||||
result = name.starts_with(search);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn predicate_albums(case_sens: bool, char_sens: bool, search: &str, probe: &Album) -> bool {
|
||||
Self::predicate_title(case_sens, char_sens, search, &probe.meta.id.title)
|
||||
}
|
||||
|
||||
fn predicate_tracks(case_sens: bool, char_sens: bool, search: &str, probe: &Track) -> bool {
|
||||
Self::predicate_title(case_sens, char_sens, search, &probe.id.title)
|
||||
}
|
||||
|
||||
fn predicate_title(case_sens: bool, char_sens: bool, search: &str, title: &str) -> bool {
|
||||
Self::normalize_search(title, !case_sens, !char_sens).starts_with(search)
|
||||
}
|
||||
|
||||
fn is_case_sensitive(artist_name: &str) -> bool {
|
||||
artist_name
|
||||
.chars()
|
||||
.any(|ch| ch.is_alphabetic() && ch.is_uppercase())
|
||||
}
|
||||
|
||||
fn is_char_sensitive(artist_name: &str) -> bool {
|
||||
// Benchmarking reveals that using AhoCorasick is slower. At a guess, this is likely due to
|
||||
// a high constant cost of AhoCorasick and the otherwise simple nature of the task.
|
||||
artist_name.chars().any(|ch| SPECIAL.contains(&ch))
|
||||
}
|
||||
|
||||
fn normalize_search(search: &str, lowercase: bool, asciify: bool) -> String {
|
||||
if asciify {
|
||||
if lowercase {
|
||||
AC.replace_all(&search.to_lowercase(), &REPLACE)
|
||||
} else {
|
||||
AC.replace_all(search, &REPLACE)
|
||||
}
|
||||
} else if lowercase {
|
||||
search.to_lowercase()
|
||||
} else {
|
||||
search.to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
use crate::tui::{
|
||||
app::machine::tests::{inner, music_hoard},
|
||||
testmod::COLLECTION,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn orig(index: Option<usize>) -> ListSelection {
|
||||
let mut artist = ListState::default();
|
||||
artist.select(index);
|
||||
|
||||
ListSelection {
|
||||
artist,
|
||||
album: ListState::default(),
|
||||
track: ListState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_incremental_search() {
|
||||
// Empty collection.
|
||||
let mut search = AppMachine::search_state(inner(music_hoard(vec![])), orig(None));
|
||||
assert_eq!(search.inner.selection.selected(), None);
|
||||
|
||||
search.state.string = String::from("album_artist 'a'");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), None);
|
||||
|
||||
// Basic test, first element.
|
||||
let mut search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist 'a'");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
// Basic test, non-first element.
|
||||
let mut search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist 'c'");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||
|
||||
// Non-lowercase.
|
||||
let mut search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("Album_Artist ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("Album_Artist 'C'");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||
|
||||
// Non-ascii.
|
||||
let mut search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist ‘c’");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||
|
||||
// Non-lowercase, non-ascii.
|
||||
let mut search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("Album_Artist ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("Album_Artist ‘C’");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||
|
||||
// Stop at name, not sort name.
|
||||
let mut search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("the ");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||
|
||||
search.state.string = String::from("the album_artist 'c'");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||
|
||||
// Search next with common prefix.
|
||||
let mut search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_artist");
|
||||
search.incremental_search(false);
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
search.incremental_search(true);
|
||||
assert_eq!(search.inner.selection.selected(), Some(1));
|
||||
|
||||
search.incremental_search(true);
|
||||
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||
|
||||
search.incremental_search(true);
|
||||
assert_eq!(search.inner.selection.selected(), Some(3));
|
||||
|
||||
search.incremental_search(true);
|
||||
assert_eq!(search.inner.selection.selected(), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_incremental_search() {
|
||||
let mut search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
search.inner.selection.increment_category();
|
||||
assert_eq!(search.inner.selection.category(), Category::Album);
|
||||
|
||||
let sel = &search.inner.selection;
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("album_title ");
|
||||
search.incremental_search(false);
|
||||
|
||||
let sel = &search.inner.selection;
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
let search = search.append_character('a').unwrap_search();
|
||||
let search = search.append_character('.').unwrap_search();
|
||||
let search = search.append_character('b').unwrap_search();
|
||||
|
||||
let sel = &search.inner.selection;
|
||||
assert_eq!(sel.selected(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_incremental_search() {
|
||||
let mut search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(1)));
|
||||
search.inner.selection.increment_category();
|
||||
search.inner.selection.increment_category();
|
||||
assert_eq!(search.inner.selection.category(), Category::Track);
|
||||
|
||||
let sel = &search.inner.selection;
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
search.state.string = String::from("track ");
|
||||
search.incremental_search(false);
|
||||
|
||||
let sel = &search.inner.selection;
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
let search = search.append_character('a').unwrap_search();
|
||||
let search = search.append_character('.').unwrap_search();
|
||||
let search = search.append_character('a').unwrap_search();
|
||||
let search = search.append_character('.').unwrap_search();
|
||||
let search = search.append_character('2').unwrap_search();
|
||||
|
||||
let sel = &search.inner.selection;
|
||||
assert_eq!(sel.selected(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search() {
|
||||
let search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
let search = search.append_character('a').unwrap_search();
|
||||
let search = search.append_character('l').unwrap_search();
|
||||
let search = search.append_character('b').unwrap_search();
|
||||
let search = search.append_character('u').unwrap_search();
|
||||
let search = search.append_character('m').unwrap_search();
|
||||
let search = search.append_character('_').unwrap_search();
|
||||
let search = search.append_character('a').unwrap_search();
|
||||
let search = search.append_character('r').unwrap_search();
|
||||
let search = search.append_character('t').unwrap_search();
|
||||
let search = search.append_character('i').unwrap_search();
|
||||
let search = search.append_character('s').unwrap_search();
|
||||
let search = search.append_character('t').unwrap_search();
|
||||
let search = search.append_character(' ').unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
let search = search.append_character('\'').unwrap_search();
|
||||
let search = search.append_character('c').unwrap_search();
|
||||
let search = search.append_character('\'').unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||
|
||||
let search = search.step_back().unwrap_search();
|
||||
let search = search.step_back().unwrap_search();
|
||||
let search = search.step_back().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
let search = search.append_character('\'').unwrap_search();
|
||||
let search = search.append_character('b').unwrap_search();
|
||||
let search = search.append_character('\'').unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(1));
|
||||
|
||||
let app = search.finish_search();
|
||||
let browse = app.unwrap_browse();
|
||||
assert_eq!(browse.inner.selection.selected(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_next_step_back() {
|
||||
let search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
let search = search.step_back().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
let search = search.append_character('a').unwrap_search();
|
||||
let search = search.search_next().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(1));
|
||||
|
||||
let search = search.search_next().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||
|
||||
let search = search.search_next().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(3));
|
||||
|
||||
let search = search.search_next().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(3));
|
||||
|
||||
let search = search.step_back().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(3));
|
||||
|
||||
let search = search.step_back().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(2));
|
||||
|
||||
let search = search.step_back().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(1));
|
||||
|
||||
let search = search.step_back().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
let search = search.step_back().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_search() {
|
||||
let search =
|
||||
AppMachine::search_state(inner(music_hoard(COLLECTION.to_owned())), orig(Some(2)));
|
||||
assert_eq!(search.inner.selection.selected(), Some(0));
|
||||
|
||||
let search = search.append_character('a').unwrap_search();
|
||||
let search = search.append_character('l').unwrap_search();
|
||||
let search = search.append_character('b').unwrap_search();
|
||||
let search = search.append_character('u').unwrap_search();
|
||||
let search = search.append_character('m').unwrap_search();
|
||||
let search = search.append_character('_').unwrap_search();
|
||||
let search = search.append_character('a').unwrap_search();
|
||||
let search = search.append_character('r').unwrap_search();
|
||||
let search = search.append_character('t').unwrap_search();
|
||||
let search = search.append_character('i').unwrap_search();
|
||||
let search = search.append_character('s').unwrap_search();
|
||||
let search = search.append_character('t').unwrap_search();
|
||||
let search = search.append_character(' ').unwrap_search();
|
||||
let search = search.append_character('\'').unwrap_search();
|
||||
let search = search.append_character('b').unwrap_search();
|
||||
let search = search.append_character('\'').unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), Some(1));
|
||||
|
||||
let browse = search.cancel_search().unwrap_browse();
|
||||
assert_eq!(browse.inner.selection.selected(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_search() {
|
||||
let search = AppMachine::search_state(inner(music_hoard(vec![])), orig(None));
|
||||
assert_eq!(search.inner.selection.selected(), None);
|
||||
|
||||
let search = search.append_character('a').unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), None);
|
||||
|
||||
let search = search.search_next().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), None);
|
||||
|
||||
let search = search.step_back().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), None);
|
||||
|
||||
let search = search.step_back().unwrap_search();
|
||||
assert_eq!(search.inner.selection.selected(), None);
|
||||
|
||||
let browse = search.cancel_search().unwrap_browse();
|
||||
assert_eq!(browse.inner.selection.selected(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(nightly)]
|
||||
#[cfg(test)]
|
||||
mod benches {
|
||||
// The purpose of these benches was to evaluate the benefit of AhoCorasick over std solutions.
|
||||
use test::Bencher;
|
||||
|
||||
use crate::tui::{app::machine::benchmod::ARTISTS, lib::MockIMusicHoard};
|
||||
|
||||
use super::*;
|
||||
|
||||
type Search = AppMachine<MockIMusicHoard, SearchState>;
|
||||
|
||||
#[bench]
|
||||
fn is_char_sensitive(b: &mut Bencher) {
|
||||
let mut iter = ARTISTS.iter().cycle();
|
||||
b.iter(|| test::black_box(Search::is_char_sensitive(&iter.next().unwrap())))
|
||||
}
|
||||
|
||||
#[bench]
|
||||
fn normalize_search(b: &mut Bencher) {
|
||||
let mut iter = ARTISTS.iter().cycle();
|
||||
b.iter(|| test::black_box(Search::normalize_search(&iter.next().unwrap(), true, true)))
|
||||
}
|
||||
}
|
312
src/tui/app/mod.rs
Normal file
312
src/tui/app/mod.rs
Normal file
@ -0,0 +1,312 @@
|
||||
mod machine;
|
||||
mod selection;
|
||||
|
||||
pub use machine::App;
|
||||
use ratatui::widgets::ListState;
|
||||
pub use selection::{Category, Selection};
|
||||
|
||||
use musichoard::collection::{
|
||||
album::{AlbumId, AlbumMeta},
|
||||
artist::{ArtistId, ArtistMeta},
|
||||
Collection,
|
||||
};
|
||||
|
||||
use crate::tui::lib::interface::musicbrainz::api::Entity;
|
||||
|
||||
pub enum AppState<B, I, R, S, F, M, E, C> {
|
||||
Browse(B),
|
||||
Info(I),
|
||||
Reload(R),
|
||||
Search(S),
|
||||
Fetch(F),
|
||||
Match(M),
|
||||
Error(E),
|
||||
Critical(C),
|
||||
}
|
||||
|
||||
pub enum AppMode<StateMode, InputMode> {
|
||||
State(StateMode),
|
||||
Input(InputMode),
|
||||
}
|
||||
|
||||
macro_rules! IAppState {
|
||||
() => {
|
||||
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 force_quit(self) -> Self;
|
||||
|
||||
fn state(self) -> IAppState!();
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn mode(self) -> AppMode<IAppState!(), Self::InputMode>;
|
||||
}
|
||||
|
||||
pub trait IAppBase {
|
||||
type APP: IApp;
|
||||
|
||||
fn no_op(self) -> Self::APP;
|
||||
}
|
||||
|
||||
pub trait IAppInteractBrowse {
|
||||
type APP: IApp;
|
||||
|
||||
fn quit(self) -> Self::APP;
|
||||
|
||||
fn increment_category(self) -> Self::APP;
|
||||
fn decrement_category(self) -> Self::APP;
|
||||
fn increment_selection(self, delta: Delta) -> Self::APP;
|
||||
fn decrement_selection(self, delta: Delta) -> Self::APP;
|
||||
|
||||
fn show_info_overlay(self) -> Self::APP;
|
||||
|
||||
fn show_reload_menu(self) -> Self::APP;
|
||||
|
||||
fn begin_search(self) -> Self::APP;
|
||||
|
||||
fn fetch_musicbrainz(self) -> Self::APP;
|
||||
}
|
||||
|
||||
pub trait IAppInteractInfo {
|
||||
type APP: IApp;
|
||||
|
||||
fn hide_info_overlay(self) -> Self::APP;
|
||||
}
|
||||
|
||||
pub trait IAppInteractReload {
|
||||
type APP: IApp;
|
||||
|
||||
fn reload_library(self) -> Self::APP;
|
||||
fn reload_database(self) -> Self::APP;
|
||||
fn hide_reload_menu(self) -> Self::APP;
|
||||
}
|
||||
|
||||
pub trait IAppInteractSearch {
|
||||
type APP: IApp;
|
||||
|
||||
fn append_character(self, ch: char) -> Self::APP;
|
||||
fn search_next(self) -> Self::APP;
|
||||
fn step_back(self) -> Self::APP;
|
||||
fn finish_search(self) -> Self::APP;
|
||||
fn cancel_search(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 {
|
||||
type APP: IApp;
|
||||
|
||||
fn dismiss_error(self) -> Self::APP;
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// needs to be mutably accessible requiring a mutable borrow of the entire struct if behind a trait.
|
||||
// This in turn complicates simultaneous field access since only a single mutable borrow is allowed.
|
||||
// Therefore, all fields are grouped into a single struct and returned as a batch.
|
||||
pub trait IAppAccess {
|
||||
fn get(&mut self) -> AppPublic;
|
||||
}
|
||||
|
||||
pub struct AppPublic<'app> {
|
||||
pub inner: AppPublicInner<'app>,
|
||||
pub state: AppPublicState<'app>,
|
||||
pub input: Option<InputPublic<'app>>,
|
||||
}
|
||||
|
||||
pub struct AppPublicInner<'app> {
|
||||
pub collection: &'app Collection,
|
||||
pub selection: &'app mut Selection,
|
||||
}
|
||||
|
||||
pub type InputPublic<'app> = &'app tui_input::Input;
|
||||
|
||||
#[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 {
|
||||
matches!(self, AppState::Search(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_is_state() {
|
||||
let state = AppPublicState::Search("get rekt");
|
||||
assert!(state.is_search());
|
||||
}
|
||||
}
|
359
src/tui/app/selection/album.rs
Normal file
359
src/tui/app/selection/album.rs
Normal file
@ -0,0 +1,359 @@
|
||||
use std::cmp;
|
||||
|
||||
use musichoard::collection::{
|
||||
album::{Album, AlbumDate, AlbumId, AlbumSeq},
|
||||
track::Track,
|
||||
};
|
||||
|
||||
use crate::tui::app::{
|
||||
selection::{
|
||||
track::{KeySelectTrack, TrackSelection},
|
||||
SelectionState,
|
||||
},
|
||||
Delta, WidgetState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AlbumSelection {
|
||||
pub state: WidgetState,
|
||||
pub track: TrackSelection,
|
||||
}
|
||||
|
||||
impl AlbumSelection {
|
||||
pub fn initialise(albums: &[Album]) -> Self {
|
||||
let mut selection = AlbumSelection {
|
||||
state: WidgetState::default(),
|
||||
track: TrackSelection::initialise(&[]),
|
||||
};
|
||||
selection.reinitialise(albums, None);
|
||||
selection
|
||||
}
|
||||
|
||||
pub fn reinitialise(&mut self, albums: &[Album], album: Option<KeySelectAlbum>) {
|
||||
if let Some(album) = album {
|
||||
let result =
|
||||
albums.binary_search_by(|a| a.meta.get_sort_key().cmp(&album.get_sort_key()));
|
||||
match result {
|
||||
Ok(index) => self.reinitialise_with_index(albums, index, album.track),
|
||||
Err(index) => self.reinitialise_with_index(albums, index, None),
|
||||
}
|
||||
} else {
|
||||
self.reinitialise_with_index(albums, 0, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn reinitialise_with_index(
|
||||
&mut self,
|
||||
albums: &[Album],
|
||||
index: usize,
|
||||
active_track: Option<KeySelectTrack>,
|
||||
) {
|
||||
if albums.is_empty() {
|
||||
self.state.list.select(None);
|
||||
self.track = TrackSelection::initialise(&[]);
|
||||
} else if index >= albums.len() {
|
||||
let end = albums.len() - 1;
|
||||
self.state.list.select(Some(end));
|
||||
self.track = TrackSelection::initialise(&albums[end].tracks);
|
||||
} else {
|
||||
self.state.list.select(Some(index));
|
||||
self.track.reinitialise(&albums[index].tracks, active_track);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(&self) -> Option<usize> {
|
||||
self.state.list.selected()
|
||||
}
|
||||
|
||||
pub fn selected_track(&self) -> Option<usize> {
|
||||
self.track.selected()
|
||||
}
|
||||
|
||||
pub fn select(&mut self, albums: &[Album], to: Option<usize>) {
|
||||
match to {
|
||||
Some(to) => self.select_to(albums, to),
|
||||
None => self.state.list.select(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_track(&mut self, albums: &[Album], to: Option<usize>) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.track.select(&albums[index].tracks, to);
|
||||
}
|
||||
}
|
||||
|
||||
fn select_to(&mut self, albums: &[Album], mut to: usize) {
|
||||
to = cmp::min(to, albums.len() - 1);
|
||||
if self.state.list.selected() != Some(to) {
|
||||
self.state.list.select(Some(to));
|
||||
self.track = TrackSelection::initialise(&albums[to].tracks);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selection_state<'a>(&self, list: &'a [Album]) -> Option<SelectionState<'a, Album>> {
|
||||
let selected = self.state.list.selected();
|
||||
selected.map(|index| SelectionState { list, index })
|
||||
}
|
||||
|
||||
pub fn state_tracks<'a>(&self, albums: &'a [Album]) -> Option<SelectionState<'a, Track>> {
|
||||
let selected = self.state.list.selected();
|
||||
selected.and_then(|index| self.track.selection_state(&albums[index].tracks))
|
||||
}
|
||||
|
||||
pub fn widget_state(&mut self) -> &mut WidgetState {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
pub fn widget_state_track(&mut self) -> &mut WidgetState {
|
||||
self.track.widget_state()
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, albums: &[Album]) {
|
||||
if self.state.list.selected() != Some(0) {
|
||||
self.reinitialise(albums, None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_track(&mut self, albums: &[Album]) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.track.reset(&albums[index].tracks);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment(&mut self, albums: &[Album], delta: Delta) {
|
||||
self.increment_by(albums, delta.as_usize(&self.state));
|
||||
}
|
||||
|
||||
pub fn increment_track(&mut self, albums: &[Album], delta: Delta) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.track.increment(&albums[index].tracks, delta);
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_by(&mut self, albums: &[Album], by: usize) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
let mut result = index.saturating_add(by);
|
||||
if result >= albums.len() {
|
||||
result = albums.len() - 1;
|
||||
}
|
||||
if self.state.list.selected() != Some(result) {
|
||||
self.state.list.select(Some(result));
|
||||
self.track = TrackSelection::initialise(&albums[result].tracks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decrement(&mut self, albums: &[Album], delta: Delta) {
|
||||
self.decrement_by(albums, delta.as_usize(&self.state));
|
||||
}
|
||||
|
||||
pub fn decrement_track(&mut self, albums: &[Album], delta: Delta) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.track.decrement(&albums[index].tracks, delta);
|
||||
}
|
||||
}
|
||||
|
||||
fn decrement_by(&mut self, albums: &[Album], by: usize) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
let result = index.saturating_sub(by);
|
||||
if self.state.list.selected() != Some(result) {
|
||||
self.state.list.select(Some(result));
|
||||
self.track = TrackSelection::initialise(&albums[result].tracks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KeySelectAlbum {
|
||||
key: (AlbumDate, AlbumSeq, AlbumId),
|
||||
track: Option<KeySelectTrack>,
|
||||
}
|
||||
|
||||
impl KeySelectAlbum {
|
||||
pub fn get(albums: &[Album], selection: &AlbumSelection) -> Option<Self> {
|
||||
selection.state.list.selected().map(|index| {
|
||||
let album = &albums[index];
|
||||
let key = album.meta.get_sort_key();
|
||||
KeySelectAlbum {
|
||||
key: (key.0.to_owned(), key.1.to_owned(), key.2.to_owned()),
|
||||
track: KeySelectTrack::get(&album.tracks, &selection.track),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_sort_key(&self) -> (&AlbumDate, &AlbumSeq, &AlbumId) {
|
||||
(&self.key.0, &self.key.1, &self.key.2)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::testmod::COLLECTION;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn album_select() {
|
||||
let albums = &COLLECTION[0].albums;
|
||||
assert!(albums.len() > 1);
|
||||
|
||||
let mut sel = AlbumSelection::initialise(albums);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.select(albums, None);
|
||||
assert_eq!(sel.selected(), None);
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.select(albums, Some(albums.len()));
|
||||
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.select_track(albums, None);
|
||||
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||
assert_eq!(sel.selected_track(), None);
|
||||
|
||||
sel.reset_track(albums);
|
||||
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.select_track(albums, Some(1));
|
||||
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||
assert_eq!(sel.selected_track(), Some(1));
|
||||
|
||||
sel.reset(albums);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_delta_line() {
|
||||
let albums = &COLLECTION[0].albums;
|
||||
assert!(albums.len() > 1);
|
||||
|
||||
let mut empty = AlbumSelection::initialise(&[]);
|
||||
assert_eq!(empty.selected(), None);
|
||||
assert_eq!(empty.selected_track(), None);
|
||||
|
||||
empty.increment(albums, Delta::Line);
|
||||
assert_eq!(empty.selected(), None);
|
||||
assert_eq!(empty.selected_track(), None);
|
||||
|
||||
empty.decrement(albums, Delta::Line);
|
||||
assert_eq!(empty.selected(), None);
|
||||
assert_eq!(empty.selected_track(), None);
|
||||
|
||||
let mut sel = AlbumSelection::initialise(albums);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.increment_track(albums, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(1));
|
||||
|
||||
// Verify that decrement that doesn't change index does not reset track.
|
||||
sel.decrement(albums, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(1));
|
||||
|
||||
sel.increment(albums, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(1));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.decrement(albums, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
for _ in 0..(albums.len() + 5) {
|
||||
sel.increment(albums, Delta::Line);
|
||||
}
|
||||
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.increment_track(albums, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||
assert_eq!(sel.selected_track(), Some(1));
|
||||
|
||||
// Verify that increment that doesn't change index does not reset track.
|
||||
sel.increment(albums, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||
assert_eq!(sel.selected_track(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_delta_page() {
|
||||
let albums = &COLLECTION[1].albums;
|
||||
assert!(albums.len() > 1);
|
||||
|
||||
let empty = AlbumSelection::initialise(&[]);
|
||||
assert_eq!(empty.selected(), None);
|
||||
|
||||
let mut sel = AlbumSelection::initialise(albums);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
assert!(albums.len() >= 4);
|
||||
sel.state.height = 3;
|
||||
|
||||
sel.increment_track(albums, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(1));
|
||||
|
||||
// Verify that decrement that doesn't change index does not reset track.
|
||||
sel.decrement(albums, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(1));
|
||||
|
||||
sel.increment(albums, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(2));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.decrement(albums, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
for _ in 0..(albums.len() + 5) {
|
||||
sel.increment(albums, Delta::Page);
|
||||
}
|
||||
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.increment_track(albums, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||
assert_eq!(sel.selected_track(), Some(1));
|
||||
|
||||
// Verify that increment that doesn't change index does not reset track.
|
||||
sel.increment(albums, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(albums.len() - 1));
|
||||
assert_eq!(sel.selected_track(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_reinitialise() {
|
||||
let albums = &COLLECTION[0].albums;
|
||||
assert!(albums.len() > 1);
|
||||
|
||||
let mut sel = AlbumSelection::initialise(albums);
|
||||
sel.state.list.select(Some(albums.len() - 1));
|
||||
sel.track.state.list.select(Some(1));
|
||||
|
||||
// Re-initialise.
|
||||
let expected = sel.clone();
|
||||
let active_album = KeySelectAlbum::get(albums, &sel);
|
||||
sel.reinitialise(albums, active_album);
|
||||
assert_eq!(sel, expected);
|
||||
|
||||
// Re-initialise out-of-bounds.
|
||||
let mut expected = sel.clone();
|
||||
expected.decrement(albums, Delta::Line);
|
||||
let active_album = KeySelectAlbum::get(albums, &sel);
|
||||
sel.reinitialise(&albums[..(albums.len() - 1)], active_album);
|
||||
assert_eq!(sel, expected);
|
||||
|
||||
// Re-initialise empty.
|
||||
let expected = AlbumSelection::initialise(&[]);
|
||||
let active_album = KeySelectAlbum::get(albums, &sel);
|
||||
sel.reinitialise(&[], active_album);
|
||||
assert_eq!(sel, expected);
|
||||
}
|
||||
}
|
414
src/tui/app/selection/artist.rs
Normal file
414
src/tui/app/selection/artist.rs
Normal file
@ -0,0 +1,414 @@
|
||||
use std::cmp;
|
||||
|
||||
use musichoard::collection::{
|
||||
album::Album,
|
||||
artist::{Artist, ArtistId},
|
||||
track::Track,
|
||||
};
|
||||
|
||||
use crate::tui::app::{
|
||||
selection::{
|
||||
album::{AlbumSelection, KeySelectAlbum},
|
||||
SelectionState,
|
||||
},
|
||||
Delta, WidgetState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ArtistSelection {
|
||||
pub state: WidgetState,
|
||||
pub album: AlbumSelection,
|
||||
}
|
||||
|
||||
impl ArtistSelection {
|
||||
pub fn initialise(artists: &[Artist]) -> Self {
|
||||
let mut selection = ArtistSelection {
|
||||
state: WidgetState::default(),
|
||||
album: AlbumSelection::initialise(&[]),
|
||||
};
|
||||
selection.reinitialise(artists, None);
|
||||
selection
|
||||
}
|
||||
|
||||
pub fn reinitialise(&mut self, artists: &[Artist], active: Option<KeySelectArtist>) {
|
||||
if let Some(active) = active {
|
||||
let result =
|
||||
artists.binary_search_by(|a| a.meta.get_sort_key().cmp(&active.get_sort_key()));
|
||||
match result {
|
||||
Ok(index) => self.reinitialise_with_index(artists, index, active.album),
|
||||
Err(index) => self.reinitialise_with_index(artists, index, None),
|
||||
}
|
||||
} else {
|
||||
self.reinitialise_with_index(artists, 0, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn reinitialise_with_index(
|
||||
&mut self,
|
||||
artists: &[Artist],
|
||||
index: usize,
|
||||
active_album: Option<KeySelectAlbum>,
|
||||
) {
|
||||
if artists.is_empty() {
|
||||
self.state.list.select(None);
|
||||
self.album = AlbumSelection::initialise(&[]);
|
||||
} else if index >= artists.len() {
|
||||
let end = artists.len() - 1;
|
||||
self.state.list.select(Some(end));
|
||||
self.album = AlbumSelection::initialise(&artists[end].albums);
|
||||
} else {
|
||||
self.state.list.select(Some(index));
|
||||
self.album
|
||||
.reinitialise(&artists[index].albums, active_album);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(&self) -> Option<usize> {
|
||||
self.state.list.selected()
|
||||
}
|
||||
|
||||
pub fn selected_album(&self) -> Option<usize> {
|
||||
self.album.selected()
|
||||
}
|
||||
|
||||
pub fn selected_track(&self) -> Option<usize> {
|
||||
self.album.selected_track()
|
||||
}
|
||||
|
||||
pub fn select(&mut self, artists: &[Artist], to: Option<usize>) {
|
||||
match to {
|
||||
Some(to) => self.select_to(artists, to),
|
||||
None => self.state.list.select(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_album(&mut self, artists: &[Artist], to: Option<usize>) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.album.select(&artists[index].albums, to);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_track(&mut self, artists: &[Artist], to: Option<usize>) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.album.select_track(&artists[index].albums, to);
|
||||
}
|
||||
}
|
||||
|
||||
fn select_to(&mut self, artists: &[Artist], mut to: usize) {
|
||||
to = cmp::min(to, artists.len() - 1);
|
||||
if self.state.list.selected() != Some(to) {
|
||||
self.state.list.select(Some(to));
|
||||
self.album = AlbumSelection::initialise(&artists[to].albums);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selection_state<'a>(&self, list: &'a [Artist]) -> Option<SelectionState<'a, Artist>> {
|
||||
let selected = self.state.list.selected();
|
||||
selected.map(|index| SelectionState { list, index })
|
||||
}
|
||||
|
||||
pub fn state_album<'a>(&self, artists: &'a [Artist]) -> Option<SelectionState<'a, Album>> {
|
||||
let selected = self.state.list.selected();
|
||||
selected.and_then(|index| self.album.selection_state(&artists[index].albums))
|
||||
}
|
||||
|
||||
pub fn state_track<'a>(&self, artists: &'a [Artist]) -> Option<SelectionState<'a, Track>> {
|
||||
let selected = self.state.list.selected();
|
||||
selected.and_then(|index| self.album.state_tracks(&artists[index].albums))
|
||||
}
|
||||
|
||||
pub fn widget_state(&mut self) -> &mut WidgetState {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
pub fn widget_state_album(&mut self) -> &mut WidgetState {
|
||||
self.album.widget_state()
|
||||
}
|
||||
|
||||
pub fn widget_state_track(&mut self) -> &mut WidgetState {
|
||||
self.album.widget_state_track()
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, artists: &[Artist]) {
|
||||
if self.state.list.selected() != Some(0) {
|
||||
self.reinitialise(artists, None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_album(&mut self, artists: &[Artist]) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.album.reset(&artists[index].albums);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_track(&mut self, artists: &[Artist]) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.album.reset_track(&artists[index].albums);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment(&mut self, artists: &[Artist], delta: Delta) {
|
||||
self.increment_by(artists, delta.as_usize(&self.state));
|
||||
}
|
||||
|
||||
pub fn increment_album(&mut self, artists: &[Artist], delta: Delta) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.album.increment(&artists[index].albums, delta);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_track(&mut self, artists: &[Artist], delta: Delta) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.album.increment_track(&artists[index].albums, delta);
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_by(&mut self, artists: &[Artist], by: usize) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
let result = index.saturating_add(by);
|
||||
self.select_to(artists, result);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decrement(&mut self, artists: &[Artist], delta: Delta) {
|
||||
self.decrement_by(artists, delta.as_usize(&self.state));
|
||||
}
|
||||
|
||||
pub fn decrement_album(&mut self, artists: &[Artist], delta: Delta) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.album.decrement(&artists[index].albums, delta);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decrement_track(&mut self, artists: &[Artist], delta: Delta) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
self.album.decrement_track(&artists[index].albums, delta);
|
||||
}
|
||||
}
|
||||
|
||||
fn decrement_by(&mut self, artists: &[Artist], by: usize) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
let result = index.saturating_sub(by);
|
||||
if self.state.list.selected() != Some(result) {
|
||||
self.state.list.select(Some(result));
|
||||
self.album = AlbumSelection::initialise(&artists[result].albums);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KeySelectArtist {
|
||||
key: (ArtistId,),
|
||||
album: Option<KeySelectAlbum>,
|
||||
}
|
||||
|
||||
impl KeySelectArtist {
|
||||
pub fn get(artists: &[Artist], selection: &ArtistSelection) -> Option<Self> {
|
||||
selection.state.list.selected().map(|index| {
|
||||
let artist = &artists[index];
|
||||
let key = artist.meta.get_sort_key();
|
||||
KeySelectArtist {
|
||||
key: (key.0.into(),),
|
||||
album: KeySelectAlbum::get(&artist.albums, &selection.album),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_sort_key(&self) -> (&str,) {
|
||||
(&self.key.0.name,)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::testmod::COLLECTION;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn artist_select() {
|
||||
let artists = &COLLECTION;
|
||||
assert!(artists.len() > 1);
|
||||
|
||||
let mut sel = ArtistSelection::initialise(artists);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_album(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.select(artists, None);
|
||||
assert_eq!(sel.selected(), None);
|
||||
assert_eq!(sel.selected_album(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.select(artists, Some(artists.len()));
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.selected_album(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.select_track(artists, None);
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.selected_album(), Some(0));
|
||||
assert_eq!(sel.selected_track(), None);
|
||||
|
||||
sel.select_album(artists, None);
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.selected_album(), None);
|
||||
assert_eq!(sel.selected_track(), None);
|
||||
|
||||
sel.reset_album(artists);
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.selected_album(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.select_track(artists, None);
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.selected_album(), Some(0));
|
||||
assert_eq!(sel.selected_track(), None);
|
||||
|
||||
sel.reset_track(artists);
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.selected_album(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.select_album(artists, Some(1));
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.selected_album(), Some(1));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
|
||||
sel.reset(artists);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.selected_album(), Some(0));
|
||||
assert_eq!(sel.selected_track(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_delta_line() {
|
||||
let artists = &COLLECTION;
|
||||
assert!(artists.len() > 1);
|
||||
|
||||
let mut empty = ArtistSelection::initialise(&[]);
|
||||
assert_eq!(empty.selected(), None);
|
||||
assert_eq!(empty.album.selected(), None);
|
||||
|
||||
empty.increment(artists, Delta::Line);
|
||||
assert_eq!(empty.selected(), None);
|
||||
assert_eq!(empty.album.selected(), None);
|
||||
|
||||
empty.decrement(artists, Delta::Line);
|
||||
assert_eq!(empty.selected(), None);
|
||||
assert_eq!(empty.album.selected(), None);
|
||||
|
||||
let mut sel = ArtistSelection::initialise(artists);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.album.selected(), Some(0));
|
||||
|
||||
sel.increment_album(artists, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.album.selected(), Some(1));
|
||||
|
||||
// Verify that decrement that doesn't change index does not reset album.
|
||||
sel.decrement(artists, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.album.selected(), Some(1));
|
||||
|
||||
sel.increment(artists, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(1));
|
||||
assert_eq!(sel.album.selected(), Some(0));
|
||||
|
||||
sel.decrement(artists, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.album.selected(), Some(0));
|
||||
|
||||
for _ in 0..(artists.len() + 5) {
|
||||
sel.increment(artists, Delta::Line);
|
||||
}
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.album.selected(), Some(0));
|
||||
|
||||
sel.increment_album(artists, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.album.selected(), Some(1));
|
||||
|
||||
// Verify that increment that doesn't change index does not reset album.
|
||||
sel.increment(artists, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.album.selected(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_delta_page() {
|
||||
let artists = &COLLECTION;
|
||||
assert!(artists.len() > 1);
|
||||
|
||||
let empty = ArtistSelection::initialise(&[]);
|
||||
assert_eq!(empty.selected(), None);
|
||||
|
||||
let mut sel = ArtistSelection::initialise(artists);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.album.selected(), Some(0));
|
||||
|
||||
assert!(artists.len() >= 4);
|
||||
sel.state.height = 3;
|
||||
|
||||
sel.increment_album(artists, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.album.selected(), Some(1));
|
||||
|
||||
// Verify that decrement that doesn't change index does not reset album.
|
||||
sel.decrement(artists, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.album.selected(), Some(1));
|
||||
|
||||
sel.increment(artists, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(2));
|
||||
assert_eq!(sel.album.selected(), Some(0));
|
||||
|
||||
sel.decrement(artists, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
assert_eq!(sel.album.selected(), Some(0));
|
||||
|
||||
for _ in 0..(artists.len() + 5) {
|
||||
sel.increment(artists, Delta::Page);
|
||||
}
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.album.selected(), Some(0));
|
||||
|
||||
sel.increment_album(artists, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.album.selected(), Some(1));
|
||||
|
||||
// Verify that increment that doesn't change index does not reset album.
|
||||
sel.increment(artists, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(artists.len() - 1));
|
||||
assert_eq!(sel.album.selected(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_reinitialise() {
|
||||
let artists = &COLLECTION;
|
||||
assert!(artists.len() > 1);
|
||||
|
||||
let mut sel = ArtistSelection::initialise(artists);
|
||||
sel.state.list.select(Some(artists.len() - 1));
|
||||
sel.album.state.list.select(Some(1));
|
||||
|
||||
// Re-initialise.
|
||||
let expected = sel.clone();
|
||||
let active_artist = KeySelectArtist::get(artists, &sel);
|
||||
sel.reinitialise(artists, active_artist);
|
||||
assert_eq!(sel, expected);
|
||||
|
||||
// Re-initialise out-of-bounds.
|
||||
let mut expected = sel.clone();
|
||||
expected.decrement(artists, Delta::Line);
|
||||
let active_artist = KeySelectArtist::get(artists, &sel);
|
||||
sel.reinitialise(&artists[..(artists.len() - 1)], active_artist);
|
||||
assert_eq!(sel, expected);
|
||||
|
||||
// Re-initialise empty.
|
||||
let expected = ArtistSelection::initialise(&[]);
|
||||
let active_artist = KeySelectArtist::get(artists, &sel);
|
||||
sel.reinitialise(&[], active_artist);
|
||||
assert_eq!(sel, expected);
|
||||
}
|
||||
}
|
382
src/tui/app/selection/mod.rs
Normal file
382
src/tui/app/selection/mod.rs
Normal file
@ -0,0 +1,382 @@
|
||||
mod album;
|
||||
mod artist;
|
||||
mod track;
|
||||
|
||||
use musichoard::collection::{album::Album, artist::Artist, track::Track, Collection};
|
||||
use ratatui::widgets::ListState;
|
||||
|
||||
use crate::tui::app::{
|
||||
selection::artist::{ArtistSelection, KeySelectArtist},
|
||||
Delta, WidgetState,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Category {
|
||||
Artist,
|
||||
Album,
|
||||
Track,
|
||||
}
|
||||
|
||||
pub struct Selection {
|
||||
active: Category,
|
||||
artist: ArtistSelection,
|
||||
}
|
||||
|
||||
pub struct SelectionState<'a, T> {
|
||||
pub list: &'a [T],
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn new(artists: &[Artist]) -> Self {
|
||||
Selection {
|
||||
active: Category::Artist,
|
||||
artist: ArtistSelection::initialise(artists),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_by_list(&mut self, selected: ListSelection) {
|
||||
self.artist.state.list = selected.artist;
|
||||
self.artist.album.state.list = selected.album;
|
||||
self.artist.album.track.state.list = selected.track;
|
||||
}
|
||||
|
||||
pub fn select_by_id(&mut self, artists: &[Artist], selected: KeySelection) {
|
||||
self.artist.reinitialise(artists, selected.artist);
|
||||
}
|
||||
|
||||
pub fn increment_category(&mut self) {
|
||||
self.active = match self.active {
|
||||
Category::Artist => Category::Album,
|
||||
Category::Album => Category::Track,
|
||||
Category::Track => Category::Track,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn decrement_category(&mut self) {
|
||||
self.active = match self.active {
|
||||
Category::Artist => Category::Artist,
|
||||
Category::Album => Category::Artist,
|
||||
Category::Track => Category::Album,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn select(&mut self, collection: &Collection, index: Option<usize>) {
|
||||
match self.active {
|
||||
Category::Artist => self.select_artist(collection, index),
|
||||
Category::Album => self.select_album(collection, index),
|
||||
Category::Track => self.select_track(collection, index),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_artist(&mut self, artists: &[Artist], index: Option<usize>) {
|
||||
self.artist.select(artists, index);
|
||||
}
|
||||
|
||||
fn select_album(&mut self, artists: &[Artist], index: Option<usize>) {
|
||||
self.artist.select_album(artists, index);
|
||||
}
|
||||
|
||||
fn select_track(&mut self, artists: &[Artist], index: Option<usize>) {
|
||||
self.artist.select_track(artists, index);
|
||||
}
|
||||
|
||||
pub fn category(&self) -> Category {
|
||||
self.active
|
||||
}
|
||||
|
||||
pub fn selected(&self) -> Option<usize> {
|
||||
match self.active {
|
||||
Category::Artist => self.selected_artist(),
|
||||
Category::Album => self.selected_album(),
|
||||
Category::Track => self.selected_track(),
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_artist(&self) -> Option<usize> {
|
||||
self.artist.selected()
|
||||
}
|
||||
|
||||
fn selected_album(&self) -> Option<usize> {
|
||||
self.artist.selected_album()
|
||||
}
|
||||
|
||||
fn selected_track(&self) -> Option<usize> {
|
||||
self.artist.selected_track()
|
||||
}
|
||||
|
||||
pub fn state_artist<'a>(&self, coll: &'a Collection) -> Option<SelectionState<'a, Artist>> {
|
||||
self.artist.selection_state(coll)
|
||||
}
|
||||
|
||||
pub fn state_album<'a>(&self, coll: &'a Collection) -> Option<SelectionState<'a, Album>> {
|
||||
self.artist.state_album(coll)
|
||||
}
|
||||
|
||||
pub fn state_track<'a>(&self, coll: &'a Collection) -> Option<SelectionState<'a, Track>> {
|
||||
self.artist.state_track(coll)
|
||||
}
|
||||
|
||||
pub fn widget_state_artist(&mut self) -> &mut WidgetState {
|
||||
self.artist.widget_state()
|
||||
}
|
||||
|
||||
pub fn widget_state_album(&mut self) -> &mut WidgetState {
|
||||
self.artist.widget_state_album()
|
||||
}
|
||||
|
||||
pub fn widget_state_track(&mut self) -> &mut WidgetState {
|
||||
self.artist.widget_state_track()
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, collection: &Collection) {
|
||||
match self.active {
|
||||
Category::Artist => self.reset_artist(collection),
|
||||
Category::Album => self.reset_album(collection),
|
||||
Category::Track => self.reset_track(collection),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_artist(&mut self, artists: &[Artist]) {
|
||||
self.artist.reset(artists);
|
||||
}
|
||||
|
||||
fn reset_album(&mut self, artists: &[Artist]) {
|
||||
self.artist.reset_album(artists);
|
||||
}
|
||||
|
||||
fn reset_track(&mut self, artists: &[Artist]) {
|
||||
self.artist.reset_track(artists);
|
||||
}
|
||||
|
||||
pub fn increment_selection(&mut self, collection: &Collection, delta: Delta) {
|
||||
match self.active {
|
||||
Category::Artist => self.increment_artist(collection, delta),
|
||||
Category::Album => self.increment_album(collection, delta),
|
||||
Category::Track => self.increment_track(collection, delta),
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_artist(&mut self, artists: &[Artist], delta: Delta) {
|
||||
self.artist.increment(artists, delta);
|
||||
}
|
||||
|
||||
fn increment_album(&mut self, artists: &[Artist], delta: Delta) {
|
||||
self.artist.increment_album(artists, delta);
|
||||
}
|
||||
|
||||
fn increment_track(&mut self, artists: &[Artist], delta: Delta) {
|
||||
self.artist.increment_track(artists, delta);
|
||||
}
|
||||
|
||||
pub fn decrement_selection(&mut self, collection: &Collection, delta: Delta) {
|
||||
match self.active {
|
||||
Category::Artist => self.decrement_artist(collection, delta),
|
||||
Category::Album => self.decrement_album(collection, delta),
|
||||
Category::Track => self.decrement_track(collection, delta),
|
||||
}
|
||||
}
|
||||
|
||||
fn decrement_artist(&mut self, artists: &[Artist], delta: Delta) {
|
||||
self.artist.decrement(artists, delta);
|
||||
}
|
||||
|
||||
fn decrement_album(&mut self, artists: &[Artist], delta: Delta) {
|
||||
self.artist.decrement_album(artists, delta);
|
||||
}
|
||||
|
||||
fn decrement_track(&mut self, artists: &[Artist], delta: Delta) {
|
||||
self.artist.decrement_track(artists, delta);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ListSelection {
|
||||
pub artist: ListState,
|
||||
pub album: ListState,
|
||||
pub track: ListState,
|
||||
}
|
||||
|
||||
impl ListSelection {
|
||||
pub fn get(selection: &Selection) -> Self {
|
||||
ListSelection {
|
||||
artist: selection.artist.state.list.clone(),
|
||||
album: selection.artist.album.state.list.clone(),
|
||||
track: selection.artist.album.track.state.list.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KeySelection {
|
||||
artist: Option<KeySelectArtist>,
|
||||
}
|
||||
|
||||
impl KeySelection {
|
||||
pub fn get(collection: &Collection, selection: &Selection) -> Self {
|
||||
KeySelection {
|
||||
artist: KeySelectArtist::get(collection, &selection.artist),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::testmod::COLLECTION;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selection_select() {
|
||||
let mut selection = Selection::new(&COLLECTION);
|
||||
|
||||
selection.select(&COLLECTION, Some(1));
|
||||
selection.increment_category();
|
||||
selection.select(&COLLECTION, Some(1));
|
||||
selection.increment_category();
|
||||
selection.select(&COLLECTION, Some(1));
|
||||
|
||||
assert_eq!(selection.active, Category::Track);
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(1));
|
||||
|
||||
selection.reset(&COLLECTION);
|
||||
|
||||
assert_eq!(selection.active, Category::Track);
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.select(&COLLECTION, Some(1));
|
||||
|
||||
assert_eq!(selection.active, Category::Track);
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(1));
|
||||
|
||||
selection.decrement_category();
|
||||
|
||||
selection.reset(&COLLECTION);
|
||||
|
||||
assert_eq!(selection.active, Category::Album);
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.select(&COLLECTION, Some(1));
|
||||
|
||||
assert_eq!(selection.active, Category::Album);
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.decrement_category();
|
||||
|
||||
selection.reset(&COLLECTION);
|
||||
|
||||
assert_eq!(selection.active, Category::Artist);
|
||||
assert_eq!(selection.artist.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_delta() {
|
||||
let mut selection = Selection::new(&COLLECTION);
|
||||
|
||||
assert_eq!(selection.active, Category::Artist);
|
||||
assert_eq!(selection.selected(), Some(0));
|
||||
assert_eq!(selection.artist.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||
assert_eq!(selection.active, Category::Artist);
|
||||
assert_eq!(selection.selected(), Some(1));
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.increment_category();
|
||||
assert_eq!(selection.active, Category::Album);
|
||||
assert_eq!(selection.selected(), Some(0));
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||
assert_eq!(selection.active, Category::Album);
|
||||
assert_eq!(selection.selected(), Some(1));
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.increment_category();
|
||||
assert_eq!(selection.active, Category::Track);
|
||||
assert_eq!(selection.selected(), Some(0));
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||
assert_eq!(selection.active, Category::Track);
|
||||
assert_eq!(selection.selected(), Some(1));
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(1));
|
||||
|
||||
selection.increment_category();
|
||||
assert_eq!(selection.active, Category::Track);
|
||||
assert_eq!(selection.selected(), Some(1));
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(1));
|
||||
|
||||
selection.decrement_selection(&COLLECTION, Delta::Line);
|
||||
assert_eq!(selection.active, Category::Track);
|
||||
assert_eq!(selection.selected(), Some(0));
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||
selection.decrement_category();
|
||||
assert_eq!(selection.active, Category::Album);
|
||||
assert_eq!(selection.selected(), Some(1));
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(1));
|
||||
|
||||
selection.decrement_selection(&COLLECTION, Delta::Line);
|
||||
assert_eq!(selection.active, Category::Album);
|
||||
assert_eq!(selection.selected(), Some(0));
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||
selection.decrement_category();
|
||||
assert_eq!(selection.active, Category::Artist);
|
||||
assert_eq!(selection.selected(), Some(1));
|
||||
assert_eq!(selection.artist.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.decrement_selection(&COLLECTION, Delta::Line);
|
||||
assert_eq!(selection.active, Category::Artist);
|
||||
assert_eq!(selection.selected(), Some(0));
|
||||
assert_eq!(selection.artist.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
|
||||
selection.increment_category();
|
||||
selection.increment_selection(&COLLECTION, Delta::Line);
|
||||
selection.decrement_category();
|
||||
selection.decrement_selection(&COLLECTION, Delta::Line);
|
||||
selection.decrement_category();
|
||||
assert_eq!(selection.active, Category::Artist);
|
||||
assert_eq!(selection.selected(), Some(0));
|
||||
assert_eq!(selection.artist.selected(), Some(0));
|
||||
assert_eq!(selection.artist.album.selected(), Some(1));
|
||||
assert_eq!(selection.artist.album.track.selected(), Some(0));
|
||||
}
|
||||
}
|
235
src/tui/app/selection/track.rs
Normal file
235
src/tui/app/selection/track.rs
Normal file
@ -0,0 +1,235 @@
|
||||
use std::cmp;
|
||||
|
||||
use musichoard::collection::track::{Track, TrackId, TrackNum};
|
||||
|
||||
use crate::tui::app::{selection::SelectionState, Delta, WidgetState};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrackSelection {
|
||||
pub state: WidgetState,
|
||||
}
|
||||
|
||||
impl TrackSelection {
|
||||
pub fn initialise(tracks: &[Track]) -> Self {
|
||||
let mut selection = TrackSelection {
|
||||
state: WidgetState::default(),
|
||||
};
|
||||
selection.reinitialise(tracks, None);
|
||||
selection
|
||||
}
|
||||
|
||||
pub fn reinitialise(&mut self, tracks: &[Track], track: Option<KeySelectTrack>) {
|
||||
if let Some(track) = track {
|
||||
let result = tracks.binary_search_by(|t| t.get_sort_key().cmp(&track.get_sort_key()));
|
||||
match result {
|
||||
Ok(index) | Err(index) => self.reinitialise_with_index(tracks, index),
|
||||
}
|
||||
} else {
|
||||
self.reinitialise_with_index(tracks, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fn reinitialise_with_index(&mut self, tracks: &[Track], index: usize) {
|
||||
if tracks.is_empty() {
|
||||
self.state.list.select(None);
|
||||
} else if index >= tracks.len() {
|
||||
self.state.list.select(Some(tracks.len() - 1));
|
||||
} else {
|
||||
self.state.list.select(Some(index));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected(&self) -> Option<usize> {
|
||||
self.state.list.selected()
|
||||
}
|
||||
|
||||
pub fn select(&mut self, tracks: &[Track], to: Option<usize>) {
|
||||
match to {
|
||||
Some(to) => self.select_to(tracks, to),
|
||||
None => self.state.list.select(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_to(&mut self, tracks: &[Track], mut to: usize) {
|
||||
to = cmp::min(to, tracks.len() - 1);
|
||||
self.state.list.select(Some(to));
|
||||
}
|
||||
|
||||
pub fn selection_state<'a>(&self, list: &'a [Track]) -> Option<SelectionState<'a, Track>> {
|
||||
let selected = self.state.list.selected();
|
||||
selected.map(|index| SelectionState { list, index })
|
||||
}
|
||||
|
||||
pub fn widget_state(&mut self) -> &mut WidgetState {
|
||||
&mut self.state
|
||||
}
|
||||
|
||||
pub fn reset(&mut self, tracks: &[Track]) {
|
||||
if self.state.list.selected() != Some(0) {
|
||||
self.reinitialise(tracks, None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment(&mut self, tracks: &[Track], delta: Delta) {
|
||||
self.increment_by(tracks, delta.as_usize(&self.state));
|
||||
}
|
||||
|
||||
fn increment_by(&mut self, tracks: &[Track], by: usize) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
let mut result = index.saturating_add(by);
|
||||
if result >= tracks.len() {
|
||||
result = tracks.len() - 1;
|
||||
}
|
||||
if self.state.list.selected() != Some(result) {
|
||||
self.state.list.select(Some(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decrement(&mut self, tracks: &[Track], delta: Delta) {
|
||||
self.decrement_by(tracks, delta.as_usize(&self.state));
|
||||
}
|
||||
|
||||
fn decrement_by(&mut self, _tracks: &[Track], by: usize) {
|
||||
if let Some(index) = self.state.list.selected() {
|
||||
let result = index.saturating_sub(by);
|
||||
if self.state.list.selected() != Some(result) {
|
||||
self.state.list.select(Some(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KeySelectTrack {
|
||||
key: (TrackNum, TrackId),
|
||||
}
|
||||
|
||||
impl KeySelectTrack {
|
||||
pub fn get(tracks: &[Track], selection: &TrackSelection) -> Option<Self> {
|
||||
selection.state.list.selected().map(|index| {
|
||||
let track = &tracks[index];
|
||||
let key = track.get_sort_key();
|
||||
KeySelectTrack {
|
||||
key: (key.0.to_owned(), key.1.to_owned()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_sort_key(&self) -> (&TrackNum, &TrackId) {
|
||||
(&self.key.0, &self.key.1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::tui::testmod::COLLECTION;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn track_select() {
|
||||
let tracks = &COLLECTION[0].albums[0].tracks;
|
||||
assert!(tracks.len() > 1);
|
||||
|
||||
let mut sel = TrackSelection::initialise(tracks);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
sel.select(tracks, None);
|
||||
assert_eq!(sel.selected(), None);
|
||||
|
||||
sel.select(tracks, Some(tracks.len()));
|
||||
assert_eq!(sel.selected(), Some(tracks.len() - 1));
|
||||
|
||||
sel.reset(tracks);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_delta_line() {
|
||||
let tracks = &COLLECTION[0].albums[0].tracks;
|
||||
assert!(tracks.len() > 1);
|
||||
|
||||
let mut empty = TrackSelection::initialise(&[]);
|
||||
assert_eq!(empty.selected(), None);
|
||||
|
||||
empty.increment(tracks, Delta::Line);
|
||||
assert_eq!(empty.selected(), None);
|
||||
|
||||
empty.decrement(tracks, Delta::Line);
|
||||
assert_eq!(empty.selected(), None);
|
||||
|
||||
let mut sel = TrackSelection::initialise(tracks);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
sel.decrement(tracks, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
sel.increment(tracks, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(1));
|
||||
|
||||
sel.decrement(tracks, Delta::Line);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
for _ in 0..(tracks.len() + 5) {
|
||||
sel.increment(tracks, Delta::Line);
|
||||
}
|
||||
assert_eq!(sel.selected(), Some(tracks.len() - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_delta_page() {
|
||||
let tracks = &COLLECTION[0].albums[0].tracks;
|
||||
assert!(tracks.len() > 1);
|
||||
|
||||
let empty = TrackSelection::initialise(&[]);
|
||||
assert_eq!(empty.selected(), None);
|
||||
|
||||
let mut sel = TrackSelection::initialise(tracks);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
assert!(tracks.len() >= 4);
|
||||
sel.state.height = 3;
|
||||
|
||||
sel.decrement(tracks, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
sel.increment(tracks, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(2));
|
||||
|
||||
sel.decrement(tracks, Delta::Page);
|
||||
assert_eq!(sel.selected(), Some(0));
|
||||
|
||||
for _ in 0..(tracks.len() + 5) {
|
||||
sel.increment(tracks, Delta::Page);
|
||||
}
|
||||
assert_eq!(sel.selected(), Some(tracks.len() - 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_reinitialise() {
|
||||
let tracks = &COLLECTION[0].albums[0].tracks;
|
||||
assert!(tracks.len() > 1);
|
||||
|
||||
let mut sel = TrackSelection::initialise(tracks);
|
||||
sel.state.list.select(Some(tracks.len() - 1));
|
||||
|
||||
// Re-initialise.
|
||||
let expected = sel.clone();
|
||||
let active_track = KeySelectTrack::get(tracks, &sel);
|
||||
sel.reinitialise(tracks, active_track);
|
||||
assert_eq!(sel, expected);
|
||||
|
||||
// Re-initialise out-of-bounds.
|
||||
let mut expected = sel.clone();
|
||||
expected.decrement(tracks, Delta::Line);
|
||||
let active_track = KeySelectTrack::get(tracks, &sel);
|
||||
sel.reinitialise(&tracks[..(tracks.len() - 1)], active_track);
|
||||
assert_eq!(sel, expected);
|
||||
|
||||
// Re-initialise empty.
|
||||
let expected = TrackSelection::initialise(&[]);
|
||||
let active_track = KeySelectTrack::get(tracks, &sel);
|
||||
sel.reinitialise(&[], active_track);
|
||||
assert_eq!(sel, expected);
|
||||
}
|
||||
}
|
@ -1,7 +1,10 @@
|
||||
use crossterm::event::{KeyEvent, MouseEvent};
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::fmt;
|
||||
use std::sync::mpsc;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EventError {
|
||||
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 {
|
||||
Key(KeyEvent),
|
||||
Mouse(MouseEvent),
|
||||
Resize(u16, u16),
|
||||
FetchComplete,
|
||||
}
|
||||
|
||||
pub struct EventChannel {
|
||||
@ -45,6 +53,16 @@ pub struct EventChannel {
|
||||
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 {
|
||||
sender: mpsc::Sender<Event>,
|
||||
}
|
||||
@ -72,9 +90,15 @@ impl EventChannel {
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSender {
|
||||
pub fn send(&self, event: Event) -> Result<(), EventError> {
|
||||
Ok(self.sender.send(event)?)
|
||||
impl IKeyEventSender for EventSender {
|
||||
fn send_key(&self, key_event: KeyEvent) -> Result<(), EventError> {
|
||||
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> {
|
||||
Ok(self.receiver.recv()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn try_recv(&self) -> Result<Event, EventError> {
|
||||
Ok(self.receiver.try_recv()?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -90,20 +119,20 @@ mod tests {
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
|
||||
use super::{Event, EventChannel, EventError};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn event_sender() {
|
||||
let channel = EventChannel::new();
|
||||
let sender = channel.sender();
|
||||
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());
|
||||
|
||||
drop(receiver);
|
||||
let result = sender.send(event);
|
||||
let result = sender.send_key(key_event);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@ -112,9 +141,9 @@ mod tests {
|
||||
let channel = EventChannel::new();
|
||||
let sender = channel.sender();
|
||||
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();
|
||||
assert!(result.is_ok());
|
||||
|
||||
@ -123,6 +152,24 @@ mod tests {
|
||||
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]
|
||||
fn errors() {
|
||||
let send_err = EventError::Send(Event::Key(KeyEvent {
|
||||
|
@ -3,71 +3,249 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use super::{
|
||||
use crate::tui::{
|
||||
app::{
|
||||
AppMode, AppState, Delta, IApp, IAppBase, IAppEventFetch, IAppInput, IAppInteractBrowse,
|
||||
IAppInteractError, IAppInteractFetch, IAppInteractInfo, IAppInteractMatch,
|
||||
IAppInteractReload, IAppInteractSearch,
|
||||
},
|
||||
event::{Event, EventError, EventReceiver},
|
||||
ui::Ui,
|
||||
};
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait EventHandler<UI> {
|
||||
fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError>;
|
||||
pub trait IEventHandler<APP: IApp> {
|
||||
fn handle_next_event(&self, app: APP) -> Result<APP, EventError>;
|
||||
}
|
||||
|
||||
trait EventHandlerPrivate<UI> {
|
||||
fn handle_key_event(ui: &mut UI, key_event: KeyEvent);
|
||||
trait IEventHandlerPrivate<APP: IApp> {
|
||||
fn handle_key_event(app: APP, 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 IApp>::InfoState, 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 IApp>::SearchState, key_event: KeyEvent) -> APP;
|
||||
fn handle_fetch_key_event(app: <APP as IApp>::FetchState, 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 TuiEventHandler {
|
||||
pub struct EventHandler {
|
||||
events: EventReceiver,
|
||||
}
|
||||
|
||||
// GRCOV_EXCL_START
|
||||
impl TuiEventHandler {
|
||||
impl EventHandler {
|
||||
pub fn new(events: EventReceiver) -> Self {
|
||||
TuiEventHandler { events }
|
||||
EventHandler { events }
|
||||
}
|
||||
}
|
||||
|
||||
impl<UI: Ui> EventHandler<UI> for TuiEventHandler {
|
||||
fn handle_next_event(&self, ui: &mut UI) -> Result<(), EventError> {
|
||||
match self.events.recv()? {
|
||||
Event::Key(key_event) => Self::handle_key_event(ui, key_event),
|
||||
Event::Mouse(_) => {}
|
||||
Event::Resize(_, _) => {}
|
||||
impl<APP: IApp> IEventHandler<APP> for EventHandler {
|
||||
fn handle_next_event(&self, app: APP) -> Result<APP, EventError> {
|
||||
Ok(match self.events.recv()? {
|
||||
Event::Key(key_event) => Self::handle_key_event(app, key_event),
|
||||
Event::FetchComplete => Self::handle_fetch_complete_event(app),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<APP: IApp> IEventHandlerPrivate<APP> for EventHandler {
|
||||
fn handle_key_event(app: APP, key_event: KeyEvent) -> APP {
|
||||
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||
match key_event.code {
|
||||
// Exit application on `Ctrl-C`.
|
||||
KeyCode::Char('c') | KeyCode::Char('C') => return app.force_quit(),
|
||||
_ => {}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
match app.mode() {
|
||||
AppMode::Input(input_mode) => Self::handle_input_key_event(input_mode, key_event),
|
||||
AppMode::State(state_mode) => match state_mode {
|
||||
AppState::Browse(browse_state) => {
|
||||
Self::handle_browse_key_event(browse_state, key_event)
|
||||
}
|
||||
AppState::Info(info_state) => Self::handle_info_key_event(info_state, key_event),
|
||||
AppState::Reload(reload_state) => {
|
||||
Self::handle_reload_key_event(reload_state, key_event)
|
||||
}
|
||||
AppState::Search(search_state) => {
|
||||
Self::handle_search_key_event(search_state, key_event)
|
||||
}
|
||||
AppState::Fetch(fetch_state) => {
|
||||
Self::handle_fetch_key_event(fetch_state, key_event)
|
||||
}
|
||||
AppState::Match(match_state) => {
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl<UI: Ui> EventHandlerPrivate<UI> for TuiEventHandler {
|
||||
fn handle_key_event(ui: &mut UI, key_event: KeyEvent) {
|
||||
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 {
|
||||
// Exit application on `ESC` or `q`.
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
ui.quit();
|
||||
}
|
||||
// Exit application on `Ctrl-C`.
|
||||
KeyCode::Char('c') | KeyCode::Char('C') => {
|
||||
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||
ui.quit();
|
||||
}
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => app.quit(),
|
||||
// Category change.
|
||||
KeyCode::Left => {
|
||||
ui.decrement_category();
|
||||
}
|
||||
KeyCode::Right => {
|
||||
ui.increment_category();
|
||||
}
|
||||
KeyCode::Left => app.decrement_category(),
|
||||
KeyCode::Right => app.increment_category(),
|
||||
// Selection change.
|
||||
KeyCode::Up => {
|
||||
ui.decrement_selection();
|
||||
KeyCode::Up => app.decrement_selection(Delta::Line),
|
||||
KeyCode::Down => app.increment_selection(Delta::Line),
|
||||
KeyCode::PageUp => app.decrement_selection(Delta::Page),
|
||||
KeyCode::PageDown => app.increment_selection(Delta::Page),
|
||||
// Toggle info overlay.
|
||||
KeyCode::Char('m') | KeyCode::Char('M') => app.show_info_overlay(),
|
||||
// Toggle reload menu.
|
||||
KeyCode::Char('g') | KeyCode::Char('G') => app.show_reload_menu(),
|
||||
// Toggle search.
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => {
|
||||
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||
app.begin_search()
|
||||
} else {
|
||||
app.no_op()
|
||||
}
|
||||
KeyCode::Down => {
|
||||
ui.increment_selection();
|
||||
}
|
||||
// Other keys.
|
||||
KeyCode::Char('f') | KeyCode::Char('F') => app.fetch_musicbrainz(),
|
||||
// Othey keys.
|
||||
_ => app.no_op(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_info_key_event(app: <APP as IApp>::InfoState, key_event: KeyEvent) -> APP {
|
||||
match key_event.code {
|
||||
// Toggle overlay.
|
||||
KeyCode::Esc
|
||||
| KeyCode::Char('q')
|
||||
| KeyCode::Char('Q')
|
||||
| KeyCode::Char('m')
|
||||
| KeyCode::Char('M') => app.hide_info_overlay(),
|
||||
// Othey keys.
|
||||
_ => app.no_op(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_reload_key_event(app: <APP as IApp>::ReloadState, key_event: KeyEvent) -> APP {
|
||||
match key_event.code {
|
||||
// Reload keys.
|
||||
KeyCode::Char('l') | KeyCode::Char('L') => app.reload_library(),
|
||||
KeyCode::Char('d') | KeyCode::Char('D') => app.reload_database(),
|
||||
// Return.
|
||||
KeyCode::Esc
|
||||
| KeyCode::Char('q')
|
||||
| KeyCode::Char('Q')
|
||||
| KeyCode::Char('g')
|
||||
| KeyCode::Char('G') => app.hide_reload_menu(),
|
||||
// Othey keys.
|
||||
_ => app.no_op(),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_search_key_event(app: <APP as IApp>::SearchState, key_event: KeyEvent) -> APP {
|
||||
if key_event.modifiers == KeyModifiers::CONTROL {
|
||||
return match key_event.code {
|
||||
KeyCode::Char('s') | KeyCode::Char('S') => app.search_next(),
|
||||
KeyCode::Char('g') | KeyCode::Char('G') => app.cancel_search(),
|
||||
_ => app.no_op(),
|
||||
};
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
// Add/remove character to search.
|
||||
KeyCode::Char(ch) => app.append_character(ch),
|
||||
KeyCode::Backspace => app.step_back(),
|
||||
// Return.
|
||||
KeyCode::Esc | KeyCode::Enter => app.finish_search(),
|
||||
// Othey keys.
|
||||
_ => app.no_op(),
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
app.dismiss_error()
|
||||
}
|
||||
|
||||
fn handle_critical_key_event(app: <APP as IApp>::CriticalState, _key_event: KeyEvent) -> APP {
|
||||
// No action is allowed.
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
1
src/tui/lib/external/mod.rs
vendored
Normal file
1
src/tui/lib/external/mod.rs
vendored
Normal file
@ -0,0 +1 @@
|
||||
pub mod musicbrainz;
|
185
src/tui/lib/external/musicbrainz/api/mod.rs
vendored
Normal file
185
src/tui/lib/external/musicbrainz/api/mod.rs
vendored
Normal file
@ -0,0 +1,185 @@
|
||||
//! Module for interacting with the [MusicBrainz API](https://musicbrainz.org/doc/MusicBrainz_API).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use musichoard::{
|
||||
collection::{
|
||||
album::{AlbumDate, AlbumInfo, AlbumMeta, AlbumSeq},
|
||||
artist::{ArtistInfo, ArtistMeta},
|
||||
musicbrainz::{MbRefOption, Mbid},
|
||||
},
|
||||
external::musicbrainz::{
|
||||
api::{
|
||||
browse::{BrowseReleaseGroupRequest, BrowseReleaseGroupResponse},
|
||||
lookup::{
|
||||
LookupArtistRequest, LookupArtistResponse, LookupReleaseGroupRequest,
|
||||
LookupReleaseGroupResponse,
|
||||
},
|
||||
search::{
|
||||
SearchArtistRequest, SearchArtistResponseArtist, SearchReleaseGroupRequest,
|
||||
SearchReleaseGroupResponseReleaseGroup,
|
||||
},
|
||||
MbArtistMeta, MbReleaseGroupMeta, MusicBrainzClient, PageSettings,
|
||||
},
|
||||
IMusicBrainzHttp,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::tui::lib::interface::musicbrainz::api::{Entity, Error, IMusicBrainz};
|
||||
|
||||
// GRCOV_EXCL_START
|
||||
pub struct MusicBrainz<Http> {
|
||||
client: MusicBrainzClient<Http>,
|
||||
}
|
||||
|
||||
impl<Http> MusicBrainz<Http> {
|
||||
pub fn new(client: MusicBrainzClient<Http>) -> Self {
|
||||
MusicBrainz { client }
|
||||
}
|
||||
}
|
||||
|
||||
impl<Http: IMusicBrainzHttp> IMusicBrainz for MusicBrainz<Http> {
|
||||
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Entity<ArtistMeta>, Error> {
|
||||
let request = LookupArtistRequest::new(mbid);
|
||||
|
||||
let mb_response = self.client.lookup_artist(&request)?;
|
||||
|
||||
Ok(from_lookup_artist_response(mb_response))
|
||||
}
|
||||
|
||||
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Entity<AlbumMeta>, Error> {
|
||||
let request = LookupReleaseGroupRequest::new(mbid);
|
||||
|
||||
let mb_response = self.client.lookup_release_group(&request)?;
|
||||
|
||||
Ok(from_lookup_release_group_response(mb_response))
|
||||
}
|
||||
|
||||
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Entity<ArtistMeta>>, Error> {
|
||||
let query = SearchArtistRequest::new().string(&artist.id.name);
|
||||
|
||||
let paging = PageSettings::default();
|
||||
let mb_response = self.client.search_artist(&query, &paging)?;
|
||||
|
||||
Ok(mb_response
|
||||
.artists
|
||||
.into_iter()
|
||||
.map(from_search_artist_response_artist)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn search_release_group(
|
||||
&mut self,
|
||||
arid: &Mbid,
|
||||
album: &AlbumMeta,
|
||||
) -> Result<Vec<Entity<AlbumMeta>>, Error> {
|
||||
// Some release groups may have a promotional early release messing up the search. Searching
|
||||
// with just the year should be enough anyway.
|
||||
let date = AlbumDate::new(album.date.year, None, None);
|
||||
|
||||
let query = SearchReleaseGroupRequest::new()
|
||||
.arid(arid)
|
||||
.and()
|
||||
.first_release_date(&date)
|
||||
.and()
|
||||
.release_group(&album.id.title);
|
||||
|
||||
let paging = PageSettings::default();
|
||||
let mb_response = self.client.search_release_group(&query, &paging)?;
|
||||
|
||||
Ok(mb_response
|
||||
.release_groups
|
||||
.into_iter()
|
||||
.map(from_search_release_group_response_release_group)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn browse_release_group(
|
||||
&mut self,
|
||||
artist: &Mbid,
|
||||
paging: &mut Option<PageSettings>,
|
||||
) -> Result<Vec<Entity<AlbumMeta>>, Error> {
|
||||
let request = BrowseReleaseGroupRequest::artist(artist);
|
||||
|
||||
let page = paging.take().unwrap_or_default();
|
||||
let mb_response = self.client.browse_release_group(&request, &page)?;
|
||||
|
||||
let page_count = mb_response.release_groups.len();
|
||||
*paging = mb_response.page.next_page(page, page_count);
|
||||
|
||||
Ok(from_browse_release_group_response(mb_response))
|
||||
}
|
||||
}
|
||||
|
||||
fn from_mb_artist_meta(meta: MbArtistMeta) -> (ArtistMeta, Option<String>) {
|
||||
let sort = Some(meta.sort_name).filter(|s| s != &meta.name);
|
||||
(
|
||||
ArtistMeta {
|
||||
id: meta.name.into(),
|
||||
sort,
|
||||
info: ArtistInfo {
|
||||
musicbrainz: MbRefOption::Some(meta.id.into()),
|
||||
properties: HashMap::new(),
|
||||
},
|
||||
},
|
||||
meta.disambiguation,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_mb_release_group_meta(meta: MbReleaseGroupMeta) -> AlbumMeta {
|
||||
AlbumMeta {
|
||||
id: meta.title.into(),
|
||||
date: meta.first_release_date,
|
||||
seq: AlbumSeq::default(),
|
||||
info: AlbumInfo {
|
||||
musicbrainz: MbRefOption::Some(meta.id.into()),
|
||||
primary_type: meta.primary_type,
|
||||
secondary_types: meta.secondary_types.unwrap_or_default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn from_lookup_artist_response(response: LookupArtistResponse) -> Entity<ArtistMeta> {
|
||||
let (entity, disambiguation) = from_mb_artist_meta(response.meta);
|
||||
Entity {
|
||||
score: None,
|
||||
entity,
|
||||
disambiguation,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_lookup_release_group_response(response: LookupReleaseGroupResponse) -> Entity<AlbumMeta> {
|
||||
Entity {
|
||||
score: None,
|
||||
entity: from_mb_release_group_meta(response.meta),
|
||||
disambiguation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_search_artist_response_artist(response: SearchArtistResponseArtist) -> Entity<ArtistMeta> {
|
||||
let (entity, disambiguation) = from_mb_artist_meta(response.meta);
|
||||
Entity {
|
||||
score: Some(response.score),
|
||||
entity,
|
||||
disambiguation,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_search_release_group_response_release_group(
|
||||
response: SearchReleaseGroupResponseReleaseGroup,
|
||||
) -> Entity<AlbumMeta> {
|
||||
Entity {
|
||||
score: Some(response.score),
|
||||
entity: from_mb_release_group_meta(response.meta),
|
||||
disambiguation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_browse_release_group_response(
|
||||
entity: BrowseReleaseGroupResponse,
|
||||
) -> Vec<Entity<AlbumMeta>> {
|
||||
let rgs = entity.release_groups.into_iter();
|
||||
let metas = rgs.map(from_mb_release_group_meta);
|
||||
metas.map(Entity::new).collect()
|
||||
}
|
||||
// GRCOV_EXCL_STOP
|
963
src/tui/lib/external/musicbrainz/daemon/mod.rs
vendored
Normal file
963
src/tui/lib/external/musicbrainz/daemon/mod.rs
vendored
Normal file
@ -0,0 +1,963 @@
|
||||
use std::{collections::VecDeque, sync::mpsc, thread, time};
|
||||
|
||||
use musichoard::external::musicbrainz::api::PageSettings;
|
||||
|
||||
use crate::tui::{
|
||||
app::EntityMatches,
|
||||
event::IFetchCompleteEventSender,
|
||||
lib::interface::musicbrainz::{
|
||||
api::{Error as ApiError, IMusicBrainz},
|
||||
daemon::{
|
||||
BrowseParams, EntityList, Error, IMbJobSender, LookupParams, MbParams, MbReturn,
|
||||
ResultSender, SearchParams,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
pub struct MusicBrainzDaemon {
|
||||
musicbrainz: Box<dyn IMusicBrainz>,
|
||||
job_receiver: mpsc::Receiver<Job>,
|
||||
job_queue: JobQueue,
|
||||
event_sender: Box<dyn IFetchCompleteEventSender>,
|
||||
}
|
||||
|
||||
struct JobQueue {
|
||||
foreground_queue: VecDeque<JobInstance>,
|
||||
background_queue: VecDeque<JobInstance>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Job {
|
||||
priority: JobPriority,
|
||||
instance: JobInstance,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
pub fn new(priority: JobPriority, instance: JobInstance) -> Self {
|
||||
Job { priority, instance }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum JobPriority {
|
||||
Foreground,
|
||||
Background,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct JobInstance {
|
||||
result_sender: ResultSender,
|
||||
requests: VecDeque<MbParams>,
|
||||
paging: Option<PageSettings>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum JobInstanceStatus {
|
||||
Continue,
|
||||
Complete,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum JobInstanceError {
|
||||
ReturnChannelDisconnected,
|
||||
EventChannelDisconnected,
|
||||
}
|
||||
|
||||
impl JobInstance {
|
||||
fn new(result_sender: ResultSender, requests: VecDeque<MbParams>) -> Self {
|
||||
JobInstance {
|
||||
result_sender,
|
||||
requests,
|
||||
paging: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum JobError {
|
||||
JobQueueEmpty,
|
||||
EventChannelDisconnected,
|
||||
}
|
||||
|
||||
pub struct JobChannel {
|
||||
sender: mpsc::Sender<Job>,
|
||||
receiver: mpsc::Receiver<Job>,
|
||||
}
|
||||
|
||||
pub struct JobSender {
|
||||
sender: mpsc::Sender<Job>,
|
||||
}
|
||||
|
||||
pub struct JobReceiver {
|
||||
receiver: mpsc::Receiver<Job>,
|
||||
}
|
||||
|
||||
impl JobChannel {
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
JobChannel { receiver, sender }
|
||||
}
|
||||
|
||||
pub fn sender(&self) -> JobSender {
|
||||
JobSender {
|
||||
sender: self.sender.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn receiver(self) -> JobReceiver {
|
||||
JobReceiver {
|
||||
receiver: self.receiver,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IMbJobSender for JobSender {
|
||||
fn submit_foreground_job(
|
||||
&self,
|
||||
result_sender: ResultSender,
|
||||
requests: VecDeque<MbParams>,
|
||||
) -> Result<(), Error> {
|
||||
self.send_job(JobPriority::Foreground, result_sender, requests)
|
||||
}
|
||||
|
||||
fn submit_background_job(
|
||||
&self,
|
||||
result_sender: ResultSender,
|
||||
requests: VecDeque<MbParams>,
|
||||
) -> Result<(), Error> {
|
||||
self.send_job(JobPriority::Background, result_sender, requests)
|
||||
}
|
||||
}
|
||||
|
||||
impl JobSender {
|
||||
fn send_job(
|
||||
&self,
|
||||
priority: JobPriority,
|
||||
result_sender: ResultSender,
|
||||
requests: VecDeque<MbParams>,
|
||||
) -> Result<(), Error> {
|
||||
let instance = JobInstance::new(result_sender, requests);
|
||||
let job = Job::new(priority, instance);
|
||||
self.sender.send(job).or(Err(Error::JobChannelDisconnected))
|
||||
}
|
||||
}
|
||||
|
||||
impl MusicBrainzDaemon {
|
||||
// GRCOV_EXCL_START
|
||||
pub fn run<MB: IMusicBrainz + 'static, ES: IFetchCompleteEventSender + 'static>(
|
||||
musicbrainz: MB,
|
||||
job_receiver: JobReceiver,
|
||||
event_sender: ES,
|
||||
) -> Result<(), Error> {
|
||||
let daemon = MusicBrainzDaemon {
|
||||
musicbrainz: Box::new(musicbrainz),
|
||||
job_receiver: job_receiver.receiver,
|
||||
job_queue: JobQueue::new(),
|
||||
event_sender: Box::new(event_sender),
|
||||
};
|
||||
daemon.main()
|
||||
}
|
||||
|
||||
fn main(mut self) -> Result<(), Error> {
|
||||
loop {
|
||||
self.enqueue_all_pending_jobs()?;
|
||||
match self.execute_next_job() {
|
||||
Ok(()) => {
|
||||
// Sleep for one second. Required by MB API rate limiting. Assume all other
|
||||
// processing takes negligible time such that regardless of how much other
|
||||
// processing there is to be done, this one second sleep is necessary.
|
||||
thread::sleep(time::Duration::from_secs(1));
|
||||
}
|
||||
Err(JobError::JobQueueEmpty) => {
|
||||
self.wait_for_jobs()?;
|
||||
}
|
||||
Err(JobError::EventChannelDisconnected) => {
|
||||
return Err(Error::EventChannelDisconnected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// GRCOV_EXCL_STOP
|
||||
|
||||
fn enqueue_all_pending_jobs(&mut self) -> Result<(), Error> {
|
||||
loop {
|
||||
match self.job_receiver.try_recv() {
|
||||
Ok(job) => self.job_queue.push_back(job),
|
||||
Err(mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
return Err(Error::JobChannelDisconnected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_next_job(&mut self) -> Result<(), JobError> {
|
||||
if let Some(instance) = self.job_queue.front_mut() {
|
||||
let result = instance.execute_next(&mut *self.musicbrainz, &mut *self.event_sender);
|
||||
match result {
|
||||
Ok(JobInstanceStatus::Continue) => {}
|
||||
Ok(JobInstanceStatus::Complete)
|
||||
| Err(JobInstanceError::ReturnChannelDisconnected) => {
|
||||
self.job_queue.pop_front();
|
||||
}
|
||||
Err(JobInstanceError::EventChannelDisconnected) => {
|
||||
return Err(JobError::EventChannelDisconnected)
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
Err(JobError::JobQueueEmpty)
|
||||
}
|
||||
|
||||
fn wait_for_jobs(&mut self) -> Result<(), Error> {
|
||||
assert!(self.job_queue.is_empty());
|
||||
|
||||
match self.job_receiver.recv() {
|
||||
Ok(job) => self.job_queue.push_back(job),
|
||||
Err(mpsc::RecvError) => return Err(Error::JobChannelDisconnected),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl JobInstance {
|
||||
fn execute_next(
|
||||
&mut self,
|
||||
musicbrainz: &mut dyn IMusicBrainz,
|
||||
event_sender: &mut dyn IFetchCompleteEventSender,
|
||||
) -> Result<JobInstanceStatus, JobInstanceError> {
|
||||
// self.requests can be empty if the caller submits an empty job.
|
||||
if let Some(params) = self.requests.front() {
|
||||
let result_sender = &mut self.result_sender;
|
||||
let paging = &mut self.paging;
|
||||
Self::execute(musicbrainz, result_sender, event_sender, params, paging)?;
|
||||
};
|
||||
|
||||
if self.paging.is_none() {
|
||||
self.requests.pop_front();
|
||||
}
|
||||
|
||||
if self.requests.is_empty() {
|
||||
Ok(JobInstanceStatus::Complete)
|
||||
} else {
|
||||
Ok(JobInstanceStatus::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
fn execute(
|
||||
musicbrainz: &mut dyn IMusicBrainz,
|
||||
result_sender: &mut ResultSender,
|
||||
event_sender: &mut dyn IFetchCompleteEventSender,
|
||||
api_params: &MbParams,
|
||||
paging: &mut Option<PageSettings>,
|
||||
) -> Result<(), JobInstanceError> {
|
||||
let result = match api_params {
|
||||
MbParams::Lookup(lookup) => match lookup {
|
||||
LookupParams::Artist(p) => musicbrainz
|
||||
.lookup_artist(&p.mbid)
|
||||
.map(|rv| EntityMatches::artist_lookup(p.artist.clone(), rv)),
|
||||
LookupParams::ReleaseGroup(p) => {
|
||||
musicbrainz.lookup_release_group(&p.mbid).map(|rv| {
|
||||
EntityMatches::album_lookup(p.artist_id.clone(), p.album_id.clone(), rv)
|
||||
})
|
||||
}
|
||||
}
|
||||
.map(MbReturn::Match),
|
||||
MbParams::Search(search) => match search {
|
||||
SearchParams::Artist(p) => musicbrainz
|
||||
.search_artist(&p.artist)
|
||||
.map(|rv| EntityMatches::artist_search(p.artist.clone(), rv)),
|
||||
SearchParams::ReleaseGroup(p) => musicbrainz
|
||||
.search_release_group(&p.artist_mbid, &p.album)
|
||||
.map(|rv| {
|
||||
EntityMatches::album_search(p.artist_id.clone(), p.album.id.clone(), rv)
|
||||
}),
|
||||
}
|
||||
.map(MbReturn::Match),
|
||||
MbParams::Browse(browse) => match browse {
|
||||
BrowseParams::ReleaseGroup(params) => {
|
||||
Self::init_paging_if_none(paging);
|
||||
musicbrainz
|
||||
.browse_release_group(¶ms.artist, paging)
|
||||
.map(|rv| EntityList::Album(rv.into_iter().map(|rg| rg.entity).collect()))
|
||||
}
|
||||
}
|
||||
.map(MbReturn::Fetch),
|
||||
};
|
||||
Self::return_result(result_sender, event_sender, result)
|
||||
}
|
||||
|
||||
fn init_paging_if_none(paging: &mut Option<PageSettings>) {
|
||||
if paging.is_none() {
|
||||
*paging = Some(PageSettings::with_max_limit());
|
||||
}
|
||||
}
|
||||
|
||||
fn return_result(
|
||||
result_sender: &mut ResultSender,
|
||||
event_sender: &mut dyn IFetchCompleteEventSender,
|
||||
result: Result<MbReturn, ApiError>,
|
||||
) -> Result<(), JobInstanceError> {
|
||||
result_sender
|
||||
.send(result)
|
||||
.map_err(|_| JobInstanceError::ReturnChannelDisconnected)?;
|
||||
|
||||
// If this send fails the event listener is dead. Don't panic as this function runs in a
|
||||
// detached thread so this might be happening during normal shut down.
|
||||
event_sender
|
||||
.send_fetch_complete()
|
||||
.map_err(|_| JobInstanceError::EventChannelDisconnected)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl JobQueue {
|
||||
fn new() -> Self {
|
||||
JobQueue {
|
||||
foreground_queue: VecDeque::new(),
|
||||
background_queue: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.foreground_queue.is_empty() && self.background_queue.is_empty()
|
||||
}
|
||||
|
||||
fn front_mut(&mut self) -> Option<&mut JobInstance> {
|
||||
self.foreground_queue
|
||||
.front_mut()
|
||||
.or_else(|| self.background_queue.front_mut())
|
||||
}
|
||||
|
||||
fn pop_front(&mut self) -> Option<JobInstance> {
|
||||
self.foreground_queue
|
||||
.pop_front()
|
||||
.or_else(|| self.background_queue.pop_front())
|
||||
}
|
||||
|
||||
fn push_back(&mut self, job: Job) {
|
||||
match job.priority {
|
||||
JobPriority::Foreground => self.foreground_queue.push_back(job.instance),
|
||||
JobPriority::Background => self.background_queue.push_back(job.instance),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use mockall::{predicate, Sequence};
|
||||
use musichoard::collection::{
|
||||
album::AlbumMeta,
|
||||
artist::{ArtistId, ArtistMeta},
|
||||
musicbrainz::{IMusicBrainzRef, MbRefOption, Mbid},
|
||||
};
|
||||
|
||||
use crate::tui::{
|
||||
event::{Event, EventError, MockIFetchCompleteEventSender},
|
||||
lib::interface::musicbrainz::api::{Entity, MockIMusicBrainz},
|
||||
testmod::COLLECTION,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn mb_ref_opt_unwrap<T>(opt: MbRefOption<T>) -> T {
|
||||
match opt {
|
||||
MbRefOption::Some(val) => val,
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mb_ref_opt_as_ref<T>(opt: &MbRefOption<T>) -> MbRefOption<&T> {
|
||||
match *opt {
|
||||
MbRefOption::Some(ref x) => MbRefOption::Some(x),
|
||||
MbRefOption::CannotHaveMbid => MbRefOption::CannotHaveMbid,
|
||||
MbRefOption::None => MbRefOption::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn musicbrainz() -> MockIMusicBrainz {
|
||||
MockIMusicBrainz::new()
|
||||
}
|
||||
|
||||
fn job_channel() -> (JobSender, JobReceiver) {
|
||||
let channel = JobChannel::new();
|
||||
let sender = channel.sender();
|
||||
let receiver = channel.receiver();
|
||||
(sender, receiver)
|
||||
}
|
||||
|
||||
fn event_sender() -> MockIFetchCompleteEventSender {
|
||||
MockIFetchCompleteEventSender::new()
|
||||
}
|
||||
|
||||
fn fetch_complete_expectation(event_sender: &mut MockIFetchCompleteEventSender, times: usize) {
|
||||
event_sender
|
||||
.expect_send_fetch_complete()
|
||||
.times(times)
|
||||
.returning(|| Ok(()));
|
||||
}
|
||||
|
||||
fn daemon(job_receiver: JobReceiver) -> MusicBrainzDaemon {
|
||||
MusicBrainzDaemon {
|
||||
musicbrainz: Box::new(musicbrainz()),
|
||||
job_receiver: job_receiver.receiver,
|
||||
job_queue: JobQueue::new(),
|
||||
event_sender: Box::new(event_sender()),
|
||||
}
|
||||
}
|
||||
|
||||
fn daemon_with(
|
||||
musicbrainz: MockIMusicBrainz,
|
||||
job_receiver: JobReceiver,
|
||||
event_sender: MockIFetchCompleteEventSender,
|
||||
) -> MusicBrainzDaemon {
|
||||
MusicBrainzDaemon {
|
||||
musicbrainz: Box::new(musicbrainz),
|
||||
job_receiver: job_receiver.receiver,
|
||||
job_queue: JobQueue::new(),
|
||||
event_sender: Box::new(event_sender),
|
||||
}
|
||||
}
|
||||
|
||||
fn mbid() -> Mbid {
|
||||
"00000000-0000-0000-0000-000000000000".try_into().unwrap()
|
||||
}
|
||||
|
||||
fn lookup_artist_requests() -> VecDeque<MbParams> {
|
||||
let artist = COLLECTION[3].meta.clone();
|
||||
let mbid = mbid();
|
||||
VecDeque::from([MbParams::lookup_artist(artist, mbid)])
|
||||
}
|
||||
|
||||
fn lookup_release_group_requests() -> VecDeque<MbParams> {
|
||||
let artist_id = COLLECTION[1].meta.id.clone();
|
||||
let album_id = COLLECTION[1].albums[0].meta.id.clone();
|
||||
let mbid = mbid();
|
||||
VecDeque::from([MbParams::lookup_release_group(artist_id, album_id, mbid)])
|
||||
}
|
||||
|
||||
fn search_artist_requests() -> VecDeque<MbParams> {
|
||||
let artist = COLLECTION[3].meta.clone();
|
||||
VecDeque::from([MbParams::search_artist(artist)])
|
||||
}
|
||||
|
||||
fn search_artist_expectations() -> (ArtistMeta, Vec<Entity<ArtistMeta>>) {
|
||||
let artist = COLLECTION[3].meta.clone();
|
||||
|
||||
let artist_match_1 = Entity::with_score(artist.clone(), 100);
|
||||
let artist_match_2 = Entity::with_score(artist.clone(), 50);
|
||||
let matches = vec![artist_match_1.clone(), artist_match_2.clone()];
|
||||
|
||||
(artist, matches)
|
||||
}
|
||||
|
||||
fn search_albums_requests() -> VecDeque<MbParams> {
|
||||
let mbref = mb_ref_opt_as_ref(&COLLECTION[1].meta.info.musicbrainz);
|
||||
let arid = mb_ref_opt_unwrap(mbref).mbid().clone();
|
||||
|
||||
let artist_id = COLLECTION[1].meta.id.clone();
|
||||
let album_1 = COLLECTION[1].albums[0].meta.clone();
|
||||
let album_4 = COLLECTION[1].albums[3].meta.clone();
|
||||
|
||||
VecDeque::from([
|
||||
MbParams::search_release_group(artist_id.clone(), arid.clone(), album_1),
|
||||
MbParams::search_release_group(artist_id.clone(), arid.clone(), album_4),
|
||||
])
|
||||
}
|
||||
|
||||
fn browse_albums_requests() -> VecDeque<MbParams> {
|
||||
let mbref = mb_ref_opt_as_ref(&COLLECTION[1].meta.info.musicbrainz);
|
||||
let arid = mb_ref_opt_unwrap(mbref).mbid().clone();
|
||||
VecDeque::from([MbParams::browse_release_group(arid)])
|
||||
}
|
||||
|
||||
fn album_artist_id() -> ArtistId {
|
||||
COLLECTION[1].meta.id.clone()
|
||||
}
|
||||
|
||||
fn album_arid_expectation() -> Mbid {
|
||||
let mbref = mb_ref_opt_as_ref(&COLLECTION[1].meta.info.musicbrainz);
|
||||
mb_ref_opt_unwrap(mbref).mbid().clone()
|
||||
}
|
||||
|
||||
fn search_album_expectations_1() -> (AlbumMeta, Vec<Entity<AlbumMeta>>) {
|
||||
let album_1 = COLLECTION[1].albums[0].meta.clone();
|
||||
let album_4 = COLLECTION[1].albums[3].meta.clone();
|
||||
|
||||
let album_match_1_1 = Entity::with_score(album_1.clone(), 100);
|
||||
let album_match_1_2 = Entity::with_score(album_4.clone(), 50);
|
||||
let matches_1 = vec![album_match_1_1.clone(), album_match_1_2.clone()];
|
||||
|
||||
(album_1, matches_1)
|
||||
}
|
||||
|
||||
fn search_album_expectations_4() -> (AlbumMeta, Vec<Entity<AlbumMeta>>) {
|
||||
let album_1 = COLLECTION[1].albums[0].meta.clone();
|
||||
let album_4 = COLLECTION[1].albums[3].meta.clone();
|
||||
|
||||
let album_match_4_1 = Entity::with_score(album_4.clone(), 100);
|
||||
let album_match_4_2 = Entity::with_score(album_1.clone(), 30);
|
||||
let matches_4 = vec![album_match_4_1.clone(), album_match_4_2.clone()];
|
||||
|
||||
(album_4, matches_4)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_job_channel_disconnected() {
|
||||
let (_, job_receiver) = job_channel();
|
||||
let mut daemon = daemon(job_receiver);
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Err(Error::JobChannelDisconnected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_job_channel_disconnected() {
|
||||
let (_, job_receiver) = job_channel();
|
||||
let mut daemon = daemon(job_receiver);
|
||||
let result = daemon.wait_for_jobs();
|
||||
assert_eq!(result, Err(Error::JobChannelDisconnected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_job_queue_empty() {
|
||||
let (_job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon(job_receiver);
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_job() {
|
||||
let (job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon(job_receiver);
|
||||
|
||||
let requests = search_artist_requests();
|
||||
let (result_sender, _) = mpsc::channel();
|
||||
let result = job_sender.submit_background_job(result_sender, requests.clone());
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
assert_eq!(daemon.job_queue.pop_front().unwrap().requests, requests);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_jobs_job() {
|
||||
let (job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon(job_receiver);
|
||||
|
||||
let requests = search_artist_requests();
|
||||
let (result_sender, _) = mpsc::channel();
|
||||
let result = job_sender.submit_background_job(result_sender, requests.clone());
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.wait_for_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
assert_eq!(daemon.job_queue.pop_front().unwrap().requests, requests);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_empty() {
|
||||
let (_job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon(job_receiver);
|
||||
|
||||
let (result_sender, _) = mpsc::channel();
|
||||
let mut instance = JobInstance::new(result_sender, VecDeque::new());
|
||||
let result = instance.execute_next(&mut musicbrainz(), &mut event_sender());
|
||||
assert_eq!(result, Ok(JobInstanceStatus::Complete));
|
||||
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||
}
|
||||
|
||||
fn lookup_artist_expectation(
|
||||
musicbrainz: &mut MockIMusicBrainz,
|
||||
mbid: &Mbid,
|
||||
lookup: &Entity<ArtistMeta>,
|
||||
) {
|
||||
let result = Ok(lookup.clone());
|
||||
musicbrainz
|
||||
.expect_lookup_artist()
|
||||
.with(predicate::eq(mbid.clone()))
|
||||
.times(1)
|
||||
.return_once(|_| result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_lookup_artist() {
|
||||
let mut musicbrainz = musicbrainz();
|
||||
let mbid = mbid();
|
||||
let artist = COLLECTION[3].meta.clone();
|
||||
let lookup = Entity::new(artist.clone());
|
||||
lookup_artist_expectation(&mut musicbrainz, &mbid, &lookup);
|
||||
|
||||
let mut event_sender = event_sender();
|
||||
fetch_complete_expectation(&mut event_sender, 1);
|
||||
|
||||
let (job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||
|
||||
let requests = lookup_artist_requests();
|
||||
let (result_sender, result_receiver) = mpsc::channel();
|
||||
let result = job_sender.submit_foreground_job(result_sender, requests);
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||
|
||||
let result = result_receiver.try_recv().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Ok(MbReturn::Match(EntityMatches::artist_lookup(
|
||||
artist, lookup
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
fn lookup_release_group_expectation(
|
||||
musicbrainz: &mut MockIMusicBrainz,
|
||||
mbid: &Mbid,
|
||||
lookup: &Entity<AlbumMeta>,
|
||||
) {
|
||||
let result = Ok(lookup.clone());
|
||||
musicbrainz
|
||||
.expect_lookup_release_group()
|
||||
.with(predicate::eq(mbid.clone()))
|
||||
.times(1)
|
||||
.return_once(|_| result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_lookup_release_group() {
|
||||
let mut musicbrainz = musicbrainz();
|
||||
let mbid = mbid();
|
||||
let album_id = COLLECTION[1].albums[0].meta.id.clone();
|
||||
let album_meta = COLLECTION[1].albums[0].meta.clone();
|
||||
let lookup = Entity::new(album_meta.clone());
|
||||
lookup_release_group_expectation(&mut musicbrainz, &mbid, &lookup);
|
||||
|
||||
let mut event_sender = event_sender();
|
||||
fetch_complete_expectation(&mut event_sender, 1);
|
||||
|
||||
let (job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||
|
||||
let requests = lookup_release_group_requests();
|
||||
let (result_sender, result_receiver) = mpsc::channel();
|
||||
let result = job_sender.submit_foreground_job(result_sender, requests);
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||
|
||||
let result = result_receiver.try_recv().unwrap();
|
||||
let artist_id = album_artist_id();
|
||||
assert_eq!(
|
||||
result,
|
||||
Ok(MbReturn::Match(EntityMatches::album_lookup(
|
||||
artist_id, album_id, lookup
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
fn search_artist_expectation(
|
||||
musicbrainz: &mut MockIMusicBrainz,
|
||||
artist: &ArtistMeta,
|
||||
matches: &[Entity<ArtistMeta>],
|
||||
) {
|
||||
let result = Ok(matches.to_owned());
|
||||
musicbrainz
|
||||
.expect_search_artist()
|
||||
.with(predicate::eq(artist.clone()))
|
||||
.times(1)
|
||||
.return_once(|_| result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_search_artist() {
|
||||
let mut musicbrainz = musicbrainz();
|
||||
let (artist, matches) = search_artist_expectations();
|
||||
search_artist_expectation(&mut musicbrainz, &artist, &matches);
|
||||
|
||||
let mut event_sender = event_sender();
|
||||
fetch_complete_expectation(&mut event_sender, 1);
|
||||
|
||||
let (job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||
|
||||
let requests = search_artist_requests();
|
||||
let (result_sender, result_receiver) = mpsc::channel();
|
||||
let result = job_sender.submit_background_job(result_sender, requests);
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||
|
||||
let result = result_receiver.try_recv().unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
Ok(MbReturn::Match(EntityMatches::artist_search(
|
||||
artist, matches
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
fn search_release_group_expectation(
|
||||
musicbrainz: &mut MockIMusicBrainz,
|
||||
seq: &mut Sequence,
|
||||
arid: &Mbid,
|
||||
album: &AlbumMeta,
|
||||
matches: &[Entity<AlbumMeta>],
|
||||
) {
|
||||
let result = Ok(matches.to_owned());
|
||||
musicbrainz
|
||||
.expect_search_release_group()
|
||||
.with(predicate::eq(arid.clone()), predicate::eq(album.clone()))
|
||||
.times(1)
|
||||
.in_sequence(seq)
|
||||
.return_once(|_, _| result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_search_release_groups() {
|
||||
let mut musicbrainz = musicbrainz();
|
||||
let arid = album_arid_expectation();
|
||||
let (album_1, matches_1) = search_album_expectations_1();
|
||||
let (album_4, matches_4) = search_album_expectations_4();
|
||||
|
||||
let mut seq = Sequence::new();
|
||||
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1);
|
||||
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_4, &matches_4);
|
||||
|
||||
let mut event_sender = event_sender();
|
||||
fetch_complete_expectation(&mut event_sender, 2);
|
||||
|
||||
let (job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||
|
||||
let requests = search_albums_requests();
|
||||
let (result_sender, result_receiver) = mpsc::channel();
|
||||
let result = job_sender.submit_background_job(result_sender, requests);
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||
|
||||
let artist_id = album_artist_id();
|
||||
|
||||
let result = result_receiver.try_recv().unwrap();
|
||||
let matches = EntityMatches::album_search(artist_id.clone(), album_1.id, matches_1);
|
||||
assert_eq!(result, Ok(MbReturn::Match(matches)));
|
||||
|
||||
let result = result_receiver.try_recv().unwrap();
|
||||
let matches = EntityMatches::album_search(artist_id.clone(), album_4.id, matches_4);
|
||||
assert_eq!(result, Ok(MbReturn::Match(matches)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_search_release_groups_result_disconnect() {
|
||||
let mut musicbrainz = musicbrainz();
|
||||
let arid = album_arid_expectation();
|
||||
let (album_1, matches_1) = search_album_expectations_1();
|
||||
|
||||
let mut seq = Sequence::new();
|
||||
search_release_group_expectation(&mut musicbrainz, &mut seq, &arid, &album_1, &matches_1);
|
||||
|
||||
let mut event_sender = event_sender();
|
||||
fetch_complete_expectation(&mut event_sender, 0);
|
||||
|
||||
let (job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||
|
||||
let requests = search_albums_requests();
|
||||
let (result_sender, _) = mpsc::channel();
|
||||
let result = job_sender.submit_background_job(result_sender, requests);
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
// Two albums were submitted, but as one job. If the first one fails due to the
|
||||
// result_channel disconnecting, all remaining requests in that job are dropped.
|
||||
assert!(daemon.job_queue.pop_front().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_search_artist_event_disconnect() {
|
||||
let mut musicbrainz = musicbrainz();
|
||||
let (artist, matches) = search_artist_expectations();
|
||||
search_artist_expectation(&mut musicbrainz, &artist, &matches);
|
||||
|
||||
let mut event_sender = event_sender();
|
||||
event_sender
|
||||
.expect_send_fetch_complete()
|
||||
.times(1)
|
||||
.return_once(|| Err(EventError::Send(Event::FetchComplete)));
|
||||
|
||||
let (job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||
|
||||
let requests = search_artist_requests();
|
||||
let (result_sender, _result_receiver) = mpsc::channel();
|
||||
let result = job_sender.submit_background_job(result_sender, requests);
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Err(JobError::EventChannelDisconnected));
|
||||
}
|
||||
|
||||
fn browse_release_group_expectation(
|
||||
musicbrainz: &mut MockIMusicBrainz,
|
||||
seq: &mut Sequence,
|
||||
mbid: &Mbid,
|
||||
page: Option<PageSettings>,
|
||||
matches: &[Entity<AlbumMeta>],
|
||||
next_page: Option<PageSettings>,
|
||||
) {
|
||||
let result = Ok(matches.to_owned());
|
||||
musicbrainz
|
||||
.expect_browse_release_group()
|
||||
.with(predicate::eq(mbid.clone()), predicate::eq(page))
|
||||
.times(1)
|
||||
.in_sequence(seq)
|
||||
.return_once(move |_, paging| {
|
||||
*paging = next_page;
|
||||
result
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_browse_release_groups() {
|
||||
let mut musicbrainz = musicbrainz();
|
||||
let arid = album_arid_expectation();
|
||||
let (_, matches_1) = search_album_expectations_1();
|
||||
let (_, matches_4) = search_album_expectations_4();
|
||||
|
||||
let mut seq = Sequence::new();
|
||||
|
||||
let page = Some(PageSettings::with_max_limit());
|
||||
let next = Some(PageSettings::with_max_limit().with_offset(10));
|
||||
browse_release_group_expectation(&mut musicbrainz, &mut seq, &arid, page, &matches_1, next);
|
||||
|
||||
let page = next;
|
||||
let next = None;
|
||||
browse_release_group_expectation(&mut musicbrainz, &mut seq, &arid, page, &matches_4, next);
|
||||
|
||||
let mut event_sender = event_sender();
|
||||
fetch_complete_expectation(&mut event_sender, 2);
|
||||
|
||||
let (job_sender, job_receiver) = job_channel();
|
||||
let mut daemon = daemon_with(musicbrainz, job_receiver, event_sender);
|
||||
|
||||
let requests = browse_albums_requests();
|
||||
let (result_sender, result_receiver) = mpsc::channel();
|
||||
let result = job_sender.submit_background_job(result_sender, requests);
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.enqueue_all_pending_jobs();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Ok(()));
|
||||
|
||||
let result = daemon.execute_next_job();
|
||||
assert_eq!(result, Err(JobError::JobQueueEmpty));
|
||||
|
||||
let result = result_receiver.try_recv().unwrap();
|
||||
let fetch = EntityList::Album(matches_1.into_iter().map(|m| m.entity).collect());
|
||||
assert_eq!(result, Ok(MbReturn::Fetch(fetch)));
|
||||
|
||||
let result = result_receiver.try_recv().unwrap();
|
||||
let fetch = EntityList::Album(matches_4.into_iter().map(|m| m.entity).collect());
|
||||
assert_eq!(result, Ok(MbReturn::Fetch(fetch)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_queue() {
|
||||
let mut queue = JobQueue::new();
|
||||
assert!(queue.is_empty());
|
||||
assert_eq!(queue.foreground_queue.len(), 0);
|
||||
assert_eq!(queue.background_queue.len(), 0);
|
||||
|
||||
let (result_sender, _) = mpsc::channel();
|
||||
let bg = Job::new(
|
||||
JobPriority::Background,
|
||||
JobInstance::new(result_sender, VecDeque::new()),
|
||||
);
|
||||
queue.push_back(bg);
|
||||
|
||||
assert!(!queue.is_empty());
|
||||
assert_eq!(queue.foreground_queue.len(), 0);
|
||||
assert_eq!(queue.background_queue.len(), 1);
|
||||
|
||||
let (result_sender, _) = mpsc::channel();
|
||||
let fg = Job::new(
|
||||
JobPriority::Foreground,
|
||||
JobInstance::new(result_sender, VecDeque::new()),
|
||||
);
|
||||
queue.push_back(fg);
|
||||
|
||||
assert!(!queue.is_empty());
|
||||
assert_eq!(queue.foreground_queue.len(), 1);
|
||||
assert_eq!(queue.background_queue.len(), 1);
|
||||
|
||||
let instance = queue.pop_front();
|
||||
assert!(instance.is_some());
|
||||
assert!(!queue.is_empty());
|
||||
assert_eq!(queue.foreground_queue.len(), 0);
|
||||
assert_eq!(queue.background_queue.len(), 1);
|
||||
|
||||
let instance = queue.pop_front();
|
||||
assert!(instance.is_some());
|
||||
assert!(queue.is_empty());
|
||||
assert_eq!(queue.foreground_queue.len(), 0);
|
||||
assert_eq!(queue.background_queue.len(), 0);
|
||||
|
||||
let instance = queue.pop_front();
|
||||
assert!(instance.is_none());
|
||||
assert!(queue.is_empty());
|
||||
}
|
||||
}
|
2
src/tui/lib/external/musicbrainz/mod.rs
vendored
Normal file
2
src/tui/lib/external/musicbrainz/mod.rs
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod api;
|
||||
pub mod daemon;
|
1
src/tui/lib/interface/mod.rs
Normal file
1
src/tui/lib/interface/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod musicbrainz;
|
45
src/tui/lib/interface/musicbrainz/api/mod.rs
Normal file
45
src/tui/lib/interface/musicbrainz/api/mod.rs
Normal file
@ -0,0 +1,45 @@
|
||||
//! Module for accessing MusicBrainz metadata.
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use musichoard::{
|
||||
collection::{album::AlbumMeta, artist::ArtistMeta, musicbrainz::Mbid},
|
||||
external::musicbrainz::api::PageSettings,
|
||||
};
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait IMusicBrainz {
|
||||
fn lookup_artist(&mut self, mbid: &Mbid) -> Result<Entity<ArtistMeta>, Error>;
|
||||
fn lookup_release_group(&mut self, mbid: &Mbid) -> Result<Entity<AlbumMeta>, Error>;
|
||||
fn search_artist(&mut self, artist: &ArtistMeta) -> Result<Vec<Entity<ArtistMeta>>, Error>;
|
||||
fn search_release_group(
|
||||
&mut self,
|
||||
arid: &Mbid,
|
||||
album: &AlbumMeta,
|
||||
) -> Result<Vec<Entity<AlbumMeta>>, Error>;
|
||||
fn browse_release_group(
|
||||
&mut self,
|
||||
artist: &Mbid,
|
||||
paging: &mut Option<PageSettings>,
|
||||
) -> Result<Vec<Entity<AlbumMeta>>, Error>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Entity<T> {
|
||||
pub score: Option<u8>,
|
||||
pub entity: T,
|
||||
pub disambiguation: Option<String>,
|
||||
}
|
||||
|
||||
impl<T> Entity<T> {
|
||||
pub fn new(entity: T) -> Self {
|
||||
Entity {
|
||||
score: None,
|
||||
entity,
|
||||
disambiguation: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type Error = musichoard::external::musicbrainz::api::Error;
|
156
src/tui/lib/interface/musicbrainz/daemon/mod.rs
Normal file
156
src/tui/lib/interface/musicbrainz/daemon/mod.rs
Normal file
@ -0,0 +1,156 @@
|
||||
use std::{collections::VecDeque, fmt, sync::mpsc};
|
||||
|
||||
use musichoard::collection::{
|
||||
album::{AlbumId, AlbumMeta},
|
||||
artist::{ArtistId, ArtistMeta},
|
||||
musicbrainz::Mbid,
|
||||
};
|
||||
|
||||
use crate::tui::{app::EntityMatches, lib::interface::musicbrainz::api::Error as MbApiError};
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
EventChannelDisconnected,
|
||||
JobChannelDisconnected,
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
Error::EventChannelDisconnected => write!(f, "the event channel is disconnected"),
|
||||
Error::JobChannelDisconnected => write!(f, "the job channel is disconnected"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type MbApiResult = Result<MbReturn, MbApiError>;
|
||||
pub type ResultSender = mpsc::Sender<MbApiResult>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MbReturn {
|
||||
Match(EntityMatches),
|
||||
Fetch(EntityList),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum EntityList {
|
||||
Album(Vec<AlbumMeta>),
|
||||
}
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait IMbJobSender {
|
||||
fn submit_foreground_job(
|
||||
&self,
|
||||
result_sender: ResultSender,
|
||||
requests: VecDeque<MbParams>,
|
||||
) -> Result<(), Error>;
|
||||
|
||||
fn submit_background_job(
|
||||
&self,
|
||||
result_sender: ResultSender,
|
||||
requests: VecDeque<MbParams>,
|
||||
) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum MbParams {
|
||||
Lookup(LookupParams),
|
||||
Search(SearchParams),
|
||||
#[allow(dead_code)] // TODO: remove with completion of #160
|
||||
Browse(BrowseParams),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LookupParams {
|
||||
Artist(LookupArtistParams),
|
||||
ReleaseGroup(LookupReleaseGroupParams),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LookupArtistParams {
|
||||
pub artist: ArtistMeta,
|
||||
pub mbid: Mbid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct LookupReleaseGroupParams {
|
||||
pub artist_id: ArtistId,
|
||||
pub album_id: AlbumId,
|
||||
pub mbid: Mbid,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SearchParams {
|
||||
Artist(SearchArtistParams),
|
||||
ReleaseGroup(SearchReleaseGroupParams),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SearchArtistParams {
|
||||
pub artist: ArtistMeta,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SearchReleaseGroupParams {
|
||||
pub artist_id: ArtistId,
|
||||
pub artist_mbid: Mbid,
|
||||
pub album: AlbumMeta,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BrowseParams {
|
||||
#[allow(dead_code)] // TODO: remove with completion of #160
|
||||
ReleaseGroup(BrowseReleaseGroupParams),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BrowseReleaseGroupParams {
|
||||
pub artist: Mbid,
|
||||
}
|
||||
|
||||
impl MbParams {
|
||||
pub fn lookup_artist(artist: ArtistMeta, mbid: Mbid) -> Self {
|
||||
MbParams::Lookup(LookupParams::Artist(LookupArtistParams { artist, mbid }))
|
||||
}
|
||||
|
||||
pub fn lookup_release_group(artist_id: ArtistId, album_id: AlbumId, mbid: Mbid) -> Self {
|
||||
MbParams::Lookup(LookupParams::ReleaseGroup(LookupReleaseGroupParams {
|
||||
artist_id,
|
||||
album_id,
|
||||
mbid,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn search_artist(artist: ArtistMeta) -> Self {
|
||||
MbParams::Search(SearchParams::Artist(SearchArtistParams { artist }))
|
||||
}
|
||||
|
||||
pub fn search_release_group(artist_id: ArtistId, artist_mbid: Mbid, album: AlbumMeta) -> Self {
|
||||
MbParams::Search(SearchParams::ReleaseGroup(SearchReleaseGroupParams {
|
||||
artist_id,
|
||||
artist_mbid,
|
||||
album,
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // TODO: to be removed by completion of #160
|
||||
pub fn browse_release_group(artist: Mbid) -> Self {
|
||||
MbParams::Browse(BrowseParams::ReleaseGroup(BrowseReleaseGroupParams {
|
||||
artist,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
assert!(!format!("{}", Error::EventChannelDisconnected).is_empty());
|
||||
assert!(!format!("{}", Error::JobChannelDisconnected).is_empty());
|
||||
}
|
||||
}
|
2
src/tui/lib/interface/musicbrainz/mod.rs
Normal file
2
src/tui/lib/interface/musicbrainz/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod api;
|
||||
pub mod daemon;
|
67
src/tui/lib/mod.rs
Normal file
67
src/tui/lib/mod.rs
Normal file
@ -0,0 +1,67 @@
|
||||
pub mod external;
|
||||
pub mod interface;
|
||||
|
||||
use musichoard::{
|
||||
collection::{
|
||||
album::{AlbumId, AlbumInfo},
|
||||
artist::{ArtistId, ArtistInfo},
|
||||
Collection,
|
||||
},
|
||||
interface::{database::IDatabase, library::ILibrary},
|
||||
IMusicHoardBase, IMusicHoardDatabase, IMusicHoardLibrary, MusicHoard,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait IMusicHoard {
|
||||
fn rescan_library(&mut self) -> Result<(), musichoard::Error>;
|
||||
fn reload_database(&mut self) -> Result<(), musichoard::Error>;
|
||||
fn get_collection(&self) -> &Collection;
|
||||
|
||||
fn merge_artist_info(
|
||||
&mut self,
|
||||
id: &ArtistId,
|
||||
info: ArtistInfo,
|
||||
) -> Result<(), musichoard::Error>;
|
||||
fn merge_album_info(
|
||||
&mut self,
|
||||
artist_id: &ArtistId,
|
||||
album_id: &AlbumId,
|
||||
info: AlbumInfo,
|
||||
) -> Result<(), musichoard::Error>;
|
||||
}
|
||||
|
||||
// GRCOV_EXCL_START
|
||||
impl<Database: IDatabase, Library: ILibrary> IMusicHoard for MusicHoard<Database, Library> {
|
||||
fn rescan_library(&mut self) -> Result<(), musichoard::Error> {
|
||||
<Self as IMusicHoardLibrary>::rescan_library(self)
|
||||
}
|
||||
|
||||
fn reload_database(&mut self) -> Result<(), musichoard::Error> {
|
||||
<Self as IMusicHoardDatabase>::reload_database(self)
|
||||
}
|
||||
|
||||
fn get_collection(&self) -> &Collection {
|
||||
<Self as IMusicHoardBase>::get_collection(self)
|
||||
}
|
||||
|
||||
fn merge_artist_info(
|
||||
&mut self,
|
||||
id: &ArtistId,
|
||||
info: ArtistInfo,
|
||||
) -> Result<(), musichoard::Error> {
|
||||
<Self as IMusicHoardDatabase>::merge_artist_info(self, id, info)
|
||||
}
|
||||
|
||||
fn merge_album_info(
|
||||
&mut self,
|
||||
artist_id: &ArtistId,
|
||||
album_id: &AlbumId,
|
||||
info: AlbumInfo,
|
||||
) -> Result<(), musichoard::Error> {
|
||||
<Self as IMusicHoardDatabase>::merge_album_info(self, artist_id, album_id, info)
|
||||
}
|
||||
}
|
||||
// GRCOV_EXCL_STOP
|
@ -4,25 +4,27 @@ use std::thread;
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use super::event::{Event, EventError, EventSender};
|
||||
use crate::tui::event::{EventError, IKeyEventSender};
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
pub trait EventListener {
|
||||
pub trait IEventListener {
|
||||
fn spawn(self) -> thread::JoinHandle<EventError>;
|
||||
}
|
||||
|
||||
pub struct TuiEventListener {
|
||||
events: EventSender,
|
||||
pub struct EventListener {
|
||||
event_sender: Box<dyn IKeyEventSender + Send>,
|
||||
}
|
||||
|
||||
// GRCOV_EXCL_START
|
||||
impl TuiEventListener {
|
||||
pub fn new(events: EventSender) -> Self {
|
||||
TuiEventListener { events }
|
||||
impl EventListener {
|
||||
pub fn new<ES: IKeyEventSender + Send + 'static>(event_sender: ES) -> Self {
|
||||
EventListener {
|
||||
event_sender: Box::new(event_sender),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventListener for TuiEventListener {
|
||||
impl IEventListener for EventListener {
|
||||
fn spawn(self) -> thread::JoinHandle<EventError> {
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
@ -32,10 +34,8 @@ impl EventListener for TuiEventListener {
|
||||
match event::read() {
|
||||
Ok(event) => {
|
||||
if let Err(err) = match event {
|
||||
CrosstermEvent::Key(e) => self.events.send(Event::Key(e)),
|
||||
CrosstermEvent::Mouse(e) => self.events.send(Event::Mouse(e)),
|
||||
CrosstermEvent::Resize(w, h) => self.events.send(Event::Resize(w, h)),
|
||||
_ => unimplemented!(),
|
||||
CrosstermEvent::Key(e) => self.event_sender.send_key(e),
|
||||
_ => Ok(()),
|
||||
} {
|
||||
return err;
|
||||
}
|
||||
|
182
src/tui/mod.rs
182
src/tui/mod.rs
@ -1,35 +1,42 @@
|
||||
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
|
||||
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use musichoard::collection;
|
||||
use ratatui::backend::Backend;
|
||||
use ratatui::Terminal;
|
||||
use std::io;
|
||||
use std::marker::PhantomData;
|
||||
mod app;
|
||||
mod event;
|
||||
mod handler;
|
||||
mod lib;
|
||||
mod listener;
|
||||
mod ui;
|
||||
|
||||
pub mod event;
|
||||
pub mod handler;
|
||||
pub mod listener;
|
||||
pub mod ui;
|
||||
pub use app::App;
|
||||
pub use event::EventChannel;
|
||||
pub use handler::EventHandler;
|
||||
pub use lib::external::musicbrainz::{
|
||||
api::MusicBrainz,
|
||||
daemon::{JobChannel, MusicBrainzDaemon},
|
||||
};
|
||||
pub use listener::EventListener;
|
||||
pub use ui::Ui;
|
||||
|
||||
use self::event::EventError;
|
||||
use self::handler::EventHandler;
|
||||
use self::listener::EventListener;
|
||||
use self::ui::Ui;
|
||||
use crossterm::{
|
||||
event::{DisableMouseCapture, EnableMouseCapture},
|
||||
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{backend::Backend, Terminal};
|
||||
use std::{io, marker::PhantomData};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
use crate::tui::{
|
||||
app::{IApp, IAppAccess},
|
||||
event::EventError,
|
||||
handler::IEventHandler,
|
||||
listener::IEventListener,
|
||||
ui::IUi,
|
||||
};
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum Error {
|
||||
Collection(String),
|
||||
Io(String),
|
||||
Event(String),
|
||||
ListenerPanic,
|
||||
}
|
||||
|
||||
impl From<collection::Error> for Error {
|
||||
fn from(err: collection::Error) -> Error {
|
||||
Error::Collection(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(err: io::Error) -> Error {
|
||||
Error::Io(err.to_string())
|
||||
@ -42,12 +49,12 @@ impl From<EventError> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Tui<B: Backend, UI> {
|
||||
pub struct Tui<B: Backend, UI: IUi, APP: IApp + IAppAccess> {
|
||||
terminal: Terminal<B>,
|
||||
_phantom: PhantomData<UI>,
|
||||
_phantom: PhantomData<(UI, APP)>,
|
||||
}
|
||||
|
||||
impl<B: Backend, UI: Ui> Tui<B, UI> {
|
||||
impl<B: Backend, UI: IUi, APP: IApp + IAppAccess> Tui<B, UI, APP> {
|
||||
fn init(&mut self) -> Result<(), Error> {
|
||||
self.terminal.hide_cursor()?;
|
||||
self.terminal.clear()?;
|
||||
@ -64,10 +71,15 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
|
||||
self.exit();
|
||||
}
|
||||
|
||||
fn main_loop(&mut self, mut ui: UI, handler: impl EventHandler<UI>) -> Result<(), Error> {
|
||||
while ui.is_running() {
|
||||
self.terminal.draw(|frame| ui.render(frame))?;
|
||||
handler.handle_next_event(&mut ui)?;
|
||||
fn main_loop(
|
||||
&mut self,
|
||||
mut app: APP,
|
||||
_ui: UI,
|
||||
handler: impl IEventHandler<APP>,
|
||||
) -> Result<(), Error> {
|
||||
while app.is_running() {
|
||||
self.terminal.draw(|frame| UI::render(&mut app, frame))?;
|
||||
app = handler.handle_next_event(app)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -75,9 +87,10 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
|
||||
|
||||
fn main(
|
||||
term: Terminal<B>,
|
||||
app: APP,
|
||||
ui: UI,
|
||||
handler: impl EventHandler<UI>,
|
||||
listener: impl EventListener,
|
||||
handler: impl IEventHandler<APP>,
|
||||
listener: impl IEventListener,
|
||||
) -> Result<(), Error> {
|
||||
let mut tui = Tui {
|
||||
terminal: term,
|
||||
@ -87,7 +100,7 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
|
||||
tui.init()?;
|
||||
|
||||
let listener_handle = listener.spawn();
|
||||
let result = tui.main_loop(ui, handler);
|
||||
let result = tui.main_loop(app, ui, handler);
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
@ -103,8 +116,8 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
|
||||
match listener_handle.join() {
|
||||
Ok(err) => return Err(err.into()),
|
||||
// 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
|
||||
// the location of the panic which at the time is hidden by the TUI.
|
||||
// will not produce an error message. This may be due to the panic simply
|
||||
// causing the process to abort in which case there is nothing to unwind.
|
||||
Err(_) => return Err(Error::ListenerPanic),
|
||||
}
|
||||
}
|
||||
@ -134,12 +147,13 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
|
||||
|
||||
pub fn run(
|
||||
term: Terminal<B>,
|
||||
app: APP,
|
||||
ui: UI,
|
||||
handler: impl EventHandler<UI>,
|
||||
listener: impl EventListener,
|
||||
handler: impl IEventHandler<APP>,
|
||||
listener: impl IEventListener,
|
||||
) -> Result<(), Error> {
|
||||
Self::enable()?;
|
||||
let result = Self::main(term, ui, handler, listener);
|
||||
let result = Self::main(term, app, ui, handler, listener);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
Self::disable()?;
|
||||
@ -156,113 +170,116 @@ impl<B: Backend, UI: Ui> Tui<B, UI> {
|
||||
// GRCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod testmod;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{io, thread};
|
||||
|
||||
use musichoard::collection::{self, Collection};
|
||||
use lib::interface::musicbrainz::daemon::MockIMbJobSender;
|
||||
use ratatui::{backend::TestBackend, Terminal};
|
||||
|
||||
use crate::tests::{MockCollectionManager, COLLECTION};
|
||||
use musichoard::collection::Collection;
|
||||
|
||||
use super::{
|
||||
event::EventError,
|
||||
handler::MockEventHandler,
|
||||
listener::MockEventListener,
|
||||
ui::{MhUi, Ui},
|
||||
Error, Tui,
|
||||
use crate::tui::{
|
||||
app::App, handler::MockIEventHandler, lib::MockIMusicHoard, listener::MockIEventListener,
|
||||
ui::Ui,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use testmod::COLLECTION;
|
||||
|
||||
pub fn terminal() -> Terminal<TestBackend> {
|
||||
let backend = TestBackend::new(150, 30);
|
||||
Terminal::new(backend).unwrap()
|
||||
}
|
||||
|
||||
pub fn ui(collection: Collection) -> MhUi<MockCollectionManager> {
|
||||
let mut collection_manager = MockCollectionManager::new();
|
||||
fn music_hoard(collection: Collection) -> MockIMusicHoard {
|
||||
let mut music_hoard = MockIMusicHoard::new();
|
||||
|
||||
collection_manager
|
||||
.expect_rescan_library()
|
||||
.returning(|| Ok(()));
|
||||
collection_manager
|
||||
.expect_get_collection()
|
||||
.return_const(collection);
|
||||
music_hoard.expect_reload_database().returning(|| Ok(()));
|
||||
music_hoard.expect_rescan_library().returning(|| Ok(()));
|
||||
music_hoard.expect_get_collection().return_const(collection);
|
||||
|
||||
MhUi::new(collection_manager).unwrap()
|
||||
music_hoard
|
||||
}
|
||||
|
||||
fn listener() -> MockEventListener {
|
||||
let mut listener = MockEventListener::new();
|
||||
fn app(collection: Collection) -> App {
|
||||
App::new(music_hoard(collection), MockIMbJobSender::new())
|
||||
}
|
||||
|
||||
fn listener() -> MockIEventListener {
|
||||
let mut listener = MockIEventListener::new();
|
||||
listener.expect_spawn().return_once(|| {
|
||||
thread::spawn(|| {
|
||||
thread::park();
|
||||
return EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "unparked"));
|
||||
EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "unparked"))
|
||||
})
|
||||
});
|
||||
listener
|
||||
}
|
||||
|
||||
fn handler() -> MockEventHandler<MhUi<MockCollectionManager>> {
|
||||
let mut handler = MockEventHandler::new();
|
||||
fn handler() -> MockIEventHandler<App> {
|
||||
let mut handler = MockIEventHandler::new();
|
||||
handler
|
||||
.expect_handle_next_event()
|
||||
.return_once(|ui: &mut MhUi<MockCollectionManager>| {
|
||||
ui.quit();
|
||||
Ok(())
|
||||
});
|
||||
.return_once(|app: App| Ok(app.force_quit()));
|
||||
handler
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run() {
|
||||
let terminal = terminal();
|
||||
let ui = ui(COLLECTION.to_owned());
|
||||
let app = app(COLLECTION.to_owned());
|
||||
let ui = Ui;
|
||||
|
||||
let listener = listener();
|
||||
let handler = handler();
|
||||
|
||||
let result = Tui::main(terminal, ui, handler, listener);
|
||||
let result = Tui::main(terminal, app, ui, handler, listener);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_error() {
|
||||
let terminal = terminal();
|
||||
let ui = ui(COLLECTION.to_owned());
|
||||
let app = app(COLLECTION.to_owned());
|
||||
let ui = Ui;
|
||||
|
||||
let listener = listener();
|
||||
|
||||
let mut handler = MockEventHandler::new();
|
||||
let mut handler = MockIEventHandler::new();
|
||||
handler
|
||||
.expect_handle_next_event()
|
||||
.return_once(|_| Err(EventError::Recv));
|
||||
|
||||
let result = Tui::main(terminal, ui, handler, listener);
|
||||
let result = Tui::main(terminal, app, ui, handler, listener);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
Error::Event(EventError::Recv.to_string())
|
||||
);
|
||||
|
||||
let error = EventError::Recv;
|
||||
assert_eq!(result.unwrap_err(), Error::Event(error.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_error() {
|
||||
let terminal = terminal();
|
||||
let ui = ui(COLLECTION.to_owned());
|
||||
let app = app(COLLECTION.to_owned());
|
||||
let ui = Ui;
|
||||
|
||||
let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error"));
|
||||
let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| error);
|
||||
while !listener_handle.is_finished() {}
|
||||
|
||||
let mut listener = MockEventListener::new();
|
||||
let mut listener = MockIEventListener::new();
|
||||
listener.expect_spawn().return_once(|| listener_handle);
|
||||
|
||||
let mut handler = MockEventHandler::new();
|
||||
let mut handler = MockIEventHandler::new();
|
||||
handler
|
||||
.expect_handle_next_event()
|
||||
.return_once(|_| Err(EventError::Recv));
|
||||
|
||||
let result = Tui::main(terminal, ui, handler, listener);
|
||||
let result = Tui::main(terminal, app, ui, handler, listener);
|
||||
assert!(result.is_err());
|
||||
|
||||
let error = EventError::Io(io::Error::new(io::ErrorKind::Interrupted, "error"));
|
||||
@ -272,32 +289,31 @@ mod tests {
|
||||
#[test]
|
||||
fn listener_panic() {
|
||||
let terminal = terminal();
|
||||
let ui = ui(COLLECTION.to_owned());
|
||||
let app = app(COLLECTION.to_owned());
|
||||
let ui = Ui;
|
||||
|
||||
let listener_handle: thread::JoinHandle<EventError> = thread::spawn(|| panic!());
|
||||
while !listener_handle.is_finished() {}
|
||||
|
||||
let mut listener = MockEventListener::new();
|
||||
let mut listener = MockIEventListener::new();
|
||||
listener.expect_spawn().return_once(|| listener_handle);
|
||||
|
||||
let mut handler = MockEventHandler::new();
|
||||
let mut handler = MockIEventHandler::new();
|
||||
handler
|
||||
.expect_handle_next_event()
|
||||
.return_once(|_| Err(EventError::Recv));
|
||||
|
||||
let result = Tui::main(terminal, ui, handler, listener);
|
||||
let result = Tui::main(terminal, app, ui, handler, listener);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), Error::ListenerPanic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
let collection_err: Error = collection::Error::DatabaseError(String::from("")).into();
|
||||
let io_err: Error = io::Error::new(io::ErrorKind::Interrupted, "error").into();
|
||||
let event_err: Error = EventError::Recv.into();
|
||||
let listener_err = Error::ListenerPanic;
|
||||
|
||||
assert!(!format!("{:?}", collection_err).is_empty());
|
||||
assert!(!format!("{:?}", io_err).is_empty());
|
||||
assert!(!format!("{:?}", event_err).is_empty());
|
||||
assert!(!format!("{:?}", listener_err).is_empty());
|
||||
|
13
src/tui/testmod.rs
Normal file
13
src/tui/testmod.rs
Normal file
@ -0,0 +1,13 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
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;
|
||||
|
||||
use crate::testmod::*;
|
||||
|
||||
pub static COLLECTION: Lazy<Vec<Artist>> = Lazy::new(|| full::full_collection!());
|
1053
src/tui/ui.rs
1053
src/tui/ui.rs
File diff suppressed because it is too large
Load Diff
243
src/tui/ui/browse_state.rs
Normal file
243
src/tui/ui/browse_state.rs
Normal file
@ -0,0 +1,243 @@
|
||||
use musichoard::collection::{
|
||||
album::{Album, AlbumStatus},
|
||||
artist::Artist,
|
||||
track::{Track, TrackFormat},
|
||||
};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
text::Line,
|
||||
widgets::{List, ListItem, Paragraph},
|
||||
};
|
||||
|
||||
use crate::tui::{
|
||||
app::WidgetState,
|
||||
ui::{display::UiDisplay, style::UiColor},
|
||||
};
|
||||
|
||||
pub struct ArtistArea {
|
||||
pub list: Rect,
|
||||
}
|
||||
|
||||
pub struct AlbumArea {
|
||||
pub list: Rect,
|
||||
pub info: Rect,
|
||||
}
|
||||
|
||||
pub struct TrackArea {
|
||||
pub list: Rect,
|
||||
pub info: Rect,
|
||||
}
|
||||
|
||||
pub struct BrowseArea {
|
||||
pub artist: ArtistArea,
|
||||
pub album: AlbumArea,
|
||||
pub track: TrackArea,
|
||||
}
|
||||
|
||||
pub struct FrameArea {
|
||||
pub browse: BrowseArea,
|
||||
pub minibuffer: Rect,
|
||||
}
|
||||
|
||||
impl FrameArea {
|
||||
pub fn new(frame: Rect) -> Self {
|
||||
let minibuffer_height = 3;
|
||||
let buffer_height = frame.height.saturating_sub(minibuffer_height);
|
||||
|
||||
let width_one_third = frame.width / 3;
|
||||
let height_one_third = buffer_height / 3;
|
||||
|
||||
let panel_width = width_one_third;
|
||||
let panel_width_last = frame.width.saturating_sub(2 * panel_width);
|
||||
let panel_height_top = buffer_height.saturating_sub(height_one_third);
|
||||
let panel_height_bottom = height_one_third;
|
||||
|
||||
let artist_list = Rect {
|
||||
x: frame.x,
|
||||
y: frame.y,
|
||||
width: panel_width,
|
||||
height: buffer_height,
|
||||
};
|
||||
|
||||
let album_list = Rect {
|
||||
x: artist_list.x + artist_list.width,
|
||||
y: frame.y,
|
||||
width: panel_width,
|
||||
height: panel_height_top,
|
||||
};
|
||||
|
||||
let album_info = Rect {
|
||||
x: album_list.x,
|
||||
y: album_list.y + album_list.height,
|
||||
width: album_list.width,
|
||||
height: panel_height_bottom,
|
||||
};
|
||||
|
||||
let track_list = Rect {
|
||||
x: album_list.x + album_list.width,
|
||||
y: frame.y,
|
||||
width: panel_width_last,
|
||||
height: panel_height_top,
|
||||
};
|
||||
|
||||
let track_info = Rect {
|
||||
x: track_list.x,
|
||||
y: track_list.y + track_list.height,
|
||||
width: track_list.width,
|
||||
height: panel_height_bottom,
|
||||
};
|
||||
|
||||
let minibuffer = Rect {
|
||||
x: frame.x,
|
||||
y: frame.y + buffer_height,
|
||||
width: frame.width,
|
||||
height: minibuffer_height,
|
||||
};
|
||||
|
||||
FrameArea {
|
||||
browse: BrowseArea {
|
||||
artist: ArtistArea { list: artist_list },
|
||||
album: AlbumArea {
|
||||
list: album_list,
|
||||
info: album_info,
|
||||
},
|
||||
track: TrackArea {
|
||||
list: track_list,
|
||||
info: track_info,
|
||||
},
|
||||
},
|
||||
minibuffer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ArtistState<'a, 'b> {
|
||||
pub active: bool,
|
||||
pub list: List<'a>,
|
||||
pub state: &'b mut WidgetState,
|
||||
}
|
||||
|
||||
impl<'a, 'b> ArtistState<'a, 'b> {
|
||||
pub fn new(
|
||||
active: bool,
|
||||
artists: &'a [Artist],
|
||||
state: &'b mut WidgetState,
|
||||
) -> ArtistState<'a, 'b> {
|
||||
let list = List::new(
|
||||
artists
|
||||
.iter()
|
||||
.map(|a| ListItem::new(a.meta.id.name.as_str()))
|
||||
.collect::<Vec<ListItem>>(),
|
||||
);
|
||||
|
||||
ArtistState {
|
||||
active,
|
||||
list,
|
||||
state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AlbumState<'a, 'b> {
|
||||
pub active: bool,
|
||||
pub list: List<'a>,
|
||||
pub state: &'b mut WidgetState,
|
||||
pub info: Paragraph<'a>,
|
||||
}
|
||||
|
||||
impl<'a, 'b> AlbumState<'a, 'b> {
|
||||
pub fn new(
|
||||
active: bool,
|
||||
albums: &'a [Album],
|
||||
state: &'b mut WidgetState,
|
||||
) -> AlbumState<'a, 'b> {
|
||||
let list = List::new(
|
||||
albums
|
||||
.iter()
|
||||
.map(Self::to_list_item)
|
||||
.collect::<Vec<ListItem>>(),
|
||||
);
|
||||
|
||||
let album = state.list.selected().map(|i| &albums[i]);
|
||||
let info = Paragraph::new(format!(
|
||||
"Title: {}\n\
|
||||
Date: {}\n\
|
||||
Type: {}\n\
|
||||
Status: {}",
|
||||
album.map(|a| a.meta.id.title.as_str()).unwrap_or(""),
|
||||
album
|
||||
.map(|a| UiDisplay::display_date(&a.meta.date, &a.meta.seq))
|
||||
.unwrap_or_default(),
|
||||
album
|
||||
.map(|a| UiDisplay::display_type(
|
||||
&a.meta.info.primary_type,
|
||||
&a.meta.info.secondary_types
|
||||
))
|
||||
.unwrap_or_default(),
|
||||
album
|
||||
.map(|a| UiDisplay::display_album_status(&a.get_status()))
|
||||
.unwrap_or("")
|
||||
));
|
||||
|
||||
AlbumState {
|
||||
active,
|
||||
list,
|
||||
state,
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_list_item(album: &Album) -> ListItem {
|
||||
let line = match album.get_status() {
|
||||
AlbumStatus::None => Line::raw(album.meta.id.title.as_str()),
|
||||
AlbumStatus::Owned(format) => match format {
|
||||
TrackFormat::Mp3 => Line::styled(album.meta.id.title.as_str(), UiColor::FG_WARN),
|
||||
TrackFormat::Flac => Line::styled(album.meta.id.title.as_str(), UiColor::FG_GOOD),
|
||||
},
|
||||
};
|
||||
ListItem::new(line)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TrackState<'a, 'b> {
|
||||
pub active: bool,
|
||||
pub list: List<'a>,
|
||||
pub state: &'b mut WidgetState,
|
||||
pub info: Paragraph<'a>,
|
||||
}
|
||||
|
||||
impl<'a, 'b> TrackState<'a, 'b> {
|
||||
pub fn new(
|
||||
active: bool,
|
||||
tracks: &'a [Track],
|
||||
state: &'b mut WidgetState,
|
||||
) -> TrackState<'a, 'b> {
|
||||
let list = List::new(
|
||||
tracks
|
||||
.iter()
|
||||
.map(|tr| ListItem::new(tr.id.title.as_str()))
|
||||
.collect::<Vec<ListItem>>(),
|
||||
);
|
||||
|
||||
let track = state.list.selected().map(|i| &tracks[i]);
|
||||
let info = Paragraph::new(format!(
|
||||
"Track: {}\n\
|
||||
Title: {}\n\
|
||||
Artist: {}\n\
|
||||
Quality: {}",
|
||||
track.map(|t| t.number.0.to_string()).unwrap_or_default(),
|
||||
track.map(|t| t.id.title.as_str()).unwrap_or(""),
|
||||
track.map(|t| t.artist.join("; ")).unwrap_or_default(),
|
||||
track
|
||||
.map(|t| UiDisplay::display_track_quality(&t.quality))
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
|
||||
TrackState {
|
||||
active,
|
||||
list,
|
||||
state,
|
||||
info,
|
||||
}
|
||||
}
|
||||
}
|
296
src/tui/ui/display.rs
Normal file
296
src/tui/ui/display.rs
Normal file
@ -0,0 +1,296 @@
|
||||
use musichoard::collection::{
|
||||
album::{
|
||||
AlbumDate, AlbumId, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType, AlbumSeq, AlbumStatus,
|
||||
},
|
||||
artist::ArtistMeta,
|
||||
musicbrainz::{IMusicBrainzRef, MbRefOption},
|
||||
track::{TrackFormat, TrackQuality},
|
||||
};
|
||||
|
||||
use crate::tui::app::{EntityMatches, MatchOption};
|
||||
|
||||
pub struct UiDisplay;
|
||||
|
||||
impl UiDisplay {
|
||||
pub fn display_date(date: &AlbumDate, seq: &AlbumSeq) -> String {
|
||||
if seq.0 > 0 {
|
||||
format!("{} ({})", Self::display_album_date(date), seq.0)
|
||||
} else {
|
||||
Self::display_album_date(date)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_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(""),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_mb_ref_option_as_url<T: IMusicBrainzRef>(option: &MbRefOption<T>) -> &str {
|
||||
match option {
|
||||
MbRefOption::Some(val) => val.url().as_str(),
|
||||
MbRefOption::CannotHaveMbid => "cannot have a MusicBrainz identifier",
|
||||
MbRefOption::None => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_type(
|
||||
primary: &Option<AlbumPrimaryType>,
|
||||
secondary: &Vec<AlbumSecondaryType>,
|
||||
) -> String {
|
||||
match primary {
|
||||
Some(ref primary) => {
|
||||
if secondary.is_empty() {
|
||||
Self::display_primary_type(primary).to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} ({})",
|
||||
Self::display_primary_type(primary),
|
||||
Self::display_secondary_types(secondary)
|
||||
)
|
||||
}
|
||||
}
|
||||
None => String::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_primary_type(value: &AlbumPrimaryType) -> &'static str {
|
||||
match value {
|
||||
AlbumPrimaryType::Album => "Album",
|
||||
AlbumPrimaryType::Single => "Single",
|
||||
AlbumPrimaryType::Ep => "EP",
|
||||
AlbumPrimaryType::Broadcast => "Broadcast",
|
||||
AlbumPrimaryType::Other => "Other",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_secondary_types(values: &Vec<AlbumSecondaryType>) -> String {
|
||||
let mut types: Vec<&'static str> = vec![];
|
||||
for value in values {
|
||||
match value {
|
||||
AlbumSecondaryType::Compilation => types.push("Compilation"),
|
||||
AlbumSecondaryType::Soundtrack => types.push("Soundtrack"),
|
||||
AlbumSecondaryType::Spokenword => types.push("Spokenword"),
|
||||
AlbumSecondaryType::Interview => types.push("Interview"),
|
||||
AlbumSecondaryType::Audiobook => types.push("Audiobook"),
|
||||
AlbumSecondaryType::AudioDrama => types.push("Audio drama"),
|
||||
AlbumSecondaryType::Live => types.push("Live"),
|
||||
AlbumSecondaryType::Remix => types.push("Remix"),
|
||||
AlbumSecondaryType::DjMix => types.push("DJ-mix"),
|
||||
AlbumSecondaryType::MixtapeStreet => types.push("Mixtape/Street"),
|
||||
AlbumSecondaryType::Demo => types.push("Demo"),
|
||||
AlbumSecondaryType::FieldRecording => types.push("Field recording"),
|
||||
}
|
||||
}
|
||||
types.join(", ")
|
||||
}
|
||||
|
||||
pub fn display_album_status(status: &AlbumStatus) -> &'static str {
|
||||
match status {
|
||||
AlbumStatus::None => "None",
|
||||
AlbumStatus::Owned(format) => match format {
|
||||
TrackFormat::Mp3 => "MP3",
|
||||
TrackFormat::Flac => "FLAC",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_track_quality(quality: &TrackQuality) -> String {
|
||||
match quality.format {
|
||||
TrackFormat::Flac => "FLAC".to_string(),
|
||||
TrackFormat::Mp3 => format!("MP3 {}kbps", quality.bitrate),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_artist_matching(artist: &ArtistMeta) -> String {
|
||||
format!("Matching artist: {}", &artist.id.name)
|
||||
}
|
||||
|
||||
pub fn display_album_matching(album: &AlbumId) -> String {
|
||||
format!("Matching album: {}", &album.title)
|
||||
}
|
||||
|
||||
pub fn display_matching_info(info: &EntityMatches) -> String {
|
||||
match info {
|
||||
EntityMatches::Artist(m) => UiDisplay::display_artist_matching(&m.matching),
|
||||
EntityMatches::Album(m) => UiDisplay::display_album_matching(&m.matching),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_match_option_artist(match_option: &MatchOption<ArtistMeta>) -> String {
|
||||
Self::display_match_option(Self::display_option_artist, match_option)
|
||||
}
|
||||
|
||||
pub fn display_match_option_album(match_option: &MatchOption<AlbumMeta>) -> String {
|
||||
Self::display_match_option(Self::display_option_album, match_option)
|
||||
}
|
||||
|
||||
fn display_match_option<Fn, T>(display_fn: Fn, match_option: &MatchOption<T>) -> String
|
||||
where
|
||||
Fn: FnOnce(&T, &Option<String>) -> String,
|
||||
{
|
||||
match match_option {
|
||||
MatchOption::Some(match_artist) => format!(
|
||||
"{}{}",
|
||||
display_fn(&match_artist.entity, &match_artist.disambiguation),
|
||||
Self::display_option_score(match_artist.score),
|
||||
),
|
||||
MatchOption::CannotHaveMbid => Self::display_cannot_have_mbid().to_string(),
|
||||
MatchOption::ManualInputMbid => Self::display_manual_input_mbid().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn display_option_artist(artist: &ArtistMeta, disambiguation: &Option<String>) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
artist.id.name,
|
||||
disambiguation
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|d| format!(" ({d})"))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn display_option_album(album: &AlbumMeta, _disambiguation: &Option<String>) -> String {
|
||||
format!(
|
||||
"{:010} | {} [{}]",
|
||||
UiDisplay::display_album_date(&album.date),
|
||||
album.id.title,
|
||||
UiDisplay::display_type(&album.info.primary_type, &album.info.secondary_types),
|
||||
)
|
||||
}
|
||||
|
||||
fn display_option_score(score: Option<u8>) -> String {
|
||||
score.map(|s| format!(" ({s}%)")).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn display_cannot_have_mbid() -> &'static str {
|
||||
"-- Cannot have a MusicBrainz Identifier --"
|
||||
}
|
||||
|
||||
fn display_manual_input_mbid() -> &'static str {
|
||||
"-- Manually enter a MusicBrainz Identifier --"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn display_album_date() {
|
||||
assert_eq!(UiDisplay::display_album_date(&AlbumDate::default()), "");
|
||||
assert_eq!(UiDisplay::display_album_date(&1990.into()), "1990");
|
||||
assert_eq!(UiDisplay::display_album_date(&(1990, 5).into()), "1990‐05");
|
||||
assert_eq!(
|
||||
UiDisplay::display_album_date(&(1990, 5, 6).into()),
|
||||
"1990‐05‐06"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_date() {
|
||||
let date: AlbumDate = 1990.into();
|
||||
assert_eq!(UiDisplay::display_date(&date, &AlbumSeq::default()), "1990");
|
||||
assert_eq!(UiDisplay::display_date(&date, &AlbumSeq(0)), "1990");
|
||||
assert_eq!(UiDisplay::display_date(&date, &AlbumSeq(5)), "1990 (5)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_primary_type() {
|
||||
assert_eq!(
|
||||
UiDisplay::display_primary_type(&AlbumPrimaryType::Album),
|
||||
"Album"
|
||||
);
|
||||
assert_eq!(
|
||||
UiDisplay::display_primary_type(&AlbumPrimaryType::Single),
|
||||
"Single"
|
||||
);
|
||||
assert_eq!(UiDisplay::display_primary_type(&AlbumPrimaryType::Ep), "EP");
|
||||
assert_eq!(
|
||||
UiDisplay::display_primary_type(&AlbumPrimaryType::Broadcast),
|
||||
"Broadcast"
|
||||
);
|
||||
assert_eq!(
|
||||
UiDisplay::display_primary_type(&AlbumPrimaryType::Other),
|
||||
"Other"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_secondary_types() {
|
||||
assert_eq!(
|
||||
UiDisplay::display_secondary_types(&vec![
|
||||
AlbumSecondaryType::Compilation,
|
||||
AlbumSecondaryType::Soundtrack,
|
||||
AlbumSecondaryType::Spokenword,
|
||||
AlbumSecondaryType::Interview,
|
||||
AlbumSecondaryType::Audiobook,
|
||||
AlbumSecondaryType::AudioDrama,
|
||||
AlbumSecondaryType::Live,
|
||||
AlbumSecondaryType::Remix,
|
||||
AlbumSecondaryType::DjMix,
|
||||
AlbumSecondaryType::MixtapeStreet,
|
||||
AlbumSecondaryType::Demo,
|
||||
AlbumSecondaryType::FieldRecording,
|
||||
]),
|
||||
"Compilation, Soundtrack, Spokenword, Interview, Audiobook, Audio drama, Live, Remix, \
|
||||
DJ-mix, Mixtape/Street, Demo, Field recording"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_type() {
|
||||
assert_eq!(UiDisplay::display_type(&None, &vec![]), "");
|
||||
assert_eq!(
|
||||
UiDisplay::display_type(&Some(AlbumPrimaryType::Album), &vec![]),
|
||||
"Album"
|
||||
);
|
||||
assert_eq!(
|
||||
UiDisplay::display_type(
|
||||
&Some(AlbumPrimaryType::Album),
|
||||
&vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation]
|
||||
),
|
||||
"Album (Live, Compilation)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_album_status() {
|
||||
assert_eq!(UiDisplay::display_album_status(&AlbumStatus::None), "None");
|
||||
assert_eq!(
|
||||
UiDisplay::display_album_status(&AlbumStatus::Owned(TrackFormat::Mp3)),
|
||||
"MP3"
|
||||
);
|
||||
assert_eq!(
|
||||
UiDisplay::display_album_status(&AlbumStatus::Owned(TrackFormat::Flac)),
|
||||
"FLAC"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_track_quality() {
|
||||
assert_eq!(
|
||||
UiDisplay::display_track_quality(&TrackQuality {
|
||||
format: TrackFormat::Flac,
|
||||
bitrate: 1411
|
||||
}),
|
||||
"FLAC"
|
||||
);
|
||||
assert_eq!(
|
||||
UiDisplay::display_track_quality(&TrackQuality {
|
||||
format: TrackFormat::Mp3,
|
||||
bitrate: 218
|
||||
}),
|
||||
"MP3 218kbps"
|
||||
);
|
||||
}
|
||||
}
|
14
src/tui/ui/error_state.rs
Normal file
14
src/tui/ui/error_state.rs
Normal file
@ -0,0 +1,14 @@
|
||||
use ratatui::{
|
||||
layout::Alignment,
|
||||
widgets::{Paragraph, Wrap},
|
||||
};
|
||||
|
||||
pub struct ErrorOverlay;
|
||||
|
||||
impl ErrorOverlay {
|
||||
pub fn paragraph(msg: &str) -> Paragraph {
|
||||
Paragraph::new(msg)
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true })
|
||||
}
|
||||
}
|
9
src/tui/ui/fetch_state.rs
Normal file
9
src/tui/ui/fetch_state.rs
Normal file
@ -0,0 +1,9 @@
|
||||
use ratatui::widgets::Paragraph;
|
||||
|
||||
pub struct FetchOverlay;
|
||||
|
||||
impl FetchOverlay {
|
||||
pub fn paragraph<'a>() -> Paragraph<'a> {
|
||||
Paragraph::new(" -- fetching --")
|
||||
}
|
||||
}
|
113
src/tui/ui/info_state.rs
Normal file
113
src/tui/ui/info_state.rs
Normal file
@ -0,0 +1,113 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use musichoard::collection::{album::Album, artist::Artist};
|
||||
use ratatui::widgets::{ListState, Paragraph};
|
||||
|
||||
use super::display::UiDisplay;
|
||||
|
||||
struct InfoOverlay;
|
||||
|
||||
impl InfoOverlay {
|
||||
const ITEM_INDENT: &'static str = " ";
|
||||
const LIST_INDENT: &'static str = " - ";
|
||||
}
|
||||
|
||||
pub struct ArtistOverlay<'a> {
|
||||
pub properties: Paragraph<'a>,
|
||||
}
|
||||
|
||||
impl<'a> ArtistOverlay<'a> {
|
||||
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::slice_to_string(v, list_indent)))
|
||||
.collect::<Vec<String>>()
|
||||
.join(&indent);
|
||||
format!("{indent}{list}")
|
||||
}
|
||||
|
||||
fn slice_to_string<S: AsRef<str>>(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}")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(artists: &'a [Artist], state: &ListState) -> ArtistOverlay<'a> {
|
||||
let artist = state.selected().map(|i| &artists[i]);
|
||||
|
||||
let item_indent = InfoOverlay::ITEM_INDENT;
|
||||
let list_indent = InfoOverlay::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.meta.id.name.as_str()).unwrap_or(""),
|
||||
artist
|
||||
.map(|a| UiDisplay::display_mb_ref_option_as_url(&a.meta.info.musicbrainz))
|
||||
.unwrap_or_default(),
|
||||
Self::opt_hashmap_to_string(
|
||||
artist.map(|a| &a.meta.info.properties),
|
||||
&double_item_indent,
|
||||
&double_list_indent
|
||||
),
|
||||
));
|
||||
|
||||
ArtistOverlay { properties }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AlbumOverlay<'a> {
|
||||
pub properties: Paragraph<'a>,
|
||||
}
|
||||
|
||||
impl<'a> AlbumOverlay<'a> {
|
||||
pub fn new(albums: &'a [Album], state: &ListState) -> AlbumOverlay<'a> {
|
||||
let album = state.selected().map(|i| &albums[i]);
|
||||
|
||||
let item_indent = InfoOverlay::ITEM_INDENT;
|
||||
|
||||
let properties = Paragraph::new(format!(
|
||||
"Album: {}\n\n{item_indent}\
|
||||
MusicBrainz: {}",
|
||||
album.map(|a| a.meta.id.title.as_str()).unwrap_or(""),
|
||||
album
|
||||
.map(|a| UiDisplay::display_mb_ref_option_as_url(&a.meta.info.musicbrainz))
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
|
||||
AlbumOverlay { properties }
|
||||
}
|
||||
}
|
20
src/tui/ui/input.rs
Normal file
20
src/tui/ui/input.rs
Normal file
@ -0,0 +1,20 @@
|
||||
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
|
||||
|
||||
use crate::tui::app::InputPublic;
|
||||
|
||||
pub struct InputOverlay;
|
||||
|
||||
impl InputOverlay {
|
||||
pub fn paragraph<'a>(text: &str) -> Paragraph<'a> {
|
||||
Paragraph::new(format!(" {text}"))
|
||||
}
|
||||
|
||||
pub fn place_cursor(input: InputPublic, area: Rect, frame: &mut Frame) {
|
||||
let width = area.width.max(4) - 4; // keep 2 for borders, 1 for left-pad, and 1 for cursor
|
||||
let scroll = input.visual_scroll(width as usize);
|
||||
frame.set_cursor_position((
|
||||
area.x + ((input.visual_cursor()).max(scroll) - scroll) as u16 + 2,
|
||||
area.y + 1,
|
||||
))
|
||||
}
|
||||
}
|
70
src/tui/ui/match_state.rs
Normal file
70
src/tui/ui/match_state.rs
Normal file
@ -0,0 +1,70 @@
|
||||
use musichoard::collection::{
|
||||
album::{AlbumId, AlbumMeta},
|
||||
artist::ArtistMeta,
|
||||
};
|
||||
use ratatui::widgets::{List, ListItem};
|
||||
|
||||
use crate::tui::{
|
||||
app::{EntityMatches, MatchOption, WidgetState},
|
||||
ui::display::UiDisplay,
|
||||
};
|
||||
|
||||
pub struct MatchOverlay<'a, 'b> {
|
||||
pub matching: String,
|
||||
pub list: List<'a>,
|
||||
pub state: &'b mut WidgetState,
|
||||
}
|
||||
|
||||
impl<'a, 'b> MatchOverlay<'a, 'b> {
|
||||
pub fn new(info: &'a EntityMatches, state: &'b mut WidgetState) -> Self {
|
||||
match info {
|
||||
EntityMatches::Artist(m) => Self::artists(&m.matching, &m.list, state),
|
||||
EntityMatches::Album(m) => Self::albums(&m.matching, &m.list, state),
|
||||
}
|
||||
}
|
||||
|
||||
fn artists(
|
||||
matching: &ArtistMeta,
|
||||
matches: &'a [MatchOption<ArtistMeta>],
|
||||
state: &'b mut WidgetState,
|
||||
) -> Self {
|
||||
let matching = UiDisplay::display_artist_matching(matching);
|
||||
|
||||
let list = Self::display_list(UiDisplay::display_match_option_artist, matches);
|
||||
|
||||
MatchOverlay {
|
||||
matching,
|
||||
list,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
fn albums(
|
||||
matching: &AlbumId,
|
||||
matches: &'a [MatchOption<AlbumMeta>],
|
||||
state: &'b mut WidgetState,
|
||||
) -> Self {
|
||||
let matching = UiDisplay::display_album_matching(matching);
|
||||
|
||||
let list = Self::display_list(UiDisplay::display_match_option_album, matches);
|
||||
|
||||
MatchOverlay {
|
||||
matching,
|
||||
list,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_list<F, T>(display: F, options: &[T]) -> List
|
||||
where
|
||||
F: FnMut(&T) -> String,
|
||||
{
|
||||
List::new(
|
||||
options
|
||||
.iter()
|
||||
.map(display)
|
||||
.map(ListItem::new)
|
||||
.collect::<Vec<ListItem>>(),
|
||||
)
|
||||
}
|
||||
}
|
95
src/tui/ui/minibuffer.rs
Normal file
95
src/tui/ui/minibuffer.rs
Normal file
@ -0,0 +1,95 @@
|
||||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
widgets::Paragraph,
|
||||
};
|
||||
|
||||
use crate::tui::{
|
||||
app::{AppPublicState, AppState},
|
||||
ui::UiDisplay,
|
||||
};
|
||||
|
||||
pub struct Minibuffer<'a> {
|
||||
pub paragraphs: Vec<Paragraph<'a>>,
|
||||
pub columns: u16,
|
||||
}
|
||||
|
||||
impl Minibuffer<'_> {
|
||||
pub fn area(ar: Rect) -> Rect {
|
||||
let space = 3;
|
||||
Rect {
|
||||
x: ar.x + 1 + space,
|
||||
y: ar.y + 1,
|
||||
width: ar.width.saturating_sub(2 + 2 * space),
|
||||
height: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(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"),
|
||||
Paragraph::new("f: fetch musicbrainz"),
|
||||
],
|
||||
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").alignment(Alignment::Center),
|
||||
Paragraph::new("ctrl+g: cancel search").alignment(Alignment::Center),
|
||||
],
|
||||
columns,
|
||||
},
|
||||
AppState::Fetch(()) => Minibuffer {
|
||||
paragraphs: vec![
|
||||
Paragraph::new("fetching..."),
|
||||
Paragraph::new("ctrl+g: abort"),
|
||||
],
|
||||
columns: 2,
|
||||
},
|
||||
AppState::Match(public) => Minibuffer {
|
||||
paragraphs: vec![
|
||||
Paragraph::new(UiDisplay::display_matching_info(public.matches)),
|
||||
Paragraph::new("ctrl+g: abort"),
|
||||
],
|
||||
columns: 2,
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
432
src/tui/ui/mod.rs
Normal file
432
src/tui/ui/mod.rs
Normal file
@ -0,0 +1,432 @@
|
||||
mod browse_state;
|
||||
mod display;
|
||||
mod error_state;
|
||||
mod fetch_state;
|
||||
mod info_state;
|
||||
mod input;
|
||||
mod match_state;
|
||||
mod minibuffer;
|
||||
mod overlay;
|
||||
mod reload_state;
|
||||
mod style;
|
||||
mod widgets;
|
||||
|
||||
use browse_state::BrowseArea;
|
||||
use ratatui::{layout::Rect, widgets::Paragraph, Frame};
|
||||
|
||||
use musichoard::collection::{album::Album, Collection};
|
||||
|
||||
use crate::tui::{
|
||||
app::{
|
||||
AppPublicState, AppState, Category, EntityMatches, IAppAccess, InputPublic, Selection,
|
||||
WidgetState,
|
||||
},
|
||||
ui::{
|
||||
browse_state::{
|
||||
AlbumArea, AlbumState, ArtistArea, ArtistState, FrameArea, TrackArea, TrackState,
|
||||
},
|
||||
display::UiDisplay,
|
||||
error_state::ErrorOverlay,
|
||||
fetch_state::FetchOverlay,
|
||||
info_state::{AlbumOverlay, ArtistOverlay},
|
||||
input::InputOverlay,
|
||||
match_state::MatchOverlay,
|
||||
minibuffer::Minibuffer,
|
||||
overlay::{OverlayBuilder, OverlaySize},
|
||||
reload_state::ReloadOverlay,
|
||||
widgets::UiWidget,
|
||||
},
|
||||
};
|
||||
|
||||
pub trait IUi {
|
||||
fn render<APP: IAppAccess>(app: &mut APP, frame: &mut Frame);
|
||||
}
|
||||
|
||||
pub struct Ui;
|
||||
|
||||
impl Ui {
|
||||
fn render_artist_column(st: ArtistState, ar: ArtistArea, fr: &mut Frame) {
|
||||
UiWidget::render_list_widget("Artists", st.list, st.state, st.active, ar.list, fr);
|
||||
}
|
||||
|
||||
fn render_album_column(st: AlbumState, ar: AlbumArea, fr: &mut Frame) {
|
||||
UiWidget::render_list_widget("Albums", st.list, st.state, st.active, ar.list, fr);
|
||||
UiWidget::render_info_widget("Album info", st.info, st.active, ar.info, fr);
|
||||
}
|
||||
|
||||
fn render_track_column(st: TrackState, ar: TrackArea, fr: &mut Frame) {
|
||||
UiWidget::render_list_widget("Tracks", st.list, st.state, st.active, ar.list, fr);
|
||||
UiWidget::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::new(state);
|
||||
let area = Minibuffer::area(ar);
|
||||
|
||||
UiWidget::render_info_widget("Minibuffer", Paragraph::new(""), false, ar, fr);
|
||||
UiWidget::render_columns(mb.paragraphs, mb.columns, false, area, fr);
|
||||
}
|
||||
|
||||
fn render_browse_frame(
|
||||
artists: &Collection,
|
||||
selection: &mut Selection,
|
||||
areas: BrowseArea,
|
||||
frame: &mut Frame,
|
||||
) {
|
||||
let active = selection.category();
|
||||
|
||||
let artist_state = ArtistState::new(
|
||||
active == Category::Artist,
|
||||
artists,
|
||||
selection.widget_state_artist(),
|
||||
);
|
||||
|
||||
Self::render_artist_column(artist_state, areas.artist, frame);
|
||||
|
||||
let albums = selection
|
||||
.state_album(artists)
|
||||
.map(|st| st.list)
|
||||
.unwrap_or_default();
|
||||
let album_state = AlbumState::new(
|
||||
active == Category::Album,
|
||||
albums,
|
||||
selection.widget_state_album(),
|
||||
);
|
||||
|
||||
Self::render_album_column(album_state, areas.album, frame);
|
||||
|
||||
let tracks = selection
|
||||
.state_track(artists)
|
||||
.map(|st| st.list)
|
||||
.unwrap_or_default();
|
||||
let track_state = TrackState::new(
|
||||
active == Category::Track,
|
||||
tracks,
|
||||
selection.widget_state_track(),
|
||||
);
|
||||
|
||||
Self::render_track_column(track_state, areas.track, frame);
|
||||
}
|
||||
|
||||
fn render_info_overlay(artists: &Collection, selection: &mut Selection, frame: &mut Frame) {
|
||||
let area = OverlayBuilder::default().build(frame.area());
|
||||
|
||||
if selection.category() == Category::Artist {
|
||||
let overlay = ArtistOverlay::new(artists, &selection.widget_state_artist().list);
|
||||
UiWidget::render_overlay_widget("Artist", overlay.properties, area, false, frame);
|
||||
} else {
|
||||
let no_albums: Vec<Album> = vec![];
|
||||
let albums = selection
|
||||
.state_album(artists)
|
||||
.map(|st| st.list)
|
||||
.unwrap_or_else(|| &no_albums);
|
||||
let overlay = AlbumOverlay::new(albums, &selection.widget_state_album().list);
|
||||
UiWidget::render_overlay_widget("Album", 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.area());
|
||||
let reload_text = ReloadOverlay::paragraph();
|
||||
UiWidget::render_overlay_widget("Reload", reload_text, area, false, frame);
|
||||
}
|
||||
|
||||
fn render_fetch_overlay(frame: &mut Frame) {
|
||||
let area = OverlayBuilder::default().build(frame.area());
|
||||
let fetch_text = FetchOverlay::paragraph();
|
||||
UiWidget::render_overlay_widget("Fetching", fetch_text, area, false, frame)
|
||||
}
|
||||
|
||||
fn render_match_overlay(info: &EntityMatches, state: &mut WidgetState, frame: &mut Frame) {
|
||||
let area = OverlayBuilder::default().build(frame.area());
|
||||
let st = MatchOverlay::new(info, state);
|
||||
UiWidget::render_overlay_list_widget(&st.matching, st.list, st.state, true, area, frame)
|
||||
}
|
||||
|
||||
fn render_input_overlay(input: InputPublic, frame: &mut Frame) {
|
||||
let area = OverlayBuilder::default()
|
||||
.with_width(OverlaySize::MarginFactor(4))
|
||||
.with_height(OverlaySize::Value(3))
|
||||
.build(frame.area());
|
||||
let input_text = InputOverlay::paragraph(input.value());
|
||||
UiWidget::render_overlay_widget("Input", input_text, area, false, frame);
|
||||
InputOverlay::place_cursor(input, area, 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.area());
|
||||
let error_text = ErrorOverlay::paragraph(msg.as_ref());
|
||||
UiWidget::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;
|
||||
|
||||
let areas = FrameArea::new(frame.area());
|
||||
|
||||
Self::render_browse_frame(collection, selection, areas.browse, frame);
|
||||
Self::render_minibuffer(&state, areas.minibuffer, frame);
|
||||
|
||||
match state {
|
||||
AppState::Info(()) => Self::render_info_overlay(collection, selection, frame),
|
||||
AppState::Reload(()) => Self::render_reload_overlay(frame),
|
||||
AppState::Fetch(()) => Self::render_fetch_overlay(frame),
|
||||
AppState::Match(public) => {
|
||||
Self::render_match_overlay(public.matches, public.state, frame)
|
||||
}
|
||||
AppState::Error(msg) => Self::render_error_overlay("Error", msg, frame),
|
||||
AppState::Critical(msg) => Self::render_error_overlay("Critical Error", msg, frame),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(input) = app.input {
|
||||
Self::render_input_overlay(input, frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use musichoard::collection::{
|
||||
album::{AlbumDate, AlbumId, AlbumInfo, AlbumMeta, AlbumPrimaryType, AlbumSecondaryType},
|
||||
artist::{Artist, ArtistId, ArtistMeta},
|
||||
musicbrainz::MbRefOption,
|
||||
};
|
||||
|
||||
use crate::tui::{
|
||||
app::{AppPublic, AppPublicInner, Delta, MatchStatePublic},
|
||||
lib::interface::musicbrainz::api::Entity,
|
||||
testmod::COLLECTION,
|
||||
tests::terminal,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
// Automock does not support returning types with generic lifetimes.
|
||||
impl<'app> IAppAccess for AppPublic<'app> {
|
||||
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::Fetch(()) => AppState::Fetch(()),
|
||||
AppState::Match(ref mut m) => AppState::Match(MatchStatePublic {
|
||||
matches: m.matches,
|
||||
state: m.state,
|
||||
}),
|
||||
AppState::Error(s) => AppState::Error(s),
|
||||
AppState::Critical(s) => AppState::Critical(s),
|
||||
},
|
||||
input: self.input,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn public_inner<'app>(
|
||||
collection: &'app Collection,
|
||||
selection: &'app mut Selection,
|
||||
) -> AppPublicInner<'app> {
|
||||
AppPublicInner {
|
||||
collection,
|
||||
selection,
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_test_suite(collection: &Collection, selection: &mut Selection) {
|
||||
let mut terminal = terminal();
|
||||
|
||||
let mut app = AppPublic {
|
||||
inner: public_inner(collection, selection),
|
||||
state: AppState::Browse(()),
|
||||
input: None,
|
||||
};
|
||||
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::Fetch(());
|
||||
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 empty_album() {
|
||||
let mut artists: Vec<Artist> = vec![Artist::new(ArtistId::new("An artist"))];
|
||||
artists[0]
|
||||
.albums
|
||||
.push(Album::new("An album", AlbumDate::default(), None, 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 artist (which cannot have a MBID).
|
||||
selection.increment_selection(artists, Delta::Line);
|
||||
selection.increment_selection(artists, Delta::Line);
|
||||
|
||||
draw_test_suite(artists, &mut selection);
|
||||
|
||||
// Change the track (which has a different track format).
|
||||
selection.decrement_selection(artists, Delta::Line);
|
||||
selection.decrement_selection(artists, Delta::Line);
|
||||
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);
|
||||
}
|
||||
|
||||
fn artist_meta() -> ArtistMeta {
|
||||
ArtistMeta::new(ArtistId::new("an artist"))
|
||||
}
|
||||
|
||||
fn artist_matches() -> EntityMatches {
|
||||
let artist = artist_meta();
|
||||
let artist_match = Entity::with_score(artist.clone(), 80);
|
||||
let list = vec![artist_match.clone(), artist_match.clone()];
|
||||
|
||||
let mut info = EntityMatches::artist_search(artist, list);
|
||||
info.push_cannot_have_mbid();
|
||||
info.push_manual_input_mbid();
|
||||
info
|
||||
}
|
||||
|
||||
fn artist_lookup() -> EntityMatches {
|
||||
let artist = artist_meta();
|
||||
let artist_lookup = Entity::new(artist.clone());
|
||||
|
||||
let mut info = EntityMatches::artist_lookup(artist, artist_lookup);
|
||||
info.push_cannot_have_mbid();
|
||||
info.push_manual_input_mbid();
|
||||
info
|
||||
}
|
||||
|
||||
fn album_artist_id() -> ArtistId {
|
||||
ArtistId::new("Artist")
|
||||
}
|
||||
|
||||
fn album_id() -> AlbumId {
|
||||
AlbumId::new("An Album")
|
||||
}
|
||||
|
||||
fn album_meta(id: AlbumId) -> AlbumMeta {
|
||||
AlbumMeta::new(
|
||||
id,
|
||||
AlbumDate::new(Some(1990), Some(5), None),
|
||||
AlbumInfo::new(
|
||||
MbRefOption::None,
|
||||
Some(AlbumPrimaryType::Album),
|
||||
vec![AlbumSecondaryType::Live, AlbumSecondaryType::Compilation],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn album_matches() -> EntityMatches {
|
||||
let artist_id = album_artist_id();
|
||||
let album_id = album_id();
|
||||
let album_meta = album_meta(album_id.clone());
|
||||
let album_match = Entity::with_score(album_meta.clone(), 80);
|
||||
let list = vec![album_match.clone(), album_match.clone()];
|
||||
|
||||
let mut info = EntityMatches::album_search(artist_id, album_id, list);
|
||||
info.push_cannot_have_mbid();
|
||||
info.push_manual_input_mbid();
|
||||
info
|
||||
}
|
||||
|
||||
fn album_lookup() -> EntityMatches {
|
||||
let artist_id = album_artist_id();
|
||||
let album_id = album_id();
|
||||
let album_meta = album_meta(album_id.clone());
|
||||
let album_lookup = Entity::new(album_meta.clone());
|
||||
|
||||
let mut info = EntityMatches::album_lookup(artist_id, album_id, album_lookup);
|
||||
info.push_cannot_have_mbid();
|
||||
info.push_manual_input_mbid();
|
||||
info
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_matche_state_suite() {
|
||||
let collection = &COLLECTION;
|
||||
let mut selection = Selection::new(collection);
|
||||
|
||||
let mut terminal = terminal();
|
||||
|
||||
let match_state_infos = vec![
|
||||
artist_matches(),
|
||||
album_matches(),
|
||||
artist_lookup(),
|
||||
album_lookup(),
|
||||
];
|
||||
|
||||
for matches in match_state_infos.iter() {
|
||||
let mut widget_state = WidgetState::default().with_selected(Some(0));
|
||||
|
||||
let mut app = AppPublic {
|
||||
inner: public_inner(collection, &mut selection),
|
||||
state: AppState::Match(MatchStatePublic {
|
||||
matches,
|
||||
state: &mut widget_state,
|
||||
}),
|
||||
input: None,
|
||||
};
|
||||
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
|
||||
|
||||
let input = tui_input::Input::default();
|
||||
app.input = Some(&input);
|
||||
terminal.draw(|frame| Ui::render(&mut app, frame)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user