ddex_parser/transform/
flatten.rs

1// core/src/transform/flatten.rs
2//! Graph to flat model transformation
3
4use crate::error::ParseError;
5
6type Result<T> = std::result::Result<T, ParseError>;
7use ddex_core::models::common::{Identifier, LocalizedString};
8use ddex_core::models::flat::{
9    ArtistInfo, DealValidity, DistributionComplexity, FlattenedMessage, MessageStats, Organization,
10    ParsedDeal, ParsedRelease, ParsedResource, ParsedTrack, PriceTier, PriceType, ProprietaryId,
11    ReleaseIdentifiers, TechnicalInfo, TerritoryComplexity, TerritoryInfo,
12};
13use ddex_core::models::graph::{
14    Artist, Deal, DealTerms, ERNMessage, Party, Release, ReleaseResourceReference, Resource,
15};
16use indexmap::IndexMap;
17use std::collections::HashMap;
18
19pub struct Flattener;
20
21impl Flattener {
22    pub fn flatten(graph: ERNMessage) -> Result<FlattenedMessage> {
23        let releases = Self::flatten_releases(&graph.releases, &graph.resources)?;
24        let resources = Self::flatten_resources(&graph.resources)?;
25        let deals = Self::flatten_deals(&graph.deals)?;
26        let parties = Self::flatten_parties(&graph.parties)?;
27
28        let stats = MessageStats {
29            release_count: graph.releases.len(),
30            track_count: 0, // Set to 0 if no tracks
31            deal_count: graph.deals.len(),
32            total_duration: 0, // Set to 0 if no duration
33        };
34
35        Ok(FlattenedMessage {
36            message_id: graph.message_header.message_id.clone(),
37            message_type: format!("{:?}", graph.message_header.message_type),
38            message_date: graph.message_header.message_created_date_time,
39            sender: Organization {
40                name: Self::get_primary_name(&graph.message_header.message_sender.party_name, "MessageSender/PartyName")?,
41                id: Self::get_primary_id(&graph.message_header.message_sender.party_id, "MessageSender/PartyId")?,
42                extensions: None,
43            },
44            recipient: Organization {
45                name: Self::get_primary_name(&graph.message_header.message_recipient.party_name, "MessageRecipient/PartyName")?,
46                id: Self::get_primary_id(&graph.message_header.message_recipient.party_id, "MessageRecipient/PartyId")?,
47                extensions: None,
48            },
49            releases,
50            resources,
51            deals,
52            parties,
53            version: format!("{:?}", graph.version),
54            profile: graph.profile.map(|p| format!("{:?}", p)),
55            stats,
56            extensions: None,
57        })
58    }
59
60    fn flatten_releases(releases: &[Release], resources: &[Resource]) -> Result<Vec<ParsedRelease>> {
61        releases
62            .iter()
63            .map(|release| Ok(ParsedRelease {
64                release_id: release.release_reference.clone(),
65                identifiers: Self::extract_identifiers(&release.release_id),
66                title: release.release_title.clone(),
67                default_title: Self::get_primary_title(&release.release_title, "Release/Title/TitleText")?,
68                subtitle: release.release_subtitle.clone(),
69                default_subtitle: release
70                    .release_subtitle
71                    .as_ref()
72                    .map(|s| Self::get_primary_title_optional(s))
73                    .flatten(),
74                display_artist: Self::format_display_artist(&release.display_artist)?,
75                artists: Self::extract_artists(&release.display_artist)?,
76                release_type: release
77                    .release_type
78                    .as_ref()
79                    .map(|t| format!("{:?}", t))
80                    .ok_or_else(|| ParseError::MissingField("Release/ReleaseType".to_string()))?,
81                genre: release.genre.first().map(|g| g.genre_text.clone()),
82                sub_genre: release.genre.first().and_then(|g| g.sub_genre.clone()),
83                tracks: Self::build_tracks(&release.release_resource_reference_list, resources)?,
84                track_count: release.release_resource_reference_list.len(),
85                disc_count: Self::count_discs(&release.release_resource_reference_list),
86                videos: Vec::new(),
87                images: Vec::new(),
88                cover_art: None,
89                release_date: release.release_date.first().and_then(|e| e.event_date),
90                original_release_date: None,
91                territories: Self::build_territories(
92                    &release.territory_code,
93                    &release.excluded_territory_code,
94                ),
95                p_line: None,
96                c_line: None,
97                parent_release: None,
98                child_releases: Vec::new(),
99                extensions: None,
100            }))
101            .collect()
102    }
103
104    fn flatten_resources(resources: &[Resource]) -> Result<IndexMap<String, ParsedResource>> {
105        resources
106            .iter()
107            .map(|resource| {
108                let parsed = ParsedResource {
109                    resource_id: resource.resource_reference.clone(),
110                    resource_type: format!("{:?}", resource.resource_type),
111                    title: Self::get_primary_title(&resource.reference_title, "Resource/ReferenceTitle/TitleText")?,
112                    duration: resource.duration,
113                    technical_details: TechnicalInfo {
114                        file_format: resource
115                            .technical_details
116                            .first()
117                            .and_then(|t| t.file_format.clone()),
118                        bitrate: resource.technical_details.first().and_then(|t| t.bitrate),
119                        sample_rate: resource
120                            .technical_details
121                            .first()
122                            .and_then(|t| t.sample_rate),
123                        file_size: resource.technical_details.first().and_then(|t| t.file_size),
124                    },
125                };
126                Ok((resource.resource_reference.clone(), parsed))
127            })
128            .collect()
129    }
130
131    fn flatten_deals(deals: &[Deal]) -> Result<Vec<ParsedDeal>> {
132        deals
133            .iter()
134            .map(|deal| Ok(ParsedDeal {
135                deal_id: deal
136                    .deal_reference
137                    .clone()
138                    .ok_or_else(|| ParseError::MissingField("Deal/DealReference".to_string()))?,
139                releases: deal.deal_release_reference.clone(),
140                validity: DealValidity {
141                    start: deal.deal_terms.start_date,
142                    end: deal.deal_terms.end_date,
143                },
144                territories: TerritoryComplexity {
145                    included: deal.deal_terms.territory_code.clone(),
146                    excluded: deal.deal_terms.excluded_territory_code.clone(),
147                },
148                distribution_channels: DistributionComplexity {
149                    included: deal
150                        .deal_terms
151                        .distribution_channel
152                        .iter()
153                        .map(|c| format!("{:?}", c))
154                        .collect(),
155                    excluded: deal
156                        .deal_terms
157                        .excluded_distribution_channel
158                        .iter()
159                        .map(|c| format!("{:?}", c))
160                        .collect(),
161                },
162                pricing: Self::build_price_tiers(&deal.deal_terms),
163                usage_rights: deal
164                    .deal_terms
165                    .use_type
166                    .iter()
167                    .map(|u| format!("{:?}", u))
168                    .collect(),
169                restrictions: Vec::new(),
170            }))
171            .collect()
172    }
173
174    fn flatten_parties(parties: &[Party]) -> Result<IndexMap<String, Party>> {
175        parties
176            .iter()
177            .map(|party| {
178                let id = Self::get_primary_id(&party.party_id, "Party/PartyId")?;
179                Ok((id, party.clone()))
180            })
181            .collect()
182    }
183
184    // Helper methods
185    fn get_primary_name(names: &[LocalizedString], field_path: &str) -> Result<String> {
186        names
187            .first()
188            .map(|n| n.text.clone())
189            .ok_or_else(|| ParseError::MissingField(field_path.to_string()))
190    }
191
192    fn get_primary_title(titles: &[LocalizedString], field_path: &str) -> Result<String> {
193        titles
194            .first()
195            .map(|t| t.text.clone())
196            .ok_or_else(|| ParseError::MissingField(field_path.to_string()))
197    }
198
199    fn get_primary_id(ids: &[Identifier], field_path: &str) -> Result<String> {
200        ids.first()
201            .map(|id| id.value.clone())
202            .ok_or_else(|| ParseError::MissingField(field_path.to_string()))
203    }
204
205    // Optional variants that return Option<T> instead of failing
206    fn get_primary_name_optional(names: &[LocalizedString]) -> Option<String> {
207        names.first().map(|n| n.text.clone())
208    }
209
210    fn get_primary_title_optional(titles: &[LocalizedString]) -> Option<String> {
211        titles.first().map(|t| t.text.clone())
212    }
213
214    fn get_primary_id_optional(ids: &[Identifier]) -> Option<String> {
215        ids.first().map(|id| id.value.clone())
216    }
217
218    #[allow(dead_code)]
219    fn count_tracks(releases: &[ParsedRelease]) -> usize {
220        releases.iter().map(|r| r.track_count).sum()
221    }
222
223    #[allow(dead_code)]
224    fn calculate_total_duration(resources: &HashMap<String, ParsedResource>) -> u64 {
225        resources
226            .values()
227            .filter_map(|r| r.duration)
228            .map(|d| d.as_secs())
229            .sum()
230    }
231
232    fn extract_identifiers(ids: &[Identifier]) -> ReleaseIdentifiers {
233        let mut identifiers = ReleaseIdentifiers {
234            upc: None,
235            ean: None,
236            catalog_number: None,
237            grid: None,
238            proprietary: Vec::new(),
239        };
240
241        for id in ids {
242            match &id.id_type {
243                ddex_core::models::common::IdentifierType::UPC => {
244                    identifiers.upc = Some(id.value.clone())
245                }
246                ddex_core::models::common::IdentifierType::EAN => {
247                    identifiers.ean = Some(id.value.clone())
248                }
249                ddex_core::models::common::IdentifierType::GRID => {
250                    identifiers.grid = Some(id.value.clone())
251                }
252                ddex_core::models::common::IdentifierType::Proprietary => {
253                    if let Some(ns) = &id.namespace {
254                        identifiers.proprietary.push(ProprietaryId {
255                            namespace: ns.clone(),
256                            value: id.value.clone(),
257                        });
258                    }
259                }
260                _ => {}
261            }
262        }
263
264        identifiers
265    }
266
267    fn format_display_artist(artists: &[Artist]) -> Result<String> {
268        let names: Result<Vec<String>> = artists
269            .iter()
270            .map(|a| Self::get_primary_name(&a.display_artist_name, "Artist/DisplayArtistName"))
271            .collect();
272        Ok(names?.join(", "))
273    }
274
275    fn extract_artists(artists: &[Artist]) -> Result<Vec<ArtistInfo>> {
276        artists
277            .iter()
278            .map(|artist| Ok(ArtistInfo {
279                name: Self::get_primary_name(&artist.display_artist_name, "Artist/DisplayArtistName")?,
280                role: artist
281                    .artist_role
282                    .first()
283                    .cloned()
284                    .ok_or_else(|| ParseError::MissingField("Artist/ArtistRole".to_string()))?,
285                party_id: artist.party_reference.clone(),
286            }))
287            .collect()
288    }
289
290    fn build_tracks(refs: &[ReleaseResourceReference], resources: &[Resource]) -> Result<Vec<ParsedTrack>> {
291        refs.iter()
292            .enumerate()
293            .map(|(idx, rref)| {
294                let resource = resources
295                    .iter()
296                    .find(|r| r.resource_reference == rref.resource_reference);
297
298                // For tracks, title is optional - if resource is missing, we can't proceed
299                let title = match resource {
300                    Some(r) => Self::get_primary_title_optional(&r.reference_title),
301                    None => None,
302                };
303
304                Ok(ParsedTrack {
305                    track_id: rref.resource_reference.clone(),
306                    isrc: resource.and_then(|r| {
307                        r.resource_id
308                            .iter()
309                            .find(|id| {
310                                matches!(
311                                    id.id_type,
312                                    ddex_core::models::common::IdentifierType::ISRC
313                                )
314                            })
315                            .map(|id| id.value.clone())
316                    }),
317                    iswc: None,
318                    position: idx + 1,
319                    track_number: rref.track_number,
320                    disc_number: rref.disc_number,
321                    side: rref.side.clone(),
322                    title: title.ok_or_else(|| ParseError::MissingField("Resource/ReferenceTitle/TitleText".to_string()))?,
323                    subtitle: None,
324                    display_artist: String::new(),
325                    artists: Vec::new(),
326                    duration: resource
327                        .and_then(|r| r.duration)
328                        .ok_or_else(|| ParseError::MissingField("Resource/Duration".to_string()))?,
329                    duration_formatted: resource
330                        .and_then(|r| r.duration)
331                        .map(ParsedTrack::format_duration)
332                        .ok_or_else(|| ParseError::MissingField("Resource/Duration".to_string()))?,
333                    file_format: None,
334                    bitrate: None,
335                    sample_rate: None,
336                    is_hidden: rref.is_hidden,
337                    is_bonus: rref.is_bonus,
338                    is_explicit: false,
339                    is_instrumental: false,
340                })
341            })
342            .collect()
343    }
344
345    fn count_discs(refs: &[ReleaseResourceReference]) -> Option<usize> {
346        refs.iter()
347            .filter_map(|r| r.disc_number)
348            .max()
349            .map(|n| n as usize)
350    }
351
352    fn build_territories(included: &[String], excluded: &[String]) -> Vec<TerritoryInfo> {
353        let mut territories = Vec::new();
354
355        for code in included {
356            territories.push(TerritoryInfo {
357                code: code.clone(),
358                included: true,
359                start_date: None,
360                end_date: None,
361                distribution_channels: Vec::new(),
362            });
363        }
364
365        for code in excluded {
366            territories.push(TerritoryInfo {
367                code: code.clone(),
368                included: false,
369                start_date: None,
370                end_date: None,
371                distribution_channels: Vec::new(),
372            });
373        }
374
375        territories
376    }
377
378    fn build_price_tiers(terms: &DealTerms) -> Vec<PriceTier> {
379        let mut tiers = Vec::new();
380
381        for price in &terms.wholesale_price {
382            tiers.push(PriceTier {
383                tier_name: None,
384                price_type: PriceType::Wholesale,
385                price: price.clone(),
386                territory: price.territory.clone(),
387                start_date: terms.start_date,
388                end_date: terms.end_date,
389            });
390        }
391
392        for price in &terms.suggested_retail_price {
393            tiers.push(PriceTier {
394                tier_name: None,
395                price_type: PriceType::SuggestedRetail,
396                price: price.clone(),
397                territory: price.territory.clone(),
398                start_date: terms.start_date,
399                end_date: terms.end_date,
400            });
401        }
402
403        tiers
404    }
405}