added market logic

This commit is contained in:
Xoconoch
2025-06-04 14:44:14 -06:00
parent 0ab091f492
commit 1f6ce13f27
11 changed files with 800 additions and 190 deletions

View File

@@ -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
current_track_constructed_metadata = {}
potential_error_marker = None
is_current_track_error = False
# 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:
has_contributors = True
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]
# 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', []))
# 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
artist_parts = [main_artist]
if featuring:
artist_parts.append(f"(feat. {featuring})")
artist_str = " ".join(artist_parts)
composer_str = "; ".join(contributors.get('composer', []))
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'))
# 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'
}
# 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 ""
# Only add contributor-based metadata if available.
if has_contributors:
c_song_metadata['artist'] = artist_str
c_song_metadata['composer'] = composer_str
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}")
# No progress reporting here - done at the track level
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
# 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
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 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 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:

View File

@@ -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)
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)
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

View File

@@ -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')
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', ''))
current_track_metadata_or_error = None
track_failed = False
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(detas[key])
song_metadata[key].append(None)
else:
song_metadata[key].append(current_track_metadata_or_error.get(key))
return song_metadata

View File

@@ -75,3 +75,8 @@ class BadCredentials(Exception):
self.msg = f"Wrong credentials email: {self.email}, password: {self.password}"
super().__init__(self.msg)
class MarketAvailabilityError(Exception):
def __init__(self, message):
self.message = message
super().__init__(self.message)

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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,12 +99,16 @@ 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')}")
@@ -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,14 +166,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
) -> 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')}")
@@ -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:
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']}")
else:
ids = get_ids(external_urls['spotify'])
c_song_metadata = tracking(ids)
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
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:
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

View File

@@ -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())

View File

@@ -24,3 +24,5 @@ qualities = {
"s_quality": "NORMAL"
}
}
stock_market = None