Skip to main content

reader/
cover_fetcher.rs

1use crate::models::Library;
2use crate::utils::save_cover;
3use config::FetchStrategy;
4use serde::Deserialize;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Duration;
8use tracing;
9
10#[derive(Debug, Default)]
11pub struct FetchReport {
12    pub found: usize,
13    pub missing: usize,
14    pub errors: usize,
15}
16
17pub struct CoverFetcher {
18    client: reqwest::Client,
19    cache_dir: PathBuf,
20    strategy: FetchStrategy,
21    lastfm_api_key: Option<String>,
22    on_progress: Arc<dyn Fn(String) + Send + Sync>,
23}
24
25impl CoverFetcher {
26    pub fn new(
27        cache_dir: PathBuf,
28        strategy: FetchStrategy,
29        lastfm_api_key: Option<String>,
30        on_progress: Arc<dyn Fn(String) + Send + Sync>,
31    ) -> Self {
32        let lastfm_api_key = lastfm_api_key
33            .map(|key| key.trim().to_string())
34            .filter(|key| !key.is_empty());
35        let client = reqwest::Client::builder()
36            .user_agent(concat!(
37                "Kopuz/",
38                env!("CARGO_PKG_VERSION"),
39                " (music-player)"
40            ))
41            .timeout(Duration::from_secs(15))
42            .build()
43            .unwrap_or_default();
44        Self {
45            client,
46            cache_dir,
47            strategy,
48            lastfm_api_key,
49            on_progress,
50        }
51    }
52
53    pub async fn fetch_missing_covers(&self, library: &mut Library) -> FetchReport {
54        let mut report = FetchReport::default();
55        let missing: Vec<usize> = library
56            .albums
57            .iter()
58            .enumerate()
59            .filter(|(_, a)| {
60                a.cover_path.is_none() && a.title != "Unknown Album" && !a.manual_cover
61            })
62            .map(|(i, _)| i)
63            .collect();
64
65        if missing.is_empty() {
66            tracing::info!("Cover fetcher: no missing covers to fetch");
67            return report;
68        }
69
70        tracing::info!("Cover fetcher: fetching {} missing covers", missing.len());
71
72        for &idx in &missing {
73            let artist = library.albums[idx].artist.clone();
74            let title = library.albums[idx].title.clone();
75            let album_id = library.albums[idx].id.clone();
76            tracing::info!("Cover fetcher: fetching {} — {}", artist, title);
77            (self.on_progress)(format!("Fetching cover: {} — {}", artist, title));
78
79            let release_id = library
80                .tracks
81                .iter()
82                .filter(|t| t.album_id == album_id)
83                .find_map(|t| t.musicbrainz_release_id.as_deref());
84
85            let result = self.fetch_cover(release_id, &title, &artist).await;
86
87            match result {
88                Some(img_data) => match save_cover(&album_id, &img_data, None, &self.cache_dir) {
89                    Ok(saved_path) => {
90                        library.albums[idx].cover_path = Some(saved_path);
91                        report.found += 1;
92                        tracing::info!(
93                            "Cover fetcher: successfully found and saved cover for {} — {}",
94                            artist,
95                            title,
96                        );
97                    }
98                    Err(e) => {
99                        report.errors += 1;
100                        tracing::warn!(
101                            "Cover fetcher: failed to save cover for {} — {}: {}",
102                            artist,
103                            title,
104                            e,
105                        );
106                    }
107                },
108                None => {
109                    report.missing += 1;
110                    tracing::info!("Cover fetcher: no cover found for {} — {}", artist, title);
111                }
112            }
113        }
114
115        tracing::info!(
116            "Cover fetcher: done — {} found, {} missing, {} errors",
117            report.found,
118            report.missing,
119            report.errors,
120        );
121        report
122    }
123
124    async fn fetch_cover(
125        &self,
126        release_id: Option<&str>,
127        album: &str,
128        artist: &str,
129    ) -> Option<Vec<u8>> {
130        match self.strategy {
131            FetchStrategy::MusicBrainzFirst => {
132                let result = self.try_musicbrainz(release_id, album, artist).await;
133                if result.is_some() {
134                    return result;
135                }
136                let key = self.lastfm_api_key.as_deref().filter(|k| !k.is_empty())?;
137                self.try_lastfm(album, artist, key).await
138            }
139            FetchStrategy::LastFmFirst => {
140                if let Some(key) = self.lastfm_api_key.as_deref().filter(|k| !k.is_empty()) {
141                    let result = self.try_lastfm(album, artist, key).await;
142                    if result.is_some() {
143                        return result;
144                    }
145                }
146                self.try_musicbrainz(release_id, album, artist).await
147            }
148            FetchStrategy::MusicBrainzOnly => self.try_musicbrainz(release_id, album, artist).await,
149            FetchStrategy::LastFmOnly => {
150                let key = self.lastfm_api_key.as_deref().filter(|k| !k.is_empty())?;
151                self.try_lastfm(album, artist, key).await
152            }
153        }
154    }
155
156    async fn try_musicbrainz(
157        &self,
158        release_id: Option<&str>,
159        album: &str,
160        artist: &str,
161    ) -> Option<Vec<u8>> {
162        let mbid = match release_id {
163            Some(id) => {
164                tracing::info!(
165                    "Cover fetcher: using provided MusicBrainz Release ID: {}",
166                    id
167                );
168                id.to_string()
169            }
170            None => {
171                tracing::info!(
172                    "Cover fetcher: no release ID, searching MusicBrainz for album \"{}\" by artist \"{}\"",
173                    album,
174                    artist
175                );
176                let found = self.search_musicbrainz_release(album, artist).await?;
177                tracing::info!(
178                    "Cover fetcher: MusicBrainz search returned Release ID: {}",
179                    found
180                );
181                found
182            }
183        };
184
185        self.sleep_rate_limit().await;
186        let resp = self
187            .client
188            .get(format!("https://coverartarchive.org/release/{mbid}"))
189            .send()
190            .await
191            .ok()?;
192
193        if !resp.status().is_success() {
194            return None;
195        }
196
197        #[derive(Deserialize)]
198        struct CoverArchiveResponse {
199            images: Vec<CoverArchiveImage>,
200        }
201        #[derive(Deserialize)]
202        struct CoverArchiveImage {
203            image: String,
204            #[serde(default)]
205            front: bool,
206            #[serde(default)]
207            types: Vec<String>,
208        }
209
210        let body: CoverArchiveResponse = resp.json().await.ok()?;
211        let url = body
212            .images
213            .iter()
214            .find(|i| i.front || i.types.iter().any(|t| t == "Front"))
215            .or_else(|| body.images.first())?;
216
217        self.client
218            .get(&url.image)
219            .send()
220            .await
221            .ok()?
222            .error_for_status()
223            .ok()?
224            .bytes()
225            .await
226            .ok()
227            .map(|b| b.to_vec())
228    }
229
230    async fn search_musicbrainz_release(&self, album: &str, artist: &str) -> Option<String> {
231        let (esc_album, esc_artist) = (
232            album.replace('\\', "\\\\").replace('"', "\\\""),
233            artist.replace('\\', "\\\\").replace('"', "\\\""),
234        );
235        let query = if artist.is_empty() || artist == "Unknown Artist" {
236            format!("release:\"{}\"", esc_album)
237        } else {
238            format!("release:\"{}\" AND artist:\"{}\"", esc_album, esc_artist)
239        };
240
241        self.sleep_rate_limit().await;
242        let resp = self
243            .client
244            .get("https://musicbrainz.org/ws/2/release/")
245            .query(&[("query", query.as_str()), ("fmt", "json")])
246            .send()
247            .await
248            .ok()?;
249
250        #[derive(Deserialize)]
251        struct SearchResponse {
252            releases: Vec<Release>,
253        }
254        #[derive(Deserialize)]
255        struct Release {
256            id: String,
257            score: u32,
258        }
259
260        let body: SearchResponse = resp.json().await.ok()?;
261        body.releases
262            .into_iter()
263            .find(|r| r.score >= 80)
264            .map(|r| r.id)
265    }
266
267    async fn try_lastfm(&self, album: &str, artist: &str, api_key: &str) -> Option<Vec<u8>> {
268        tracing::info!(
269            "Cover fetcher: querying Last.fm for album \"{}\" by artist \"{}\"",
270            album,
271            artist
272        );
273        self.sleep_rate_limit().await;
274        let resp = self
275            .client
276            .get("https://ws.audioscrobbler.com/2.0/")
277            .query(&[
278                ("method", "album.getinfo"),
279                ("api_key", api_key),
280                ("artist", artist),
281                ("album", album),
282                ("format", "json"),
283            ])
284            .send()
285            .await
286            .ok()?;
287
288        #[derive(Deserialize)]
289        struct LastfmResponse {
290            album: Option<LastfmAlbum>,
291        }
292        #[derive(Deserialize)]
293        struct LastfmAlbum {
294            image: Vec<LastfmImage>,
295        }
296        #[derive(Deserialize)]
297        struct LastfmImage {
298            #[serde(rename = "#text")]
299            url: String,
300            size: String,
301        }
302
303        let body: LastfmResponse = resp.json().await.ok()?;
304        let image = body.album?.image;
305
306        let url = image
307            .iter()
308            .find(|i| i.size == "mega")
309            .or_else(|| image.iter().find(|i| i.size == "extralarge"))
310            .or_else(|| image.iter().find(|i| i.size == "large"))?;
311
312        if url.url.is_empty() {
313            return None;
314        }
315
316        self.client
317            .get(&url.url)
318            .send()
319            .await
320            .ok()?
321            .error_for_status()
322            .ok()?
323            .bytes()
324            .await
325            .ok()
326            .map(|b| b.to_vec())
327    }
328
329    async fn sleep_rate_limit(&self) {
330        tokio::time::sleep(Duration::from_millis(1100)).await;
331    }
332}