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}