1use crate::json::Json;
7use crate::sources::Prose;
8use crate::text;
9use std::collections::{BTreeMap, BTreeSet};
10
11pub const SOURCE: &str = "discogs";
13pub const REQUEST_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2500);
15
16pub const MAX_AGE_SECONDS: u64 = 5 * 60 * 60;
18
19pub fn fresh(fetched_at: u64, now: u64) -> bool {
21 fetched_at != 0 && now >= fetched_at && now - fetched_at < MAX_AGE_SECONDS
22}
23
24pub fn label_id(url: &str) -> Option<u64> {
27 let path = ["https://www.discogs.com/", "https://discogs.com/"]
28 .iter()
29 .find_map(|prefix| url.strip_prefix(prefix))?;
30 let path = path.strip_prefix("fr/").unwrap_or(path);
31 let label = path.strip_prefix("label/")?;
32 let digits: String = label.chars().take_while(char::is_ascii_digit).collect();
33 if digits.is_empty()
34 || !matches!(
35 label.as_bytes().get(digits.len()).copied(),
36 None | Some(b'-' | b'/' | b'?' | b'#')
37 )
38 {
39 return None;
40 }
41 digits.parse::<u64>().ok().filter(|id| *id > 0)
42}
43
44pub fn label_url(id: u64) -> String {
46 format!("https://www.discogs.com/label/{id}")
47}
48
49pub fn api_url(id: u64) -> String {
51 format!("https://api.discogs.com/labels/{id}")
52}
53
54pub fn artist_api_url(id: u64) -> String {
56 format!("https://api.discogs.com/artists/{id}")
57}
58
59pub fn profile(response: &Json, id: u64, expected_name: &str) -> Option<Prose> {
62 let found_id = response.field_u32("id")? as u64;
63 let found_name = response.field_str("name")?;
64 if found_id != id || text::normalize(&found_name) != text::normalize(expected_name) {
65 return None;
66 }
67 let content = response.field_str("profile")?;
68 let content = content.trim();
69 if content.is_empty() {
70 return None;
71 }
72 Some(Prose {
73 text: content.to_string(),
74 url: label_url(id),
75 lang: "und".to_string(),
78 licence: "CC0".to_string(),
79 })
80}
81
82pub fn referenced_labels(raw: &str) -> Vec<u64> {
84 referenced_ids(raw, 'l')
85}
86
87pub fn referenced_artists(raw: &str) -> Vec<u64> {
89 referenced_ids(raw, 'a')
90}
91
92fn referenced_ids(raw: &str, kind: char) -> Vec<u64> {
93 let mut ids = Vec::new();
94 let mut seen = BTreeSet::new();
95 let mut rest = raw;
96 while let Some(start) = rest.find('[') {
97 let after = &rest[start + 1..];
98 let Some(end) = after.find(']') else { break };
99 if let Some(id) = reference_id(&after[..end], kind)
100 && seen.insert(id)
101 {
102 ids.push(id);
103 }
104 rest = &after[end + 1..];
105 }
106 ids
107}
108
109pub fn render_profile(
112 raw: &str,
113 labels: &BTreeMap<u64, String>,
114 artists: &BTreeMap<u64, String>,
115) -> String {
116 let mut out = String::with_capacity(raw.len());
117 let mut rest = raw;
118 while let Some(start) = rest.find('[') {
119 out.push_str(&rest[..start]);
120 let after = &rest[start + 1..];
121 let Some(end) = after.find(']') else {
122 out.push_str(&rest[start..]);
123 return out;
124 };
125 let token = &after[..end];
126 if let Some(id) = reference_id(token, 'l') {
127 match labels.get(&id) {
128 Some(name) => out.push_str(name),
129 None => out.push_str(&format!("Label #{id}")),
130 }
131 } else if let Some(id) = reference_id(token, 'a') {
132 match artists.get(&id) {
133 Some(name) => out.push_str(name),
134 None => out.push_str(&format!("Artist #{id}")),
135 }
136 } else if let Some(name) = token.strip_prefix("l=") {
137 out.push_str(name);
138 } else if let Some(name) = token.strip_prefix("a=") {
139 out.push_str(name);
140 } else if !matches!(token, "b" | "/b" | "i" | "/i" | "u" | "/u" | "s" | "/s") {
141 out.push('[');
142 out.push_str(token);
143 out.push(']');
144 }
145 rest = &after[end + 1..];
146 }
147 out.push_str(rest);
148 out
149}
150
151pub fn label_name(response: &Json, id: u64) -> Option<String> {
153 named_entity(response, id)
154}
155
156pub fn artist_name(response: &Json, id: u64) -> Option<String> {
158 named_entity(response, id)
159}
160
161fn named_entity(response: &Json, id: u64) -> Option<String> {
162 if response.field_u32("id")? as u64 != id {
163 return None;
164 }
165 response
166 .field_str("name")
167 .filter(|name| !name.trim().is_empty())
168}
169
170fn reference_id(token: &str, kind: char) -> Option<u64> {
171 let digits = token.strip_prefix(kind)?;
172 if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
173 return None;
174 }
175 digits.parse::<u64>().ok().filter(|id| *id > 0)
176}
177
178#[cfg(test)]
179#[path = "discogs_tests.rs"]
180mod tests;