diff --git a/deezspot/deezloader/__download__.py b/deezspot/deezloader/__download__.py index cc05d3c..fea9288 100644 --- a/deezspot/deezloader/__download__.py +++ b/deezspot/deezloader/__download__.py @@ -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 diff --git a/deezspot/deezloader/__init__.py b/deezspot/deezloader/__init__.py index be3eaf4..463df56 100644 --- a/deezspot/deezloader/__init__.py +++ b/deezspot/deezloader/__init__.py @@ -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 diff --git a/deezspot/libutils/metadata_converter.py b/deezspot/libutils/metadata_converter.py index 21a691f..89180c9 100644 --- a/deezspot/libutils/metadata_converter.py +++ b/deezspot/libutils/metadata_converter.py @@ -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'] = '' diff --git a/deezspot/libutils/utils.py b/deezspot/libutils/utils.py index 9c03b08..00ca297 100644 --- a/deezspot/libutils/utils.py +++ b/deezspot/libutils/utils.py @@ -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 @@ -55,21 +61,23 @@ 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 @@ -78,26 +86,30 @@ def sanitize_name(string, max_length=200): for old, new in replacements.items(): string = string.replace(old, new) - + # Remove any other non-printable characters string = ''.join(char for char in string if char.isprintable()) - + # Remove leading/trailing whitespace string = string.strip() - + # Replace multiple spaces with a single space string = re.sub(r'\s+', ' ', string) - + # Truncate if too long if len(string) > max_length: string = string[:max_length] # Ensure we don't end with a dot or space (can cause issues in some filesystems) string = string.rstrip('. ') - + # Provide a fallback for empty strings 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 @@ -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 diff --git a/deezspot/libutils/write_m3u.py b/deezspot/libutils/write_m3u.py index 4f7d9a6..afcbde1 100644 --- a/deezspot/libutils/write_m3u.py +++ b/deezspot/libutils/write_m3u.py @@ -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") - - 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") - + m3u_path = os.path.join(playlist_m3u_dir, f"{sanitize_name(playlist_name)}.m3u") + ensure_m3u_header(m3u_path) + for track in tracks: + 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") \ No newline at end of file + return os.path.join(playlist_m3u_dir, f"{sanitize_name(playlist_name)}.m3u") \ No newline at end of file diff --git a/deezspot/models/download/preferences.py b/deezspot/models/download/preferences.py index c72e08b..de13682 100644 --- a/deezspot/models/download/preferences.py +++ b/deezspot/models/download/preferences.py @@ -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 \ No newline at end of file + 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 = "; " \ No newline at end of file diff --git a/deezspot/spotloader/__download__.py b/deezspot/spotloader/__download__.py index 4f4e956..3b251e4 100644 --- a/deezspot/spotloader/__download__.py +++ b/deezspot/spotloader/__download__.py @@ -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, diff --git a/deezspot/spotloader/__init__.py b/deezspot/spotloader/__init__.py index 90309e9..13d3ef5 100644 --- a/deezspot/spotloader/__init__.py +++ b/deezspot/spotloader/__init__.py @@ -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