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