Fix m3u file naming handling

This commit is contained in:
Xoconoch
2025-08-10 18:50:56 -06:00
parent 5c3364a4f3
commit 9d63bdc9fb
8 changed files with 208 additions and 184 deletions

View File

@@ -266,7 +266,10 @@ class EASY_DW:
self.__track_obj: trackCbObject = preferences.song_metadata self.__track_obj: trackCbObject = preferences.song_metadata
# Convert it to the dictionary format needed for legacy functions # Convert it to the dictionary format needed for legacy functions
self.__song_metadata = self._track_object_to_dict(self.__track_obj) artist_separator = getattr(preferences, 'artist_separator', '; ')
self.__song_metadata_dict = track_object_to_dict(self.__track_obj, source_type='deezer', artist_separator=artist_separator)
# Maintain legacy attribute expected elsewhere
self.__song_metadata = self.__song_metadata_dict
self.__download_type = "track" self.__download_type = "track"
self.__c_quality = qualities[self.__quality_download] self.__c_quality = qualities[self.__quality_download]
@@ -324,7 +327,8 @@ class EASY_DW:
It intelligently finds the album information based on the download context. It intelligently finds the album information based on the download context.
""" """
# Use the unified metadata converter # Use the unified metadata converter
metadata_dict = track_object_to_dict(track_obj, source_type='deezer') artist_separator = getattr(self.__preferences, 'artist_separator', '; ')
metadata_dict = track_object_to_dict(track_obj, source_type='deezer', artist_separator=artist_separator)
# Check for track_position and disk_number in the original API data # Check for track_position and disk_number in the original API data
# These might be directly available in the infos_dw dictionary for Deezer tracks # These might be directly available in the infos_dw dictionary for Deezer tracks
@@ -345,8 +349,9 @@ class EASY_DW:
custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None) custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None)
custom_track_format = getattr(self.__preferences, 'custom_track_format', None) custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
pad_tracks = getattr(self.__preferences, 'pad_tracks', True) pad_tracks = getattr(self.__preferences, 'pad_tracks', True)
self.__song_metadata_dict['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
self.__song_path = set_path( self.__song_path = set_path(
self.__song_metadata, self.__song_metadata_dict,
self.__output_dir, self.__output_dir,
self.__song_quality, self.__song_quality,
self.__file_format, self.__file_format,
@@ -360,7 +365,7 @@ class EASY_DW:
custom_track_format = getattr(self.__preferences, 'custom_track_format', None) custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
pad_tracks = getattr(self.__preferences, 'pad_tracks', True) pad_tracks = getattr(self.__preferences, 'pad_tracks', True)
self.__song_path = set_path( self.__song_path = set_path(
self.__song_metadata, self.__song_metadata_dict,
self.__output_dir, self.__output_dir,
self.__song_quality, self.__song_quality,
self.__file_format, self.__file_format,
@@ -953,7 +958,8 @@ class DW_ALBUM:
def _album_object_to_dict(self, album_obj: albumCbObject) -> dict: def _album_object_to_dict(self, album_obj: albumCbObject) -> dict:
"""Converts an albumObject to a dictionary for tagging and path generation.""" """Converts an albumObject to a dictionary for tagging and path generation."""
# Use the unified metadata converter # Use the unified metadata converter
return album_object_to_dict(album_obj, source_type='deezer') artist_separator = getattr(self.__preferences, 'artist_separator', '; ')
return album_object_to_dict(album_obj, source_type='deezer', artist_separator=artist_separator)
def _track_object_to_dict(self, track_obj: any, album_obj: albumCbObject) -> dict: def _track_object_to_dict(self, track_obj: any, album_obj: albumCbObject) -> dict:
"""Converts a track object to a dictionary with album context.""" """Converts a track object to a dictionary with album context."""
@@ -973,10 +979,12 @@ class DW_ALBUM:
genres=getattr(track_obj, 'genres', []) genres=getattr(track_obj, 'genres', [])
) )
# Use the unified metadata converter # Use the unified metadata converter
return track_object_to_dict(full_track, source_type='deezer') artist_separator = getattr(self.__preferences, 'artist_separator', '; ')
return track_object_to_dict(full_track, source_type='deezer', artist_separator=artist_separator)
else: else:
# Use the unified metadata converter # Use the unified metadata converter
return track_object_to_dict(track_obj, source_type='deezer') artist_separator = getattr(self.__preferences, 'artist_separator', '; ')
return track_object_to_dict(track_obj, source_type='deezer', artist_separator=artist_separator)
def __init__( def __init__(
self, self,
@@ -992,6 +1000,7 @@ class DW_ALBUM:
self.__recursive_quality = self.__preferences.recursive_quality self.__recursive_quality = self.__preferences.recursive_quality
album_obj: albumCbObject = self.__preferences.song_metadata album_obj: albumCbObject = self.__preferences.song_metadata
self.__song_metadata = self._album_object_to_dict(album_obj) self.__song_metadata = self._album_object_to_dict(album_obj)
self.__song_metadata['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
def dw(self) -> Album: def dw(self) -> Album:
from deezspot.deezloader.deegw_api import API_GW from deezspot.deezloader.deegw_api import API_GW
@@ -1139,7 +1148,8 @@ class DW_PLAYLIST:
def _track_object_to_dict(self, track_obj: any) -> dict: def _track_object_to_dict(self, track_obj: any) -> dict:
# Use the unified metadata converter # Use the unified metadata converter
return track_object_to_dict(track_obj, source_type='deezer') artist_separator = getattr(self.__preferences, 'artist_separator', '; ')
return track_object_to_dict(track_obj, source_type='deezer', artist_separator=artist_separator)
def dw(self) -> Playlist: def dw(self) -> Playlist:
playlist_obj: playlistCbObject = self.__preferences.json_data playlist_obj: playlistCbObject = self.__preferences.json_data

View File

@@ -125,7 +125,8 @@ class DeeLogin:
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market=stock_market, market=stock_market,
playlist_context=None playlist_context=None,
artist_separator: str = "; "
) -> Track: ) -> Track:
link_is_valid(link_track) link_is_valid(link_track)
@@ -192,6 +193,7 @@ class DeeLogin:
preferences.bitrate = bitrate preferences.bitrate = bitrate
preferences.save_cover = save_cover preferences.save_cover = save_cover
preferences.market = market preferences.market = market
preferences.artist_separator = artist_separator
if playlist_context: if playlist_context:
preferences.json_data = playlist_context['json_data'] preferences.json_data = playlist_context['json_data']
@@ -226,7 +228,8 @@ class DeeLogin:
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market=stock_market, market=stock_market,
playlist_context=None playlist_context=None,
artist_separator: str = "; "
) -> Album: ) -> Album:
link_is_valid(link_album) link_is_valid(link_album)
@@ -273,6 +276,7 @@ class DeeLogin:
preferences.bitrate = bitrate preferences.bitrate = bitrate
preferences.save_cover = save_cover preferences.save_cover = save_cover
preferences.market = market preferences.market = market
preferences.artist_separator = artist_separator
if playlist_context: if playlist_context:
preferences.json_data = playlist_context['json_data'] preferences.json_data = playlist_context['json_data']
@@ -305,7 +309,8 @@ class DeeLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market=stock_market market=stock_market,
artist_separator: str = "; "
) -> Playlist: ) -> Playlist:
link_is_valid(link_playlist) link_is_valid(link_playlist)
@@ -339,6 +344,7 @@ class DeeLogin:
preferences.bitrate = bitrate preferences.bitrate = bitrate
preferences.save_cover = save_cover preferences.save_cover = save_cover
preferences.market = market preferences.market = market
preferences.artist_separator = artist_separator
playlist = DW_PLAYLIST(preferences).dw() playlist = DW_PLAYLIST(preferences).dw()
@@ -591,7 +597,8 @@ class DeeLogin:
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market=stock_market, market=stock_market,
playlist_context=None playlist_context=None,
artist_separator: str = "; "
) -> Track: ) -> Track:
link_dee = self.convert_spoty_to_dee_link_track(link_track) link_dee = self.convert_spoty_to_dee_link_track(link_track)
@@ -613,7 +620,8 @@ class DeeLogin:
bitrate=bitrate, bitrate=bitrate,
save_cover=save_cover, save_cover=save_cover,
market=market, market=market,
playlist_context=playlist_context playlist_context=playlist_context,
artist_separator=artist_separator
) )
return track return track
@@ -636,7 +644,8 @@ class DeeLogin:
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market=stock_market, market=stock_market,
playlist_context=None playlist_context=None,
artist_separator: str = "; "
) -> Album: ) -> Album:
link_dee = self.convert_spoty_to_dee_link_album(link_album) link_dee = self.convert_spoty_to_dee_link_album(link_album)
@@ -656,7 +665,8 @@ class DeeLogin:
bitrate=bitrate, bitrate=bitrate,
save_cover=save_cover, save_cover=save_cover,
market=market, market=market,
playlist_context=playlist_context playlist_context=playlist_context,
artist_separator=artist_separator
) )
return album return album
@@ -678,7 +688,8 @@ class DeeLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market=stock_market market=stock_market,
artist_separator: str = "; "
) -> Playlist: ) -> Playlist:
link_is_valid(link_playlist) link_is_valid(link_playlist)
@@ -821,7 +832,8 @@ class DeeLogin:
custom_track_format=custom_track_format, pad_tracks=pad_tracks, custom_track_format=custom_track_format, pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay, retry_delay_increase=retry_delay_increase, initial_retry_delay=initial_retry_delay, retry_delay_increase=retry_delay_increase,
max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, max_retries=max_retries, convert_to=convert_to, bitrate=bitrate,
save_cover=save_cover, market=market, playlist_context=playlist_context save_cover=save_cover, market=market, playlist_context=playlist_context,
artist_separator=artist_separator
) )
tracks.append(downloaded_track) tracks.append(downloaded_track)
@@ -898,7 +910,8 @@ class DeeLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market=stock_market market=stock_market,
artist_separator: str = "; "
) -> Track: ) -> Track:
query = f"track:{song} artist:{artist}" query = f"track:{song} artist:{artist}"
@@ -928,7 +941,8 @@ class DeeLogin:
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate, bitrate=bitrate,
save_cover=save_cover, save_cover=save_cover,
market=market market=market,
artist_separator=artist_separator
) )
return track return track
@@ -950,7 +964,8 @@ class DeeLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market=stock_market market=stock_market,
artist_separator: str = "; "
) -> Episode: ) -> Episode:
logger.warning("Episode download logic is not fully refactored and might not work as expected with new reporting.") logger.warning("Episode download logic is not fully refactored and might not work as expected with new reporting.")
@@ -990,6 +1005,7 @@ class DeeLogin:
preferences.save_cover = save_cover preferences.save_cover = save_cover
preferences.is_episode = True preferences.is_episode = True
preferences.market = market preferences.market = market
preferences.artist_separator = artist_separator
episode = DW_EPISODE(preferences).dw() episode = DW_EPISODE(preferences).dw()
@@ -1012,7 +1028,8 @@ class DeeLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market=stock_market market=stock_market,
artist_separator: str = "; "
) -> Smart: ) -> Smart:
link_is_valid(link) link_is_valid(link)
@@ -1040,7 +1057,7 @@ class DeeLogin:
custom_track_format=custom_track_format, pad_tracks=pad_tracks, custom_track_format=custom_track_format, pad_tracks=pad_tracks,
initial_retry_delay=initial_retry_delay, retry_delay_increase=retry_delay_increase, initial_retry_delay=initial_retry_delay, retry_delay_increase=retry_delay_increase,
max_retries=max_retries, convert_to=convert_to, bitrate=bitrate, max_retries=max_retries, convert_to=convert_to, bitrate=bitrate,
save_cover=save_cover, market=market save_cover=save_cover, market=market, artist_separator=artist_separator
) )
smart.type = "track" smart.type = "track"
smart.track = track smart.track = track
@@ -1055,7 +1072,7 @@ class DeeLogin:
pad_tracks=pad_tracks, initial_retry_delay=initial_retry_delay, pad_tracks=pad_tracks, initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase, max_retries=max_retries, retry_delay_increase=retry_delay_increase, max_retries=max_retries,
convert_to=convert_to, bitrate=bitrate, save_cover=save_cover, convert_to=convert_to, bitrate=bitrate, save_cover=save_cover,
market=market market=market, artist_separator=artist_separator
) )
smart.type = "album" smart.type = "album"
smart.album = album smart.album = album
@@ -1070,7 +1087,7 @@ class DeeLogin:
pad_tracks=pad_tracks, initial_retry_delay=initial_retry_delay, pad_tracks=pad_tracks, initial_retry_delay=initial_retry_delay,
retry_delay_increase=retry_delay_increase, max_retries=max_retries, retry_delay_increase=retry_delay_increase, max_retries=max_retries,
convert_to=convert_to, bitrate=bitrate, save_cover=save_cover, convert_to=convert_to, bitrate=bitrate, save_cover=save_cover,
market=market market=market, artist_separator=artist_separator
) )
smart.type = "playlist" smart.type = "playlist"
smart.playlist = playlist smart.playlist = playlist

View File

@@ -108,7 +108,7 @@ def _get_best_image_url(images: Any, source_type: str) -> Optional[str]:
return None return None
def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> Dict[str, Any]: def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None, artist_separator: str = "; ") -> Dict[str, Any]:
""" """
Convert a track object to a dictionary format for tagging. Convert a track object to a dictionary format for tagging.
Supports both Spotify and Deezer track objects. Supports both Spotify and Deezer track objects.
@@ -116,6 +116,7 @@ def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> D
Args: Args:
track_obj: Track object from Spotify or Deezer API track_obj: Track object from Spotify or Deezer API
source_type: Optional source type ('spotify' or 'deezer'). If None, auto-detected. source_type: Optional source type ('spotify' or 'deezer'). If None, auto-detected.
artist_separator: Separator string for joining multiple artists
Returns: Returns:
Dictionary with standardized metadata tags Dictionary with standardized metadata tags
@@ -142,7 +143,7 @@ def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> D
# Artist information # Artist information
if hasattr(track_obj, 'artists') and track_obj.artists: if hasattr(track_obj, 'artists') and track_obj.artists:
tags['artist'] = "; ".join([getattr(artist, 'name', '') for artist in track_obj.artists]) tags['artist'] = artist_separator.join([getattr(artist, 'name', '') for artist in track_obj.artists])
else: else:
tags['artist'] = '' tags['artist'] = ''
@@ -157,7 +158,7 @@ def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> D
# Album artists # Album artists
if hasattr(album, 'artists') and album.artists: if hasattr(album, 'artists') and album.artists:
tags['ar_album'] = "; ".join([getattr(artist, 'name', '') for artist in album.artists]) tags['ar_album'] = artist_separator.join([getattr(artist, 'name', '') for artist in album.artists])
else: else:
tags['ar_album'] = '' tags['ar_album'] = ''
@@ -210,7 +211,7 @@ def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> D
return tags return tags
def album_object_to_dict(album_obj: Any, source_type: Optional[str] = None) -> Dict[str, Any]: def album_object_to_dict(album_obj: Any, source_type: Optional[str] = None, artist_separator: str = "; ") -> Dict[str, Any]:
""" """
Convert an album object to a dictionary format for tagging. Convert an album object to a dictionary format for tagging.
Supports both Spotify and Deezer album objects. Supports both Spotify and Deezer album objects.
@@ -218,6 +219,7 @@ def album_object_to_dict(album_obj: Any, source_type: Optional[str] = None) -> D
Args: Args:
album_obj: Album object from Spotify or Deezer API album_obj: Album object from Spotify or Deezer API
source_type: Optional source type ('spotify' or 'deezer'). If None, auto-detected. source_type: Optional source type ('spotify' or 'deezer'). If None, auto-detected.
artist_separator: Separator string for joining multiple album artists
Returns: Returns:
Dictionary with standardized metadata tags Dictionary with standardized metadata tags
@@ -236,7 +238,7 @@ def album_object_to_dict(album_obj: Any, source_type: Optional[str] = None) -> D
# Album artists # Album artists
if hasattr(album_obj, 'artists') and album_obj.artists: if hasattr(album_obj, 'artists') and album_obj.artists:
tags['ar_album'] = "; ".join([getattr(artist, 'name', '') for artist in album_obj.artists]) tags['ar_album'] = artist_separator.join([getattr(artist, 'name', '') for artist in album_obj.artists])
else: else:
tags['ar_album'] = '' tags['ar_album'] = ''

View File

@@ -1,6 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
import re import re
from unicodedata import normalize
from os import makedirs from os import makedirs
from datetime import datetime from datetime import datetime
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -43,6 +44,11 @@ def __check_dir(directory):
def sanitize_name(string, max_length=200): def sanitize_name(string, max_length=200):
"""Sanitize a string for use as a filename or directory name. """Sanitize a string for use as a filename or directory name.
This version maps filesystem-conflicting ASCII characters to Unicode
lookalikes (mostly fullwidth forms) rather than dropping or replacing
with ASCII fallbacks. This preserves readability while avoiding
path-separator or Windows-invalid characters.
Args: Args:
string: The string to sanitize string: The string to sanitize
max_length: Maximum length for the resulting string max_length: Maximum length for the resulting string
@@ -55,21 +61,23 @@ def sanitize_name(string, max_length=200):
# Convert to string if not already # Convert to string if not already
string = str(string) string = str(string)
# Enhance character replacement for filenames # Map invalid/reserved characters to Unicode fullwidth or similar lookalikes
# to avoid filesystem conflicts while keeping readability.
# Windows-invalid: < > : " / \ | ? * and control chars
replacements = { replacements = {
"\\": "-", # Backslash to hyphen "\\": "", # U+FF3C FULLWIDTH REVERSE SOLIDUS
"/": "-", # Forward slash to hyphen "/": "", # U+FF0F FULLWIDTH SOLIDUS
":": "-", # Colon to hyphen ":": "", # U+FF1A FULLWIDTH COLON
"*": "+", # Asterisk to plus "*": "", # U+FF0A FULLWIDTH ASTERISK
"?": "", # Question mark removed "?": "", # U+FF1F FULLWIDTH QUESTION MARK
"\"": "'", # Double quote to single quote "\"": "", # U+FF02 FULLWIDTH QUOTATION MARK
"<": "[", # Less than to open bracket "<": "", # U+FF1C FULLWIDTH LESS-THAN SIGN
">": "]", # Greater than to close bracket ">": "", # U+FF1E FULLWIDTH GREATER-THAN SIGN
"|": "-", # Pipe to hyphen "|": "", # U+FF5C FULLWIDTH VERTICAL LINE
"&": "and", # Ampersand to 'and' "&": "", # U+FF06 FULLWIDTH AMPERSAND
"$": "s", # Dollar to 's' "$": "", # U+FF04 FULLWIDTH DOLLAR SIGN
";": ",", # Semicolon to comma ";": "", # U+FF1B FULLWIDTH SEMICOLON
"\t": " ", # Tab to space "\t": " ", # Tab to space
"\n": " ", # Newline to space "\n": " ", # Newline to space
"\r": " ", # Carriage return to space "\r": " ", # Carriage return to space
@@ -78,26 +86,30 @@ def sanitize_name(string, max_length=200):
for old, new in replacements.items(): for old, new in replacements.items():
string = string.replace(old, new) string = string.replace(old, new)
# Remove any other non-printable characters # Remove any other non-printable characters
string = ''.join(char for char in string if char.isprintable()) string = ''.join(char for char in string if char.isprintable())
# Remove leading/trailing whitespace # Remove leading/trailing whitespace
string = string.strip() string = string.strip()
# Replace multiple spaces with a single space # Replace multiple spaces with a single space
string = re.sub(r'\s+', ' ', string) string = re.sub(r'\s+', ' ', string)
# Truncate if too long # Truncate if too long
if len(string) > max_length: if len(string) > max_length:
string = string[:max_length] string = string[:max_length]
# Ensure we don't end with a dot or space (can cause issues in some filesystems) # Ensure we don't end with a dot or space (can cause issues in some filesystems)
string = string.rstrip('. ') string = string.rstrip('. ')
# Provide a fallback for empty strings # Provide a fallback for empty strings
if not string: if not string:
string = "Unknown" string = "Unknown"
# Normalize to NFC to keep composed characters stable but avoid
# compatibility decomposition that might revert fullwidth mappings.
string = normalize('NFC', string)
return string return string
@@ -127,6 +139,11 @@ def apply_custom_format(format_str, metadata: dict, pad_tracks=True) -> str:
def replacer(match): def replacer(match):
full_key = match.group(1) # e.g., "artist", "ar_album_1" full_key = match.group(1) # e.g., "artist", "ar_album_1"
# Allow custom artist/album-artist separator to be provided via metadata
separator = metadata.get('artist_separator', ';')
if not isinstance(separator, str) or separator == "":
separator = ';'
# Check for specific indexed placeholders: artist_INDEX or ar_album_INDEX # Check for specific indexed placeholders: artist_INDEX or ar_album_INDEX
# Allows %artist_1%, %ar_album_1%, etc. # Allows %artist_1%, %ar_album_1%, etc.
indexed_artist_match = re.fullmatch(r'(artist|ar_album)_(\d+)', full_key) indexed_artist_match = re.fullmatch(r'(artist|ar_album)_(\d+)', full_key)
@@ -143,8 +160,8 @@ def apply_custom_format(format_str, metadata: dict, pad_tracks=True) -> str:
items = [] items = []
if isinstance(raw_value, str): if isinstance(raw_value, str):
# Split semicolon-separated strings and strip whitespace # Split by provided separator and strip whitespace
items = [item.strip() for item in raw_value.split(';') if item.strip()] items = [item.strip() for item in raw_value.split(separator) if item.strip()]
elif isinstance(raw_value, list): elif isinstance(raw_value, list):
# Convert all items to string, strip whitespace # Convert all items to string, strip whitespace
items = [str(item).strip() for item in raw_value if str(item).strip()] items = [str(item).strip() for item in raw_value if str(item).strip()]
@@ -193,13 +210,14 @@ def __get_dir(song_metadata, output_dir, custom_dir_format=None, pad_tracks=True
__check_dir(output_dir) __check_dir(output_dir)
return output_dir return output_dir
# Apply the custom format string. # Apply formatting per path component so only slashes from the format
# pad_tracks is passed along in case 'tracknum' or 'discnum' are used in dir format. # create directories; slashes from data are sanitized inside components.
formatted_path_segment = apply_custom_format(custom_dir_format, song_metadata, pad_tracks) format_parts = custom_dir_format.split("/")
formatted_parts = [
# Sanitize each component of the formatted path segment apply_custom_format(part, song_metadata, pad_tracks) for part in format_parts
]
sanitized_path_segment = "/".join( sanitized_path_segment = "/".join(
sanitize_name(part) for part in formatted_path_segment.split("/") sanitize_name(part) for part in formatted_parts
) )
# Join with the base output directory # Join with the base output directory

View File

@@ -10,74 +10,74 @@ from deezspot.models.download import Track
def create_m3u_file(output_dir: str, playlist_name: str) -> str: def create_m3u_file(output_dir: str, playlist_name: str) -> str:
""" """
Creates an m3u playlist file with the proper header. Creates an m3u playlist file with the proper header.
Returns full path to the m3u file.
Args:
output_dir: Base output directory
playlist_name: Name of the playlist (will be sanitized)
Returns:
str: Full path to the created m3u file
""" """
playlist_m3u_dir = os.path.join(output_dir, "playlists") playlist_m3u_dir = os.path.join(output_dir, "playlists")
os.makedirs(playlist_m3u_dir, exist_ok=True) os.makedirs(playlist_m3u_dir, exist_ok=True)
playlist_name_sanitized = sanitize_name(playlist_name) playlist_name_sanitized = sanitize_name(playlist_name)
m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u") m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u")
# Always ensure header exists (idempotent)
if not os.path.exists(m3u_path): if not os.path.exists(m3u_path):
with open(m3u_path, "w", encoding="utf-8") as m3u_file: with open(m3u_path, "w", encoding="utf-8") as m3u_file:
m3u_file.write("#EXTM3U\n") m3u_file.write("#EXTM3U\n")
logger.debug(f"Created m3u playlist file: {m3u_path}") logger.debug(f"Created m3u playlist file: {m3u_path}")
return m3u_path return m3u_path
def ensure_m3u_header(m3u_path: str) -> None:
"""Ensure an existing m3u has the header; create if missing."""
if not os.path.exists(m3u_path):
os.makedirs(os.path.dirname(m3u_path), exist_ok=True)
with open(m3u_path, "w", encoding="utf-8") as m3u_file:
m3u_file.write("#EXTM3U\n")
# Prefer the actual file that exists on disk; if the stored path doesn't exist,
# attempt to find the same basename with a different extension (e.g., due to conversion).
_AUDIO_EXTS_TRY = [
".flac", ".mp3", ".m4a", ".aac", ".alac", ".ogg", ".opus", ".wav", ".aiff"
]
def _resolve_existing_song_path(song_path: str) -> Union[str, None]:
if not song_path:
return None
if os.path.exists(song_path):
return song_path
base, _ = os.path.splitext(song_path)
for ext in _AUDIO_EXTS_TRY:
candidate = base + ext
if os.path.exists(candidate):
return candidate
return None
def _get_track_duration_seconds(track: Track) -> int: def _get_track_duration_seconds(track: Track) -> int:
"""
Extract track duration in seconds from track metadata.
Args:
track: Track object
Returns:
int: Duration in seconds, defaults to 0 if not available
"""
try: try:
# Try to get duration from tags first
if hasattr(track, 'tags') and track.tags: if hasattr(track, 'tags') and track.tags:
if 'duration' in track.tags: if 'duration' in track.tags:
return int(float(track.tags['duration'])) return int(float(track.tags['duration']))
elif 'length' in track.tags: elif 'length' in track.tags:
return int(float(track.tags['length'])) return int(float(track.tags['length']))
# Try to get from song_metadata if available
if hasattr(track, 'song_metadata') and hasattr(track.song_metadata, 'duration_ms'): if hasattr(track, 'song_metadata') and hasattr(track.song_metadata, 'duration_ms'):
return int(track.song_metadata.duration_ms / 1000) return int(track.song_metadata.duration_ms / 1000)
# Fallback to 0 if no duration found
return 0 return 0
except (ValueError, AttributeError, TypeError): except (ValueError, AttributeError, TypeError):
return 0 return 0
def _get_track_info(track: Track) -> tuple: def _get_track_info(track: Track) -> tuple:
"""
Extract artist and title information from track.
Args:
track: Track object
Returns:
tuple: (artist, title) strings
"""
try: try:
if hasattr(track, 'tags') and track.tags: if hasattr(track, 'tags') and track.tags:
artist = track.tags.get('artist', 'Unknown Artist') artist = track.tags.get('artist', 'Unknown Artist')
title = track.tags.get('music', track.tags.get('title', 'Unknown Title')) title = track.tags.get('music', track.tags.get('title', 'Unknown Title'))
return artist, title return artist, title
elif hasattr(track, 'song_metadata'): elif hasattr(track, 'song_metadata'):
sep = ", "
if hasattr(track, 'tags') and track.tags:
sep = track.tags.get('artist_separator', sep)
if hasattr(track.song_metadata, 'artists') and track.song_metadata.artists: if hasattr(track.song_metadata, 'artists') and track.song_metadata.artists:
artist = ', '.join([a.name for a in track.song_metadata.artists]) artist = sep.join([a.name for a in track.song_metadata.artists])
else: else:
artist = 'Unknown Artist' artist = 'Unknown Artist'
title = getattr(track.song_metadata, 'title', 'Unknown Title') title = getattr(track.song_metadata, 'title', 'Unknown Title')
@@ -89,40 +89,28 @@ def _get_track_info(track: Track) -> tuple:
def append_track_to_m3u(m3u_path: str, track: Union[str, Track]) -> None: def append_track_to_m3u(m3u_path: str, track: Union[str, Track]) -> None:
""" """Append a single track to m3u with EXTINF and a resolved path."""
Appends a single track to an existing m3u file with extended format. ensure_m3u_header(m3u_path)
Args:
m3u_path: Full path to the m3u file
track: Track object or string path to track file
"""
if isinstance(track, str): if isinstance(track, str):
# Legacy support for string paths resolved = _resolve_existing_song_path(track)
track_path = track if not resolved:
if not track_path or not os.path.exists(track_path):
return return
playlist_m3u_dir = os.path.dirname(m3u_path) playlist_m3u_dir = os.path.dirname(m3u_path)
relative_path = os.path.relpath(track_path, start=playlist_m3u_dir) relative_path = os.path.relpath(resolved, start=playlist_m3u_dir)
with open(m3u_path, "a", encoding="utf-8") as m3u_file: with open(m3u_path, "a", encoding="utf-8") as m3u_file:
m3u_file.write(f"{relative_path}\n") m3u_file.write(f"{relative_path}\n")
else: else:
# Track object with full metadata
if (not isinstance(track, Track) or if (not isinstance(track, Track) or
not track.success or not track.success or
not hasattr(track, 'song_path') or not hasattr(track, 'song_path')):
not track.song_path or return
not os.path.exists(track.song_path)): resolved = _resolve_existing_song_path(track.song_path)
if not resolved:
return return
playlist_m3u_dir = os.path.dirname(m3u_path) playlist_m3u_dir = os.path.dirname(m3u_path)
relative_path = os.path.relpath(track.song_path, start=playlist_m3u_dir) relative_path = os.path.relpath(resolved, start=playlist_m3u_dir)
# Get track metadata
duration = _get_track_duration_seconds(track) duration = _get_track_duration_seconds(track)
artist, title = _get_track_info(track) artist, title = _get_track_info(track)
with open(m3u_path, "a", encoding="utf-8") as m3u_file: with open(m3u_path, "a", encoding="utf-8") as m3u_file:
m3u_file.write(f"#EXTINF:{duration},{artist} - {title}\n") m3u_file.write(f"#EXTINF:{duration},{artist} - {title}\n")
m3u_file.write(f"{relative_path}\n") m3u_file.write(f"{relative_path}\n")
@@ -130,57 +118,19 @@ def append_track_to_m3u(m3u_path: str, track: Union[str, Track]) -> None:
def write_tracks_to_m3u(output_dir: str, playlist_name: str, tracks: List[Track]) -> str: def write_tracks_to_m3u(output_dir: str, playlist_name: str, tracks: List[Track]) -> str:
""" """
Creates an m3u file and writes all successful tracks to it at once using extended format. Legacy batch method. Creates an m3u and writes provided tracks.
Prefer progressive usage: create_m3u_file(...) once, then append_track_to_m3u(...) per track.
Args:
output_dir: Base output directory
playlist_name: Name of the playlist (will be sanitized)
tracks: List of Track objects
Returns:
str: Full path to the created m3u file
""" """
playlist_m3u_dir = os.path.join(output_dir, "playlists") playlist_m3u_dir = os.path.join(output_dir, "playlists")
os.makedirs(playlist_m3u_dir, exist_ok=True) os.makedirs(playlist_m3u_dir, exist_ok=True)
m3u_path = os.path.join(playlist_m3u_dir, f"{sanitize_name(playlist_name)}.m3u")
playlist_name_sanitized = sanitize_name(playlist_name) ensure_m3u_header(m3u_path)
m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u") for track in tracks:
append_track_to_m3u(m3u_path, track)
with open(m3u_path, "w", encoding="utf-8") as m3u_file:
m3u_file.write("#EXTM3U\n")
for track in tracks:
if (isinstance(track, Track) and
track.success and
hasattr(track, 'song_path') and
track.song_path and
os.path.exists(track.song_path)):
relative_song_path = os.path.relpath(track.song_path, start=playlist_m3u_dir)
# Get track metadata
duration = _get_track_duration_seconds(track)
artist, title = _get_track_info(track)
# Write EXTINF line with duration and metadata
m3u_file.write(f"#EXTINF:{duration},{artist} - {title}\n")
m3u_file.write(f"{relative_song_path}\n")
logger.info(f"Created m3u playlist file at: {m3u_path}") logger.info(f"Created m3u playlist file at: {m3u_path}")
return m3u_path return m3u_path
def get_m3u_path(output_dir: str, playlist_name: str) -> str: def get_m3u_path(output_dir: str, playlist_name: str) -> str:
"""
Get the expected path for an m3u file without creating it.
Args:
output_dir: Base output directory
playlist_name: Name of the playlist (will be sanitized)
Returns:
str: Full path where the m3u file would be located
"""
playlist_m3u_dir = os.path.join(output_dir, "playlists") playlist_m3u_dir = os.path.join(output_dir, "playlists")
playlist_name_sanitized = sanitize_name(playlist_name) return os.path.join(playlist_m3u_dir, f"{sanitize_name(playlist_name)}.m3u")
return os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u")

View File

@@ -13,11 +13,13 @@ class Preferences:
self.recursive_download = None self.recursive_download = None
self.not_interface = None self.not_interface = None
self.make_zip = None self.make_zip = None
self.real_time_dl = None , self.real_time_dl = None
self.custom_dir_format = None, self.custom_dir_format = None
self.custom_track_format = None, self.custom_track_format = None
self.pad_tracks = True # Default to padded track numbers (01, 02, etc.) self.pad_tracks = True # Default to padded track numbers (01, 02, etc.)
self.initial_retry_delay = 30 # Default initial retry delay in seconds self.initial_retry_delay = 30 # Default initial retry delay in seconds
self.retry_delay_increase = 30 # Default increase in delay between retries in seconds self.retry_delay_increase = 30 # Default increase in delay between retries in seconds
self.max_retries = 5 # Default maximum number of retries per track self.max_retries = 5 # Default maximum number of retries per track
self.save_cover: bool = False # Option to save a cover.jpg image self.save_cover: bool = False # Option to save a cover.jpg image
# New: artist separator for joining multiple artists or album artists
self.artist_separator: str = "; "

View File

@@ -104,7 +104,13 @@ class EASY_DW:
self.__link = preferences.link self.__link = preferences.link
self.__output_dir = preferences.output_dir self.__output_dir = preferences.output_dir
self.__song_metadata = preferences.song_metadata self.__song_metadata = preferences.song_metadata
self.__song_metadata_dict = _track_object_to_dict(self.__song_metadata) # Convert song metadata to dict with configured artist separator
artist_separator = getattr(preferences, 'artist_separator', '; ')
if parent == 'album' and hasattr(self.__song_metadata, 'album'):
# When iterating album tracks later we will still need the separator, but for initial dict, use track conversion
self.__song_metadata_dict = track_object_to_dict(self.__song_metadata, source_type='spotify', artist_separator=artist_separator)
else:
self.__song_metadata_dict = track_object_to_dict(self.__song_metadata, source_type='spotify', artist_separator=artist_separator)
self.__not_interface = preferences.not_interface self.__not_interface = preferences.not_interface
self.__quality_download = preferences.quality_download or "NORMAL" self.__quality_download = preferences.quality_download or "NORMAL"
self.__recursive_download = preferences.recursive_download self.__recursive_download = preferences.recursive_download
@@ -136,6 +142,8 @@ class EASY_DW:
custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None) custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None)
custom_track_format = getattr(self.__preferences, 'custom_track_format', None) custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
pad_tracks = getattr(self.__preferences, 'pad_tracks', True) pad_tracks = getattr(self.__preferences, 'pad_tracks', True)
# Ensure the separator is available to formatting utils for indexed placeholders
self.__song_metadata_dict['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
self.__song_path = set_path( self.__song_path = set_path(
self.__song_metadata_dict, self.__song_metadata_dict,
self.__output_dir, self.__output_dir,
@@ -150,6 +158,7 @@ class EASY_DW:
custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None) custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None)
custom_track_format = getattr(self.__preferences, 'custom_track_format', None) custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
pad_tracks = getattr(self.__preferences, 'pad_tracks', True) pad_tracks = getattr(self.__preferences, 'pad_tracks', True)
self.__song_metadata_dict['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
self.__song_path = set_path( self.__song_path = set_path(
self.__song_metadata_dict, self.__song_metadata_dict,
self.__output_dir, self.__output_dir,
@@ -200,7 +209,7 @@ class EASY_DW:
parent_info = { parent_info = {
"type": "album", "type": "album",
"title": album_meta.title, "title": album_meta.title,
"artist": "; ".join([a.name for a in album_meta.artists]), "artist": getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in album_meta.artists]),
"total_tracks": total_tracks_val, "total_tracks": total_tracks_val,
"url": f"https://open.spotify.com/album/{album_meta.ids.spotify if album_meta.ids else ''}" "url": f"https://open.spotify.com/album/{album_meta.ids.spotify if album_meta.ids else ''}"
} }
@@ -313,7 +322,7 @@ class EASY_DW:
except Exception as e: except Exception as e:
song_title = self.__song_metadata.title song_title = self.__song_metadata.title
artist_name = "; ".join([a.name for a in self.__song_metadata.artists]) artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
error_message = f"Download failed for '{song_title}' by '{artist_name}' (URL: {self.__link}). Original error: {str(e)}" error_message = f"Download failed for '{song_title}' by '{artist_name}' (URL: {self.__link}). Original error: {str(e)}"
logger.error(error_message) logger.error(error_message)
traceback.print_exc() traceback.print_exc()
@@ -334,7 +343,7 @@ class EASY_DW:
# This handles cases where download_try didn't raise an exception but self.__c_track.success is still False. # This handles cases where download_try didn't raise an exception but self.__c_track.success is still False.
if hasattr(self, '_EASY_DW__c_track') and self.__c_track and not self.__c_track.success: if hasattr(self, '_EASY_DW__c_track') and self.__c_track and not self.__c_track.success:
song_title = self.__song_metadata.title song_title = self.__song_metadata.title
artist_name = "; ".join([a.name for a in self.__song_metadata.artists]) artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
original_error_msg = getattr(self.__c_track, 'error_message', "Download failed for an unspecified reason after attempt.") original_error_msg = getattr(self.__c_track, 'error_message', "Download failed for an unspecified reason after attempt.")
error_msg_template = "Cannot download '{title}' by '{artist}'. Reason: {reason}" error_msg_template = "Cannot download '{title}' by '{artist}'. Reason: {reason}"
final_error_msg = error_msg_template.format(title=song_title, artist=artist_name, reason=original_error_msg) final_error_msg = error_msg_template.format(title=song_title, artist=artist_name, reason=original_error_msg)
@@ -362,7 +371,7 @@ class EASY_DW:
def download_try(self) -> Track: def download_try(self) -> Track:
current_title = self.__song_metadata.title current_title = self.__song_metadata.title
current_album = self.__song_metadata.album.title if self.__song_metadata.album else '' current_album = self.__song_metadata.album.title if self.__song_metadata.album else ''
current_artist = "; ".join([a.name for a in self.__song_metadata.artists]) current_artist = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
# Call the new check_track_exists function from skip_detection.py # Call the new check_track_exists function from skip_detection.py
# It needs: original_song_path, title, album, convert_to, logger # It needs: original_song_path, title, album, convert_to, logger
@@ -571,7 +580,7 @@ class EASY_DW:
os.remove(self.__song_path) os.remove(self.__song_path)
# Add track info to exception # Add track info to exception
track_name = self.__song_metadata.title track_name = self.__song_metadata.title
artist_name = "; ".join([a.name for a in self.__song_metadata.artists]) artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
final_error_msg = f"Maximum retry limit reached for '{track_name}' by '{artist_name}' (local: {max_retries}, global: {GLOBAL_MAX_RETRIES}). Last error: {str(e)}" final_error_msg = f"Maximum retry limit reached for '{track_name}' by '{artist_name}' (local: {max_retries}, global: {GLOBAL_MAX_RETRIES}). Last error: {str(e)}"
# Store error on track object # Store error on track object
if hasattr(self, '_EASY_DW__c_track') and self.__c_track: if hasattr(self, '_EASY_DW__c_track') and self.__c_track:
@@ -736,7 +745,7 @@ class EASY_DW:
if os.path.exists(self.__song_path): if os.path.exists(self.__song_path):
os.remove(self.__song_path) # Clean up partial file os.remove(self.__song_path) # Clean up partial file
track_name = self.__song_metadata.title track_name = self.__song_metadata.title
artist_name = "; ".join([a.name for a in self.__song_metadata.artists]) artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
final_error_msg = f"Maximum retry limit reached for '{track_name}' by '{artist_name}' (local: {max_retries}, global: {GLOBAL_MAX_RETRIES}). Last error: {str(e)}" final_error_msg = f"Maximum retry limit reached for '{track_name}' by '{artist_name}' (local: {max_retries}, global: {GLOBAL_MAX_RETRIES}). Last error: {str(e)}"
if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode: if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode:
self.__c_episode.success = False self.__c_episode.success = False
@@ -788,7 +797,7 @@ class EASY_DW:
pass pass
unregister_active_download(self.__song_path) unregister_active_download(self.__song_path)
episode_title = self.__song_metadata.title episode_title = self.__song_metadata.title
artist_name = "; ".join([a.name for a in self.__song_metadata.artists]) artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
final_error_msg = f"Error during real-time download for episode '{episode_title}' by '{artist_name}' (URL: {self.__link}). Error: {str(e_realtime)}" final_error_msg = f"Error during real-time download for episode '{episode_title}' by '{artist_name}' (URL: {self.__link}). Error: {str(e_realtime)}"
logger.error(final_error_msg) logger.error(final_error_msg)
if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode: if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode:
@@ -814,7 +823,7 @@ class EASY_DW:
pass pass
unregister_active_download(self.__song_path) unregister_active_download(self.__song_path)
episode_title = self.__song_metadata.title episode_title = self.__song_metadata.title
artist_name = "; ".join([a.name for a in self.__song_metadata.artists]) artist_name = getattr(self.__preferences, 'artist_separator', '; ').join([a.name for a in self.__song_metadata.artists])
final_error_msg = f"Error during standard download for episode '{episode_title}' by '{artist_name}' (URL: {self.__link}). Error: {str(e_standard)}" final_error_msg = f"Error during standard download for episode '{episode_title}' by '{artist_name}' (URL: {self.__link}). Error: {str(e_standard)}"
logger.error(final_error_msg) logger.error(final_error_msg)
if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode: if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode:
@@ -953,7 +962,8 @@ class DW_ALBUM:
album.upc = album_obj.ids.upc album.upc = album_obj.ids.upc
tracks = album.tracks tracks = album.tracks
album.md5_image = self.__ids album.md5_image = self.__ids
album.tags = _album_object_to_dict(self.__album_metadata) # For top-level album tags if needed album.tags = album_object_to_dict(self.__album_metadata, source_type='spotify', artist_separator=getattr(self.__preferences, 'artist_separator', '; ')) # For top-level album tags if needed
album.tags['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
album_base_directory = get_album_directory( album_base_directory = get_album_directory(
album.tags, album.tags,

View File

@@ -97,7 +97,8 @@ class SpoLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market: list[str] | None = stock_market market: list[str] | None = stock_market,
artist_separator: str = "; "
) -> Track: ) -> Track:
song_metadata = None song_metadata = None
try: try:
@@ -108,7 +109,7 @@ class SpoLogin:
if song_metadata is None: 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.") 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.title} - {'; '.join([a.name for a in song_metadata.artists])}") logger.info(f"Starting download for track: {song_metadata.title} - {artist_separator.join([a.name for a in song_metadata.artists])}")
preferences = Preferences() preferences = Preferences()
preferences.real_time_dl = real_time_dl preferences.real_time_dl = real_time_dl
@@ -135,6 +136,7 @@ class SpoLogin:
preferences.bitrate = bitrate preferences.bitrate = bitrate
preferences.save_cover = save_cover preferences.save_cover = save_cover
preferences.market = market preferences.market = market
preferences.artist_separator = artist_separator
track = DW_TRACK(preferences).dw() track = DW_TRACK(preferences).dw()
@@ -179,7 +181,8 @@ class SpoLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market: list[str] | None = stock_market market: list[str] | None = stock_market,
artist_separator: str = "; "
) -> Album: ) -> Album:
try: try:
link_is_valid(link_album) link_is_valid(link_album)
@@ -192,7 +195,7 @@ class SpoLogin:
if song_metadata is None: 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.") 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.title} - {'; '.join([a.name for a in song_metadata.artists])}") logger.info(f"Starting download for album: {song_metadata.title} - {artist_separator.join([a.name for a in song_metadata.artists])}")
preferences = Preferences() preferences = Preferences()
preferences.real_time_dl = real_time_dl preferences.real_time_dl = real_time_dl
@@ -221,6 +224,7 @@ class SpoLogin:
preferences.bitrate = bitrate preferences.bitrate = bitrate
preferences.save_cover = save_cover preferences.save_cover = save_cover
preferences.market = market preferences.market = market
preferences.artist_separator = artist_separator
album = DW_ALBUM(preferences).dw() album = DW_ALBUM(preferences).dw()
@@ -251,7 +255,8 @@ class SpoLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market: list[str] | None = stock_market market: list[str] | None = stock_market,
artist_separator: str = "; "
) -> Playlist: ) -> Playlist:
try: try:
link_is_valid(link_playlist) link_is_valid(link_playlist)
@@ -327,6 +332,7 @@ class SpoLogin:
preferences.bitrate = bitrate preferences.bitrate = bitrate
preferences.save_cover = save_cover preferences.save_cover = save_cover
preferences.market = market preferences.market = market
preferences.artist_separator = artist_separator
playlist = DW_PLAYLIST(preferences).dw() playlist = DW_PLAYLIST(preferences).dw()
@@ -356,7 +362,8 @@ class SpoLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market: list[str] | None = stock_market market: list[str] | None = stock_market,
artist_separator: str = "; "
) -> Episode: ) -> Episode:
try: try:
link_is_valid(link_episode) link_is_valid(link_episode)
@@ -396,6 +403,7 @@ class SpoLogin:
preferences.bitrate = bitrate preferences.bitrate = bitrate
preferences.save_cover = save_cover preferences.save_cover = save_cover
preferences.market = market preferences.market = market
preferences.artist_separator = artist_separator
episode = DW_EPISODE(preferences).dw() episode = DW_EPISODE(preferences).dw()
@@ -428,7 +436,8 @@ class SpoLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
market: list[str] | None = stock_market, market: list[str] | None = stock_market,
save_cover=stock_save_cover save_cover=stock_save_cover,
artist_separator: str = "; "
): ):
""" """
Download all albums (or a subset based on album_type and limit) from an artist. Download all albums (or a subset based on album_type and limit) from an artist.
@@ -468,7 +477,8 @@ class SpoLogin:
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate, bitrate=bitrate,
market=market, market=market,
save_cover=save_cover save_cover=save_cover,
artist_separator=artist_separator
) )
downloaded_albums.append(downloaded_album) downloaded_albums.append(downloaded_album)
return downloaded_albums return downloaded_albums
@@ -495,7 +505,8 @@ class SpoLogin:
convert_to=None, convert_to=None,
bitrate=None, bitrate=None,
save_cover=stock_save_cover, save_cover=stock_save_cover,
market: list[str] | None = stock_market market: list[str] | None = stock_market,
artist_separator: str = "; "
) -> Smart: ) -> Smart:
try: try:
link_is_valid(link) link_is_valid(link)
@@ -528,7 +539,8 @@ class SpoLogin:
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate, bitrate=bitrate,
save_cover=save_cover, save_cover=save_cover,
market=market market=market,
artist_separator=artist_separator
) )
smart.type = "track" smart.type = "track"
smart.track = track smart.track = track
@@ -554,7 +566,8 @@ class SpoLogin:
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate, bitrate=bitrate,
save_cover=save_cover, save_cover=save_cover,
market=market market=market,
artist_separator=artist_separator
) )
smart.type = "album" smart.type = "album"
smart.album = album smart.album = album
@@ -580,7 +593,8 @@ class SpoLogin:
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate, bitrate=bitrate,
save_cover=save_cover, save_cover=save_cover,
market=market market=market,
artist_separator=artist_separator
) )
smart.type = "playlist" smart.type = "playlist"
smart.playlist = playlist smart.playlist = playlist
@@ -605,7 +619,8 @@ class SpoLogin:
convert_to=convert_to, convert_to=convert_to,
bitrate=bitrate, bitrate=bitrate,
save_cover=save_cover, save_cover=save_cover,
market=market market=market,
artist_separator=artist_separator
) )
smart.type = "episode" smart.type = "episode"
smart.episode = episode smart.episode = episode