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}