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
# 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.__c_quality = qualities[self.__quality_download]
@@ -324,7 +327,8 @@ class EASY_DW:
It intelligently finds the album information based on the download context.
"""
# 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
# 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_track_format = getattr(self.__preferences, 'custom_track_format', None)
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_metadata,
self.__song_metadata_dict,
self.__output_dir,
self.__song_quality,
self.__file_format,
@@ -360,7 +365,7 @@ class EASY_DW:
custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
pad_tracks = getattr(self.__preferences, 'pad_tracks', True)
self.__song_path = set_path(
self.__song_metadata,
self.__song_metadata_dict,
self.__output_dir,
self.__song_quality,
self.__file_format,
@@ -953,7 +958,8 @@ class DW_ALBUM:
def _album_object_to_dict(self, album_obj: albumCbObject) -> dict:
"""Converts an albumObject to a dictionary for tagging and path generation."""
# 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:
"""Converts a track object to a dictionary with album context."""
@@ -973,10 +979,12 @@ class DW_ALBUM:
genres=getattr(track_obj, 'genres', [])
)
# 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:
# 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__(
self,
@@ -992,6 +1000,7 @@ class DW_ALBUM:
self.__recursive_quality = self.__preferences.recursive_quality
album_obj: albumCbObject = self.__preferences.song_metadata
self.__song_metadata = self._album_object_to_dict(album_obj)
self.__song_metadata['artist_separator'] = getattr(self.__preferences, 'artist_separator', '; ')
def dw(self) -> Album:
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:
# 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:
playlist_obj: playlistCbObject = self.__preferences.json_data

View File

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

View File

@@ -108,7 +108,7 @@ def _get_best_image_url(images: Any, source_type: str) -> Optional[str]:
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.
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:
track_obj: Track object from Spotify or Deezer API
source_type: Optional source type ('spotify' or 'deezer'). If None, auto-detected.
artist_separator: Separator string for joining multiple artists
Returns:
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
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:
tags['artist'] = ''
@@ -157,7 +158,7 @@ def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> D
# 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:
tags['ar_album'] = ''
@@ -210,7 +211,7 @@ def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> D
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.
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:
album_obj: Album object from Spotify or Deezer API
source_type: Optional source type ('spotify' or 'deezer'). If None, auto-detected.
artist_separator: Separator string for joining multiple album artists
Returns:
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
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:
tags['ar_album'] = ''

View File

@@ -1,6 +1,7 @@
#!/usr/bin/python3
import re
from unicodedata import normalize
from os import makedirs
from datetime import datetime
from urllib.parse import urlparse
@@ -43,6 +44,11 @@ def __check_dir(directory):
def sanitize_name(string, max_length=200):
"""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:
string: The string to sanitize
max_length: Maximum length for the resulting string
@@ -56,20 +62,22 @@ def sanitize_name(string, max_length=200):
# Convert to string if not already
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 = {
"\\": "-", # Backslash to hyphen
"/": "-", # Forward slash to hyphen
":": "-", # Colon to hyphen
"*": "+", # Asterisk to plus
"?": "", # Question mark removed
"\"": "'", # Double quote to single quote
"<": "[", # Less than to open bracket
">": "]", # Greater than to close bracket
"|": "-", # Pipe to hyphen
"&": "and", # Ampersand to 'and'
"$": "s", # Dollar to 's'
";": ",", # Semicolon to comma
"\\": "", # U+FF3C FULLWIDTH REVERSE SOLIDUS
"/": "", # U+FF0F FULLWIDTH SOLIDUS
":": "", # U+FF1A FULLWIDTH COLON
"*": "", # U+FF0A FULLWIDTH ASTERISK
"?": "", # U+FF1F FULLWIDTH QUESTION MARK
"\"": "", # U+FF02 FULLWIDTH QUOTATION MARK
"<": "", # U+FF1C FULLWIDTH LESS-THAN SIGN
">": "", # U+FF1E FULLWIDTH GREATER-THAN SIGN
"|": "", # U+FF5C FULLWIDTH VERTICAL LINE
"&": "", # U+FF06 FULLWIDTH AMPERSAND
"$": "", # U+FF04 FULLWIDTH DOLLAR SIGN
";": "", # U+FF1B FULLWIDTH SEMICOLON
"\t": " ", # Tab to space
"\n": " ", # Newline to space
"\r": " ", # Carriage return to space
@@ -99,6 +107,10 @@ def sanitize_name(string, max_length=200):
if not string:
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
# Keep the original function name for backward compatibility
@@ -127,6 +139,11 @@ def apply_custom_format(format_str, metadata: dict, pad_tracks=True) -> str:
def replacer(match):
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
# Allows %artist_1%, %ar_album_1%, etc.
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 = []
if isinstance(raw_value, str):
# Split semicolon-separated strings and strip whitespace
items = [item.strip() for item in raw_value.split(';') if item.strip()]
# Split by provided separator and strip whitespace
items = [item.strip() for item in raw_value.split(separator) if item.strip()]
elif isinstance(raw_value, list):
# Convert all items to string, strip whitespace
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)
return output_dir
# Apply the custom format string.
# pad_tracks is passed along in case 'tracknum' or 'discnum' are used in dir format.
formatted_path_segment = apply_custom_format(custom_dir_format, song_metadata, pad_tracks)
# Sanitize each component of the formatted path segment
# Apply formatting per path component so only slashes from the format
# create directories; slashes from data are sanitized inside components.
format_parts = custom_dir_format.split("/")
formatted_parts = [
apply_custom_format(part, song_metadata, pad_tracks) for part in format_parts
]
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

View File

@@ -10,74 +10,74 @@ from deezspot.models.download import Track
def create_m3u_file(output_dir: str, playlist_name: str) -> str:
"""
Creates an m3u playlist file with the proper header.
Args:
output_dir: Base output directory
playlist_name: Name of the playlist (will be sanitized)
Returns:
str: Full path to the created m3u file
Returns full path to the m3u file.
"""
playlist_m3u_dir = os.path.join(output_dir, "playlists")
os.makedirs(playlist_m3u_dir, exist_ok=True)
playlist_name_sanitized = sanitize_name(playlist_name)
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):
with open(m3u_path, "w", encoding="utf-8") as m3u_file:
m3u_file.write("#EXTM3U\n")
logger.debug(f"Created m3u playlist file: {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:
"""
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 to get duration from tags first
if hasattr(track, 'tags') and track.tags:
if 'duration' in track.tags:
return int(float(track.tags['duration']))
elif 'length' in track.tags:
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'):
return int(track.song_metadata.duration_ms / 1000)
# Fallback to 0 if no duration found
return 0
except (ValueError, AttributeError, TypeError):
return 0
def _get_track_info(track: Track) -> tuple:
"""
Extract artist and title information from track.
Args:
track: Track object
Returns:
tuple: (artist, title) strings
"""
try:
if hasattr(track, 'tags') and track.tags:
artist = track.tags.get('artist', 'Unknown Artist')
title = track.tags.get('music', track.tags.get('title', 'Unknown Title'))
return artist, title
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:
artist = ', '.join([a.name for a in track.song_metadata.artists])
artist = sep.join([a.name for a in track.song_metadata.artists])
else:
artist = 'Unknown Artist'
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:
"""
Appends a single track to an existing m3u file with extended format.
Args:
m3u_path: Full path to the m3u file
track: Track object or string path to track file
"""
"""Append a single track to m3u with EXTINF and a resolved path."""
ensure_m3u_header(m3u_path)
if isinstance(track, str):
# Legacy support for string paths
track_path = track
if not track_path or not os.path.exists(track_path):
resolved = _resolve_existing_song_path(track)
if not resolved:
return
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:
m3u_file.write(f"{relative_path}\n")
else:
# Track object with full metadata
if (not isinstance(track, Track) or
not track.success or
not hasattr(track, 'song_path') or
not track.song_path or
not os.path.exists(track.song_path)):
not hasattr(track, 'song_path')):
return
resolved = _resolve_existing_song_path(track.song_path)
if not resolved:
return
playlist_m3u_dir = os.path.dirname(m3u_path)
relative_path = os.path.relpath(track.song_path, start=playlist_m3u_dir)
# Get track metadata
relative_path = os.path.relpath(resolved, start=playlist_m3u_dir)
duration = _get_track_duration_seconds(track)
artist, title = _get_track_info(track)
with open(m3u_path, "a", encoding="utf-8") as m3u_file:
m3u_file.write(f"#EXTINF:{duration},{artist} - {title}\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:
"""
Creates an m3u file and writes all successful tracks to it at once using extended format.
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
Legacy batch method. Creates an m3u and writes provided tracks.
Prefer progressive usage: create_m3u_file(...) once, then append_track_to_m3u(...) per track.
"""
playlist_m3u_dir = os.path.join(output_dir, "playlists")
os.makedirs(playlist_m3u_dir, exist_ok=True)
playlist_name_sanitized = sanitize_name(playlist_name)
m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u")
with open(m3u_path, "w", encoding="utf-8") as m3u_file:
m3u_file.write("#EXTM3U\n")
m3u_path = os.path.join(playlist_m3u_dir, f"{sanitize_name(playlist_name)}.m3u")
ensure_m3u_header(m3u_path)
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")
append_track_to_m3u(m3u_path, track)
logger.info(f"Created m3u playlist file at: {m3u_path}")
return m3u_path
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_name_sanitized = sanitize_name(playlist_name)
return os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u")
return os.path.join(playlist_m3u_dir, f"{sanitize_name(playlist_name)}.m3u")

View File

@@ -13,11 +13,13 @@ class Preferences:
self.recursive_download = None
self.not_interface = None
self.make_zip = None
self.real_time_dl = None ,
self.custom_dir_format = None,
self.custom_track_format = None,
self.real_time_dl = None
self.custom_dir_format = None
self.custom_track_format = None
self.pad_tracks = True # Default to padded track numbers (01, 02, etc.)
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.max_retries = 5 # Default maximum number of retries per track
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.__output_dir = preferences.output_dir
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.__quality_download = preferences.quality_download or "NORMAL"
self.__recursive_download = preferences.recursive_download
@@ -136,6 +142,8 @@ class EASY_DW:
custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None)
custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
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_metadata_dict,
self.__output_dir,
@@ -150,6 +158,7 @@ class EASY_DW:
custom_dir_format = getattr(self.__preferences, 'custom_dir_format', None)
custom_track_format = getattr(self.__preferences, 'custom_track_format', None)
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_metadata_dict,
self.__output_dir,
@@ -200,7 +209,7 @@ class EASY_DW:
parent_info = {
"type": "album",
"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,
"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:
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)}"
logger.error(error_message)
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.
if hasattr(self, '_EASY_DW__c_track') and self.__c_track and not self.__c_track.success:
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.")
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)
@@ -362,7 +371,7 @@ class EASY_DW:
def download_try(self) -> Track:
current_title = self.__song_metadata.title
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
# It needs: original_song_path, title, album, convert_to, logger
@@ -571,7 +580,7 @@ class EASY_DW:
os.remove(self.__song_path)
# Add track info to exception
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)}"
# Store error on track object
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):
os.remove(self.__song_path) # Clean up partial file
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)}"
if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode:
self.__c_episode.success = False
@@ -788,7 +797,7 @@ class EASY_DW:
pass
unregister_active_download(self.__song_path)
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)}"
logger.error(final_error_msg)
if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode:
@@ -814,7 +823,7 @@ class EASY_DW:
pass
unregister_active_download(self.__song_path)
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)}"
logger.error(final_error_msg)
if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode:
@@ -953,7 +962,8 @@ class DW_ALBUM:
album.upc = album_obj.ids.upc
tracks = album.tracks
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.tags,

View File

@@ -97,7 +97,8 @@ class SpoLogin:
convert_to=None,
bitrate=None,
save_cover=stock_save_cover,
market: list[str] | None = stock_market
market: list[str] | None = stock_market,
artist_separator: str = "; "
) -> Track:
song_metadata = None
try:
@@ -108,7 +109,7 @@ class SpoLogin:
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.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.real_time_dl = real_time_dl
@@ -135,6 +136,7 @@ class SpoLogin:
preferences.bitrate = bitrate
preferences.save_cover = save_cover
preferences.market = market
preferences.artist_separator = artist_separator
track = DW_TRACK(preferences).dw()
@@ -179,7 +181,8 @@ class SpoLogin:
convert_to=None,
bitrate=None,
save_cover=stock_save_cover,
market: list[str] | None = stock_market
market: list[str] | None = stock_market,
artist_separator: str = "; "
) -> Album:
try:
link_is_valid(link_album)
@@ -192,7 +195,7 @@ class SpoLogin:
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.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.real_time_dl = real_time_dl
@@ -221,6 +224,7 @@ class SpoLogin:
preferences.bitrate = bitrate
preferences.save_cover = save_cover
preferences.market = market
preferences.artist_separator = artist_separator
album = DW_ALBUM(preferences).dw()
@@ -251,7 +255,8 @@ class SpoLogin:
convert_to=None,
bitrate=None,
save_cover=stock_save_cover,
market: list[str] | None = stock_market
market: list[str] | None = stock_market,
artist_separator: str = "; "
) -> Playlist:
try:
link_is_valid(link_playlist)
@@ -327,6 +332,7 @@ class SpoLogin:
preferences.bitrate = bitrate
preferences.save_cover = save_cover
preferences.market = market
preferences.artist_separator = artist_separator
playlist = DW_PLAYLIST(preferences).dw()
@@ -356,7 +362,8 @@ class SpoLogin:
convert_to=None,
bitrate=None,
save_cover=stock_save_cover,
market: list[str] | None = stock_market
market: list[str] | None = stock_market,
artist_separator: str = "; "
) -> Episode:
try:
link_is_valid(link_episode)
@@ -396,6 +403,7 @@ class SpoLogin:
preferences.bitrate = bitrate
preferences.save_cover = save_cover
preferences.market = market
preferences.artist_separator = artist_separator
episode = DW_EPISODE(preferences).dw()
@@ -428,7 +436,8 @@ class SpoLogin:
convert_to=None,
bitrate=None,
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.
@@ -468,7 +477,8 @@ class SpoLogin:
convert_to=convert_to,
bitrate=bitrate,
market=market,
save_cover=save_cover
save_cover=save_cover,
artist_separator=artist_separator
)
downloaded_albums.append(downloaded_album)
return downloaded_albums
@@ -495,7 +505,8 @@ class SpoLogin:
convert_to=None,
bitrate=None,
save_cover=stock_save_cover,
market: list[str] | None = stock_market
market: list[str] | None = stock_market,
artist_separator: str = "; "
) -> Smart:
try:
link_is_valid(link)
@@ -528,7 +539,8 @@ class SpoLogin:
convert_to=convert_to,
bitrate=bitrate,
save_cover=save_cover,
market=market
market=market,
artist_separator=artist_separator
)
smart.type = "track"
smart.track = track
@@ -554,7 +566,8 @@ class SpoLogin:
convert_to=convert_to,
bitrate=bitrate,
save_cover=save_cover,
market=market
market=market,
artist_separator=artist_separator
)
smart.type = "album"
smart.album = album
@@ -580,7 +593,8 @@ class SpoLogin:
convert_to=convert_to,
bitrate=bitrate,
save_cover=save_cover,
market=market
market=market,
artist_separator=artist_separator
)
smart.type = "playlist"
smart.playlist = playlist
@@ -605,7 +619,8 @@ class SpoLogin:
convert_to=convert_to,
bitrate=bitrate,
save_cover=save_cover,
market=market
market=market,
artist_separator=artist_separator
)
smart.type = "episode"
smart.episode = episode