1use std::sync::LazyLock;
12use std::time::Duration;
13
14use regex::Regex;
15use serde::Deserialize;
16use tokio::sync::Mutex;
17use tokio::time::Instant;
18
19use crate::musicbrainz::{
20 Artist, ArtistCredit, Label, LabelInfo, Medium, Recording, Release, ReleaseGroup, ReleaseTrack,
21};
22
23const BASE: &str = "https://api.discogs.com";
24const SPACING: Duration = Duration::from_millis(1050);
25const MAX_SPACING: Duration = Duration::from_secs(10);
26const ATTEMPTS: u32 = 10;
27const RELEASE_TTL: Duration = Duration::from_secs(7 * 24 * 3600);
28const SEARCH_TTL: Duration = Duration::from_secs(24 * 3600);
29
30#[derive(Debug, thiserror::Error)]
31pub enum DiscogsError {
32 #[error("Discogs request failed: {0}")]
33 Http(#[from] reqwest::Error),
34 #[error("Discogs is refusing requests (HTTP {0}) and did not recover")]
35 Unavailable(u16),
36 #[error("Discogs sent something unreadable: {0}")]
37 Decode(String),
38}
39
40impl DiscogsError {
41 pub fn is_transient(&self) -> bool {
43 match self {
44 Self::Unavailable(_) => true,
45 Self::Http(e) => e.is_timeout() || e.is_connect() || e.status().is_some_and(retryable),
46 Self::Decode(_) => false,
47 }
48 }
49}
50
51pub struct Discogs {
52 http: reqwest::Client,
53 token: String,
54 base: String,
55 gate: Mutex<Gate>,
56 cache: Option<std::path::PathBuf>,
59}
60
61impl Discogs {
62 pub fn new(token: &str, contact: &str) -> Self {
63 Self::with_base(BASE, token, contact)
64 }
65
66 pub fn with_base(base: &str, token: &str, contact: &str) -> Self {
67 let agent = format!("sift/{} ( {contact} )", env!("CARGO_PKG_VERSION"));
68 Self {
69 http: reqwest::Client::builder()
70 .user_agent(agent)
71 .timeout(Duration::from_secs(30))
72 .build()
73 .expect("static client config"),
74 token: token.to_string(),
75 base: base.trim_end_matches('/').to_string(),
76 gate: Mutex::new(Gate::default()),
77 cache: None,
78 }
79 }
80
81 pub fn with_cache(mut self, dir: std::path::PathBuf) -> Self {
83 self.cache = Some(dir);
84 self
85 }
86
87 fn cache_path(&self, path: &str, query: &[(&str, &str)]) -> Option<std::path::PathBuf> {
88 use std::hash::{Hash, Hasher};
89 let dir = self.cache.as_ref()?;
90 let mut h = std::collections::hash_map::DefaultHasher::new();
91 (path, query).hash(&mut h);
92 Some(dir.join(format!("discogs-{:016x}.json", h.finish())))
93 }
94
95 async fn get<T: for<'de> Deserialize<'de>>(
96 &self,
97 path: &str,
98 query: &[(&str, &str)],
99 ) -> Result<T, DiscogsError> {
100 let ttl = if path.starts_with("releases/") {
101 RELEASE_TTL
102 } else {
103 SEARCH_TTL
104 };
105 let cached = self.cache_path(path, query);
106 if let Some(file) = &cached {
107 let fresh = tokio::fs::metadata(file)
108 .await
109 .ok()
110 .and_then(|m| m.modified().ok())
111 .and_then(|t| t.elapsed().ok())
112 .is_some_and(|age| age < ttl);
113 if fresh
114 && let Ok(body) = tokio::fs::read(file).await
115 && let Ok(v) = serde_json::from_slice(&body)
116 {
117 return Ok(v);
118 }
119 }
120 let body: bytes::Bytes = self.fetch(path, query).await?;
121 let value =
122 serde_json::from_slice(&body).map_err(|e| DiscogsError::Decode(e.to_string()))?;
123 if let Some(file) = cached {
124 if let Some(dir) = file.parent() {
125 let _ = tokio::fs::create_dir_all(dir).await;
126 }
127 let _ = tokio::fs::write(&file, &body).await;
128 }
129 Ok(value)
130 }
131
132 async fn fetch(
133 &self,
134 path: &str,
135 query: &[(&str, &str)],
136 ) -> Result<bytes::Bytes, DiscogsError> {
137 let mut gate = self.gate.lock().await;
138 let mut attempt = 0;
139 loop {
140 gate.wait().await;
141 let sent = self
142 .http
143 .get(format!("{}/{path}", self.base))
144 .header(
145 reqwest::header::AUTHORIZATION,
146 format!("Discogs token={}", self.token),
147 )
148 .query(query)
149 .send()
150 .await;
151 attempt += 1;
152 let backoff = Duration::from_secs(2u64 << attempt.min(5));
153 let (wait, failure) = match sent {
154 Ok(resp) if retryable(resp.status()) => {
155 let wait = resp
156 .headers()
157 .get(reqwest::header::RETRY_AFTER)
158 .and_then(|v| v.to_str().ok())
159 .and_then(|v| v.trim().parse::<u64>().ok())
160 .map_or(backoff, Duration::from_secs);
161 (wait, DiscogsError::Unavailable(resp.status().as_u16()))
162 }
163 Ok(resp) => {
164 gate.succeeded();
165 return Ok(resp.error_for_status()?.bytes().await?);
166 }
167 Err(e) if e.is_timeout() || e.is_connect() => (backoff, DiscogsError::Http(e)),
168 Err(e) => return Err(e.into()),
169 };
170 if attempt >= ATTEMPTS {
171 return Err(failure);
172 }
173 gate.refused(wait.clamp(Duration::from_secs(1), Duration::from_secs(60)));
174 }
175 }
176
177 pub async fn search(&self, artist: &str, album: &str) -> Result<Vec<u64>, DiscogsError> {
179 #[derive(Debug, Deserialize, Default)]
180 struct Results {
181 #[serde(default)]
182 results: Vec<Hit>,
183 }
184 #[derive(Debug, Deserialize)]
185 struct Hit {
186 id: u64,
187 }
188 let found: Results = self
189 .get(
190 "database/search",
191 &[
192 ("type", "release"),
193 ("artist", artist),
194 ("release_title", album),
195 ("per_page", "10"),
196 ],
197 )
198 .await?;
199 Ok(found.results.into_iter().map(|h| h.id).collect())
200 }
201
202 pub async fn release(&self, id: u64, index_tracks: bool) -> Result<Release, DiscogsError> {
206 let r: DRelease = self.get(&format!("releases/{id}"), &[]).await?;
207 Ok(map_release(id, r, index_tracks))
208 }
209
210 pub async fn image(&self, url: &str) -> Result<Vec<u8>, DiscogsError> {
213 let resp = self
214 .http
215 .get(url)
216 .header(
217 reqwest::header::AUTHORIZATION,
218 format!("Discogs token={}", self.token),
219 )
220 .send()
221 .await?;
222 Ok(resp.error_for_status()?.bytes().await?.to_vec())
223 }
224}
225
226struct Gate {
227 next: Option<Instant>,
228 spacing: Duration,
229}
230
231impl Default for Gate {
232 fn default() -> Self {
233 Self {
234 next: None,
235 spacing: SPACING,
236 }
237 }
238}
239
240impl Gate {
241 async fn wait(&self) {
242 if let Some(t) = self.next {
243 tokio::time::sleep_until(t).await;
244 }
245 }
246
247 fn succeeded(&mut self) {
248 self.spacing = self.spacing.mul_f64(0.9).max(SPACING);
249 self.next = Some(Instant::now() + self.spacing);
250 }
251
252 fn refused(&mut self, wait: Duration) {
253 self.spacing = (self.spacing * 2).min(MAX_SPACING);
254 self.next = Some(Instant::now() + wait.max(self.spacing));
255 }
256}
257
258fn retryable(status: reqwest::StatusCode) -> bool {
259 status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
260}
261
262#[derive(Debug, Clone, Deserialize, Default)]
263struct DArtist {
264 #[serde(default)]
265 name: String,
266 #[serde(default)]
269 join: String,
270}
271
272#[derive(Debug, Deserialize, Default)]
273struct DLabel {
274 #[serde(default)]
275 name: String,
276 #[serde(default)]
277 catno: Option<String>,
278}
279
280#[derive(Debug, Deserialize, Default)]
281struct DImage {
282 #[serde(rename = "type", default)]
283 kind: Option<String>,
284 uri: Option<String>,
285}
286
287#[derive(Debug, Deserialize, Default)]
288struct DTrack {
289 #[serde(default)]
290 position: String,
291 #[serde(default)]
292 type_: String,
293 #[serde(default)]
294 title: String,
295 #[serde(default)]
296 duration: String,
297 #[serde(default)]
298 artists: Vec<DArtist>,
299 #[serde(default)]
300 sub_tracks: Vec<DTrack>,
301}
302
303#[derive(Debug, Deserialize, Default)]
304struct DRelease {
305 #[serde(default)]
306 title: String,
307 #[serde(default)]
308 artists: Vec<DArtist>,
309 #[serde(default)]
310 labels: Vec<DLabel>,
311 released: Option<String>,
312 country: Option<String>,
313 #[serde(default)]
314 tracklist: Vec<DTrack>,
315 #[serde(default)]
316 images: Vec<DImage>,
317}
318
319fn map_release(id: u64, r: DRelease, index_tracks: bool) -> Release {
320 let date = r.released.as_deref().and_then(clean_date);
321 let cover_url = r
322 .images
323 .iter()
324 .find(|i| i.kind.as_deref() == Some("primary"))
325 .or_else(|| r.images.first())
326 .and_then(|i| i.uri.clone());
327 Release {
328 id: format!("discogs:{id}"),
329 title: r.title,
330 date: date.clone(),
331 country: r.country.filter(|c| !c.is_empty()),
332 status: None,
333 disambiguation: None,
334 artist_credit: artist_credits(&r.artists),
335 release_group: Some(ReleaseGroup {
336 id: String::new(),
337 first_release_date: date,
338 primary_type: None,
339 }),
340 label_info: r
341 .labels
342 .iter()
343 .map(|l| LabelInfo {
344 catalog_number: l.catno.clone().filter(|c| !c.is_empty()),
345 label: Some(Label {
346 name: l.name.clone(),
347 }),
348 })
349 .collect(),
350 media: build_media(flatten_tracks(&r.tracklist, index_tracks)),
351 cover_url,
352 }
353}
354
355fn artist_credits(artists: &[DArtist]) -> Vec<ArtistCredit> {
358 artists
359 .iter()
360 .map(|a| {
361 let name = strip_disambiguation(&a.name);
362 let join = a.join.trim();
363 ArtistCredit {
364 joinphrase: if join.is_empty() {
365 String::new()
366 } else {
367 format!(" {join} ")
368 },
369 artist: Artist {
370 id: String::new(),
371 name: name.clone(),
372 },
373 name,
374 }
375 })
376 .collect()
377}
378
379fn strip_disambiguation(name: &str) -> String {
382 static SUFFIX: LazyLock<Regex> =
383 LazyLock::new(|| Regex::new(r"^(.*) \(\d+\)$").expect("static regex"));
384 match SUFFIX.captures(name) {
385 Some(c) => c[1].to_string(),
386 None => name.to_string(),
387 }
388}
389
390fn clean_date(s: &str) -> Option<String> {
393 let mut parts: Vec<&str> = s.trim().split('-').collect();
394 while matches!(parts.last(), Some(p) if !p.is_empty() && p.chars().all(|c| c == '0')) {
395 parts.pop();
396 }
397 (!parts.is_empty() && !parts[0].is_empty()).then(|| parts.join("-"))
398}
399
400fn parse_duration(s: &str) -> Option<u64> {
402 let s = s.trim();
403 if s.is_empty() {
404 return None;
405 }
406 let parts: Vec<u64> = s
407 .split(':')
408 .map(|p| p.trim().parse().ok())
409 .collect::<Option<_>>()?;
410 let secs = match parts.as_slice() {
411 [m, s] => m * 60 + s,
412 [h, m, s] => h * 3600 + m * 60 + s,
413 _ => return None,
414 };
415 Some(secs * 1000)
416}
417
418struct FlatTrack {
419 position: String,
420 title: String,
421 duration: String,
422 artists: Vec<DArtist>,
423}
424
425fn flatten_tracks(tracks: &[DTrack], index_tracks: bool) -> Vec<FlatTrack> {
428 let mut out = Vec::new();
429 for t in tracks {
430 match t.type_.as_str() {
431 "heading" => {}
432 "index" => {
433 for sub in &t.sub_tracks {
434 let title = if index_tracks {
435 format!("{}: {}", t.title, sub.title)
436 } else {
437 sub.title.clone()
438 };
439 out.push(FlatTrack {
440 position: sub.position.clone(),
441 title,
442 duration: sub.duration.clone(),
443 artists: if sub.artists.is_empty() {
444 t.artists.clone()
445 } else {
446 sub.artists.clone()
447 },
448 });
449 }
450 }
451 _ => out.push(FlatTrack {
452 position: t.position.clone(),
453 title: t.title.clone(),
454 duration: t.duration.clone(),
455 artists: t.artists.clone(),
456 }),
457 }
458 }
459 out
460}
461
462static DISC_DASH: LazyLock<Regex> =
463 LazyLock::new(|| Regex::new(r"(?i)^[a-z]*(\d+)-(\d+)$").expect("static regex"));
464static DISC_DOT: LazyLock<Regex> =
465 LazyLock::new(|| Regex::new(r"^(\d+)\.(\d+)$").expect("static regex"));
466static DIGITS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d+)$").expect("static regex"));
467
468fn parse_position(pos: &str, seq: &mut u32) -> (u32, u32) {
473 let pos = pos.trim();
474 if let Some(c) = DISC_DASH.captures(pos) {
475 return (c[1].parse().unwrap_or(1), c[2].parse().unwrap_or(1));
476 }
477 if let Some(c) = DISC_DOT.captures(pos) {
478 return (c[1].parse().unwrap_or(1), c[2].parse().unwrap_or(1));
479 }
480 if let Some(c) = DIGITS.captures(pos) {
481 return (1, c[1].parse().unwrap_or(1));
482 }
483 *seq += 1;
484 (1, *seq)
485}
486
487fn build_media(flat: Vec<FlatTrack>) -> Vec<Medium> {
488 let mut seq = 0u32;
489 let mut by_disc: std::collections::BTreeMap<u32, Vec<ReleaseTrack>> = Default::default();
490 for f in flat {
491 let (disc, position) = parse_position(&f.position, &mut seq);
492 by_disc.entry(disc).or_default().push(ReleaseTrack {
493 id: String::new(),
494 position,
495 title: f.title,
496 length: parse_duration(&f.duration),
497 artist_credit: artist_credits(&f.artists),
498 recording: Recording {
499 id: String::new(),
500 length: None,
501 },
502 });
503 }
504 by_disc
505 .into_iter()
506 .map(|(position, tracks)| Medium {
507 position,
508 format: None,
509 tracks,
510 })
511 .collect()
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn strips_the_discogs_disambiguation_suffix() {
520 assert_eq!(strip_disambiguation("Boards of Canada"), "Boards of Canada");
521 assert_eq!(strip_disambiguation("Artist (2)"), "Artist");
522 assert_eq!(strip_disambiguation("Artist (12)"), "Artist");
523 }
524
525 #[test]
526 fn drops_trailing_zero_date_parts() {
527 assert_eq!(clean_date("2019-00-00"), Some("2019".into()));
528 assert_eq!(clean_date("2019-03-00"), Some("2019-03".into()));
529 assert_eq!(clean_date("2002-02-18"), Some("2002-02-18".into()));
530 assert_eq!(clean_date(""), None);
531 assert_eq!(clean_date("0000-00-00"), None);
532 }
533
534 #[test]
535 fn parses_minute_and_hour_durations() {
536 assert_eq!(parse_duration("3:45"), Some(225_000));
537 assert_eq!(parse_duration("1:02:03"), Some(3_723_000));
538 assert_eq!(parse_duration(""), None);
539 assert_eq!(parse_duration("garbage"), None);
540 }
541
542 #[test]
543 fn vinyl_sides_are_counted_in_order_on_one_disc() {
544 let flat = vec!["A1", "A2", "B1"]
545 .into_iter()
546 .map(|p| FlatTrack {
547 position: p.into(),
548 title: p.into(),
549 duration: String::new(),
550 artists: Vec::new(),
551 })
552 .collect();
553 let media = build_media(flat);
554 assert_eq!(media.len(), 1);
555 assert_eq!(media[0].position, 1);
556 let positions: Vec<u32> = media[0].tracks.iter().map(|t| t.position).collect();
557 assert_eq!(positions, [1, 2, 3]);
558 }
559
560 #[test]
561 fn disc_and_track_come_from_the_numeric_forms() {
562 let mut seq = 0;
563 assert_eq!(parse_position("1-3", &mut seq), (1, 3));
564 assert_eq!(parse_position("CD1-3", &mut seq), (1, 3));
565 assert_eq!(parse_position("2.3", &mut seq), (2, 3));
566 assert_eq!(parse_position("3", &mut seq), (1, 3));
567 }
568
569 #[test]
570 fn an_index_tracks_sub_tracks_are_flattened() {
571 let json = r#"[
572 {"position":"1","type_":"track","title":"Intro","duration":"1:00"},
573 {"type_":"index","title":"Medley","sub_tracks":[
574 {"position":"2","title":"Part One","duration":"2:00"},
575 {"position":"3","title":"Part Two","duration":"3:00"}
576 ]}
577 ]"#;
578 let tracks: Vec<DTrack> = serde_json::from_str(json).unwrap();
579 let plain = flatten_tracks(&tracks, false);
580 assert_eq!(
581 plain.iter().map(|t| t.title.as_str()).collect::<Vec<_>>(),
582 ["Intro", "Part One", "Part Two"]
583 );
584 let prefixed = flatten_tracks(&tracks, true);
585 assert_eq!(
586 prefixed
587 .iter()
588 .map(|t| t.title.as_str())
589 .collect::<Vec<_>>(),
590 ["Intro", "Medley: Part One", "Medley: Part Two"]
591 );
592 }
593
594 #[test]
595 fn headings_carry_no_track() {
596 let json = r#"[
597 {"type_":"heading","title":"Side A"},
598 {"position":"A1","type_":"track","title":"Song","duration":"3:00"}
599 ]"#;
600 let tracks: Vec<DTrack> = serde_json::from_str(json).unwrap();
601 let flat = flatten_tracks(&tracks, false);
602 assert_eq!(flat.len(), 1);
603 assert_eq!(flat[0].title, "Song");
604 }
605}