diff --git a/deezspot/deezloader/__download__.py b/deezspot/deezloader/__download__.py index 504d6e8..06c0ed8 100644 --- a/deezspot/deezloader/__download__.py +++ b/deezspot/deezloader/__download__.py @@ -983,6 +983,7 @@ class DW_ALBUM: # Report album initializing status album_name_for_report = self.__song_metadata.get('album', 'Unknown Album') total_tracks_for_report = self.__song_metadata.get('nb_tracks', 0) + album_link_for_report = self.__preferences.link # Get album link from preferences Download_JOB.report_progress({ "type": "album", @@ -990,7 +991,7 @@ class DW_ALBUM: "status": "initializing", "total_tracks": total_tracks_for_report, "title": album_name_for_report, - "url": f"https://deezer.com/album/{self.__ids}" + "url": album_link_for_report # Use the actual album link }) infos_dw = API_GW.get_album_data(self.__ids)['data'] @@ -1024,67 +1025,99 @@ class DW_ALBUM: total_tracks = len(infos_dw) for a in range(total_tracks): track_number = a + 1 - c_infos_dw = infos_dw[a] + # c_infos_dw is from API_GW.get_album_data, used for SNG_ID, SNG_TITLE etc. + c_infos_dw_item = infos_dw[a] - # Retrieve the contributors info from the API response. - # It might be an empty list. - contributors = c_infos_dw.get('SNG_CONTRIBUTORS', {}) + # self.__song_metadata is the dict-of-lists from API.tracking_album + # We need to construct c_song_metadata_for_easydw for the current track 'a' + # by picking the ath element from each list in self.__song_metadata. - # Check if contributors is an empty list. - if isinstance(contributors, list) and not contributors: - # Flag indicating we do NOT have contributors data to process. - has_contributors = False - else: - has_contributors = True + current_track_constructed_metadata = {} + potential_error_marker = None + is_current_track_error = False - # If we have contributor data, build the artist and composer strings. - if has_contributors: - main_artist = "; ".join(contributors.get('main_artist', [])) - featuring = "; ".join(contributors.get('featuring', [])) + # Check the 'music' field first for an error dict from API.tracking_album + if 'music' in self.__song_metadata and isinstance(self.__song_metadata['music'], list) and len(self.__song_metadata['music']) > a: + music_field_value = self.__song_metadata['music'][a] + if isinstance(music_field_value, dict) and 'error_type' in music_field_value: + is_current_track_error = True + potential_error_marker = music_field_value + # The error marker dict itself will serve as the metadata for the failed track object + current_track_constructed_metadata = potential_error_marker + + if not is_current_track_error: + # Populate current_track_constructed_metadata from self.__song_metadata lists + for key, value_list_template in self.__song_metadata_items: # self.__song_metadata_items is items() of dict-of-lists + if isinstance(value_list_template, list): # e.g. self.__song_metadata['artist'] + if len(self.__song_metadata[key]) > a: + current_track_constructed_metadata[key] = self.__song_metadata[key][a] + else: + current_track_constructed_metadata[key] = "Unknown" # Fallback if list is too short + else: # Album-wide metadata (e.g. 'album', 'label') + current_track_constructed_metadata[key] = self.__song_metadata[key] - artist_parts = [main_artist] - if featuring: - artist_parts.append(f"(feat. {featuring})") - artist_str = " ".join(artist_parts) - composer_str = "; ".join(contributors.get('composer', [])) - - # Build the core track metadata. - # When there is no contributor info, we intentionally leave out the 'artist' - # and 'composer' keys so that the album-level metadata merge will supply them. - c_song_metadata = { - 'music': c_infos_dw.get('SNG_TITLE', 'Unknown'), - 'album': self.__song_metadata['album'], - 'date': c_infos_dw.get('DIGITAL_RELEASE_DATE', ''), - 'genre': self.__song_metadata.get('genre', 'Latin Music'), - 'tracknum': f"{track_number}", - 'discnum': f"{c_infos_dw.get('DISK_NUMBER', 1)}", - 'isrc': c_infos_dw.get('ISRC', ''), - 'album_artist': derived_album_artist_from_contributors, - 'publisher': 'CanZion R', - 'duration': int(c_infos_dw.get('DURATION', 0)), - 'explicit': '1' if c_infos_dw.get('EXPLICIT_LYRICS', '0') == '1' else '0' - } - - # Only add contributor-based metadata if available. - if has_contributors: - c_song_metadata['artist'] = artist_str - c_song_metadata['composer'] = composer_str + # Ensure essential fields from c_infos_dw_item are preferred or added if missing from API.tracking_album results + current_track_constructed_metadata['music'] = current_track_constructed_metadata.get('music') or c_infos_dw_item.get('SNG_TITLE', 'Unknown') + # artist might be complex due to contributors, rely on what API.tracking_album prepared + # current_track_constructed_metadata['artist'] = current_track_constructed_metadata.get('artist') # Already populated or None + current_track_constructed_metadata['tracknum'] = current_track_constructed_metadata.get('tracknum') or f"{track_number}" + current_track_constructed_metadata['discnum'] = current_track_constructed_metadata.get('discnum') or f"{c_infos_dw_item.get('DISK_NUMBER', 1)}" + current_track_constructed_metadata['isrc'] = current_track_constructed_metadata.get('isrc') or c_infos_dw_item.get('ISRC', '') + current_track_constructed_metadata['duration'] = current_track_constructed_metadata.get('duration') or int(c_infos_dw_item.get('DURATION', 0)) + current_track_constructed_metadata['explicit'] = current_track_constructed_metadata.get('explicit') or ('1' if c_infos_dw_item.get('EXPLICIT_LYRICS', '0') == '1' else '0') + current_track_constructed_metadata['album_artist'] = current_track_constructed_metadata.get('album_artist') or derived_album_artist_from_contributors + + if is_current_track_error: + error_type = potential_error_marker.get('error_type', 'UnknownError') + error_message = potential_error_marker.get('message', 'An unknown error occurred.') + track_name_for_log = potential_error_marker.get('name', c_infos_dw_item.get('SNG_TITLE', f'Track {track_number}')) + track_id_for_log = potential_error_marker.get('ids', c_infos_dw_item.get('SNG_ID')) + + # Construct market_info string based on actual checked_markets from error dict or preferences fallback + checked_markets_str = "" + if error_type == 'MarketAvailabilityError': + # Prefer checked_markets from the error dict if available + if 'checked_markets' in c_infos_dw_item and c_infos_dw_item['checked_markets']: + checked_markets_str = c_infos_dw_item['checked_markets'] + # Fallback to preferences.market if not in error dict (though it should be) + elif self.__preferences.market: + if isinstance(self.__preferences.market, list): + checked_markets_str = ", ".join([m.upper() for m in self.__preferences.market]) + elif isinstance(self.__preferences.market, str): + checked_markets_str = self.__preferences.market.upper() + market_log_info = f" (Market(s): {checked_markets_str})" if checked_markets_str else "" + + logger.warning(f"Skipping download of track '{track_name_for_log}' (ID: {track_id_for_log}) in album '{album.album_name}' due to {error_type}{market_log_info}: {error_message}") + + failed_track_link = f"https://deezer.com/track/{track_id_for_log}" if track_id_for_log else self.__preferences.link # Fallback to album link + + # current_track_constructed_metadata is already the error_marker dict + track = Track( + tags=current_track_constructed_metadata, + song_path=None, file_format=None, quality=None, + link=failed_track_link, + ids=track_id_for_log + ) + track.success = False + track.error_message = error_message + tracks.append(track) + # Optionally, report progress for this failed track within the album context here + continue # to the next track in the album - # No progress reporting here - done at the track level - # Merge album-level metadata (only add fields not already set in c_song_metadata) - for key, item in self.__song_metadata_items: - if key not in c_song_metadata: - if isinstance(item, list): - c_song_metadata[key] = self.__song_metadata[key][a] if len(self.__song_metadata[key]) > a else 'Unknown' - else: - c_song_metadata[key] = self.__song_metadata[key] + # This was the old logic, current_track_constructed_metadata should be fairly complete now or an error dict. + # for key, item in self.__song_metadata_items: + # if key not in current_track_constructed_metadata: + # if isinstance(item, list): + # current_track_constructed_metadata[key] = self.__song_metadata[key][a] if len(self.__song_metadata[key]) > a else 'Unknown' + # else: + # current_track_constructed_metadata[key] = self.__song_metadata[key] # Continue with the rest of your processing (media handling, download, etc.) - c_infos_dw['media_url'] = medias[a] + c_infos_dw_item['media_url'] = medias[a] # medias is from Download_JOB.check_sources(infos_dw, ...) c_preferences = deepcopy(self.__preferences) - c_preferences.song_metadata = c_song_metadata.copy() - c_preferences.ids = c_infos_dw['SNG_ID'] + c_preferences.song_metadata = current_track_constructed_metadata.copy() + c_preferences.ids = c_infos_dw_item['SNG_ID'] c_preferences.track_number = track_number # Add additional information for consistent parent info @@ -1093,22 +1126,41 @@ class DW_ALBUM: c_preferences.total_tracks = total_tracks c_preferences.link = f"https://deezer.com/track/{c_preferences.ids}" + current_track_object = None try: - track = EASY_DW(c_infos_dw, c_preferences, parent='album').download_try() - except TrackNotFound: - try: - song = f"{c_song_metadata['music']} - {c_song_metadata.get('artist', self.__song_metadata['artist'])}" - ids = API.not_found(song, c_song_metadata['music']) - c_infos_dw = API_GW.get_song_data(ids) - c_media = Download_JOB.check_sources([c_infos_dw], self.__quality_download) - c_infos_dw['media_url'] = c_media[0] - track = EASY_DW(c_infos_dw, c_preferences, parent='album').download_try() - except TrackNotFound: - track = Track(c_song_metadata, None, None, None, None, None) - track.success = False - track.error_message = f"Track not found after fallback attempt for: {song}" - logger.warning(f"Track not found: {song} :( Details: {track.error_message}. URL: {c_preferences.link if c_preferences else 'N/A'}") - tracks.append(track) + # This is where EASY_DW().easy_dw() or EASY_DW().download_try() is effectively called + current_track_object = EASY_DW(c_infos_dw_item, c_preferences, parent='album').easy_dw() + + except TrackNotFound as e_tnf: + logger.error(f"Track '{c_preferences.song_metadata.get('music', 'Unknown Track')}' by '{c_preferences.song_metadata.get('artist', 'Unknown Artist')}' (Album: {album.album_name}) failed: {str(e_tnf)}") + current_track_object = Track(c_preferences.song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + current_track_object.success = False + current_track_object.error_message = str(e_tnf) + except QualityNotFound as e_qnf: + logger.error(f"Quality issue for track '{c_preferences.song_metadata.get('music', 'Unknown Track')}' (Album: {album.album_name}): {str(e_qnf)}") + current_track_object = Track(c_preferences.song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + current_track_object.success = False + current_track_object.error_message = str(e_qnf) + except requests.exceptions.ConnectionError as e_conn: + logger.error(f"Connection error for track '{c_preferences.song_metadata.get('music', 'Unknown Track')}' (Album: {album.album_name}): {str(e_conn)}") + current_track_object = Track(c_preferences.song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + current_track_object.success = False + current_track_object.error_message = str(e_conn) # Store specific connection error + except Exception as e_general: # Catch any other unexpected error during this track's processing + logger.error(f"Unexpected error for track '{c_preferences.song_metadata.get('music', 'Unknown Track')}' (Album: {album.album_name}): {str(e_general)}") + current_track_object = Track(c_preferences.song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + current_track_object.success = False + current_track_object.error_message = str(e_general) + + if current_track_object: + tracks.append(current_track_object) + else: # Should not happen if exceptions are caught, but as a fallback + logger.error(f"Track object was not created for SNG_ID {c_infos_dw_item['SNG_ID']} in album {album.album_name}. Skipping.") + # Create a generic failed track to ensure list length matches expectation if needed elsewhere + failed_placeholder = Track(c_preferences.song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + failed_placeholder.success = False + failed_placeholder.error_message = "Track processing failed to produce a result object." + tracks.append(failed_placeholder) # Save album cover image if self.__preferences.save_cover and album.image and album_base_directory: @@ -1137,7 +1189,7 @@ class DW_ALBUM: "status": "done", "total_tracks": total_tracks, "title": album_name, - "url": f"https://deezer.com/album/{self.__ids}" + "url": album_link_for_report # Use the actual album link }) return album @@ -1198,37 +1250,117 @@ class DW_PLAYLIST: ) # Process each track - for idx, (c_infos_dw, c_media, c_song_metadata) in enumerate(zip(infos_dw, medias, self.__song_metadata), 1): + for idx, (c_infos_dw_item, c_media, c_song_metadata_item) in enumerate(zip(infos_dw, medias, self.__song_metadata), 1): - # Skip if song metadata is not valid - if type(c_song_metadata) is str: + # Skip if song metadata indicates an error (e.g., from market availability or NoDataApi) + if isinstance(c_song_metadata_item, dict) and 'error_type' in c_song_metadata_item: + track_name = c_song_metadata_item.get('name', 'Unknown Track') + track_ids = c_song_metadata_item.get('ids') + error_message = c_song_metadata_item.get('message', 'Unknown error.') + error_type = c_song_metadata_item.get('error_type', 'UnknownError') + # market_info = f" (Market: {self.__preferences.market})" if self.__preferences.market and error_type == 'MarketAvailabilityError' else "" + # Construct market_info string based on actual checked_markets from error dict or preferences fallback + checked_markets_str = "" + if error_type == 'MarketAvailabilityError': + # Prefer checked_markets from the error dict if available + if 'checked_markets' in c_song_metadata_item and c_song_metadata_item['checked_markets']: + checked_markets_str = c_song_metadata_item['checked_markets'] + # Fallback to preferences.market if not in error dict (though it should be) + elif self.__preferences.market: + if isinstance(self.__preferences.market, list): + checked_markets_str = ", ".join([m.upper() for m in self.__preferences.market]) + elif isinstance(self.__preferences.market, str): + checked_markets_str = self.__preferences.market.upper() + market_log_info = f" (Market(s): {checked_markets_str})" if checked_markets_str else "" + + logger.warning(f"Skipping download for track '{track_name}' (ID: {track_ids}) from playlist '{playlist_name_sanitized}' due to {error_type}{market_log_info}: {error_message}") + + failed_track_link = f"https://deezer.com/track/{track_ids}" if track_ids else self.__preferences.link # Fallback to playlist link + + # c_song_metadata_item is the error dict, use it as tags for the Track object + track = Track( + tags=c_song_metadata_item, + song_path=None, file_format=None, quality=None, + link=failed_track_link, + ids=track_ids + ) + track.success = False + track.error_message = error_message + tracks.append(track) + # Optionally, report progress for this failed track within the playlist context here + continue # Move to the next track in the playlist + + # Original check for string type, should be less common if API returns dicts for errors + if type(c_song_metadata_item) is str: + logger.warning(f"Track metadata is a string for a track in playlist '{playlist_name_sanitized}': '{c_song_metadata_item}'. Skipping.") + # Create a basic failed track object if metadata is just a string error + # This is a fallback, ideally c_song_metadata_item would be an error dict + error_placeholder_tags = {'name': 'Unknown Track (metadata error)', 'artist': 'Unknown Artist', 'error_type': 'StringError', 'message': c_song_metadata_item} + track = Track( + tags=error_placeholder_tags, + song_path=None, file_format=None, quality=None, + link=self.__preferences.link, # Playlist link + ids=None # No specific ID available from string error + ) + track.success = False + track.error_message = c_song_metadata_item + tracks.append(track) continue - c_infos_dw['media_url'] = c_media + c_infos_dw_item['media_url'] = c_media c_preferences = deepcopy(self.__preferences) - c_preferences.ids = c_infos_dw['SNG_ID'] - c_preferences.song_metadata = c_song_metadata + c_preferences.ids = c_infos_dw_item['SNG_ID'] + c_preferences.song_metadata = c_song_metadata_item # This is the full metadata dict for a successful track c_preferences.track_number = idx c_preferences.total_tracks = total_tracks # Download the track using the EASY_DW downloader - track = EASY_DW(c_infos_dw, c_preferences, parent='playlist').easy_dw() + # Wrap this in a try-except block to handle individual track failures + current_track_object = None + try: + current_track_object = EASY_DW(c_infos_dw_item, c_preferences, parent='playlist').easy_dw() + except TrackNotFound as e_tnf: + logger.error(f"Track '{c_preferences.song_metadata.get('music', 'Unknown Track')}' by '{c_preferences.song_metadata.get('artist', 'Unknown Artist')}' (Playlist: {playlist_name_sanitized}) failed: {str(e_tnf)}") + current_track_object = Track(c_preferences.song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + current_track_object.success = False + current_track_object.error_message = str(e_tnf) + except QualityNotFound as e_qnf: + logger.error(f"Quality issue for track '{c_preferences.song_metadata.get('music', 'Unknown Track')}' (Playlist: {playlist_name_sanitized}): {str(e_qnf)}") + current_track_object = Track(c_preferences.song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + current_track_object.success = False + current_track_object.error_message = str(e_qnf) + except requests.exceptions.ConnectionError as e_conn: # Catch connection errors specifically + logger.error(f"Connection error for track '{c_preferences.song_metadata.get('music', 'Unknown Track')}' (Playlist: {playlist_name_sanitized}): {str(e_conn)}") + current_track_object = Track(c_preferences.song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + current_track_object.success = False + current_track_object.error_message = str(e_conn) # Store specific connection error + except Exception as e_general: # Catch any other unexpected error during this track's processing + logger.error(f"Unexpected error for track '{c_preferences.song_metadata.get('music', 'Unknown Track')}' (Playlist: {playlist_name_sanitized}): {str(e_general)}") + current_track_object = Track(c_preferences.song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + current_track_object.success = False + current_track_object.error_message = str(e_general) # Track-level progress reporting is handled in EASY_DW - - # Only log a warning if the track failed and was NOT intentionally skipped - if not track.success and not getattr(track, 'was_skipped', False): - song = f"{c_song_metadata['music']} - {c_song_metadata['artist']}" - error_detail = getattr(track, 'error_message', 'Download failed for unspecified reason.') - logger.warning(f"Cannot download '{song}'. Reason: {error_detail} (Link: {track.link or c_preferences.link})") - - tracks.append(track) + if current_track_object: # Ensure a track object was created + tracks.append(current_track_object) + # Only log a warning if the track failed and was NOT intentionally skipped + if not current_track_object.success and not getattr(current_track_object, 'was_skipped', False): + # The error logging is now done within the except blocks above, more specifically. + pass # logger.warning(f"Cannot download '{song}'. Reason: {error_detail} (Link: {track.link or c_preferences.link})") + else: + # This case should ideally not be reached if exceptions are handled correctly. + logger.error(f"Track object was not created for SNG_ID {c_infos_dw_item['SNG_ID']} in playlist {playlist_name_sanitized}. Skipping.") + # Create a generic failed track to ensure list length matches expectation if needed elsewhere + failed_placeholder = Track(c_preferences.song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + failed_placeholder.success = False + failed_placeholder.error_message = "Track processing failed to produce a result object." + tracks.append(failed_placeholder) # --- Append the final track path to the m3u file --- # Build a relative path from the playlists directory - if track.success and hasattr(track, 'song_path') and track.song_path: + if current_track_object and current_track_object.success and hasattr(current_track_object, 'song_path') and current_track_object.song_path: relative_song_path = os.path.relpath( - track.song_path, + current_track_object.song_path, start=os.path.join(self.__output_dir, "playlists") ) with open(m3u_path, "a", encoding="utf-8") as m3u_file: diff --git a/deezspot/deezloader/__init__.py b/deezspot/deezloader/__init__.py index 1a32bca..c97dabe 100644 --- a/deezspot/deezloader/__init__.py +++ b/deezspot/deezloader/__init__.py @@ -26,12 +26,13 @@ from deezspot.exceptions import ( TrackNotFound, NoDataApi, AlbumNotFound, + MarketAvailabilityError, ) from deezspot.libutils.utils import ( create_zip, get_ids, link_is_valid, - what_kind, + what_kind ) from deezspot.libutils.others_settings import ( stock_output, @@ -40,8 +41,10 @@ from deezspot.libutils.others_settings import ( stock_not_interface, stock_zip, stock_save_cover, + stock_market ) from deezspot.libutils.logging_utils import ProgressReporter, logger +import requests API() @@ -105,14 +108,24 @@ class DeeLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market=stock_market ) -> Track: link_is_valid(link_track) ids = get_ids(link_track) + song_metadata = None + market_str = market + if isinstance(market, list): + market_str = ", ".join([m.upper() for m in market]) + elif isinstance(market, str): + market_str = market.upper() try: - song_metadata = API.tracking(ids) + song_metadata = API.tracking(ids, market=market) + except MarketAvailabilityError as e: + logger.error(f"Track {ids} is not available in market(s) '{market_str}'. Error: {e.message}") + raise TrackNotFound(url=link_track, message=e.message) from e except NoDataApi: infos = self.__gw_api.get_song_data(ids) @@ -120,7 +133,13 @@ class DeeLogin: raise TrackNotFound(link_track) ids = infos['FALLBACK']['SNG_ID'] - song_metadata = API.tracking(ids) + try: + song_metadata = API.tracking(ids, market=market) + except MarketAvailabilityError as e: + logger.error(f"Fallback track {ids} is not available in market(s) '{market_str}'. Error: {e.message}") + raise TrackNotFound(url=link_track, message=e.message) from e + except NoDataApi: + raise TrackNotFound(link_track) preferences = Preferences() preferences.link = link_track @@ -131,19 +150,16 @@ class DeeLogin: preferences.recursive_quality = recursive_quality preferences.recursive_download = recursive_download preferences.not_interface = not_interface - # New custom formatting preferences: preferences.custom_dir_format = custom_dir_format preferences.custom_track_format = custom_track_format - # Track number padding option preferences.pad_tracks = pad_tracks - # Retry parameters preferences.initial_retry_delay = initial_retry_delay preferences.retry_delay_increase = retry_delay_increase preferences.max_retries = max_retries - # Audio conversion parameter preferences.convert_to = convert_to preferences.bitrate = bitrate preferences.save_cover = save_cover + preferences.market = market track = DW_TRACK(preferences).dw() @@ -165,18 +181,25 @@ class DeeLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market=stock_market ) -> Album: link_is_valid(link_album) ids = get_ids(link_album) + album_json = None + market_str = market + if isinstance(market, list): + market_str = ", ".join([m.upper() for m in market]) + elif isinstance(market, str): + market_str = market.upper() try: album_json = API.get_album(ids) except NoDataApi: raise AlbumNotFound(link_album) - song_metadata = API.tracking_album(album_json) + song_metadata = API.tracking_album(album_json, market=market) preferences = Preferences() preferences.link = link_album @@ -189,19 +212,16 @@ class DeeLogin: preferences.recursive_download = recursive_download preferences.not_interface = not_interface preferences.make_zip = make_zip - # New custom formatting preferences: preferences.custom_dir_format = custom_dir_format preferences.custom_track_format = custom_track_format - # Track number padding option preferences.pad_tracks = pad_tracks - # Retry parameters preferences.initial_retry_delay = initial_retry_delay preferences.retry_delay_increase = retry_delay_increase preferences.max_retries = max_retries - # Audio conversion parameter preferences.convert_to = convert_to preferences.bitrate = bitrate preferences.save_cover = save_cover + preferences.market = market album = DW_ALBUM(preferences).dw() @@ -223,7 +243,8 @@ class DeeLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market=stock_market ) -> Playlist: link_is_valid(link_playlist) @@ -231,20 +252,93 @@ class DeeLogin: song_metadata = [] playlist_json = API.get_playlist(ids) + market_str_playlist = market + if isinstance(market, list): + market_str_playlist = ", ".join([m.upper() for m in market]) + elif isinstance(market, str): + market_str_playlist = market.upper() for track in playlist_json['tracks']['data']: c_ids = track['id'] + c_song_metadata_item = None + track_title_for_error = track.get('title', 'Unknown Track') + track_artist_for_error = track.get('artist', {}).get('name', 'Unknown Artist') try: - c_song_metadata = API.tracking(c_ids) + c_song_metadata_item = API.tracking(c_ids, market=market) + except MarketAvailabilityError as e: + logger.warning(f"Track '{track_title_for_error}' (ID: {c_ids}) in playlist not available in market(s) '{market_str_playlist}': {e.message}") + c_song_metadata_item = { + 'error_type': 'MarketAvailabilityError', + 'message': e.message, + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': c_ids, + 'checked_markets': market_str_playlist + } except NoDataApi: infos = self.__gw_api.get_song_data(c_ids) if not "FALLBACK" in infos: - c_song_metadata = f"{track['title']} - {track['artist']['name']}" + logger.warning(f"Track '{track_title_for_error}' (ID: {c_ids}) in playlist not found on Deezer and no fallback.") + c_song_metadata_item = { + 'error_type': 'NoDataApi', + 'message': f"Track {track_title_for_error} - {track_artist_for_error} (ID: {c_ids}) not found.", + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': c_ids + } else: - c_song_metadata = API.tracking(c_ids) - - song_metadata.append(c_song_metadata) + fallback_ids = infos['FALLBACK']['SNG_ID'] + try: + c_song_metadata_item = API.tracking(fallback_ids, market=market) + except MarketAvailabilityError as e_fallback: + logger.warning(f"Fallback track (Original ID: {c_ids}, Fallback ID: {fallback_ids}) for '{track_title_for_error}' in playlist not available in market(s) '{market_str_playlist}': {e_fallback.message}") + c_song_metadata_item = { + 'error_type': 'MarketAvailabilityError', + 'message': e_fallback.message, + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': fallback_ids, + 'checked_markets': market_str_playlist + } + except NoDataApi: + logger.warning(f"Fallback track (Original ID: {c_ids}, Fallback ID: {fallback_ids}) for '{track_title_for_error}' in playlist also not found on Deezer.") + c_song_metadata_item = { + 'error_type': 'NoDataApi', + 'message': f"Fallback for track {track_title_for_error} (ID: {fallback_ids}) also not found.", + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': fallback_ids + } + except requests.exceptions.ConnectionError as e_conn_fallback: + logger.warning(f"Connection error fetching metadata for fallback track (Original ID: {c_ids}, Fallback ID: {fallback_ids}) for '{track_title_for_error}' in playlist: {str(e_conn_fallback)}") + c_song_metadata_item = { + 'error_type': 'ConnectionError', + 'message': f"Connection error on fallback: {str(e_conn_fallback)}", + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': fallback_ids + } + except requests.exceptions.ConnectionError as e_conn: + logger.warning(f"Connection error fetching metadata for track '{track_title_for_error}' (ID: {c_ids}) in playlist: {str(e_conn)}") + c_song_metadata_item = { + 'error_type': 'ConnectionError', + 'message': f"Connection error: {str(e_conn)}", + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': c_ids + } + except Exception as e_other_metadata: + logger.warning(f"Unexpected error fetching metadata for track '{track_title_for_error}' (ID: {c_ids}) in playlist: {str(e_other_metadata)}") + c_song_metadata_item = { + 'error_type': 'MetadataError', + 'message': str(e_other_metadata), + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': c_ids + } + + song_metadata.append(c_song_metadata_item) preferences = Preferences() preferences.link = link_playlist @@ -257,19 +351,16 @@ class DeeLogin: preferences.recursive_download = recursive_download preferences.not_interface = not_interface preferences.make_zip = make_zip - # New custom formatting preferences: preferences.custom_dir_format = custom_dir_format preferences.custom_track_format = custom_track_format - # Track number padding option preferences.pad_tracks = pad_tracks - # Retry parameters preferences.initial_retry_delay = initial_retry_delay preferences.retry_delay_increase = retry_delay_increase preferences.max_retries = max_retries - # Audio conversion parameter preferences.convert_to = convert_to preferences.bitrate = bitrate preferences.save_cover = save_cover + preferences.market = market playlist = DW_PLAYLIST(preferences).dw() @@ -287,7 +378,8 @@ class DeeLogin: pad_tracks=True, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market=stock_market ) -> list[Track]: link_is_valid(link_artist) @@ -305,7 +397,8 @@ class DeeLogin: pad_tracks=pad_tracks, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) for track in playlist_json ] @@ -348,7 +441,8 @@ class DeeLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market=stock_market ) -> Track: track_link_dee = self.convert_spoty_to_dee_link_track(link_track) @@ -368,7 +462,8 @@ class DeeLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) return track @@ -468,7 +563,8 @@ class DeeLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market=stock_market ) -> Album: link_dee = self.convert_spoty_to_dee_link_album(link_album) @@ -486,7 +582,8 @@ class DeeLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) return album @@ -507,7 +604,8 @@ class DeeLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market=stock_market ) -> Playlist: link_is_valid(link_playlist) @@ -585,7 +683,8 @@ class DeeLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) tracks.append(downloaded_track) except (TrackNotFound, NoDataApi) as e: @@ -641,7 +740,8 @@ class DeeLogin: pad_tracks=True, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market=stock_market ) -> Track: query = f"track:{song} artist:{artist}" @@ -675,7 +775,8 @@ class DeeLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) return track @@ -696,18 +797,34 @@ class DeeLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market=stock_market ) -> Episode: link_is_valid(link_episode) ids = get_ids(link_episode) + episode_metadata = None + market_str_episode = market + if isinstance(market, list): + market_str_episode = ", ".join([m.upper() for m in market]) + elif isinstance(market, str): + market_str_episode = market.upper() try: - episode_metadata = API.tracking(ids) + episode_metadata = API.tracking(ids, market=market) + except MarketAvailabilityError as e: + logger.error(f"Episode {ids} is not available in market(s) '{market_str_episode}'. Error: {e.message}") + # For episodes, structure of error might be different than TrackNotFound expects if it uses track-specific fields + # Creating a message that TrackNotFound can use + raise TrackNotFound(url=link_episode, message=f"Episode not available in market(s) '{market_str_episode}': {e.message}") from e except NoDataApi: infos = self.__gw_api.get_episode_data(ids) if not infos: - raise TrackNotFound("Episode not found") + raise TrackNotFound(f"Episode {ids} not found") + # For episodes, API.tracking is usually not called again with GW API data in this flow. + # We construct metadata directly. + # No direct market check here as available_countries might not be in GW response for episodes. + # The initial API.tracking call is the main point for market check for episodes. episode_metadata = { 'music': infos.get('EPISODE_TITLE', ''), 'artist': infos.get('SHOW_NAME', ''), @@ -738,6 +855,7 @@ class DeeLogin: preferences.bitrate = bitrate preferences.save_cover = save_cover preferences.is_episode = True + preferences.market = market episode = DW_EPISODE(preferences).dw() @@ -759,7 +877,8 @@ class DeeLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market=stock_market ) -> Smart: link_is_valid(link) @@ -804,7 +923,8 @@ class DeeLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) smart.type = "track" smart.track = track @@ -833,7 +953,8 @@ class DeeLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) smart.type = "album" smart.album = album @@ -862,7 +983,8 @@ class DeeLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) smart.type = "playlist" smart.playlist = playlist diff --git a/deezspot/deezloader/dee_api.py b/deezspot/deezloader/dee_api.py index 19a8d34..95fb523 100644 --- a/deezspot/deezloader/dee_api.py +++ b/deezspot/deezloader/dee_api.py @@ -11,6 +11,7 @@ from deezspot.exceptions import ( NoDataApi, QuotaExceeded, TrackNotFound, + MarketAvailabilityError, ) from deezspot.libutils.logging_utils import logger import requests @@ -222,17 +223,51 @@ class API: image = req_get(image_url).content if len(image) == 13: + logger.debug(f"Received 13-byte image for md5_image: {md5_image}. Attempting fallback image.") image_url = cls.get_img_url("", size) image = req_get(image_url).content + if len(image) == 13: + logger.warning(f"Fallback image for md5_image {md5_image} (using empty md5) also resulted in a 13-byte response.") return image @classmethod - def tracking(cls, ids, album = False) -> dict: + def tracking(cls, ids, album = False, market = None) -> dict: song_metadata = {} json_track = cls.get_track(ids) - # Ensure ISRC is always fetched + # Market availability check + if market: + available_countries = json_track.get("available_countries") + track_available_in_specified_markets = False + markets_checked_str = "" + + if isinstance(market, list): + markets_checked_str = ", ".join([m.upper() for m in market]) + if available_countries: + for m_code in market: + if m_code.upper() in available_countries: + track_available_in_specified_markets = True + break # Found in one market, no need to check further + else: # available_countries is None or empty + track_available_in_specified_markets = False # Cannot be available if API lists no countries + elif isinstance(market, str): + markets_checked_str = market.upper() + if available_countries and market.upper() in available_countries: + track_available_in_specified_markets = True + else: # available_countries is None or empty, or market not in list + track_available_in_specified_markets = False + else: + logger.warning(f"Market parameter has an unexpected type: {type(market)}. Skipping market check.") + track_available_in_specified_markets = True # Default to available if market param is malformed + + if not track_available_in_specified_markets: + track_title = json_track.get('title', 'Unknown Title') + artist_name = json_track.get('artist', {}).get('name', 'Unknown Artist') + error_msg = f"Track '{track_title}' by '{artist_name}' (ID: {ids}) is not available in market(s): '{markets_checked_str}'." + logger.warning(error_msg) + raise MarketAvailabilityError(message=error_msg) + song_metadata['isrc'] = json_track.get('isrc', '') if not album: @@ -254,7 +289,6 @@ class API: song_metadata['ar_album'] = "; ".join(ar_album) song_metadata['album'] = album_json['title'] song_metadata['label'] = album_json['label'] - # Ensure UPC is fetched from album data song_metadata['upc'] = album_json.get('upc', '') song_metadata['nb_tracks'] = album_json['nb_tracks'] @@ -275,13 +309,12 @@ class API: song_metadata['year'] = convert_to_date(json_track['release_date']) song_metadata['bpm'] = json_track['bpm'] song_metadata['duration'] = json_track['duration'] - # song_metadata['isrc'] = json_track['isrc'] # Already handled above song_metadata['gain'] = json_track['gain'] return song_metadata @classmethod - def tracking_album(cls, album_json): + def tracking_album(cls, album_json, market = None): song_metadata: dict[ str, Union[list, str, int, datetime] @@ -292,12 +325,11 @@ class API: "discnum": [], "bpm": [], "duration": [], - "isrc": [], # Ensure isrc list is present for tracks + "isrc": [], "gain": [], "album": album_json['title'], "label": album_json['label'], "year": convert_to_date(album_json['release_date']), - # Ensure UPC is fetched at album level "upc": album_json.get('upc', ''), "nb_tracks": album_json['nb_tracks'] } @@ -318,16 +350,76 @@ class API: song_metadata['ar_album'] = "; ".join(ar_album) sm_items = song_metadata.items() - for track in album_json['tracks']['data']: - c_ids = track['id'] - detas = cls.tracking(c_ids, album = True) + for track_info_from_album_json in album_json['tracks']['data']: + c_ids = track_info_from_album_json['id'] + track_title_for_error = track_info_from_album_json.get('title', 'Unknown Track') + track_artist_for_error = track_info_from_album_json.get('artist', {}).get('name', 'Unknown Artist') + + current_track_metadata_or_error = None + track_failed = False - for key, item in sm_items: - if type(item) is list: - # Ensure ISRC is appended for each track - if key == 'isrc': - song_metadata[key].append(detas.get('isrc', '')) + try: + # Get detailed metadata for the current track + current_track_metadata_or_error = cls.tracking(c_ids, album=True, market=market) + except MarketAvailabilityError as e: + market_str = market + if isinstance(market, list): + market_str = ", ".join([m.upper() for m in market]) + elif isinstance(market, str): + market_str = market.upper() + logger.warning(f"Track '{track_title_for_error}' (ID: {c_ids}) in album '{album_json.get('title','Unknown Album')}' not available in market(s) '{market_str}': {e.message}") + current_track_metadata_or_error = { + 'error_type': 'MarketAvailabilityError', + 'message': e.message, + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': c_ids, + 'checked_markets': market_str # Store the markets that were checked + } + track_failed = True + except NoDataApi as e_nd: + logger.warning(f"Track '{track_title_for_error}' (ID: {c_ids}) in album '{album_json.get('title','Unknown Album')}' data not found: {str(e_nd)}") + current_track_metadata_or_error = { + 'error_type': 'NoDataApi', + 'message': str(e_nd), + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': c_ids + } + track_failed = True + except requests.exceptions.ConnectionError as e_conn: # Added to catch connection errors here + logger.warning(f"Connection error fetching metadata for track '{track_title_for_error}' (ID: {c_ids}) in album '{album_json.get('title','Unknown Album')}': {str(e_conn)}") + current_track_metadata_or_error = { + 'error_type': 'ConnectionError', + 'message': f"Connection error: {str(e_conn)}", + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': c_ids + } + track_failed = True + except Exception as e_other_track_meta: # Catch any other unexpected error for this specific track + logger.warning(f"Unexpected error fetching metadata for track '{track_title_for_error}' (ID: {c_ids}) in album '{album_json.get('title','Unknown Album')}': {str(e_other_track_meta)}") + current_track_metadata_or_error = { + 'error_type': 'TrackMetadataError', + 'message': str(e_other_track_meta), + 'name': track_title_for_error, + 'artist': track_artist_for_error, + 'ids': c_ids + } + track_failed = True + + for key, list_template in sm_items: + if isinstance(list_template, list): + if track_failed: + if key == 'music': + song_metadata[key].append(current_track_metadata_or_error) + elif key == 'artist' and isinstance(current_track_metadata_or_error, dict): + song_metadata[key].append(current_track_metadata_or_error.get('artist')) + elif key == 'ids' and isinstance(current_track_metadata_or_error, dict): + pass + else: + song_metadata[key].append(None) else: - song_metadata[key].append(detas[key]) + song_metadata[key].append(current_track_metadata_or_error.get(key)) return song_metadata diff --git a/deezspot/exceptions.py b/deezspot/exceptions.py index 1f1acaa..9e7cce5 100644 --- a/deezspot/exceptions.py +++ b/deezspot/exceptions.py @@ -74,4 +74,9 @@ class BadCredentials(Exception): else: self.msg = f"Wrong credentials email: {self.email}, password: {self.password}" - super().__init__(self.msg) \ No newline at end of file + super().__init__(self.msg) + +class MarketAvailabilityError(Exception): + def __init__(self, message): + self.message = message + super().__init__(self.message) \ No newline at end of file diff --git a/deezspot/libutils/others_settings.py b/deezspot/libutils/others_settings.py index 54b1652..fd8f3fb 100644 --- a/deezspot/libutils/others_settings.py +++ b/deezspot/libutils/others_settings.py @@ -23,3 +23,4 @@ stock_not_interface = False stock_zip = False stock_real_time_dl = False stock_save_cover = False # Default for saving cover image +stock_market = None diff --git a/deezspot/libutils/skip_detection.py b/deezspot/libutils/skip_detection.py index b909584..a4afc9b 100644 --- a/deezspot/libutils/skip_detection.py +++ b/deezspot/libutils/skip_detection.py @@ -5,6 +5,7 @@ from mutagen import File from mutagen.easyid3 import EasyID3 from mutagen.oggvorbis import OggVorbis from mutagen.flac import FLAC +from mutagen.mp3 import MP3 # Added for explicit MP3 type checking # from mutagen.mp4 import MP4 # MP4 is usually handled by File for .m4a # AUDIO_FORMATS and get_output_path will be imported from audio_converter @@ -30,9 +31,24 @@ def read_metadata_from_file(file_path, logger): title = None album = None - if isinstance(audio, EasyID3): # MP3 + if isinstance(audio, EasyID3): # This might occur if easy=True was used, but we use easy=False + # This branch is less likely to be hit with current File(..., easy=False) usage for MP3s title = audio.get('title', [None])[0] album = audio.get('album', [None])[0] + elif isinstance(audio, MP3): # Correctly handle MP3 objects when easy=False + # For mutagen.mp3.MP3, tags are typically accessed via audio.tags (an ID3 object) + # Common ID3 frames for title and album are TIT2 and TALB respectively. + # The .text attribute of a frame object usually holds a list of strings. + if audio.tags: + title_frame = audio.tags.get('TIT2') + if title_frame: + title = title_frame.text[0] if title_frame.text else None + + album_frame = audio.tags.get('TALB') + if album_frame: + album = album_frame.text[0] if album_frame.text else None + else: + logger.debug(f"No tags found in MP3 file: {file_path}") elif isinstance(audio, OggVorbis): # OGG title = audio.get('TITLE', [None])[0] # Vorbis tags are case-insensitive but typically uppercase album = audio.get('ALBUM', [None])[0] diff --git a/deezspot/models/track.py b/deezspot/models/track.py index 234279c..6a34dab 100644 --- a/deezspot/models/track.py +++ b/deezspot/models/track.py @@ -13,7 +13,11 @@ class Track: self.tags = tags self.__set_tags() - self.song_name = f"{self.music} - {self.artist}" + + music_display = getattr(self, 'music', getattr(self, 'name', "Unknown Track")) + artist_display = getattr(self, 'artist', "Unknown Artist") + self.song_name = f"{music_display} - {artist_display}" + self.song_path = song_path self.file_format = file_format self.quality = quality diff --git a/deezspot/spotloader/__download__.py b/deezspot/spotloader/__download__.py index fb00344..46c1e11 100644 --- a/deezspot/spotloader/__download__.py +++ b/deezspot/spotloader/__download__.py @@ -1204,31 +1204,123 @@ class DW_PLAYLIST: m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u") if not os.path.exists(m3u_path): with open(m3u_path, "w", encoding="utf-8") as m3u_file: - m3u_file.write("#EXTM3U\\n") + m3u_file.write("#EXTM3U\n") # ------------------------------------- playlist = Playlist() tracks = playlist.tracks for idx, c_song_metadata in enumerate(self.__song_metadata): + # Check if c_song_metadata indicates a pre-identified error from metadata fetching stage + if isinstance(c_song_metadata, dict) and 'error_type' in c_song_metadata: + track_name = c_song_metadata.get('name', 'Unknown Track') + track_ids = c_song_metadata.get('ids', None) + error_message = c_song_metadata.get('error_message', 'Unknown error during metadata retrieval.') + error_type = c_song_metadata.get('error_type', 'UnknownError') + + logger.warning(f"Skipping download for track '{track_name}' (ID: {track_ids}) from playlist '{playlist_name}' due to {error_type}: {error_message}") + + # Create a placeholder Track object to represent this failure + # The link might not be available or relevant if IDs itself was the issue + failed_track_link = f"https://open.spotify.com/track/{track_ids}" if track_ids else None + + # Basic metadata for the Track object constructor + # We use c_song_metadata itself as it contains name, ids, etc. + # Ensure it's a dict for Track constructor + track_obj_metadata = c_song_metadata if isinstance(c_song_metadata, dict) else {'name': track_name, 'ids': track_ids} + + track = Track( + tags=track_obj_metadata, + song_path=None, + file_format=None, + quality=None, + link=failed_track_link, + ids=track_ids + ) + track.success = False + track.error_message = error_message + tracks.append(track) + continue # Move to the next track in the playlist + + # Original handling for string type (though this should be less common with new error dicts) if type(c_song_metadata) is str: - print(f"Track not found {c_song_metadata} :(") + logger.warning(f"Encountered string as song metadata for a track in playlist '{playlist_name}': {c_song_metadata}. Treating as error.") + # Attempt to create a basic Track object with this string as an error message. + # This is a fallback for older error reporting styles. + error_track_name = "Unknown Track (error)" + error_track_ids = None + # Try to parse some info if the string is very specific, otherwise use generic. + if "Track not found" in c_song_metadata: + # This was an old message format, may not contain structured info. + pass # Keep generic error_track_name for now. + + track = Track( + tags={'name': error_track_name, 'ids': error_track_ids, 'artist': 'Unknown Artist'}, # Minimal metadata + song_path=None, + file_format=None, + quality=None, + link=None, # No reliable link from just an error string + ids=error_track_ids + ) + track.success = False + track.error_message = c_song_metadata # The string itself is the error + tracks.append(track) continue + + # If c_song_metadata is a valid metadata dictionary (no 'error_type') c_preferences = deepcopy(self.__preferences) - c_preferences.ids = c_song_metadata['ids'] + c_preferences.ids = c_song_metadata.get('ids') # Use .get for safety, though it should exist c_preferences.song_metadata = c_song_metadata c_preferences.json_data = self.__json_data # Pass playlist data for reporting c_preferences.track_number = idx + 1 # Track number in the playlist + c_preferences.link = f"https://open.spotify.com/track/{c_preferences.ids}" if c_preferences.ids else None - # Use track-level reporting through EASY_DW - track = EASY_DW(c_preferences, parent='playlist').easy_dw() + easy_dw_instance = EASY_DW(c_preferences, parent='playlist') + track = None # Initialize track for this iteration - # Only log a warning if the track failed and was NOT intentionally skipped - if not track.success and not getattr(track, 'was_skipped', False): - song = f"{c_song_metadata['music']} - {c_song_metadata['artist']}" - error_detail = getattr(track, 'error_message', 'Download failed for unspecified reason.') - logger.warning(f"Cannot download '{song}' from playlist '{playlist_name}'. Reason: {error_detail} (URL: {track.link or c_preferences.link})") + try: + track = easy_dw_instance.easy_dw() + except TrackNotFound as e_track_nf: + track = easy_dw_instance.get_no_dw_track() # Retrieve the track instance from EASY_DW + # Ensure track object is a valid Track instance and has error info + if not isinstance(track, Track): # Fallback if get_no_dw_track didn't return a Track + track = Track(c_song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + track.success = False # Explicitly set success to False + # Ensure error message is set, preferring the one from the exception if track doesn't have one + if not getattr(track, 'error_message', None) or str(e_track_nf): # Prioritize exception message if available + track.error_message = str(e_track_nf) + + song_name_log = c_song_metadata.get('music', 'Unknown Song') + artist_name_log = c_song_metadata.get('artist', 'Unknown Artist') + playlist_name_log = self.__json_data.get('name', 'Unknown Playlist') + logger.warning( + f"Failed to download track '{song_name_log}' by '{artist_name_log}' from playlist '{playlist_name_log}'. " + f"Reason: {track.error_message} (URL: {track.link or c_preferences.link})" + ) + except Exception as e_generic: + # Catch any other unexpected exceptions during the track download process + track = Track(c_song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + track.success = False + track.error_message = f"An unexpected error occurred while processing track: {str(e_generic)}" + + song_name_log = c_song_metadata.get('music', 'Unknown Song') + artist_name_log = c_song_metadata.get('artist', 'Unknown Artist') + playlist_name_log = self.__json_data.get('name', 'Unknown Playlist') + logger.error( + f"Unexpected error downloading track '{song_name_log}' by '{artist_name_log}' from playlist '{playlist_name_log}'. " + f"Reason: {track.error_message} (URL: {track.link or c_preferences.link})" + ) + + # Ensure track is not None before appending (should be assigned in try/except) + if track is None: + # This is a fallback, should ideally not be reached. + track = Track(c_song_metadata, None, None, None, c_preferences.link, c_preferences.ids) + track.success = False + track.error_message = "Track processing resulted in an unhandled null track object." + logger.error(f"Track '{c_song_metadata.get('music', 'Unknown Track')}' from playlist '{self.__json_data.get('name', 'Unknown Playlist')}' " + f"was not properly processed.") tracks.append(track) + # --- Append the final track path to the m3u file using a relative path --- if track.success and hasattr(track, 'song_path') and track.song_path: # Build the relative path from the playlists directory diff --git a/deezspot/spotloader/__init__.py b/deezspot/spotloader/__init__.py index 8309763..532dfb2 100644 --- a/deezspot/spotloader/__init__.py +++ b/deezspot/spotloader/__init__.py @@ -3,9 +3,9 @@ import traceback from os.path import isfile from deezspot.easy_spoty import Spo from librespot.core import Session -from deezspot.exceptions import InvalidLink +from deezspot.exceptions import InvalidLink, MarketAvailabilityError from deezspot.spotloader.__spo_api__ import tracking, tracking_album, tracking_episode -from deezspot.spotloader.spotify_settings import stock_quality +from deezspot.spotloader.spotify_settings import stock_quality, stock_market from deezspot.libutils.utils import ( get_ids, link_is_valid, @@ -33,7 +33,8 @@ from deezspot.libutils.others_settings import ( stock_not_interface, stock_zip, stock_save_cover, - stock_real_time_dl + stock_real_time_dl, + stock_market ) from deezspot.libutils.logging_utils import logger, ProgressReporter @@ -98,13 +99,17 @@ class SpoLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market: list[str] | None = stock_market ) -> Track: try: link_is_valid(link_track) ids = get_ids(link_track) - song_metadata = tracking(ids) + song_metadata = tracking(ids, market=market) + if song_metadata is None: + raise Exception(f"Could not retrieve metadata for track {link_track}. It might not be available or an API error occurred.") + logger.info(f"Starting download for track: {song_metadata.get('music', 'Unknown')} - {song_metadata.get('artist', 'Unknown')}") preferences = Preferences() @@ -131,10 +136,14 @@ class SpoLogin: preferences.convert_to = convert_to preferences.bitrate = bitrate preferences.save_cover = save_cover + preferences.market = market track = DW_TRACK(preferences).dw() return track + except MarketAvailabilityError as e: + logger.error(f"Track download failed due to market availability: {str(e)}") + raise except Exception as e: logger.error(f"Failed to download track: {str(e)}") traceback.print_exc() @@ -157,15 +166,20 @@ class SpoLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market: list[str] | None = stock_market ) -> Album: try: link_is_valid(link_album) ids = get_ids(link_album) - # Use stored credentials for API calls album_json = Spo.get_album(ids) - song_metadata = tracking_album(album_json) + if not album_json: + raise Exception(f"Could not retrieve album data for {link_album}.") + song_metadata = tracking_album(album_json, market=market) + if song_metadata is None: + raise Exception(f"Could not process album metadata for {link_album}. It might not be available in the specified market(s) or an API error occurred.") + logger.info(f"Starting download for album: {song_metadata.get('album', 'Unknown')} - {song_metadata.get('ar_album', 'Unknown')}") preferences = Preferences() @@ -194,10 +208,14 @@ class SpoLogin: preferences.convert_to = convert_to preferences.bitrate = bitrate preferences.save_cover = save_cover + preferences.market = market album = DW_ALBUM(preferences).dw() return album + except MarketAvailabilityError as e: + logger.error(f"Album download failed due to market availability: {str(e)}") + raise except Exception as e: logger.error(f"Failed to download album: {str(e)}") traceback.print_exc() @@ -220,30 +238,95 @@ class SpoLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market: list[str] | None = stock_market ) -> Playlist: try: link_is_valid(link_playlist) ids = get_ids(link_playlist) song_metadata = [] - # Use stored credentials for API calls playlist_json = Spo.get_playlist(ids) + if not playlist_json: + raise Exception(f"Could not retrieve playlist data for {link_playlist}.") logger.info(f"Starting download for playlist: {playlist_json.get('name', 'Unknown')}") - for track in playlist_json['tracks']['items']: - is_track = track['track'] - if not is_track: + for track_item_wrapper in playlist_json['tracks']['items']: + track_info = track_item_wrapper.get('track') + c_song_metadata = None # Initialize for each item + + if not track_info: + logger.warning(f"Skipping an item in playlist {playlist_json.get('name', 'Unknown Playlist')} as it does not appear to be a valid track object.") + # Create a placeholder for this unidentifiable item + c_song_metadata = { + 'name': 'Unknown Skipped Item', + 'ids': None, + 'error_type': 'InvalidItemStructure', + 'error_message': 'Playlist item was not a valid track object.' + } + song_metadata.append(c_song_metadata) continue - external_urls = is_track['external_urls'] - if not external_urls: - c_song_metadata = f"The track \"{is_track['name']}\" is not available on Spotify :(" - logger.warning(f"Track not available: {is_track['name']}") + + track_name_for_logs = track_info.get('name', 'Unknown Track') + track_id_for_logs = track_info.get('id', 'Unknown ID') # Track's own ID if available + external_urls = track_info.get('external_urls') + + if not external_urls or not external_urls.get('spotify'): + logger.warning(f"Track \"{track_name_for_logs}\" (ID: {track_id_for_logs}) in playlist {playlist_json.get('name', 'Unknown Playlist')} is not available on Spotify or has no URL.") + c_song_metadata = { + 'name': track_name_for_logs, + 'ids': track_id_for_logs, # Use track's own ID if available, otherwise will be None + 'error_type': 'MissingTrackURL', + 'error_message': f"Track \"{track_name_for_logs}\" is not available on Spotify or has no URL." + } else: - ids = get_ids(external_urls['spotify']) - c_song_metadata = tracking(ids) - song_metadata.append(c_song_metadata) + track_spotify_url = external_urls['spotify'] + track_ids_from_url = get_ids(track_spotify_url) # This is the ID used for fetching with 'tracking' + try: + # Market check for each track is done within tracking() + # Pass market. tracking() will raise MarketAvailabilityError if unavailable. + fetched_metadata = tracking(track_ids_from_url, market=market) + if fetched_metadata: + c_song_metadata = fetched_metadata + else: + # tracking() returned None, but didn't raise MarketAvailabilityError. General fetch error. + logger.warning(f"Could not retrieve full metadata for track {track_name_for_logs} (ID: {track_ids_from_url}, URL: {track_spotify_url}) in playlist {playlist_json.get('name', 'Unknown Playlist')}. API error or other issue.") + c_song_metadata = { + 'name': track_name_for_logs, + 'ids': track_ids_from_url, + 'error_type': 'MetadataFetchError', + 'error_message': f"Failed to fetch full metadata for track {track_name_for_logs}." + } + except MarketAvailabilityError as e: + logger.warning(f"Track {track_name_for_logs} (ID: {track_ids_from_url}, URL: {track_spotify_url}) in playlist {playlist_json.get('name', 'Unknown Playlist')} is not available in the specified market(s). Skipping. Error: {str(e)}") + c_song_metadata = { + 'name': track_name_for_logs, + 'ids': track_ids_from_url, + 'error_type': 'MarketAvailabilityError', + 'error_message': str(e) + } + except Exception as e_tracking: # Catch any other unexpected error from tracking() + logger.error(f"Unexpected error fetching metadata for track {track_name_for_logs} (ID: {track_ids_from_url}, URL: {track_spotify_url}): {str(e_tracking)}") + c_song_metadata = { + 'name': track_name_for_logs, + 'ids': track_ids_from_url, + 'error_type': 'UnexpectedTrackingError', + 'error_message': f"Unexpected error fetching metadata: {str(e_tracking)}" + } + + if c_song_metadata: # Ensure something is appended + song_metadata.append(c_song_metadata) + else: + # This case should ideally not be reached if logic above is complete + logger.error(f"Logic error: c_song_metadata remained None for track {track_name_for_logs} in playlist {playlist_json.get('name', 'Unknown Playlist')}") + song_metadata.append({ + 'name': track_name_for_logs, + 'ids': track_id_for_logs or track_ids_from_url, + 'error_type': 'InternalLogicError', + 'error_message': 'Internal error processing playlist track metadata.' + }) + preferences = Preferences() preferences.real_time_dl = real_time_dl @@ -271,10 +354,14 @@ class SpoLogin: preferences.convert_to = convert_to preferences.bitrate = bitrate preferences.save_cover = save_cover + preferences.market = market playlist = DW_PLAYLIST(preferences).dw() return playlist + except MarketAvailabilityError as e: + logger.error(f"Playlist download failed due to market availability issues with one or more tracks: {str(e)}") + raise except Exception as e: logger.error(f"Failed to download playlist: {str(e)}") traceback.print_exc() @@ -296,14 +383,19 @@ class SpoLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market: list[str] | None = stock_market ) -> Episode: try: link_is_valid(link_episode) ids = get_ids(link_episode) - # Use stored credentials for API calls episode_json = Spo.get_episode(ids) - episode_metadata = tracking_episode(ids) + if not episode_json: + raise Exception(f"Could not retrieve episode data for {link_episode} from API.") + + episode_metadata = tracking_episode(ids, market=market) + if episode_metadata is None: + raise Exception(f"Could not process episode metadata for {link_episode}. It might not be available in the specified market(s) or an API error occurred.") logger.info(f"Starting download for episode: {episode_metadata.get('name', 'Unknown')} - {episode_metadata.get('show', 'Unknown')}") @@ -331,10 +423,14 @@ class SpoLogin: preferences.convert_to = convert_to preferences.bitrate = bitrate preferences.save_cover = save_cover + preferences.market = market episode = DW_EPISODE(preferences).dw() return episode + except MarketAvailabilityError as e: + logger.error(f"Episode download failed due to market availability: {str(e)}") + raise except Exception as e: logger.error(f"Failed to download episode: {str(e)}") traceback.print_exc() @@ -358,7 +454,9 @@ class SpoLogin: retry_delay_increase=30, max_retries=5, convert_to=None, - bitrate=None + bitrate=None, + market: list[str] | None = stock_market, + save_cover=stock_save_cover ): """ Download all albums (or a subset based on album_type and limit) from an artist. @@ -396,7 +494,9 @@ class SpoLogin: retry_delay_increase=retry_delay_increase, max_retries=max_retries, convert_to=convert_to, - bitrate=bitrate + bitrate=bitrate, + market=market, + save_cover=save_cover ) downloaded_albums.append(downloaded_album) return downloaded_albums @@ -422,7 +522,8 @@ class SpoLogin: max_retries=5, convert_to=None, bitrate=None, - save_cover=stock_save_cover + save_cover=stock_save_cover, + market: list[str] | None = stock_market ) -> Smart: try: link_is_valid(link) @@ -454,7 +555,8 @@ class SpoLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) smart.type = "track" smart.track = track @@ -479,7 +581,8 @@ class SpoLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) smart.type = "album" smart.album = album @@ -504,7 +607,8 @@ class SpoLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) smart.type = "playlist" smart.playlist = playlist @@ -528,7 +632,8 @@ class SpoLogin: max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, - save_cover=save_cover + save_cover=save_cover, + market=market ) smart.type = "episode" smart.episode = episode diff --git a/deezspot/spotloader/__spo_api__.py b/deezspot/spotloader/__spo_api__.py index 4cd0ecd..5a88a86 100644 --- a/deezspot/spotloader/__spo_api__.py +++ b/deezspot/spotloader/__spo_api__.py @@ -5,6 +5,23 @@ from datetime import datetime from deezspot.libutils.utils import convert_to_date import traceback from deezspot.libutils.logging_utils import logger +from deezspot.exceptions import MarketAvailabilityError + +def _check_market_availability(item_name: str, item_type: str, api_available_markets: list[str] | None, user_markets: list[str] | None): + """Checks if an item is available in any of the user-specified markets.""" + if user_markets and api_available_markets is not None: + is_available_in_any_user_market = any(m in api_available_markets for m in user_markets) + if not is_available_in_any_user_market: + markets_str = ", ".join(user_markets) + raise MarketAvailabilityError(f"{item_type} '{item_name}' not available in provided market(s): {markets_str}") + elif user_markets and api_available_markets is None: + # Log a warning if user specified markets, but API response doesn't include 'available_markets' + # This might indicate the item is available in all markets or API doesn't provide this info for this item type. + # For now, we proceed without raising an error, as we cannot confirm it's "not available". + logger.warning( + f"Market availability check for {item_type} '{item_name}' skipped: " + "API response did not include 'available_markets' field. Assuming availability." + ) def _get_best_image_urls(images_list): urls = {'image': '', 'image2': '', 'image3': ''} @@ -28,7 +45,7 @@ def _get_best_image_urls(images_list): return urls -def tracking(ids, album_data_for_track=None): +def tracking(ids, album_data_for_track=None, market: list[str] | None = None): datas = {} try: json_track = Spo.get_track(ids) @@ -36,6 +53,11 @@ def tracking(ids, album_data_for_track=None): logger.error(f"Failed to get track details for ID: {ids} from Spotify API.") return None + # Perform market availability check for the track + track_name_for_check = json_track.get('name', f'Track ID {ids}') + api_track_markets = json_track.get('available_markets') + _check_market_availability(track_name_for_check, "Track", api_track_markets, market) + # Album details section # Use provided album_data_for_track if available (from tracking_album context) # Otherwise, fetch from track's album info or make a new API call for more details @@ -129,6 +151,8 @@ def tracking(ids, album_data_for_track=None): datas['ids'] = ids logger.debug(f"Successfully tracked metadata for track {ids}") + except MarketAvailabilityError: # Re-raise to be caught by the calling download method + raise except Exception as e: logger.error(f"Failed to track metadata for track {ids}: {str(e)}") logger.debug(traceback.format_exc()) @@ -136,13 +160,18 @@ def tracking(ids, album_data_for_track=None): return datas -def tracking_album(album_json): +def tracking_album(album_json, market: list[str] | None = None): if not album_json: logger.error("tracking_album received None or empty album_json.") return None song_metadata = {} try: + # Perform market availability check for the album itself + album_name_for_check = album_json.get('name', f"Album ID {album_json.get('id', 'Unknown')}") + api_album_markets = album_json.get('available_markets') + _check_market_availability(album_name_for_check, "Album", api_album_markets, market) + initial_list_fields = { "music": [], "artist": [], "tracknum": [], "discnum": [], "duration": [], "isrc": [], "ids": [], "explicit_list": [], "popularity_list": [] @@ -201,7 +230,8 @@ def tracking_album(album_json): continue # Pass the main album_json as album_data_for_track to avoid refetching it in tracking() - track_details = tracking(c_ids, album_data_for_track=album_json) + # Also pass the market parameter + track_details = tracking(c_ids, album_data_for_track=album_json, market=market) if track_details: song_metadata['music'].append(track_details.get('music', 'Unknown Track')) @@ -234,6 +264,8 @@ def tracking_album(album_json): logger.debug(f"Successfully tracked metadata for album {album_json.get('id', 'N/A')}") + except MarketAvailabilityError: # Re-raise + raise except Exception as e: logger.error(f"Failed to track album metadata for album ID {album_json.get('id', 'N/A') if album_json else 'N/A'}: {str(e)}") logger.debug(traceback.format_exc()) @@ -241,7 +273,7 @@ def tracking_album(album_json): return song_metadata -def tracking_episode(ids): +def tracking_episode(ids, market: list[str] | None = None): datas = {} try: json_episode = Spo.get_episode(ids) @@ -249,6 +281,11 @@ def tracking_episode(ids): logger.error(f"Failed to get episode details for ID: {ids} from Spotify API.") return None + # Perform market availability check for the episode + episode_name_for_check = json_episode.get('name', f'Episode ID {ids}') + api_episode_markets = json_episode.get('available_markets') + _check_market_availability(episode_name_for_check, "Episode", api_episode_markets, market) + image_urls = _get_best_image_urls(json_episode.get('images', [])) datas.update(image_urls) @@ -305,6 +342,8 @@ def tracking_episode(ids): logger.debug(f"Successfully tracked metadata for episode {ids}") + except MarketAvailabilityError: # Re-raise + raise except Exception as e: logger.error(f"Failed to track episode metadata for ID {ids}: {str(e)}") logger.debug(traceback.format_exc()) diff --git a/deezspot/spotloader/spotify_settings.py b/deezspot/spotloader/spotify_settings.py index 02f1274..dee2f69 100644 --- a/deezspot/spotloader/spotify_settings.py +++ b/deezspot/spotloader/spotify_settings.py @@ -24,3 +24,5 @@ qualities = { "s_quality": "NORMAL" } } + +stock_market = None