kopuz-reader 0.9.0

A modern, lightweight music player built with Rust and Dioxus.
Documentation
use crate::models::Library;
use crate::utils::save_cover;
use config::FetchStrategy;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tracing;

#[derive(Debug, Default)]
pub struct FetchReport {
    pub found: usize,
    pub missing: usize,
    pub errors: usize,
}

pub struct CoverFetcher {
    client: reqwest::Client,
    cache_dir: PathBuf,
    strategy: FetchStrategy,
    lastfm_api_key: Option<String>,
    on_progress: Arc<dyn Fn(String) + Send + Sync>,
}

impl CoverFetcher {
    pub fn new(
        cache_dir: PathBuf,
        strategy: FetchStrategy,
        lastfm_api_key: Option<String>,
        on_progress: Arc<dyn Fn(String) + Send + Sync>,
    ) -> Self {
        let lastfm_api_key = lastfm_api_key
            .map(|key| key.trim().to_string())
            .filter(|key| !key.is_empty());
        let client = reqwest::Client::builder()
            .user_agent(concat!(
                "Kopuz/",
                env!("CARGO_PKG_VERSION"),
                " (music-player)"
            ))
            .timeout(Duration::from_secs(15))
            .build()
            .unwrap_or_default();
        Self {
            client,
            cache_dir,
            strategy,
            lastfm_api_key,
            on_progress,
        }
    }

    pub async fn fetch_missing_covers(&self, library: &mut Library) -> FetchReport {
        let mut report = FetchReport::default();
        let missing: Vec<usize> = library
            .albums
            .iter()
            .enumerate()
            .filter(|(_, a)| {
                a.cover_path.as_ref().is_none_or(|p| !p.exists())
                    && a.title != "Unknown Album"
                    && !a.manual_cover
            })
            .map(|(i, _)| i)
            .collect();

        if missing.is_empty() {
            tracing::info!("Cover fetcher: no missing covers to fetch");
            return report;
        }

        tracing::info!("Cover fetcher: fetching {} missing covers", missing.len());

        for &idx in &missing {
            let artist = library.albums[idx].artist.clone();
            let title = library.albums[idx].title.clone();
            let album_id = library.albums[idx].id.clone();
            tracing::info!("Cover fetcher: fetching {} — {}", artist, title);
            (self.on_progress)(format!("Fetching cover: {} — {}", artist, title));

            let release_id = library
                .tracks
                .iter()
                .filter(|t| t.album_id == album_id)
                .find_map(|t| t.musicbrainz_release_id.as_deref());

            let result = self.fetch_cover(release_id, &title, &artist).await;

            match result {
                Some(img_data) => match save_cover(&album_id, &img_data, None, &self.cache_dir) {
                    Ok(saved_path) => {
                        library.albums[idx].cover_path = Some(saved_path);
                        report.found += 1;
                        tracing::info!(
                            "Cover fetcher: successfully found and saved cover for {} — {}",
                            artist,
                            title,
                        );
                    }
                    Err(e) => {
                        report.errors += 1;
                        tracing::warn!(
                            "Cover fetcher: failed to save cover for {} — {}: {}",
                            artist,
                            title,
                            e,
                        );
                    }
                },
                None => {
                    report.missing += 1;
                    tracing::info!("Cover fetcher: no cover found for {} — {}", artist, title);
                }
            }
        }

        tracing::info!(
            "Cover fetcher: done — {} found, {} missing, {} errors",
            report.found,
            report.missing,
            report.errors,
        );
        report
    }

    async fn fetch_cover(
        &self,
        release_id: Option<&str>,
        album: &str,
        artist: &str,
    ) -> Option<Vec<u8>> {
        match self.strategy {
            FetchStrategy::MusicBrainzFirst => {
                let result = self.try_musicbrainz(release_id, album, artist).await;
                if result.is_some() {
                    return result;
                }
                let key = self.lastfm_api_key.as_deref().filter(|k| !k.is_empty())?;
                self.try_lastfm(album, artist, key).await
            }
            FetchStrategy::LastFmFirst => {
                if let Some(key) = self.lastfm_api_key.as_deref().filter(|k| !k.is_empty()) {
                    let result = self.try_lastfm(album, artist, key).await;
                    if result.is_some() {
                        return result;
                    }
                }
                self.try_musicbrainz(release_id, album, artist).await
            }
            FetchStrategy::MusicBrainzOnly => self.try_musicbrainz(release_id, album, artist).await,
            FetchStrategy::LastFmOnly => {
                let key = self.lastfm_api_key.as_deref().filter(|k| !k.is_empty())?;
                self.try_lastfm(album, artist, key).await
            }
        }
    }

    async fn try_musicbrainz(
        &self,
        release_id: Option<&str>,
        album: &str,
        artist: &str,
    ) -> Option<Vec<u8>> {
        let mbid = match release_id {
            Some(id) => {
                tracing::info!(
                    "Cover fetcher: using provided MusicBrainz Release ID: {}",
                    id
                );
                id.to_string()
            }
            None => {
                tracing::info!(
                    "Cover fetcher: no release ID, searching MusicBrainz for album \"{}\" by artist \"{}\"",
                    album,
                    artist
                );
                let found = self.search_musicbrainz_release(album, artist).await?;
                tracing::info!(
                    "Cover fetcher: MusicBrainz search returned Release ID: {}",
                    found
                );
                found
            }
        };

        self.sleep_rate_limit().await;
        let resp = self
            .client
            .get(format!("https://coverartarchive.org/release/{mbid}"))
            .send()
            .await
            .ok()?;

        if !resp.status().is_success() {
            return None;
        }

        #[derive(Deserialize)]
        struct CoverArchiveResponse {
            images: Vec<CoverArchiveImage>,
        }
        #[derive(Deserialize)]
        struct CoverArchiveImage {
            image: String,
            #[serde(default)]
            front: bool,
            #[serde(default)]
            types: Vec<String>,
        }

        let body: CoverArchiveResponse = resp.json().await.ok()?;
        let url = body
            .images
            .iter()
            .find(|i| i.front || i.types.iter().any(|t| t == "Front"))
            .or_else(|| body.images.first())?;

        self.client
            .get(&url.image)
            .send()
            .await
            .ok()?
            .error_for_status()
            .ok()?
            .bytes()
            .await
            .ok()
            .map(|b| b.to_vec())
    }

    async fn search_musicbrainz_release(&self, album: &str, artist: &str) -> Option<String> {
        let (esc_album, esc_artist) = (
            album.replace('\\', "\\\\").replace('"', "\\\""),
            artist.replace('\\', "\\\\").replace('"', "\\\""),
        );
        let query = if artist.is_empty() || artist == "Unknown Artist" {
            format!("release:\"{}\"", esc_album)
        } else {
            format!("release:\"{}\" AND artist:\"{}\"", esc_album, esc_artist)
        };

        self.sleep_rate_limit().await;
        let resp = self
            .client
            .get("https://musicbrainz.org/ws/2/release/")
            .query(&[("query", query.as_str()), ("fmt", "json")])
            .send()
            .await
            .ok()?;

        #[derive(Deserialize)]
        struct SearchResponse {
            releases: Vec<Release>,
        }
        #[derive(Deserialize)]
        struct Release {
            id: String,
            score: u32,
        }

        let body: SearchResponse = resp.json().await.ok()?;
        body.releases
            .into_iter()
            .find(|r| r.score >= 80)
            .map(|r| r.id)
    }

    async fn try_lastfm(&self, album: &str, artist: &str, api_key: &str) -> Option<Vec<u8>> {
        tracing::info!(
            "Cover fetcher: querying Last.fm for album \"{}\" by artist \"{}\"",
            album,
            artist
        );
        self.sleep_rate_limit().await;
        let resp = self
            .client
            .get("https://ws.audioscrobbler.com/2.0/")
            .query(&[
                ("method", "album.getinfo"),
                ("api_key", api_key),
                ("artist", artist),
                ("album", album),
                ("format", "json"),
            ])
            .send()
            .await
            .ok()?;

        #[derive(Deserialize)]
        struct LastfmResponse {
            album: Option<LastfmAlbum>,
        }
        #[derive(Deserialize)]
        struct LastfmAlbum {
            image: Vec<LastfmImage>,
        }
        #[derive(Deserialize)]
        struct LastfmImage {
            #[serde(rename = "#text")]
            url: String,
            size: String,
        }

        let body: LastfmResponse = resp.json().await.ok()?;
        let image = body.album?.image;

        let url = image
            .iter()
            .find(|i| i.size == "mega")
            .or_else(|| image.iter().find(|i| i.size == "extralarge"))
            .or_else(|| image.iter().find(|i| i.size == "large"))?;

        if url.url.is_empty() {
            return None;
        }

        self.client
            .get(&url.url)
            .send()
            .await
            .ok()?
            .error_for_status()
            .ok()?
            .bytes()
            .await
            .ok()
            .map(|b| b.to_vec())
    }

    async fn sleep_rate_limit(&self) {
        tokio::time::sleep(Duration::from_millis(1100)).await;
    }
}