Skip to main content

qobuz_api/metadata/
extractor.rs

1//! Metadata extraction from API models.
2
3use crate::api::content::{
4    albums::{Album, Image},
5    artists::Artist,
6    tracks::Track,
7};
8
9/// Simplified album artist info for metadata passing.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct AlbumArtistBrief {
12    /// Artist name.
13    pub name: Option<String>,
14    /// Artist roles (e.g., "main-artist", "composer").
15    pub roles: Option<Vec<String>>,
16}
17
18/// Comprehensive metadata extracted from API models.
19///
20/// Carries raw data needed by the embedder for format-specific processing.
21/// The embedder performs complex extraction logic (artists from performers,
22/// composers, format-specific handling) based on this data.
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
24pub struct ComprehensiveMetadata {
25    /// Track title.
26    pub title: Option<String>,
27    /// Track version (e.g., "Remastered 2015").
28    pub track_version: Option<String>,
29    /// Album title.
30    pub album: Option<String>,
31    /// Album version (e.g., "Deluxe Edition").
32    pub album_version: Option<String>,
33    /// Raw performers string from API (e.g., "Name, `MainArtist` - Other, Composer").
34    pub performers: Option<String>,
35    /// Track performer artist name.
36    pub performer_name: Option<String>,
37    /// Album primary artist name.
38    pub album_artist_name: Option<String>,
39    /// Album artists with roles for format-specific album artist handling.
40    pub album_artists: Vec<AlbumArtistBrief>,
41    /// Album composer name.
42    pub album_composer_name: Option<String>,
43    /// Track composer name.
44    pub track_composer_name: Option<String>,
45    /// Genre name.
46    pub genre: Option<String>,
47    /// Album download release date (highest priority).
48    pub album_release_date_download: Option<String>,
49    /// Album original release date.
50    pub album_release_date_original: Option<String>,
51    /// Track original release date.
52    pub track_release_date_original: Option<String>,
53    /// Album release timestamp (unix).
54    pub released_at: Option<i64>,
55    /// Copyright notice (from track).
56    pub copyright: Option<String>,
57    /// ISRC code.
58    pub isrc: Option<String>,
59    /// Universal Product Code.
60    pub upc: Option<String>,
61    /// Product URL for commercial information.
62    pub product_url: Option<String>,
63    /// Record label name.
64    pub label: Option<String>,
65    /// Release type (album, single, compilation, etc.).
66    pub release_type: Option<String>,
67    /// Product type (fallback for media type).
68    pub product_type: Option<String>,
69    /// Track number.
70    pub track_number: Option<i32>,
71    /// Total tracks in album.
72    pub track_total: Option<i32>,
73    /// Disc number.
74    pub disc_number: Option<i32>,
75    /// Total discs in album.
76    pub disc_total: Option<i32>,
77    /// Explicit content flag.
78    pub parental_warning: Option<bool>,
79    /// Whether track has a `work` field (classical music indicator).
80    pub is_classical: bool,
81    /// Best available cover art URL.
82    pub cover_art_url: Option<String>,
83    /// Downloaded cover art binary data.
84    pub cover_art_data: Option<Vec<u8>>,
85}
86
87/// Selects the best available cover art URL from an [`Image`](crate::api::content::albums::Image).
88///
89/// Resolution priority (highest to lowest): mega > extralarge > large > medium > thumbnail > small.
90#[must_use]
91pub fn best_cover_url(image: &Image) -> Option<String> {
92    image
93        .mega
94        .clone()
95        .or_else(|| image.extra_large.clone())
96        .or_else(|| image.large.clone())
97        .or_else(|| image.medium.clone())
98        .or_else(|| image.thumbnail.clone())
99        .or_else(|| image.small.clone())
100}
101
102/// Extracts comprehensive metadata from a track, its album, and artist.
103///
104/// Collects raw data from API models for the embedder to process with
105/// format-specific logic.
106///
107/// # Arguments
108///
109/// * `track` - The track to extract metadata from
110/// * `album` - Optional album containing the track
111/// * `artist` - Optional artist for the track
112///
113/// # Returns
114///
115/// A `ComprehensiveMetadata` struct with all extracted fields.
116pub fn extract_comprehensive_metadata(
117    track: &Track,
118    album: Option<&Album>,
119    artist: Option<&Artist>,
120) -> ComprehensiveMetadata {
121    let performer_name = track
122        .performer
123        .as_ref()
124        .and_then(|a| a.name.clone())
125        .or_else(|| {
126            let a = artist?;
127            a.name.clone()
128        });
129
130    let album_artists: Vec<AlbumArtistBrief> = album
131        .and_then(|a| a.artists.as_ref())
132        .map(|artists| {
133            artists
134                .iter()
135                .map(|a| AlbumArtistBrief {
136                    name: a.name.clone(),
137                    roles: a.roles.clone(),
138                })
139                .collect()
140        })
141        .unwrap_or_default();
142
143    ComprehensiveMetadata {
144        title: track.title.clone(),
145        track_version: track.version.clone(),
146        album: album.and_then(|a| a.title.clone()),
147        album_version: album.and_then(|a| a.version.clone()),
148
149        performers: track.performers.clone(),
150        performer_name,
151        album_artist_name: album
152            .and_then(|a| a.artist.as_ref())
153            .and_then(|a| a.name.clone()),
154        album_artists,
155        album_composer_name: album
156            .and_then(|a| a.composer.as_ref())
157            .and_then(|a| a.name.clone()),
158        track_composer_name: track.composer.as_ref().and_then(|a| a.name.clone()),
159
160        genre: album
161            .and_then(|a| a.genre.as_ref())
162            .and_then(|g| g.name.clone()),
163        album_release_date_download: album.and_then(|a| a.release_date_download.clone()),
164        album_release_date_original: album.and_then(|a| a.release_date_original.clone()),
165        track_release_date_original: track.release_date_original.clone(),
166        released_at: album.and_then(|a| a.released_at),
167        copyright: track.copyright.clone(),
168        isrc: track.isrc.clone(),
169        upc: album.and_then(|a| a.upc.clone()),
170        product_url: album.and_then(|a| a.product_url.clone()),
171        label: album
172            .and_then(|a| a.label.as_ref())
173            .and_then(|l| l.name.clone()),
174        release_type: album.and_then(|a| a.release_type.clone()),
175        product_type: album.and_then(|a| a.product_type.clone()),
176        track_number: track.track_number,
177        track_total: album.and_then(|a| a.tracks_count),
178        disc_number: track.media_number,
179        disc_total: album.and_then(|a| a.media_count),
180        parental_warning: track.parental_warning,
181        is_classical: track.work.is_some(),
182
183        cover_art_url: album
184            .and_then(|a| a.image.as_ref())
185            .and_then(best_cover_url),
186        cover_art_data: None,
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use {
193        anyhow::{Result, ensure},
194        serde_json::from_str,
195    };
196
197    use crate::{
198        api::content::{
199            albums::{Album, Image},
200            tracks::Track,
201        },
202        metadata::extractor::{best_cover_url, extract_comprehensive_metadata},
203    };
204
205    /// Makes a test track from JSON.
206    ///
207    /// # Errors
208    ///
209    /// Returns an error if deserialization fails.
210    fn make_track() -> Result<Track> {
211        let json =
212            r#"{"id":1,"title":"So What","track_number":1,"media_number":1,"isrc":"USPJ11001001"}"#;
213        Ok(from_str(json)?)
214    }
215
216    /// Makes a test album from JSON.
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if deserialization fails.
221    fn make_album() -> Result<Album> {
222        let json = r#"{"id":"123","title":"Kind of Blue","release_date_original":"1959-08-17","copyright":"(C) 1959","genre":{"id":1,"name":"Jazz"},"label":{"id":1,"name":"Columbia"},"artist":{"id":1,"name":"Miles Davis"},"image":{"small":"s","thumbnail":"t","medium":"m","large":"l","extralarge":"xl","mega":"mega"}}"#;
223        Ok(from_str(json)?)
224    }
225
226    /// Tests extract all fields.
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if the test setup or assertion fails.
231    #[test]
232    fn extract_all_fields() -> Result<()> {
233        let track = make_track()?;
234        let album = make_album()?;
235        let meta = extract_comprehensive_metadata(&track, Some(&album), None);
236        ensure!(meta.title.as_deref() == Some("So What"));
237        ensure!(meta.album.as_deref() == Some("Kind of Blue"));
238        ensure!(meta.album_artist_name.as_deref() == Some("Miles Davis"));
239        ensure!(meta.genre.as_deref() == Some("Jazz"));
240        ensure!(meta.album_release_date_original.as_deref() == Some("1959-08-17"));
241        ensure!(meta.copyright.is_none());
242        ensure!(meta.label.as_deref() == Some("Columbia"));
243        ensure!(meta.track_number == Some(1));
244        ensure!(meta.disc_number == Some(1));
245        ensure!(meta.isrc.as_deref() == Some("USPJ11001001"));
246        ensure!(meta.cover_art_url.as_deref() == Some("mega"));
247        Ok(())
248    }
249
250    /// Tests extract with no album.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if the test setup or assertion fails.
255    #[test]
256    fn extract_with_no_album() -> Result<()> {
257        let track = make_track()?;
258        let meta = extract_comprehensive_metadata(&track, None, None);
259        ensure!(meta.album.is_none());
260        ensure!(meta.album_artist_name.is_none());
261        ensure!(meta.genre.is_none());
262        ensure!(meta.cover_art_url.is_none());
263        ensure!(meta.title.as_deref() == Some("So What"));
264        Ok(())
265    }
266
267    /// Tests cover art priority.
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if the test setup or assertion fails.
272    #[test]
273    fn cover_art_priority() -> Result<()> {
274        let image = Image {
275            small: Some("s".into()),
276            ..Image::default()
277        };
278        ensure!(best_cover_url(&image) == Some("s".to_string()));
279
280        let image = Image {
281            small: Some("s".into()),
282            large: Some("l".into()),
283            ..Image::default()
284        };
285        ensure!(best_cover_url(&image) == Some("l".to_string()));
286
287        let image = Image::default();
288        ensure!(best_cover_url(&image).is_none());
289        Ok(())
290    }
291
292    /// Tests special characters preserved.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if the test setup or assertion fails.
297    #[test]
298    fn special_characters_preserved() -> Result<()> {
299        let json = r#"{"id":4,"title":"Café Résumé — Über","track_number":1}"#;
300        let track: Track = from_str(json)?;
301        let meta = extract_comprehensive_metadata(&track, None, None);
302        ensure!(meta.title.as_deref() == Some("Café Résumé — Über"));
303        Ok(())
304    }
305}