aede_core/
discogs.rs

1//! Discogs label profiles reached through a MusicBrainz URL relationship.
2//!
3//! This module parses identifiers and responses without making requests. The
4//! CLI decides when a reader explicitly asks to go online.
5
6use crate::json::Json;
7use crate::sources::Prose;
8use crate::text;
9use std::collections::{BTreeMap, BTreeSet};
10
11/// Source name in the attributed layer.
12pub const SOURCE: &str = "discogs";
13/// At most 24 requests per minute, below the unauthenticated limit of 25.
14pub const REQUEST_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2500);
15
16/// Keep a margin below the API terms' six-hour display limit.
17pub const MAX_AGE_SECONDS: u64 = 5 * 60 * 60;
18
19/// Whether a stored answer is young enough to display under the API terms.
20pub fn fresh(fetched_at: u64, now: u64) -> bool {
21    fetched_at != 0 && now >= fetched_at && now - fetched_at < MAX_AGE_SECONDS
22}
23
24/// Accept only a Discogs label URL and use its numeric identifier on our own
25/// fixed API host. No URL found in external metadata is fetched directly.
26pub 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
44/// Public page to cite beside a Discogs profile.
45pub fn label_url(id: u64) -> String {
46    format!("https://www.discogs.com/label/{id}")
47}
48
49/// Fixed API endpoint for a label ID already checked against a Discogs URL.
50pub fn api_url(id: u64) -> String {
51    format!("https://api.discogs.com/labels/{id}")
52}
53
54/// Fixed API endpoint for an artist named in a label profile.
55pub fn artist_api_url(id: u64) -> String {
56    format!("https://api.discogs.com/artists/{id}")
57}
58
59/// A label's profile is CC0 data under Discogs' API terms. The display also
60/// prints the required “Data provided by Discogs.” notice beside the text.
61pub 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        // Discogs profiles are user-written, and the API does not state the
76        // language of each one. “und” avoids claiming it is English.
77        lang: "und".to_string(),
78        licence: "CC0".to_string(),
79    })
80}
81
82/// IDs named by Discogs' `[l123]` label-link markup, in first-use order.
83pub fn referenced_labels(raw: &str) -> Vec<u64> {
84    referenced_ids(raw, 'l')
85}
86
87/// Artist IDs named by Discogs' `[a123]` markup, in first-use order.
88pub 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
109/// Plain-text presentation of the Discogs profile. Unknown and malformed
110/// markup is retained so a source's ordinary bracketed text is not lost.
111pub 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
151/// The name from a second label lookup must belong to the requested ID.
152pub fn label_name(response: &Json, id: u64) -> Option<String> {
153    named_entity(response, id)
154}
155
156/// The name from an artist lookup must belong to the requested ID.
157pub 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;