use std::{
cmp::min,
collections::HashMap,
path::Path,
process::{ExitCode, Termination},
sync::Arc,
};
use itertools::Itertools as _;
use crate::{
cl::{ImageProcessingArgs, SearchOptions, SearchQuery},
cover::{Cover, CoverKey, SearchReference},
http::SourceHttpClient,
perceptual_hash::PerceptualHash,
source::{Source, SourceError},
};
pub mod cl;
mod cover;
#[cfg(all(unix, feature = "generate-extras"))]
pub mod extras;
mod http;
mod perceptual_hash;
mod source;
pub mod tags;
pub mod walk;
async fn search_all_sources(query: &Arc<SearchQuery>, search: &Arc<SearchOptions>) -> Vec<Cover> {
let cache_dir = match http::default_cache_dir() {
Ok(d) => d,
Err(err) => {
log::error!("Failed to compute cache directory: {err:#}");
return Vec::new();
}
};
let mut sources_searches = Vec::with_capacity(search.cover_sources.len());
for source_name in search.cover_sources.iter().copied() {
let source: Box<dyn Source> = (&source_name).into();
let mut http = match SourceHttpClient::new(
source_name,
source.user_agent(),
source.timeout(),
source.common_headers(),
source.rate_limit().as_ref(),
&cache_dir,
) {
Ok(h) => Arc::new(h),
Err(err) => {
log::error!("Failed to initialize HTTP for {source_name}: {err:#}");
continue;
}
};
let query = Arc::clone(query);
let source_task = tokio::spawn(async move {
let results = source
.search(query.artist.as_deref(), &query.album, &mut http)
.await
.map_err(|err| SourceError {
err,
source: source_name,
})?;
Ok((results, source_name))
});
sources_searches.push(source_task);
}
futures::future::join_all(sources_searches)
.await
.into_iter()
.filter_map(|res| {
res.inspect_err(|err| {
log::error!("Failed to get source search results: {err:#}");
})
.ok()
})
.filter_map(|res: anyhow::Result<_>| {
res.inspect_err(|err| {
log::error!("{err:#}");
})
.map(|(res, source_name)| {
if res.is_empty() {
log::debug!("Source {source_name} has no results");
} else {
log::debug!(
"Source {} results:\n{}",
source_name,
res.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
);
}
res
})
.ok()
})
.flatten()
.collect()
}
async fn find_reference_hash(results: &[Cover]) -> Option<PerceptualHash> {
let reference_candidates = results
.iter()
.filter(|cover| cover.relevance.is_reference())
.sorted_by(|a, b| cover::compare(b, a, &cover::CompareMode::Reference));
for reference_candidate in reference_candidates {
match reference_candidate.perceptual_hash().await {
Ok(hash) => {
log::debug!("Reference cover is {reference_candidate}");
return Some(hash);
}
Err(err) => {
log::warn!(
"Failed to compute perceptual hash for reference {reference_candidate}: {err}"
);
}
}
}
None
}
async fn compute_perceptual_hashes(results: &[Cover]) -> HashMap<CoverKey, PerceptualHash> {
#[expect(clippy::redundant_iter_cloned)]
futures::future::join_all(results.iter().cloned().map(|cover| {
tokio::spawn(async move {
let hash = cover
.perceptual_hash()
.await
.inspect_err(|err| {
log::warn!("Failed to compute perceptual hash for {cover}: {err:#}");
})
.ok()?;
Some((cover.key(), hash))
})
}))
.await
.into_iter()
.filter_map(Result::ok)
.flatten()
.collect()
}
pub enum SearchStatus {
Found,
NotFound,
}
impl Termination for SearchStatus {
fn report(self) -> ExitCode {
match self {
SearchStatus::Found => ExitCode::SUCCESS,
SearchStatus::NotFound => ExitCode::FAILURE,
}
}
}
pub async fn search_and_download(
output: &Path,
query: Arc<SearchQuery>,
search_opts: Arc<SearchOptions>,
image_proc: &ImageProcessingArgs,
) -> anyhow::Result<SearchStatus> {
let mut results = search_all_sources(&query, &search_opts).await;
let reference_hash = find_reference_hash(&results).await;
results.retain(|cover| {
let size = min(cover.size_px.value_hint().0, cover.size_px.value_hint().1);
search_opts.matches_min_size(size)
});
let reference = if let Some(reference_hash) = reference_hash {
let hashes = compute_perceptual_hashes(&results).await;
Some(SearchReference {
reference: reference_hash,
hashes,
})
} else {
None
};
results.sort_unstable_by(|a, b| {
cover::compare(
b,
a,
&cover::CompareMode::Search {
search_opts: &search_opts,
reference: &reference,
},
)
});
if results.is_empty() {
log::debug!("No results");
} else {
log::debug!("Sorted results:\n{}", results.iter().join("\n"));
}
for result in results {
match result.download(output, image_proc, &search_opts).await {
Ok(()) => return Ok(SearchStatus::Found),
Err(err) => {
log::error!("Cover download failed: {err:#}");
}
}
}
log::warn!(
"No cover to download for artist {:?} and album {:?}",
query.artist,
query.album
);
Ok(SearchStatus::NotFound)
}