import re from abc import abstractmethod from datetime import datetime from os import path from typing import Callable from lxml import etree from delivery_metadata.clients.direct_delivery.dms_encoding_profiles import ( ImageDmsEncodingProfile, ) from delivery_metadata.clients.ows_track.instant_grats import InstantGrats from delivery_metadata.constants import ( UNDETERMINED_LANGUAGE_CODE, USER_DEFINED, CommercialModelType, DeliveryType, DistributionFormatId, DownloadStreamRights, EditionType, InstrumentRole, ParticipantRole, ProductType, UseType, ) from delivery_metadata.constants.ddex import ( DDEX_SCHEMA_LOCATION, DDEX_XML_NAMESPACE, ORCHARD_NAMESPACE, ) from delivery_metadata.exceptions import ( DDEXPartyIDEmpty, EncodingProfileIncorrect, EncodingProfileNotFound, GenreMappingMissing, MissingTrackSpatialIsrc, PricingDataNotFound, ) from delivery_metadata.logic.metadata_formatters.ddex.ddex_ern_4_3 import ( doc_root, ) from delivery_metadata.logic.metadata_formatters.ddex.ddex_ern_4_3.mapping import ( DdexErn43Mapping, ) from delivery_metadata.logic.metadata_formatters.metadata_formatter import ( DeliveryMetadataFormatter, ) from delivery_metadata.models.delivery_rights import ( DeliveryRights, ) from delivery_metadata.models.schemas import ( CopyrightLine, CustomPricing, DeliveryMetadata, ImageTechnicalFileDetail, LocalizedMetadata, Participant, ParticipantFullnameLocalization, Product, TechnicalFileDetail, Track, ) from delivery_metadata.utils.datetime import seconds_to_iso8601_duration from delivery_metadata.utils.md5 import calculate_md5 E = doc_root.element_maker class DdexErn43Formatter(DeliveryMetadataFormatter): def __init__( self, delivery_metadata: DeliveryMetadata, delivery_rights: DeliveryRights, ): super().__init__(delivery_metadata, delivery_rights) self.mappings = DdexErn43Mapping() @property def media_type(self) -> str: return "application/xml" def generate_new_release_message(self) -> etree._Element: new_release_message = E.NewReleaseMessage( self._generate_message_header(), self._generate_party_list(), self._generate_resource_list(), self._generate_release_list(), *self._generate_deal_list(), {"{" + DDEX_XML_NAMESPACE + "}schemaLocation": DDEX_SCHEMA_LOCATION}, LanguageAndScriptCode=self._get_language(), AvsVersionId="5", ) # remove namespace from all tags except root tag for element in new_release_message.getiterator(): local_name = etree.QName(element).localname if local_name == "NewReleaseMessage": continue element.tag = local_name etree.cleanup_namespaces(new_release_message) return new_release_message def format(self) -> str: new_release_message = self.generate_new_release_message() current_file_path: str = path.realpath(__file__) current_directory = path.dirname(current_file_path) file_path = path.join(current_directory, "ddex_ern_4_3.xsd") return self._schema_validate(new_release_message, file_path) def _generate_message_header(self) -> etree._Element: """Generate MessageHeader element.""" metadata = self.delivery_metadata message_id = ( f"{metadata.product.product_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}" ) message_creation_date = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") return E.MessageHeader( E.MessageThreadId(str(metadata.product.product_id)), E.MessageId(message_id), E.MessageFileName(self._get_ddex_xml_filename()), self._generate_messaging_party( E.MessageSender, metadata.delivery.store.sender_ddex_party_id, metadata.delivery.store.sender_ddex_party_name, ), self._generate_messaging_party( E.MessageRecipient, metadata.delivery.store.recipient_ddex_party_id, metadata.delivery.store.recipient_ddex_party_name, ), E.MessageCreatedDateTime(message_creation_date), ) def _generate_messaging_party( self, messaging_party_root: Callable[..., etree._Element], party_id: str | None, party_name: str, ) -> etree._Element: """Generate a messaging party element. Args: messaging_party_root (functools.partial): ElementMaker.__call__ method. E.MessageSender | E.MessageRecipient party_id (str): Party ID party_name (str): Party name """ if not party_id: raise DDEXPartyIDEmpty("party_id required for DDEX formatter.") return messaging_party_root( E.PartyId(party_id), E.PartyName( E.FullName(party_name), ), ) def _generate_party_list(self) -> etree._Element: metadata = self.delivery_metadata # Flatten list of participants into one list participants = self._get_all_participants_for_party_list() if not self.delivery_metadata.delivery.store.is_user_defined_contributors_supported: participants = self._filter_user_defined_roles(participants) return E.PartyList( E.Party( E.PartyReference(self._get_party_id_imprint(metadata.product)), E.PartyName( E.FullName(metadata.product.imprint), ), ), *[ self._generate_party_elements( participant, metadata.delivery.delivery_type, metadata.delivery.store.product_provided_store_artists, ) for participant in participants ], ) def _generate_localized_party_name_tags( self, participant_fullname_localizations: frozenset[ParticipantFullnameLocalization] | None, ) -> list[etree._Element]: return ( [ E.PartyName( E.FullName(participant_fullname_localization.localized_fullname), LanguageAndScriptCode=participant_fullname_localization.language_code, ) for participant_fullname_localization in sorted( participant_fullname_localizations, key=lambda participant_fullname_localization: ( participant_fullname_localization.language_code ), ) if participant_fullname_localization.localized_fullname ] if self.delivery_metadata.delivery.store.is_localization_supported and participant_fullname_localizations else [] ) def _generate_party_elements( self, participant: Participant, delivery_type: DeliveryType, product_provided_artists: str | None, ) -> etree._Element: """Generate Party section elements.""" return E.Party( E.PartyReference(self._get_party_id_participant(participant)), E.PartyName( E.FullName(participant.fullname), ), *self._generate_localized_party_name_tags( participant.fullname_localizations ), ) @abstractmethod def _generate_resource_list(self) -> etree._Element: ... def _generate_track_resource_element( self, track_index: int, track: Track, track_element: Callable[..., etree._Element], track_edition_element: Callable[..., etree._Element], track_type: str, ) -> etree._Element: spatial_edition: list[etree._Element] = [] if ( track.has_spatial_assets and self.delivery_metadata.delivery.store.has_atmos_profile ): if not track.spatial_isrc: raise MissingTrackSpatialIsrc( "Spatial ISRC is required for immersive track" ) spatial_edition = [ self._generate_track_resource_edition( track_edition_element, track, track_index, track.spatial_isrc, EditionType.IMMERSIVE_EDITION, is_spatial=True, ), ] return track_element( E.ResourceReference(f"A{track_index}"), E.Type(track_type), self._generate_track_resource_edition( track_edition_element, track, track_index, track.isrc, EditionType.NON_IMMERSIVE_EDITION, ), *spatial_edition, E.DisplayTitleText( f"{track.track_name} ({track.track_version})" if track.track_version else track.track_name ), E.DisplayTitle( E.TitleText(track.track_name), *([E.SubTitle(track.track_version)] if track.track_version else []), ), *self._generate_localized_tags(track.localized_metadata), E.DisplayArtistName( self._format_display_artist_name( self._get_display_artists(track.participants) ), ), *self._generate_display_artist_node( self._get_display_artists(track.participants) ), *self._generate_contributor_node( self._get_contributors(track.participants) ), E.Duration( seconds_to_iso8601_duration(track.asset_duration_seconds), ), E.ParentalWarningType( self.mappings.lyrics_explicitness_to_parental_warning_type[ track.lyrics_explicitness ], ), *self._generate_related_release_element(), *self._generate_language_of_performance_element( track.language_of_performance_ietf_rfc_5646_code ), ) def _generate_track_resource_edition( self, track_edition_element: Callable[..., etree._Element], track: Track, track_index: int, track_isrc: str, edition_type: EditionType, is_spatial: bool = False, ) -> etree._Element: return track_edition_element( E.Type(edition_type.value), E.ResourceId( E.ISRC(track_isrc), ), E.PLine( *([E.Year(track.p_line.year)] if track.p_line.year else []), E.PLineText(f"(P) {track.p_line.text}"), ), *self._generate_track_technical_detail( track_index=track_index, track=track, is_spatial=is_spatial, ), ) def _generate_language_of_performance_element( self, language: str | None ) -> list[etree._Element]: if not language: return [] return [E.LanguageOfPerformance(language)] @abstractmethod def _generate_track_resource_elements(self) -> list[etree._Element]: ... @abstractmethod def _generate_track_technical_detail( self, track_index: int, track: Track, is_spatial: bool = False ) -> list[etree._Element]: ... def _generate_image_elements(self) -> list[etree._Element]: metadata = self.delivery_metadata if not metadata.image_file_details: return [] return [ self._generate_image_element( resource_reference_index=file_index, image_type="FrontCoverImage", proprietary_id=f"IMAGE_COVER_{metadata.product.upc}", namespace=ORCHARD_NAMESPACE, file_detail=file_detail, ) for file_index, file_detail in enumerate( metadata.image_file_details, start=(metadata.product.track_count + 1) ) ] def _generate_image_element( self, resource_reference_index: int, image_type: str, proprietary_id: str, namespace: str, file_detail: ImageTechnicalFileDetail, ) -> etree._Element: return E.Image( E.ResourceReference(f"A{resource_reference_index}"), E.Type(image_type), E.ResourceId( E.ProprietaryId(proprietary_id, Namespace=namespace), ), *self._generate_image_technical_details( resource_reference_index, file_detail, ), ) def _generate_image_technical_details( self, resource_reference_number: int, file_detail: ImageTechnicalFileDetail ) -> list[etree._Element]: if self.delivery_metadata.delivery.is_metadata_update: return [] return [ E.TechnicalDetails( E.TechnicalResourceDetailsReference(f"T{resource_reference_number}"), E.ImageCodecType( self.mappings.image_codec_type_mapping[file_detail.format] ), *self._generate_image_dimension_height(file_detail.image_height), *self._generate_image_dimension_width(file_detail.image_width), self._generate_file_element(file_detail), E.IsProvidedInDelivery("true"), ), ] def _generate_image_dimension_width( self, image_width: int | None ) -> list[etree._Element]: return ( [E.ImageWidth(str(image_width), UnitOfMeasure="Pixel")] if image_width else [] ) def _generate_image_dimension_height( self, image_height: int | None ) -> list[etree._Element]: return ( [E.ImageHeight(str(image_height), UnitOfMeasure="Pixel")] if image_height else [] ) def _generate_file_element( self, file_detail: TechnicalFileDetail ) -> etree._Element: file_node = E.File( E.URI(self._get_file_uri(file_detail)), ) if file_detail.placeholder_hash: file_node.append( E.HashSum( E.Algorithm(file_detail.hash_algorithm), E.HashSumValue(file_detail.placeholder_hash), ) ) return file_node def _generate_audio_bit_rate( self, audio_bitrate: int | None ) -> list[etree._Element]: return ( [E.BitRate(str(audio_bitrate), UnitOfMeasure="bps")] if audio_bitrate else [] ) def _generate_audio_number_of_channels( self, audio_number_of_channels: int | None ) -> list[etree._Element]: return ( [E.NumberOfChannels(str(audio_number_of_channels))] if audio_number_of_channels else [] ) def _generate_audio_sampling_rate( self, audio_sampling_rate: int | None ) -> list[etree._Element]: return ( [E.SamplingRate(str(audio_sampling_rate), UnitOfMeasure="Hz")] if audio_sampling_rate else [] ) def _generate_audio_bits_per_sample( self, audio_bits_per_sample: int | None ) -> list[etree._Element]: return ( [E.BitsPerSample(str(audio_bits_per_sample))] if audio_bits_per_sample else [] ) def _generate_clip_details_node( self, track_index: int, track: Track, profile_format: str, is_clip_profile: bool ) -> list[etree._Element]: if is_clip_profile: return [] return [ E.ClipDetails( E.TechnicalResourceDetailsReference( f"T{track_index}{profile_format.upper()}C" ), E.ClipType("Preview"), E.Timing( E.StartPoint(str(track.preview_start_time_seconds)), E.DurationUsed( seconds_to_iso8601_duration( min( track.duration_seconds - track.preview_start_time_seconds, 30, ) ) ), ), E.ExpressionType("Instructive"), ) ] def _generate_genre_node(self, product: Product) -> etree._Element: if not product.genre_mappings: return E.Genre( E.GenreText(product.genre_name), *self._generate_subgenre_node(product.subgenre_name), ) genre_mapping = next( ( mapping for mapping in product.genre_mappings if mapping.subgenre_id == product.subgenre_id ), None, ) if not genre_mapping: raise GenreMappingMissing( f"{product.genre_name}/{product.subgenre_name} not mapped" ) genre = ( genre_mapping.genre_code if genre_mapping.genre_code else genre_mapping.genre ) subgenre = ( genre_mapping.subgenre_code if genre_mapping.subgenre_code else genre_mapping.subgenre ) return E.Genre( E.GenreText(genre), *self._generate_subgenre_node(subgenre), ) def _generate_subgenre_node(self, subgenre: str | None) -> list[etree._Element]: if not subgenre: return [] return [E.SubGenre(subgenre)] def _generate_display_artist_node( self, participants: list[Participant], ) -> list[etree._Element]: return [ E.DisplayArtist( E.ArtistPartyReference(self._get_party_id_participant(participant)), self._generate_display_artist_role_node( self._get_preferred_display_artist_role(participant.roles) ), *self._generate_artistic_roles_for_display_artist(participant.roles), SequenceNumber=str(index), ) for index, participant in enumerate(participants, start=1) ] def _generate_display_artist_role_node( self, artist_role: ParticipantRole ) -> etree._Element: ddex_role = self.mappings.artist_role_mapping.get(artist_role) if not ddex_role: return E.DisplayArtistRole( USER_DEFINED, Namespace=ORCHARD_NAMESPACE, UserDefinedValue=artist_role.value.title(), ) else: return E.DisplayArtistRole(ddex_role) def _generate_artistic_roles_for_display_artist( self, roles: set[ParticipantRole] ) -> list[etree._Element]: artistic_roles = list(roles.intersection(self._get_contributor_roles())) # Render remaining main artist role(s) not in # DisplayArtistRole tag as ArtisticRole tags display_artist_role = self._get_preferred_display_artist_role(roles) main_artist_roles = list(roles.intersection(self._get_main_artist_roles())) # Remove the DisplayArtistRole from main artist roles list. if display_artist_role in main_artist_roles: main_artist_roles.remove(display_artist_role) # Add remaining main roles to ArtisticRole list if main_artist_roles: artistic_roles.extend(main_artist_roles) # Sort by role priority, then alphabetically artistic_roles.sort( key=lambda r: (self._get_artist_role_priority_for_roles(r), r.value) ) # map DDEX values to roles mapped_roles = [ (artist_role, self.mappings.contributor_role_mapping.get(artist_role)) for artist_role in artistic_roles ] # deduplicate roles while preserving if user defined or not filtered_roles = { ddex_role or role.value: ddex_role is None for (role, ddex_role) in mapped_roles } return [ self._generate_artistic_role_node(role_name, user_defined) for role_name, user_defined in filtered_roles.items() ] def _generate_artistic_role_node( self, role_name: str, user_defined: bool ) -> etree._Element: if user_defined: return E.ArtisticRole( USER_DEFINED, Namespace=ORCHARD_NAMESPACE, UserDefinedValue=role_name.title(), ) return E.ArtisticRole(role_name) def _generate_contributor_node( self, participants: list[Participant], ) -> list[etree._Element]: if not self.delivery_metadata.delivery.store.is_user_defined_contributors_supported: participants = self._filter_user_defined_roles(participants) return [ E.Contributor( E.ContributorPartyReference( self._get_party_id_participant(participant) ), *[ self._generate_contributor_role_node(artist_role) for artist_role in sorted( participant.roles.intersection(self._get_contributor_roles()), key=lambda role: role.value, ) if participant.roles.issubset(self._get_contributor_roles()) ], *self._generate_instrument_type_nodes(participant.instruments), SequenceNumber=str(index), ) for index, participant in enumerate(participants, start=1) ] def _generate_contributor_role_node( self, contributor_role: ParticipantRole ) -> etree._Element: # map orchard contributor role to DDEX value ddex_role = self.mappings.contributor_role_mapping.get(contributor_role) if not ddex_role: return E.Role( USER_DEFINED, Namespace=ORCHARD_NAMESPACE, UserDefinedValue=contributor_role.value.title(), ) else: return E.Role(ddex_role) def _generate_instrument_type_nodes( self, instruments: set[InstrumentRole] ) -> list[etree._Element]: return [ self._generate_instrument_type_node(instrument_role) for instrument_role in sorted( instruments, key=lambda role: role.value, ) ] def _generate_instrument_type_node( self, instrument_role: InstrumentRole ) -> etree._Element: ddex_role = self.mappings.instrument_role_mapping.get(instrument_role) if not ddex_role: role = instrument_role.value.title() # ************** Temporary patch for AVS 9 values until we officially upgrade if instrument_role in [ InstrumentRole.BRASS_HORN, InstrumentRole.ELECTRONICS_SAMPLER, InstrumentRole.ELECTRONICS_THEREMIN, InstrumentRole.GUITAR_BASS_GUITAR, InstrumentRole.KEYBOARDS_HARMONIUM, InstrumentRole.PERCUSSION_BELLS, InstrumentRole.PERCUSSION_DRUMS, ]: role = re.sub( r"^(Brass|Electronics|Guitar|Keyboards|Percussion) - ", "", role, ).replace(" ", "") # / ************** return E.InstrumentType( USER_DEFINED, Namespace=ORCHARD_NAMESPACE, UserDefinedValue=role, ) else: return E.InstrumentType(ddex_role) def _generate_release_p_line( self, release_pline: CopyrightLine | None, track_pline: CopyrightLine ) -> etree._Element: p_line = release_pline if release_pline else track_pline return E.PLine( *([E.Year(p_line.year)] if p_line.year else []), E.PLineText(f"(P) {p_line.text}"), ) def _generate_localized_tags( self, localized_metadata: list[LocalizedMetadata] | None ) -> list[etree._Element]: return ( [ E.DisplayTitle( E.TitleText( localized_data.localized_name if localized_data.localized_name else "" ), *self._generate_subtitle_node(localized_data.localized_version), LanguageAndScriptCode=(localized_data.language_code), ) for localized_data in localized_metadata ] if self.delivery_metadata.delivery.store.is_localization_supported and localized_metadata else [] ) def _generate_release_list(self) -> etree._Element: product_meta = self.delivery_metadata.product return E.ReleaseList( E.Release( E.ReleaseReference("R0"), self._generate_release_type_node(), E.ReleaseId( *self._generate_release_grid_node(product_meta.release_grid), E.ICPN(product_meta.display_upc), *self._generate_catalog_number_node(product_meta), ), E.DisplayTitleText(self._get_display_title(product_meta.product_name)), E.DisplayTitle( E.TitleText(self._get_display_title(product_meta.product_name)), *self._generate_subtitle_node(product_meta.delivered_version), ), *self._generate_localized_tags(product_meta.localized_metadata), E.DisplayArtistName( self._format_display_artist_name( self._get_release_display_artists() + self._get_release_non_primary_display_artists() ), ), *self._generate_display_artist_node( self._get_release_display_artists() + self._get_release_non_primary_display_artists() ), E.ReleaseLabelReference(self._get_party_id_imprint(product_meta)), self._generate_release_p_line( product_meta.p_line, product_meta.first_track.p_line ), E.CLine( *( [E.Year(product_meta.c_line.year)] if product_meta.c_line.year else [] ), E.CLineText(f"(C) {product_meta.c_line.text}"), ), self._generate_genre_node(product_meta), E.OriginalReleaseDate( product_meta.original_release_date if product_meta.original_release_date else product_meta.release_date ), E.ParentalWarningType( self.mappings.lyrics_explicitness_to_parental_warning_type[ product_meta.lyrics_explicitness ], ), *self._generate_ismultiartistcompilation_element(), self._generate_track_resource_group(), ), *self._generate_track_release(), ) def _generate_ismultiartistcompilation_element(self) -> list[etree._Element]: return [] @abstractmethod def _generate_track_release(self) -> list[etree._Element]: ... def _generate_track_release_elements( self, tracks: list[Track], start_index: int = 1 ) -> list[etree._Element]: product_meta = self.delivery_metadata.product return [ E.TrackRelease( E.ReleaseReference(f"R{track_index}"), E.ReleaseId( E.ProprietaryId( f"{track.upc}_{track.isrc}", Namespace=ORCHARD_NAMESPACE ), ), E.DisplayTitleText(track.track_name), E.DisplayTitle( E.TitleText(track.track_name), *self._generate_subtitle_node(track.track_version), ), *self._generate_localized_tags(track.localized_metadata), E.ReleaseResourceReference(f"A{track_index}"), E.ReleaseLabelReference(self._get_party_id_imprint(product_meta)), self._generate_genre_node(product_meta), IsMainRelease="false", ) for track_index, track in enumerate(tracks, start=start_index) ] def _generate_release_type_node(self) -> etree._Element: product_type = self._get_product_type() release_type = self.mappings.release_type_mapping.get(product_type) if release_type: return E.ReleaseType(release_type) else: return E.ReleaseType( USER_DEFINED, UserDefinedValue=product_type.value, ) def _generate_release_grid_node(self, grid: str | None) -> list[etree._Element]: if not grid: return [] return [E.GRid(grid)] def _generate_subtitle_node(self, subtitle: str | None) -> list[etree._Element]: if not subtitle: return [] return [E.SubTitle(subtitle)] def _generate_catalog_number_node(self, product: Product) -> list[etree._Element]: catalog_number = None if product.product_code and ( product.distribution_format_id == DistributionFormatId.DIGITAL_AUDIO ): catalog_number = product.product_code else: if self._show_sony_gras_tags() and product.release_sony_product_no: catalog_number = product.release_sony_product_no elif not self._show_sony_gras_tags() and product.vendor_catalog_number: catalog_number = product.vendor_catalog_number if not catalog_number: return [] return [ E.CatalogNumber( catalog_number, Namespace=ORCHARD_NAMESPACE, ) ] @abstractmethod def _generate_track_resource_group(self) -> etree._Element: ... def _generate_image_resource_group( self, resource_group_node: etree._Element ) -> etree._Element: return self._generate_image_resource_group_content_items( resource_group_node, self.delivery_metadata.product.track_count + 1, self.delivery_metadata.delivery.store.image_encoding_profiles, ) def _generate_resource_group_content( self, grouped_tracks: dict[int, list[Track]], resource_group_node: etree._Element, resource_ref_number: int = 0, ) -> etree._Element: for cd, tracks in grouped_tracks.items(): sub_resource_group_node = E.ResourceGroup( E.DisplayTitleText(f"Component {cd}"), E.DisplayTitle( E.TitleText(f"Component {cd}"), ), E.SequenceNumber(str(cd)), ResourceGroupType="Component", ) for track_index, track in enumerate(tracks, start=1): resource_ref_number = resource_ref_number + 1 sub_resource_group_node.append( E.ResourceGroupContentItem( E.SequenceNumber(str(track_index)), E.ReleaseResourceReference(f"A{resource_ref_number}"), *self._generate_instant_grats_tag(track.instant_grats), ), ) resource_group_node.append(sub_resource_group_node) return resource_group_node def _generate_instant_grats_tag( self, instant_grats: InstantGrats | None ) -> list[etree._Element]: return [E.IsInstantGratificationResource("true")] if instant_grats else [] def _generate_image_resource_group_content_items( self, resource_group_node: etree._Element, start_index: int, image_encoding_profiles: list[ImageDmsEncodingProfile] | None, ) -> etree._Element: if not image_encoding_profiles: return resource_group_node for index in range(start_index, start_index + len(image_encoding_profiles)): resource_group_node.append( E.ResourceGroupContentItem( E.ReleaseResourceReference(f"A{index}"), ) ) return resource_group_node def _generate_related_release_element(self) -> list[etree._Element]: return [] def _show_sony_gras_tags(self) -> bool: # todo import logic from ows-metadata # set Sony gras label data for when this is true return self.delivery_metadata.delivery.store.is_sony_gras_store def _get_language(self) -> str: return ( self.delivery_metadata.product.metadata_language_ietf_rfc_5646_code or UNDETERMINED_LANGUAGE_CODE ) def _get_display_title(self, product_name: str) -> str: # can be overridden by store class # there's an override logic in ows-metadata for YT content ID 569 # # if (self::YOUTUBE_ART_TRACK_STORE_ID == $this->getStoreDmsID()) { # $artistInfoArray = $this->fetchArtists($trackInfo); # $performerArtists = array(); # $featuredArtists = array(); # foreach ($artistInfoArray as $artistInfo) { # if (in_array('performer', $artistInfo->role)) { # $performerArtists[] = $artistInfo->name; # } # if (in_array('featuring', $artistInfo->role)) { # $featuredArtists[] = $artistInfo->name; # } # } # $performers = !empty($performerArtists) ? implode(', ', $performerArtists) . ' - ' : ''; # $featured = !empty($featuredArtists) ? ' (ft. ' . implode(', ', $featuredArtists) . ')' : ''; # return $performers . $titleText . $featured; # } else { return product_name def _get_preferred_display_artist_role( self, roles: set[ParticipantRole] ) -> ParticipantRole: main_artist_roles = list(roles.intersection(self._get_main_artist_roles())) main_artist_roles.sort( key=self._get_artist_role_priority_for_roles, reverse=True ) return main_artist_roles.pop() def _get_product_type(self) -> ProductType: return self.delivery_metadata.product.product_type def _get_all_participants_for_party_list(self) -> list[Participant]: """Generate a flattened list of all parties in release.""" all_participants: dict[str, Participant] = {} # Add release-level participants for participant in self._get_release_display_artists(): all_participants.update({participant.fullname: participant}) for participant in self._get_release_non_primary_display_artists(): all_participants.update({participant.fullname: participant}) # Add track-level participants self._update_party_list_with_all_track_participants(all_participants) participants = [participant for key, participant in all_participants.items()] participants.sort(key=self._get_artist_role_priority) return participants def _update_party_list_with_all_track_participants( self, all_participants: dict[str, Participant] ) -> None: for track in self.delivery_metadata.product.tracks: for participant in track.participants: all_participants.update({participant.fullname: participant}) def _generate_deal_list(self) -> list[etree._Element]: """Generate deal list.""" # Takedown; don't render a DealList tag if not self.delivery_metadata.delivery.allowed_territories: return [] if not self.delivery_metadata.product.distribution_rights: return [] return [ E.DealList( self._generate_release_deal_tag(0), *self._generate_track_release_deal(), ) ] @abstractmethod def _generate_track_release_deal(self) -> list[etree._Element]: ... def _generate_release_deal_tag( self, deal_release_reference_count: int, track: Track | None = None, ) -> etree._Element: """Generate DealReleaseReference tag.""" deal_tags = self._generate_deal_tags(track) return E.ReleaseDeal( E.DealReleaseReference(f"R{deal_release_reference_count}"), *deal_tags, ) def _generate_deal_tags( self, track: Track | None = None, ) -> list[etree._Element]: delivery_rights = ( self.delivery_rights.track_rights[track.track_id] if track else self.delivery_rights.product_rights ) return [ E.Deal( E.DealTerms( *[E.TerritoryCode(territory) for territory in rights.territories], self._generate_validity_period_tag( rights.start_date, rights.end_date, ), E.CommercialModelType( self.mappings.commercial_model_type_mapping[ commercial_model_type ] ), *( self._generate_use_type_tag(use_type) for use_type in sorted(use_types) ), *( self._generate_price_information_tag( rights.custom_price, rights.price_code ) if UseType.PERMANENT_DOWNLOAD in use_types and CommercialModelType.PAY == commercial_model_type else [] ), *([E.IsPreOrderDeal("true")] if rights.is_preorder else []), *( self._generate_instant_grat_list_tag( rights.instant_gratification_tracks ) ), ) ) for rights in delivery_rights for ( commercial_model_type, use_types, ) in self._get_commercial_models_by_distribution_rights( rights.distribution_rights ).items() if not ( commercial_model_type != CommercialModelType.PAY and rights.is_preorder ) ] def _generate_instant_grat_list_tag( self, instant_grat_tracks: set[int] | None ) -> list[etree._Element]: return ( [ E.InstantGratificationResourceList( *[ E.DealResourceReference(f"A{track_index}") for track_index, track in enumerate( self.delivery_metadata.product.tracks, start=1 ) for tuid in instant_grat_tracks if track.track_id == tuid ] ) ] if instant_grat_tracks else [] ) def _generate_use_type_tag(self, use_type: UseType) -> etree._Element: return ( E.UseType(self.mappings.use_type_mapping[use_type]) if self.mappings.use_type_mapping.get(use_type) else E.UseType("UserDefined", UserDefinedValue=use_type.value) ) def _generate_price_information_tag( self, custom_price: CustomPricing | None, price_code: str | None, ) -> list[etree._Element]: if not custom_price and not price_code: return [] if custom_price: return [ E.PriceInformation( E.WholesalePricePerUnit( custom_price.custom_price, CurrencyCode=custom_price.custom_currency_code, ) ) ] elif price_code: return [ E.PriceInformation(E.PriceCode(price_code, Namespace=ORCHARD_NAMESPACE)) ] return [] def _generate_validity_period_tag( self, start_date: datetime, end_date: datetime | None ) -> etree._Element: # Specify UTC for timed release dates timed_release_format = "%Y-%m-%dT%H:%M:%SZ" regular_format = "%Y-%m-%dT%H:%M:%S" start_date_format = ( timed_release_format if ( self.delivery_metadata.product.timed_release_datetime and start_date == datetime.strptime( self.delivery_metadata.product.timed_release_datetime, timed_release_format, ) ) else regular_format ) end_date_format = ( timed_release_format if ( self.delivery_metadata.product.timed_release_datetime and end_date and end_date == datetime.strptime( self.delivery_metadata.product.timed_release_datetime, timed_release_format, ) ) else regular_format ) return E.ValidityPeriod( E.StartDateTime(start_date.strftime(start_date_format)), *([E.EndDateTime(end_date.strftime(end_date_format))] if end_date else []), ) def _get_commercial_models_by_distribution_rights( self, distribution_rights: set[DownloadStreamRights] ) -> dict[CommercialModelType, set[UseType]]: ddex_commercial_model_to_use_type_map: dict[ CommercialModelType, set[UseType], ] = {} for distro_rights in sorted(distribution_rights): for ( commercial_model, use_types, ) in self.delivery_metadata.delivery.store.ddex_commercial_model_to_use_type_map[ distro_rights ].items(): ddex_commercial_model_to_use_type_map.setdefault( commercial_model, set() ).update(use_types) return ddex_commercial_model_to_use_type_map def _get_ddex_xml_filename(self) -> str: ddex_xml_technical_detail = self._get_ddex_xml_technical_detail() xml_file_path, xml_file_name = ddex_xml_technical_detail.full_file_path_and_name return xml_file_name def _get_ddex_xml_technical_detail(self) -> TechnicalFileDetail: for file_detail in self.delivery_metadata.metadata_file_details: if file_detail.profile_subtype == "Ddex XML": return file_detail raise EncodingProfileNotFound( f"DDEX XML profile missing for store " f"{self.delivery_metadata.delivery.store.store_id}" ) def _format_territories_data(self) -> dict[str, list[str]]: territories_with_start_date = ( self.delivery_metadata.territory_codes_with_start_date ) territories_dict: dict[str, list[str]] = {} for key, value in sorted(territories_with_start_date.items()): if value in territories_dict: territories_dict[value].append(key) else: territories_dict[value] = [key] return territories_dict def _format_pricing_and_territories_data( self, track: Track | None = None ) -> dict[str, dict[str | CustomPricing, set[str]]]: territories_with_start_date = ( self.delivery_metadata.territory_codes_with_start_date ) territory_pricing = ( track.territory_pricing_dict if track else self.delivery_metadata.product.territory_pricing_dict ) territories_dict: dict[str, dict[str | CustomPricing, set[str]]] = {} # key = country, value = date for key, value in territories_with_start_date.items(): price = territory_pricing.get(key) if not price: # TODO - raise exception? do this earlier on in datasourcing? raise PricingDataNotFound(f"Pricing data not found for {key}") territories_dict.setdefault(value, {}).setdefault(price, set()).add(key) # returns a dict in format {date: price: {territories}}} return territories_dict def _get_technical_resource_detail_reference( self, track_index: int, profile_format: str, clip_length: int | None ) -> str: return "T{track_index}{profile_format}{clip}".format( track_index=track_index, profile_format=profile_format.upper(), clip=("CLIP" if clip_length else ""), ) def _get_party_id_imprint(self, product: Product) -> str: return f"P_{calculate_md5(product.imprint)}_I" def _get_party_id_participant(self, participant: Participant) -> str: return f"P_{calculate_md5(participant.fullname)}_P" def _get_file_uri(self, resource_file_detail: TechnicalFileDetail) -> str: ddex_xml_technical_detail = self._get_ddex_xml_technical_detail() xml_file_path, xml_file_name = ddex_xml_technical_detail.full_file_path_and_name self._check_ddex_profile_consistency(xml_file_path, resource_file_detail) resource_file_path, resource_file_name = ( resource_file_detail.full_file_path_and_name ) return "/".join( [ resource_file_path.replace(xml_file_path, "").strip("/"), resource_file_name, ] ) def _check_ddex_profile_consistency( self, xml_file_path: str, tech_detail: TechnicalFileDetail ) -> None: file_path, file_name = tech_detail.full_file_path_and_name if not file_path.startswith(xml_file_path): raise EncodingProfileIncorrect( f"{tech_detail.__class__.__name__.replace('TechnicalFileDetail', '')}" f" resource file path different from XML file path" ) def _filter_user_defined_roles( self, participants: list[Participant] ) -> list[Participant]: """ Filter out user-defined roles from participants. - Removes participants that only have user-defined roles - Keeps participants with at least one non-user-defined role - Removes user-defined roles from participants with mixed roles """ non_user_defined_contributors = [] for participant in participants: filtered_roles = { role for role in participant.roles if self.mappings.contributor_role_mapping.get(role) is not None } filtered_instruments = { instrument for instrument in participant.instruments if self.mappings.instrument_role_mapping.get(instrument) is not None } # Only keep participant if they have at least one non-user-defined role or instrument if filtered_roles or filtered_instruments: # Create new participant with filtered roles and instruments filtered_participant = Participant( fullname=participant.fullname, roles=filtered_roles, instruments=filtered_instruments, label_participant_id=participant.label_participant_id, apple_music_id=participant.apple_music_id, spotify_id=participant.spotify_id, spotify_artist_key=participant.spotify_artist_key, global_participant_id=participant.global_participant_id, fullname_localizations=participant.fullname_localizations, ) non_user_defined_contributors.append(filtered_participant) return non_user_defined_contributors