Skip to main content

qobuz_api/metadata/
extractor.rs

1//! Metadata extraction from API models.
2
3use crate::models::{
4    album::{Album, Image},
5    artist::Artist,
6    track::Track,
7};
8
9/// Simplified album artist info for metadata passing.
10#[derive(Clone, Debug, PartialEq)]
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)]
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::models::album::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        metadata::extractor::{best_cover_url, extract_comprehensive_metadata},
199        models::{
200            album::{Album, Image},
201            track::Track,
202        },
203    };
204
205    fn make_track() -> Result<Track> {
206        let json =
207            r#"{"id":1,"title":"So What","track_number":1,"media_number":1,"isrc":"USPJ11001001"}"#;
208        Ok(from_str(json)?)
209    }
210
211    fn make_album() -> Result<Album> {
212        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"}}"#;
213        Ok(from_str(json)?)
214    }
215
216    #[test]
217    fn extract_all_fields() -> Result<()> {
218        let track = make_track()?;
219        let album = make_album()?;
220        let meta = extract_comprehensive_metadata(&track, Some(&album), None);
221        ensure!(meta.title.as_deref() == Some("So What"));
222        ensure!(meta.album.as_deref() == Some("Kind of Blue"));
223        ensure!(meta.album_artist_name.as_deref() == Some("Miles Davis"));
224        ensure!(meta.genre.as_deref() == Some("Jazz"));
225        ensure!(meta.album_release_date_original.as_deref() == Some("1959-08-17"));
226        ensure!(meta.copyright.is_none());
227        ensure!(meta.label.as_deref() == Some("Columbia"));
228        ensure!(meta.track_number == Some(1));
229        ensure!(meta.disc_number == Some(1));
230        ensure!(meta.isrc.as_deref() == Some("USPJ11001001"));
231        ensure!(meta.cover_art_url.as_deref() == Some("mega"));
232        Ok(())
233    }
234
235    #[test]
236    fn extract_with_no_album() -> Result<()> {
237        let track = make_track()?;
238        let meta = extract_comprehensive_metadata(&track, None, None);
239        ensure!(meta.album.is_none());
240        ensure!(meta.album_artist_name.is_none());
241        ensure!(meta.genre.is_none());
242        ensure!(meta.cover_art_url.is_none());
243        ensure!(meta.title.as_deref() == Some("So What"));
244        Ok(())
245    }
246
247    #[test]
248    fn cover_art_priority() -> Result<()> {
249        let image = Image {
250            small: Some("s".into()),
251            ..Image::default()
252        };
253        ensure!(best_cover_url(&image) == Some("s".to_string()));
254
255        let image = Image {
256            small: Some("s".into()),
257            large: Some("l".into()),
258            ..Image::default()
259        };
260        ensure!(best_cover_url(&image) == Some("l".to_string()));
261
262        let image = Image::default();
263        ensure!(best_cover_url(&image).is_none());
264        Ok(())
265    }
266
267    #[test]
268    fn special_characters_preserved() -> Result<()> {
269        let json = r#"{"id":4,"title":"Café Résumé — Über","track_number":1}"#;
270        let track: Track = from_str(json)?;
271        let meta = extract_comprehensive_metadata(&track, None, None);
272        ensure!(meta.title.as_deref() == Some("Café Résumé — Über"));
273        Ok(())
274    }
275}