Unified a bunch of download logic, fixed m3u not registering file conversion

This commit is contained in:
Xoconoch
2025-08-01 15:57:51 -06:00
parent 24cf97c032
commit 2057c9c7e8
8 changed files with 1272 additions and 527 deletions

View File

@@ -37,6 +37,17 @@ from deezspot.libutils.utils import (
save_cover_image,
__get_dir as get_album_directory,
)
from deezspot.libutils.write_m3u import create_m3u_file, append_track_to_m3u
from deezspot.libutils.metadata_converter import track_object_to_dict, album_object_to_dict
from deezspot.libutils.progress_reporter import (
report_track_initializing, report_track_skipped, report_track_retrying,
report_track_realtime_progress, report_track_error, report_track_done,
report_album_initializing, report_album_done, report_playlist_initializing, report_playlist_done
)
from deezspot.libutils.taggers import (
enhance_metadata_with_image, add_deezer_enhanced_metadata, process_and_tag_track,
save_cover_image_for_track
)
from mutagen.flac import FLAC
from mutagen.mp3 import MP3
from mutagen.id3 import ID3
@@ -65,76 +76,21 @@ from deezspot.models.callback.playlist import playlistObject as playlistCbObject
from deezspot.models.callback.common import IDs
from deezspot.models.callback.user import userObject
# Use unified metadata converter
def _track_object_to_dict(track_obj: trackCbObject) -> dict:
"""
Convert a track object to a dictionary format for tagging.
Similar to spotloader's approach for consistent metadata handling.
"""
if not track_obj:
return {}
tags = {}
# Track details
tags['music'] = track_obj.title
tags['tracknum'] = track_obj.track_number if track_obj.track_number is not None else 0
tags['discnum'] = track_obj.disc_number if track_obj.disc_number is not None else 1
tags['duration'] = track_obj.duration_ms // 1000 if track_obj.duration_ms else 0
tags['explicit'] = track_obj.explicit
if track_obj.ids:
tags['ids'] = track_obj.ids.deezer
tags['isrc'] = track_obj.ids.isrc
tags['artist'] = "; ".join([artist.name for artist in track_obj.artists])
# Album details
if track_obj.album:
album = track_obj.album
tags['album'] = album.title
tags['ar_album'] = "; ".join([artist.name for artist in album.artists])
tags['nb_tracks'] = album.total_tracks
if album.release_date:
tags['year'] = album.release_date.get('year', 0)
if album.ids:
tags['upc'] = album.ids.upc
tags['album_id'] = album.ids.deezer
tags['genre'] = "; ".join(album.genres) if album.genres else ""
# Extract image information if available
if hasattr(album, 'images') and album.images:
tags['image'] = album.images
return tags
return track_object_to_dict(track_obj, source_type='deezer')
# Use unified metadata converter
def _album_object_to_dict(album_obj: albumCbObject) -> dict:
"""
Convert an album object to a dictionary format for tagging.
Similar to spotloader's approach for consistent metadata handling.
"""
if not album_obj:
return {}
tags = {}
# Album details
tags['album'] = album_obj.title
tags['ar_album'] = "; ".join([artist.name for artist in album_obj.artists])
tags['nb_tracks'] = album_obj.total_tracks
if album_obj.release_date:
tags['year'] = album_obj.release_date.get('year', 0)
if album_obj.ids:
tags['upc'] = album_obj.ids.upc
tags['album_id'] = album_obj.ids.deezer
if hasattr(album_obj, 'images') and album_obj.images:
tags['image'] = album_obj.images
tags['genre'] = "; ".join(album_obj.genres) if album_obj.genres else ""
return tags
return album_object_to_dict(album_obj, source_type='deezer')
class Download_JOB:
progress_reporter = None
@@ -367,18 +323,9 @@ class EASY_DW:
expected by legacy tagging and path functions.
It intelligently finds the album information based on the download context.
"""
# Use the new global helper function for basic conversion
metadata_dict = _track_object_to_dict(track_obj)
# Use the unified metadata converter
metadata_dict = track_object_to_dict(track_obj, source_type='deezer')
# Add Deezer-specific metadata handling that isn't in the basic helper
# For full album downloads, use complete album data from preferences if available
if self.__parent == 'album' and hasattr(self.__preferences, 'json_data'):
album_data_source = self.__preferences.json_data
# Update album-related fields with more complete information
if hasattr(album_data_source, 'label'):
metadata_dict['label'] = album_data_source.label
# 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
if self.__infos_dw:
@@ -386,13 +333,6 @@ class EASY_DW:
metadata_dict['tracknum'] = self.__infos_dw['track_position']
if 'disk_number' in self.__infos_dw:
metadata_dict['discnum'] = self.__infos_dw['disk_number']
# Check for contributors from original API data
if 'contributors' in self.__infos_dw:
# Filter for main artists only
main_artists = [c['name'] for c in self.__infos_dw['contributors'] if c.get('role') == 'Main']
if main_artists:
metadata_dict['album_artist'] = "; ".join(main_artists)
return metadata_dict
@@ -454,12 +394,16 @@ class EASY_DW:
self.__c_episode.set_fallback_ids(self.__fallback_ids)
def easy_dw(self) -> Track:
# Get image URL and enhance metadata
if self.__infos_dw.get('__TYPE__') == 'episode':
pic = self.__infos_dw.get('EPISODE_IMAGE_MD5', '')
else:
pic = self.__infos_dw['ALB_PICTURE']
image = API.choose_img(pic)
self.__song_metadata['image'] = image
# Process image data using unified utility
self.__song_metadata = enhance_metadata_with_image(self.__song_metadata)
song = f"{self.__song_metadata['music']} - {self.__song_metadata['artist']}"
# Check if track already exists based on metadata
@@ -489,32 +433,15 @@ class EASY_DW:
parent_obj, current_track_val, total_tracks_val = self._get_parent_context()
status_obj = skippedObject(
ids=self.__track_obj.ids,
# Report track skipped status
report_track_skipped(
track_obj=self.__track_obj,
reason=f"Track already exists in desired format at {existing_file_path}",
convert_to=self.__convert_to,
bitrate=self.__bitrate
)
callback_obj = trackCallbackObject(
track=self.__track_obj,
status_info=status_obj,
parent=parent_obj,
preferences=self.__preferences,
parent_obj=parent_obj,
current_track=current_track_val,
total_tracks=total_tracks_val
)
if self.__parent is None:
summary = summaryObject(
skipped_tracks=[self.__track_obj],
total_skipped=1
)
status_obj.summary = summary
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
)
skipped_item = Track(
self.__song_metadata,
@@ -620,11 +547,23 @@ class EASY_DW:
current_item.error_message = final_error_msg
raise TrackNotFound(message=final_error_msg, url=current_link_attr)
# If we reach here, the item should be successful and not skipped.
# If we reach here, the item should be successful and not skipped.
if current_item.success:
if self.__infos_dw.get('__TYPE__') != 'episode': # Assuming pic is for tracks
current_item.md5_image = pic # Set md5_image for tracks
write_tags(current_item)
current_item.md5_image = pic # Set md5_image for tracks
# Apply tags using unified utility with Deezer enhancements
from deezspot.deezloader.dee_api import API_GW
enhanced_metadata = add_deezer_enhanced_metadata(
self.__song_metadata,
self.__infos_dw,
self.__ids,
API_GW
)
process_and_tag_track(
track=current_item,
metadata_dict=enhanced_metadata,
source_type='deezer'
)
return current_item
@@ -710,20 +649,14 @@ class EASY_DW:
try:
self.__write_track()
status_obj = initializingObject(ids=self.__track_obj.ids)
callback_obj = trackCallbackObject(
track=self.__track_obj,
status_info=status_obj,
parent=parent_obj,
# Report track initialization status
report_track_initializing(
track_obj=self.__track_obj,
preferences=self.__preferences,
parent_obj=parent_obj,
current_track=current_track_val,
total_tracks=total_tracks_val
)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
)
register_active_download(self.__song_path)
try:
@@ -761,16 +694,22 @@ class EASY_DW:
raise TrackNotFound(f"Failed to process {self.__song_path}. Error: {str(e_decrypt)}") from e_decrypt
self.__add_more_tags()
self.__c_track.tags = self.__song_metadata
if self.__preferences.save_cover and self.__song_metadata.get('image'):
try:
track_directory = os.path.dirname(self.__song_path)
save_cover_image(self.__song_metadata['image'], track_directory, "cover.jpg")
logger.info(f"Saved cover image for track in {track_directory}")
except Exception as e_img_save:
logger.warning(f"Failed to save cover image for track: {e_img_save}")
# Add Deezer-specific enhanced metadata and apply tags
from deezspot.deezloader.dee_api import API_GW
enhanced_metadata = add_deezer_enhanced_metadata(
self.__song_metadata,
self.__infos_dw,
self.__ids,
API_GW
)
# Apply tags using unified utility
process_and_tag_track(
track=self.__c_track,
metadata_dict=enhanced_metadata,
source_type='deezer',
save_cover=getattr(self.__preferences, 'save_cover', False)
)
if self.__convert_to:
format_name, bitrate = self._parse_format_string(self.__convert_to)
@@ -794,8 +733,19 @@ class EASY_DW:
logger.error(f"Audio conversion error: {str(conv_error)}. Proceeding with original format.")
register_active_download(path_before_conversion)
write_tags(self.__c_track)
# Apply tags using unified utility with Deezer enhancements
from deezspot.deezloader.dee_api import API_GW
enhanced_metadata = add_deezer_enhanced_metadata(
self.__song_metadata,
self.__infos_dw,
self.__ids,
API_GW
)
process_and_tag_track(
track=self.__c_track,
metadata_dict=enhanced_metadata,
source_type='deezer'
)
self.__c_track.success = True
unregister_active_download(self.__song_path)
@@ -896,7 +846,19 @@ class EASY_DW:
self.__c_track.success = True
self.__write_episode()
write_tags(self.__c_track)
# Apply tags using unified utility with Deezer enhancements
from deezspot.deezloader.dee_api import API_GW
enhanced_metadata = add_deezer_enhanced_metadata(
self.__song_metadata,
self.__infos_dw,
self.__ids,
API_GW
)
process_and_tag_track(
track=self.__c_track,
metadata_dict=enhanced_metadata,
source_type='deezer'
)
return self.__c_track
@@ -954,49 +916,7 @@ class EASY_DW:
return format_name, bitrate
def __add_more_tags(self) -> None:
"""
Add Deezer-specific metadata to the song metadata dictionary.
This preserves Deezer's unique features like lyrics and contributors.
"""
contributors = self.__infos_dw.get('SNG_CONTRIBUTORS', {})
# Add contributor information
self.__song_metadata['author'] = "; ".join(contributors.get('author', []))
self.__song_metadata['composer'] = "; ".join(contributors.get('composer', []))
self.__song_metadata['lyricist'] = "; ".join(contributors.get('lyricist', []))
if contributors.get('composerlyricist'):
self.__song_metadata['composer'] = "; ".join(contributors.get('composerlyricist', []))
# Add version information if available
self.__song_metadata['version'] = self.__infos_dw.get('VERSION', '')
# Initialize lyric fields
self.__song_metadata['lyric'] = ""
self.__song_metadata['copyright'] = ""
self.__song_metadata['lyric_sync'] = []
# Add lyrics if available
if self.__infos_dw.get('LYRICS_ID', 0) != 0:
try:
lyrics_data = API_GW.get_lyric(self.__ids)
if lyrics_data and "LYRICS_TEXT" in lyrics_data:
self.__song_metadata['lyric'] = lyrics_data["LYRICS_TEXT"]
if lyrics_data and "LYRICS_SYNC_JSON" in lyrics_data:
self.__song_metadata['lyric_sync'] = trasform_sync_lyric(
lyrics_data['LYRICS_SYNC_JSON']
)
except Exception as e:
logger.warning(f"Failed to retrieve lyrics: {str(e)}")
# Extract album artist from contributors with 'Main' role
if 'contributors' in self.__infos_dw:
main_artists = [c['name'] for c in self.__infos_dw['contributors'] if c.get('role') == 'Main']
if main_artists:
self.__song_metadata['album_artist'] = "; ".join(main_artists)
# Removed __add_more_tags() - now handled by unified libutils/taggers.py
class DW_TRACK:
def __init__(
@@ -1030,8 +950,8 @@ class DW_TRACK:
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 new global helper function
return _album_object_to_dict(album_obj)
# Use the unified metadata converter
return album_object_to_dict(album_obj, source_type='deezer')
def _track_object_to_dict(self, track_obj: any, album_obj: albumCbObject) -> dict:
"""Converts a track object to a dictionary with album context."""
@@ -1050,24 +970,11 @@ class DW_ALBUM:
album=album_obj, # Use the parent album
genres=getattr(track_obj, 'genres', [])
)
# Now use the new track object with the global helper
track_dict = _track_object_to_dict(full_track)
# Use the unified metadata converter
return track_object_to_dict(full_track, source_type='deezer')
else:
# Use the global helper function with additional album context
track_dict = _track_object_to_dict(track_obj)
# Add album-specific context that might not be in the track object
if album_obj:
track_dict['album'] = album_obj.title
track_dict['album_artist'] = "; ".join([artist.name for artist in album_obj.artists])
track_dict['upc'] = album_obj.ids.upc if album_obj.ids else None
track_dict['genre'] = "; ".join(album_obj.genres)
# Preserve ISRC if available in the track object
if track_obj.ids and track_obj.ids.isrc and not track_dict.get('isrc'):
track_dict['isrc'] = track_obj.ids.isrc
return track_dict
# Use the unified metadata converter
return track_object_to_dict(track_obj, source_type='deezer')
def __init__(
self,
@@ -1087,13 +994,8 @@ class DW_ALBUM:
def dw(self) -> Album:
# Report album initializing status
album_obj = self.__preferences.json_data
status_obj = initializingObject(ids=album_obj.ids)
callback_obj = albumCallbackObject(album=album_obj, status_info=status_obj)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
)
# Report album initialization status
report_album_initializing(album_obj)
infos_dw = API_GW.get_album_data(self.__ids)['data']
md5_image = infos_dw[0]['ALB_PICTURE']
@@ -1127,7 +1029,6 @@ class DW_ALBUM:
total_tracks = len(infos_dw)
for a, album_track_obj in enumerate(album_obj.tracks):
track_number = a + 1
c_infos_dw_item = infos_dw[a]
# Update track object with proper track position and disc number from API
@@ -1138,7 +1039,7 @@ class DW_ALBUM:
# Ensure we have valid values, not None
if album_track_obj.track_number is None:
album_track_obj.track_number = track_number
album_track_obj.track_number = a + 1 # Fallback to sequential if API doesn't provide
if album_track_obj.disc_number is None:
album_track_obj.disc_number = 1
@@ -1162,7 +1063,7 @@ class DW_ALBUM:
c_preferences.song_metadata = full_track_obj
c_preferences.ids = full_track_obj.ids.deezer
c_preferences.track_number = track_number
c_preferences.track_number = a + 1 # For progress reporting only
c_preferences.total_tracks = total_tracks
c_preferences.link = f"https://deezer.com/track/{c_preferences.ids}"
@@ -1214,12 +1115,8 @@ class DW_ALBUM:
total_failed=len(failed_tracks_cb)
)
status_obj_done = doneObject(ids=album_obj.ids, summary=summary_obj)
callback_obj_done = albumCallbackObject(album=album_obj, status_info=status_obj_done)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj_done
)
# Report album completion status
report_album_done(album_obj, summary_obj)
return album
@@ -1238,22 +1135,8 @@ class DW_PLAYLIST:
self.__quality_download = self.__preferences.quality_download
def _track_object_to_dict(self, track_obj: any) -> dict:
return {
"music": track_obj.title,
"artist": "; ".join([artist.name for artist in track_obj.artists]),
"album": track_obj.album.title,
"tracknum": 0,
"discnum": 0,
"duration": track_obj.duration_ms // 1000 if track_obj.duration_ms else 0,
"year": 0,
"explicit": False,
"isrc": track_obj.ids.isrc if hasattr(track_obj.ids, 'isrc') else None,
"album_artist": "",
"upc": None,
"label": "",
"genre": "",
"image": None,
}
# Use the unified metadata converter
return track_object_to_dict(track_obj, source_type='deezer')
def dw(self) -> Playlist:
playlist_obj: playlistCbObject = self.__preferences.json_data
@@ -1271,12 +1154,7 @@ class DW_PLAYLIST:
playlist = Playlist()
tracks = playlist.tracks
playlist_m3u_dir = os.path.join(self.__output_dir, "playlists")
os.makedirs(playlist_m3u_dir, exist_ok=True)
m3u_path = os.path.join(playlist_m3u_dir, f"{playlist_name_sanitized}.m3u")
if not os.path.exists(m3u_path):
with open(m3u_path, "w", encoding="utf-8") as m3u_file:
m3u_file.write("#EXTM3U\n")
m3u_path = create_m3u_file(self.__output_dir, playlist_obj.title)
medias = Download_JOB.check_sources(infos_dw, self.__quality_download)
@@ -1341,12 +1219,7 @@ class DW_PLAYLIST:
if current_track_object:
tracks.append(current_track_object)
if current_track_object.success and hasattr(current_track_object, 'song_path') and current_track_object.song_path:
relative_song_path = os.path.relpath(
current_track_object.song_path,
start=playlist_m3u_dir
)
with open(m3u_path, "a", encoding="utf-8") as m3u_file:
m3u_file.write(f"{relative_song_path}\n")
append_track_to_m3u(m3u_path, current_track_object.song_path)
if self.__make_zip:
zip_name = f"{self.__output_dir}/{playlist_obj.title} [playlist {self.__ids}]"

View File

@@ -796,16 +796,8 @@ class DeeLogin:
callback_obj_done = playlistCallbackObject(playlist=playlist_obj, status_info=status_obj_done)
report_progress(reporter=self.progress_reporter, callback_obj=callback_obj_done)
playlist_m3u_dir = os.path.join(output_dir, "playlists")
os.makedirs(playlist_m3u_dir, exist_ok=True)
m3u_path = os.path.join(playlist_m3u_dir, f"{sanitize_name(playlist_obj.title)}.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:
relative_song_path = os.path.relpath(track.song_path, start=playlist_m3u_dir)
m3u_file.write(f"{relative_song_path}\n")
logger.info(f"Created m3u playlist file at: {m3u_path}")
from deezspot.libutils.write_m3u import write_tracks_to_m3u
m3u_path = write_tracks_to_m3u(output_dir, playlist_obj.title, tracks)
if make_zip:
zip_name = f"{output_dir}/playlist_{sanitize_name(playlist_obj.title)}.zip"

View File

@@ -0,0 +1,278 @@
#!/usr/bin/python3
from datetime import datetime
from typing import Dict, Any, Optional, Union
def _detect_source_type(obj: Any) -> str:
"""
Auto-detect whether an object is from Spotify or Deezer based on its IDs.
Args:
obj: Track or album object with ids attribute
Returns:
str: 'spotify' or 'deezer'
"""
if hasattr(obj, 'ids') and obj.ids:
if hasattr(obj.ids, 'spotify') and obj.ids.spotify:
return 'spotify'
elif hasattr(obj.ids, 'deezer') and obj.ids.deezer:
return 'deezer'
return 'spotify' # Default fallback
def _get_platform_id(ids_obj: Any, source_type: str) -> Optional[str]:
"""
Get the appropriate platform ID based on source type.
Args:
ids_obj: IDs object containing platform-specific IDs
source_type: 'spotify' or 'deezer'
Returns:
Platform-specific ID or None
"""
if not ids_obj:
return None
if source_type == 'spotify':
return getattr(ids_obj, 'spotify', None)
else: # deezer
return getattr(ids_obj, 'deezer', None)
def _format_release_date(release_date: Any, source_type: str) -> Optional[datetime]:
"""
Format release date based on source type and data structure.
Args:
release_date: Release date object/dict from API
source_type: 'spotify' or 'deezer'
Returns:
datetime object or None
"""
if not release_date:
return None
try:
if source_type == 'spotify' and isinstance(release_date, dict):
# Spotify format: create datetime object
if 'year' in release_date:
return datetime(
year=release_date['year'],
month=release_date.get('month', 1),
day=release_date.get('day', 1)
)
elif source_type == 'deezer':
# Deezer format: extract year only
if isinstance(release_date, dict) and 'year' in release_date:
return datetime(year=release_date['year'], month=1, day=1)
elif hasattr(release_date, 'year'):
return datetime(year=release_date.year, month=1, day=1)
except (ValueError, TypeError, AttributeError):
pass
return None
def _get_best_image_url(images: Any, source_type: str) -> Optional[str]:
"""
Get the best image URL based on source type and format.
Args:
images: Images data from API
source_type: 'spotify' or 'deezer'
Returns:
Best image URL or None
"""
if not images:
return None
if source_type == 'spotify' and isinstance(images, list):
# Spotify: find highest resolution image
best_image = max(images, key=lambda i: i.get('height', 0) * i.get('width', 0), default=None)
return best_image.get('url') if best_image else None
elif source_type == 'deezer':
# Deezer: images might be direct URL or object
if isinstance(images, str):
return images
elif isinstance(images, list) and images:
return images[0] if isinstance(images[0], str) else None
elif hasattr(images, 'url'):
return images.url
return None
def track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> Dict[str, Any]:
"""
Convert a track object to a dictionary format for tagging.
Supports both Spotify and Deezer track objects.
Args:
track_obj: Track object from Spotify or Deezer API
source_type: Optional source type ('spotify' or 'deezer'). If None, auto-detected.
Returns:
Dictionary with standardized metadata tags
"""
if not track_obj:
return {}
# Auto-detect source if not specified
if source_type is None:
source_type = _detect_source_type(track_obj)
tags = {}
# Basic track details
tags['music'] = getattr(track_obj, 'title', '')
tags['tracknum'] = getattr(track_obj, 'track_number', 0) if getattr(track_obj, 'track_number', None) is not None else 0
tags['discnum'] = getattr(track_obj, 'disc_number', 1) if getattr(track_obj, 'disc_number', None) is not None else 1
tags['duration'] = (getattr(track_obj, 'duration_ms', 0) // 1000) if getattr(track_obj, 'duration_ms', None) else 0
# Platform-specific IDs
if hasattr(track_obj, 'ids') and track_obj.ids:
tags['ids'] = _get_platform_id(track_obj.ids, source_type)
tags['isrc'] = getattr(track_obj.ids, 'isrc', None)
# Artist information
if hasattr(track_obj, 'artists') and track_obj.artists:
tags['artist'] = "; ".join([getattr(artist, 'name', '') for artist in track_obj.artists])
else:
tags['artist'] = ''
# Explicit flag (mainly for Deezer)
if hasattr(track_obj, 'explicit'):
tags['explicit'] = track_obj.explicit
# Album details
if hasattr(track_obj, 'album') and track_obj.album:
album = track_obj.album
tags['album'] = getattr(album, 'title', '')
# Album artists
if hasattr(album, 'artists') and album.artists:
tags['ar_album'] = "; ".join([getattr(artist, 'name', '') for artist in album.artists])
else:
tags['ar_album'] = ''
tags['nb_tracks'] = getattr(album, 'total_tracks', 0)
# Calculate total discs from all tracks in album for proper metadata
if hasattr(album, 'tracks') and album.tracks:
disc_numbers = [getattr(track, 'disc_number', 1) for track in album.tracks if hasattr(track, 'disc_number')]
tags['nb_discs'] = max(disc_numbers, default=1)
else:
tags['nb_discs'] = 1
# Release date handling
release_date = getattr(album, 'release_date', None)
tags['year'] = _format_release_date(release_date, source_type)
# Platform-specific album IDs
if hasattr(album, 'ids') and album.ids:
tags['upc'] = getattr(album.ids, 'upc', None)
tags['album_id'] = _get_platform_id(album.ids, source_type)
# Image handling
if hasattr(album, 'images'):
tags['image'] = _get_best_image_url(album.images, source_type)
else:
tags['image'] = None
# Additional album metadata
tags['label'] = getattr(album, 'label', '')
tags['copyright'] = getattr(album, 'copyright', '')
# Genre handling (more common in Deezer)
if hasattr(album, 'genres') and album.genres:
tags['genre'] = "; ".join(album.genres) if isinstance(album.genres, list) else str(album.genres)
else:
tags['genre'] = ""
# Default/Placeholder values for compatibility
tags['bpm'] = tags.get('bpm', 'Unknown')
tags['gain'] = tags.get('gain', 'Unknown')
tags['lyric'] = tags.get('lyric', '')
tags['author'] = tags.get('author', '')
tags['composer'] = tags.get('composer', '')
tags['lyricist'] = tags.get('lyricist', '')
tags['version'] = tags.get('version', '')
return tags
def album_object_to_dict(album_obj: Any, source_type: Optional[str] = None) -> Dict[str, Any]:
"""
Convert an album object to a dictionary format for tagging.
Supports both Spotify and Deezer album objects.
Args:
album_obj: Album object from Spotify or Deezer API
source_type: Optional source type ('spotify' or 'deezer'). If None, auto-detected.
Returns:
Dictionary with standardized metadata tags
"""
if not album_obj:
return {}
# Auto-detect source if not specified
if source_type is None:
source_type = _detect_source_type(album_obj)
tags = {}
# Basic album details
tags['album'] = getattr(album_obj, 'title', '')
# Album artists
if hasattr(album_obj, 'artists') and album_obj.artists:
tags['ar_album'] = "; ".join([getattr(artist, 'name', '') for artist in album_obj.artists])
else:
tags['ar_album'] = ''
tags['nb_tracks'] = getattr(album_obj, 'total_tracks', 0)
# Release date handling
release_date = getattr(album_obj, 'release_date', None)
tags['year'] = _format_release_date(release_date, source_type)
# Platform-specific album IDs
if hasattr(album_obj, 'ids') and album_obj.ids:
tags['upc'] = getattr(album_obj.ids, 'upc', None)
tags['album_id'] = _get_platform_id(album_obj.ids, source_type)
# Image handling
if hasattr(album_obj, 'images'):
tags['image'] = _get_best_image_url(album_obj.images, source_type)
else:
tags['image'] = None
# Additional metadata
tags['label'] = getattr(album_obj, 'label', '')
tags['copyright'] = getattr(album_obj, 'copyright', '')
# Genre handling (more common in Deezer)
if hasattr(album_obj, 'genres') and album_obj.genres:
tags['genre'] = "; ".join(album_obj.genres) if isinstance(album_obj.genres, list) else str(album_obj.genres)
else:
tags['genre'] = ""
return tags
# Backward compatibility aliases for easy migration
def _track_object_to_dict(track_obj: Any, source_type: Optional[str] = None) -> Dict[str, Any]:
"""Legacy alias for track_object_to_dict"""
return track_object_to_dict(track_obj, source_type)
def _album_object_to_dict(album_obj: Any, source_type: Optional[str] = None) -> Dict[str, Any]:
"""Legacy alias for album_object_to_dict"""
return album_object_to_dict(album_obj, source_type)

View File

@@ -0,0 +1,369 @@
#!/usr/bin/python3
from typing import Optional, Any, Dict, List
from deezspot.libutils.logging_utils import report_progress
from deezspot.models.callback import (
trackCallbackObject, albumCallbackObject, playlistCallbackObject,
initializingObject, skippedObject, retryingObject, realTimeObject,
errorObject, doneObject, summaryObject
)
def _get_reporter():
"""Get the active progress reporter from Download_JOB"""
# Import here to avoid circular imports
try:
from deezspot.spotloader.__download__ import Download_JOB as SpotifyDJ
if hasattr(SpotifyDJ, 'progress_reporter') and SpotifyDJ.progress_reporter:
return SpotifyDJ.progress_reporter
except ImportError:
pass
try:
from deezspot.deezloader.__download__ import Download_JOB as DeezerDJ
if hasattr(DeezerDJ, 'progress_reporter') and DeezerDJ.progress_reporter:
return DeezerDJ.progress_reporter
except ImportError:
pass
return None
def report_track_initializing(
track_obj: Any,
preferences: Any,
parent_obj: Optional[Any] = None,
current_track: Optional[int] = None,
total_tracks: Optional[int] = None
) -> None:
"""
Report track initialization status.
Args:
track_obj: Track object being initialized
preferences: Preferences object with convert_to/bitrate info
parent_obj: Parent object (album/playlist) if applicable
current_track: Current track number for progress
total_tracks: Total tracks for progress
"""
status_obj = initializingObject(
ids=getattr(track_obj, 'ids', None),
convert_to=getattr(preferences, 'convert_to', None),
bitrate=getattr(preferences, 'bitrate', None)
)
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=current_track or getattr(preferences, 'track_number', None),
total_tracks=total_tracks,
parent=parent_obj
)
reporter = _get_reporter()
if reporter:
report_progress(reporter=reporter, callback_obj=callback_obj)
def report_track_skipped(
track_obj: Any,
reason: str,
preferences: Any,
parent_obj: Optional[Any] = None,
current_track: Optional[int] = None,
total_tracks: Optional[int] = None
) -> None:
"""
Report track skipped status.
Args:
track_obj: Track object being skipped
reason: Reason for skipping
preferences: Preferences object with convert_to/bitrate info
parent_obj: Parent object (album/playlist) if applicable
current_track: Current track number for progress
total_tracks: Total tracks for progress
"""
status_obj = skippedObject(
ids=getattr(track_obj, 'ids', None),
reason=reason,
convert_to=getattr(preferences, 'convert_to', None),
bitrate=getattr(preferences, 'bitrate', None)
)
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=current_track or getattr(preferences, 'track_number', None),
total_tracks=total_tracks,
parent=parent_obj
)
reporter = _get_reporter()
if reporter:
report_progress(reporter=reporter, callback_obj=callback_obj)
def report_track_retrying(
track_obj: Any,
retry_count: int,
seconds_left: int,
error: str,
preferences: Any,
parent_obj: Optional[Any] = None,
current_track: Optional[int] = None,
total_tracks: Optional[int] = None
) -> None:
"""
Report track retry status.
Args:
track_obj: Track object being retried
retry_count: Current retry attempt number
seconds_left: Seconds until next retry
error: Error that caused the retry
preferences: Preferences object with convert_to/bitrate info
parent_obj: Parent object (album/playlist) if applicable
current_track: Current track number for progress
total_tracks: Total tracks for progress
"""
status_obj = retryingObject(
ids=getattr(track_obj, 'ids', None),
retry_count=retry_count,
seconds_left=seconds_left,
error=error,
convert_to=getattr(preferences, 'convert_to', None),
bitrate=getattr(preferences, 'bitrate', None)
)
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=current_track or getattr(preferences, 'track_number', None),
total_tracks=total_tracks,
parent=parent_obj
)
reporter = _get_reporter()
if reporter:
report_progress(reporter=reporter, callback_obj=callback_obj)
def report_track_realtime_progress(
track_obj: Any,
time_elapsed: int,
progress: int,
preferences: Any,
parent_obj: Optional[Any] = None,
current_track: Optional[int] = None,
total_tracks: Optional[int] = None
) -> None:
"""
Report real-time track download progress.
Args:
track_obj: Track object being downloaded
time_elapsed: Milliseconds elapsed
progress: Progress percentage (0-100)
preferences: Preferences object with convert_to/bitrate info
parent_obj: Parent object (album/playlist) if applicable
current_track: Current track number for progress
total_tracks: Total tracks for progress
"""
status_obj = realTimeObject(
ids=getattr(track_obj, 'ids', None),
time_elapsed=time_elapsed,
progress=progress,
convert_to=getattr(preferences, 'convert_to', None),
bitrate=getattr(preferences, 'bitrate', None)
)
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=current_track or getattr(preferences, 'track_number', None),
total_tracks=total_tracks,
parent=parent_obj
)
reporter = _get_reporter()
if reporter:
report_progress(reporter=reporter, callback_obj=callback_obj)
def report_track_error(
track_obj: Any,
error: str,
preferences: Any,
parent_obj: Optional[Any] = None,
current_track: Optional[int] = None,
total_tracks: Optional[int] = None
) -> None:
"""
Report track error status.
Args:
track_obj: Track object that errored
error: Error message
preferences: Preferences object with convert_to/bitrate info
parent_obj: Parent object (album/playlist) if applicable
current_track: Current track number for progress
total_tracks: Total tracks for progress
"""
status_obj = errorObject(
ids=getattr(track_obj, 'ids', None),
error=error,
convert_to=getattr(preferences, 'convert_to', None),
bitrate=getattr(preferences, 'bitrate', None)
)
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=current_track or getattr(preferences, 'track_number', None),
total_tracks=total_tracks,
parent=parent_obj
)
reporter = _get_reporter()
if reporter:
report_progress(reporter=reporter, callback_obj=callback_obj)
def report_track_done(
track_obj: Any,
preferences: Any,
summary: Optional[summaryObject] = None,
parent_obj: Optional[Any] = None,
current_track: Optional[int] = None,
total_tracks: Optional[int] = None
) -> None:
"""
Report track completion status.
Args:
track_obj: Track object that completed
preferences: Preferences object with convert_to/bitrate info
summary: Optional summary object for single track downloads
parent_obj: Parent object (album/playlist) if applicable
current_track: Current track number for progress
total_tracks: Total tracks for progress
"""
status_obj = doneObject(
ids=getattr(track_obj, 'ids', None),
summary=summary,
convert_to=getattr(preferences, 'convert_to', None),
bitrate=getattr(preferences, 'bitrate', None)
)
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=current_track or getattr(preferences, 'track_number', None),
total_tracks=total_tracks,
parent=parent_obj
)
reporter = _get_reporter()
if reporter:
report_progress(reporter=reporter, callback_obj=callback_obj)
def report_album_initializing(album_obj: Any) -> None:
"""
Report album initialization status.
Args:
album_obj: Album object being initialized
"""
status_obj = initializingObject(ids=getattr(album_obj, 'ids', None))
callback_obj = albumCallbackObject(album=album_obj, status_info=status_obj)
reporter = _get_reporter()
if reporter:
report_progress(reporter=reporter, callback_obj=callback_obj)
def report_album_done(album_obj: Any, summary: summaryObject) -> None:
"""
Report album completion status.
Args:
album_obj: Album object that completed
summary: Summary of track download results
"""
status_obj = doneObject(ids=getattr(album_obj, 'ids', None), summary=summary)
callback_obj = albumCallbackObject(album=album_obj, status_info=status_obj)
reporter = _get_reporter()
if reporter:
report_progress(reporter=reporter, callback_obj=callback_obj)
def report_playlist_initializing(playlist_obj: Any) -> None:
"""
Report playlist initialization status.
Args:
playlist_obj: Playlist object being initialized
"""
status_obj = initializingObject(ids=getattr(playlist_obj, 'ids', None))
callback_obj = playlistCallbackObject(playlist=playlist_obj, status_info=status_obj)
reporter = _get_reporter()
if reporter:
report_progress(reporter=reporter, callback_obj=callback_obj)
def report_playlist_done(playlist_obj: Any, summary: summaryObject) -> None:
"""
Report playlist completion status.
Args:
playlist_obj: Playlist object that completed
summary: Summary of track download results
"""
status_obj = doneObject(ids=getattr(playlist_obj, 'ids', None), summary=summary)
callback_obj = playlistCallbackObject(playlist=playlist_obj, status_info=status_obj)
reporter = _get_reporter()
if reporter:
report_progress(reporter=reporter, callback_obj=callback_obj)
# Convenience function for generic track reporting
def report_track_status(
status_type: str,
track_obj: Any,
preferences: Any,
parent_obj: Optional[Any] = None,
current_track: Optional[int] = None,
total_tracks: Optional[int] = None,
**kwargs
) -> None:
"""
Generic track status reporting function.
Args:
status_type: Type of status ('initializing', 'skipped', 'retrying', 'error', 'done', 'realtime')
track_obj: Track object
preferences: Preferences object
parent_obj: Parent object if applicable
current_track: Current track number
total_tracks: Total tracks
**kwargs: Additional parameters specific to status type
"""
if status_type == 'initializing':
report_track_initializing(track_obj, preferences, parent_obj, current_track, total_tracks)
elif status_type == 'skipped':
report_track_skipped(track_obj, kwargs.get('reason', 'Unknown'), preferences, parent_obj, current_track, total_tracks)
elif status_type == 'retrying':
report_track_retrying(track_obj, kwargs.get('retry_count', 0), kwargs.get('seconds_left', 0),
kwargs.get('error', 'Unknown'), preferences, parent_obj, current_track, total_tracks)
elif status_type == 'error':
report_track_error(track_obj, kwargs.get('error', 'Unknown'), preferences, parent_obj, current_track, total_tracks)
elif status_type == 'done':
report_track_done(track_obj, preferences, kwargs.get('summary'), parent_obj, current_track, total_tracks)
elif status_type == 'realtime':
report_track_realtime_progress(track_obj, kwargs.get('time_elapsed', 0), kwargs.get('progress', 0),
preferences, parent_obj, current_track, total_tracks)

View File

@@ -4,6 +4,7 @@ import os
from mutagen import File
from mutagen.easyid3 import EasyID3
from mutagen.oggvorbis import OggVorbis
from mutagen.oggopus import OggOpus
from mutagen.flac import FLAC
from mutagen.mp3 import MP3 # Added for explicit MP3 type checking
# from mutagen.mp4 import MP4 # MP4 is usually handled by File for .m4a
@@ -52,6 +53,9 @@ def read_metadata_from_file(file_path, logger):
elif isinstance(audio, OggVorbis): # OGG
title = audio.get('TITLE', [None])[0] # Vorbis tags are case-insensitive but typically uppercase
album = audio.get('ALBUM', [None])[0]
elif isinstance(audio, OggOpus): # OPUS
title = audio.get('TITLE', [None])[0] # Opus files use Vorbis comments, similar to OGG
album = audio.get('ALBUM', [None])[0]
elif isinstance(audio, FLAC): # FLAC
title = audio.get('TITLE', [None])[0]
album = audio.get('ALBUM', [None])[0]

View File

@@ -0,0 +1,321 @@
#!/usr/bin/python3
import os
from typing import Dict, Any, Optional, Union
from deezspot.libutils.utils import request
from deezspot.libutils.logging_utils import logger
from deezspot.__taggers__ import write_tags
from deezspot.models.download import Track, Episode
def fetch_and_process_image(image_url_or_bytes: Union[str, bytes, None]) -> Optional[bytes]:
"""
Fetch and process image data from URL or return bytes directly.
Args:
image_url_or_bytes: Image URL string, bytes, or None
Returns:
Image bytes or None if failed/not available
"""
if not image_url_or_bytes:
return None
if isinstance(image_url_or_bytes, bytes):
return image_url_or_bytes
if isinstance(image_url_or_bytes, str):
try:
response = request(image_url_or_bytes)
return response.content
except Exception as e:
logger.warning(f"Failed to fetch image from URL {image_url_or_bytes}: {e}")
return None
return None
def enhance_metadata_with_image(metadata_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
Enhance metadata dictionary by fetching image data if image URL is present.
Args:
metadata_dict: Metadata dictionary potentially containing image URL
Returns:
Enhanced metadata dictionary with image bytes
"""
image_url = metadata_dict.get('image')
if image_url:
image_bytes = fetch_and_process_image(image_url)
if image_bytes:
metadata_dict['image'] = image_bytes
return metadata_dict
def add_deezer_enhanced_metadata(
metadata_dict: Dict[str, Any],
infos_dw: Dict[str, Any],
track_ids: str,
api_gw_instance: Any = None
) -> Dict[str, Any]:
"""
Add Deezer-specific enhanced metadata including contributors, lyrics, and version info.
Args:
metadata_dict: Base metadata dictionary
infos_dw: Deezer track information dictionary
track_ids: Track IDs for lyrics fetching
api_gw_instance: API gateway instance for lyrics retrieval
Returns:
Enhanced metadata dictionary with Deezer-specific data
"""
# Add contributor information
contributors = infos_dw.get('SNG_CONTRIBUTORS', {})
metadata_dict['author'] = "; ".join(contributors.get('author', []))
metadata_dict['composer'] = "; ".join(contributors.get('composer', []))
metadata_dict['lyricist'] = "; ".join(contributors.get('lyricist', []))
# Handle composer+lyricist combination
if contributors.get('composerlyricist'):
metadata_dict['composer'] = "; ".join(contributors.get('composerlyricist', []))
# Add version information
metadata_dict['version'] = infos_dw.get('VERSION', '')
# Initialize lyric fields
metadata_dict['lyric'] = ""
metadata_dict['copyright'] = ""
metadata_dict['lyric_sync'] = []
# Add lyrics if available and API instance provided
if api_gw_instance and infos_dw.get('LYRICS_ID', 0) != 0:
try:
lyrics_data = api_gw_instance.get_lyric(track_ids)
if lyrics_data and "LYRICS_TEXT" in lyrics_data:
metadata_dict['lyric'] = lyrics_data["LYRICS_TEXT"]
if lyrics_data and "LYRICS_SYNC_JSON" in lyrics_data:
# Import here to avoid circular imports
from deezspot.libutils.utils import trasform_sync_lyric
metadata_dict['lyric_sync'] = trasform_sync_lyric(
lyrics_data['LYRICS_SYNC_JSON']
)
except Exception as e:
logger.warning(f"Failed to retrieve lyrics: {str(e)}")
# Extract album artist from contributors with 'Main' role
if 'contributors' in infos_dw:
main_artists = [c['name'] for c in infos_dw['contributors'] if c.get('role') == 'Main']
if main_artists:
metadata_dict['album_artist'] = "; ".join(main_artists)
return metadata_dict
def add_spotify_enhanced_metadata(metadata_dict: Dict[str, Any], track_obj: Any) -> Dict[str, Any]:
"""
Add Spotify-specific enhanced metadata.
Args:
metadata_dict: Base metadata dictionary
track_obj: Spotify track object
Returns:
Enhanced metadata dictionary with Spotify-specific data
"""
# Spotify tracks already have most metadata from the unified converter
# Add any Spotify-specific enhancements here if needed in the future
# Ensure image is processed
return enhance_metadata_with_image(metadata_dict)
def prepare_track_metadata(
metadata_dict: Dict[str, Any],
source_type: str = 'unknown',
enhanced_data: Optional[Dict[str, Any]] = None,
api_instance: Any = None,
track_ids: Optional[str] = None
) -> Dict[str, Any]:
"""
Prepare and enhance track metadata for tagging based on source type.
Args:
metadata_dict: Base metadata dictionary
source_type: Source type ('spotify', 'deezer', or 'unknown')
enhanced_data: Additional source-specific data (infos_dw for Deezer)
api_instance: API instance for additional data fetching
track_ids: Track IDs for API calls
Returns:
Fully prepared metadata dictionary
"""
# Always process images first
metadata_dict = enhance_metadata_with_image(metadata_dict)
if source_type == 'deezer' and enhanced_data:
metadata_dict = add_deezer_enhanced_metadata(
metadata_dict,
enhanced_data,
track_ids or '',
api_instance
)
elif source_type == 'spotify':
metadata_dict = add_spotify_enhanced_metadata(metadata_dict, enhanced_data)
return metadata_dict
def apply_tags_to_track(track: Track, metadata_dict: Dict[str, Any]) -> None:
"""
Apply metadata tags to a track object and write them to the file.
Args:
track: Track object to tag
metadata_dict: Metadata dictionary containing tag information
"""
if not track or not metadata_dict:
return
try:
track.tags = metadata_dict
write_tags(track)
logger.debug(f"Successfully applied tags to track: {metadata_dict.get('music', 'Unknown')}")
except Exception as e:
logger.error(f"Failed to apply tags to track: {e}")
def apply_tags_to_episode(episode: Episode, metadata_dict: Dict[str, Any]) -> None:
"""
Apply metadata tags to an episode object and write them to the file.
Args:
episode: Episode object to tag
metadata_dict: Metadata dictionary containing tag information
"""
if not episode or not metadata_dict:
return
try:
episode.tags = metadata_dict
write_tags(episode)
logger.debug(f"Successfully applied tags to episode: {metadata_dict.get('music', 'Unknown')}")
except Exception as e:
logger.error(f"Failed to apply tags to episode: {e}")
def save_cover_image_for_track(
metadata_dict: Dict[str, Any],
track_path: str,
save_cover: bool = False,
cover_filename: str = "cover.jpg"
) -> None:
"""
Save cover image for a track if requested and image data is available.
Args:
metadata_dict: Metadata dictionary potentially containing image data
track_path: Path to the track file
save_cover: Whether to save cover image
cover_filename: Filename for the cover image
"""
if not save_cover or not metadata_dict.get('image'):
return
try:
from deezspot.libutils.utils import save_cover_image
track_directory = os.path.dirname(track_path)
# Handle both URL and bytes
image_data = metadata_dict['image']
if isinstance(image_data, str):
image_bytes = fetch_and_process_image(image_data)
else:
image_bytes = image_data
if image_bytes:
save_cover_image(image_bytes, track_directory, cover_filename)
logger.info(f"Saved cover image for track in {track_directory}")
except Exception as e:
logger.warning(f"Failed to save cover image for track: {e}")
# Convenience function that combines metadata preparation and tagging
def process_and_tag_track(
track: Track,
metadata_dict: Dict[str, Any],
source_type: str = 'unknown',
enhanced_data: Optional[Dict[str, Any]] = None,
api_instance: Any = None,
track_ids: Optional[str] = None,
save_cover: bool = False
) -> None:
"""
Complete metadata processing and tagging workflow for a track.
Args:
track: Track object to process
metadata_dict: Base metadata dictionary
source_type: Source type ('spotify', 'deezer', or 'unknown')
enhanced_data: Additional source-specific data
api_instance: API instance for additional data fetching
track_ids: Track IDs for API calls
save_cover: Whether to save cover image
"""
# Prepare enhanced metadata
prepared_metadata = prepare_track_metadata(
metadata_dict,
source_type,
enhanced_data,
api_instance,
track_ids
)
# Apply tags to track
apply_tags_to_track(track, prepared_metadata)
# Save cover image if requested
if hasattr(track, 'song_path') and track.song_path:
save_cover_image_for_track(prepared_metadata, track.song_path, save_cover)
def process_and_tag_episode(
episode: Episode,
metadata_dict: Dict[str, Any],
source_type: str = 'unknown',
enhanced_data: Optional[Dict[str, Any]] = None,
api_instance: Any = None,
track_ids: Optional[str] = None,
save_cover: bool = False
) -> None:
"""
Complete metadata processing and tagging workflow for an episode.
Args:
episode: Episode object to process
metadata_dict: Base metadata dictionary
source_type: Source type ('spotify', 'deezer', or 'unknown')
enhanced_data: Additional source-specific data
api_instance: API instance for additional data fetching
track_ids: Track IDs for API calls
save_cover: Whether to save cover image
"""
# Prepare enhanced metadata
prepared_metadata = prepare_track_metadata(
metadata_dict,
source_type,
enhanced_data,
api_instance,
track_ids
)
# Apply tags to episode
apply_tags_to_episode(episode, prepared_metadata)
# Save cover image if requested
if hasattr(episode, 'episode_path') and episode.episode_path:
save_cover_image_for_track(prepared_metadata, episode.episode_path, save_cover)

View File

@@ -0,0 +1,101 @@
#!/usr/bin/python3
import os
from typing import List, Union
from deezspot.libutils.utils import sanitize_name
from deezspot.libutils.logging_utils import logger
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
"""
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")
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 append_track_to_m3u(m3u_path: str, track_path: str) -> None:
"""
Appends a single track path to an existing m3u file.
Args:
m3u_path: Full path to the m3u file
track_path: Full path to the track file
"""
if not track_path or not os.path.exists(track_path):
return
playlist_m3u_dir = os.path.dirname(m3u_path)
relative_path = os.path.relpath(track_path, start=playlist_m3u_dir)
with open(m3u_path, "a", encoding="utf-8") as m3u_file:
m3u_file.write(f"{relative_path}\n")
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.
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")
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)
m3u_file.write(f"{relative_song_path}\n")
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")

View File

@@ -32,6 +32,17 @@ from deezspot.libutils.utils import (
save_cover_image,
__get_dir as get_album_directory,
)
from deezspot.libutils.write_m3u import create_m3u_file, append_track_to_m3u
from deezspot.libutils.metadata_converter import track_object_to_dict, album_object_to_dict
from deezspot.libutils.progress_reporter import (
report_track_initializing, report_track_skipped, report_track_retrying,
report_track_realtime_progress, report_track_error, report_track_done,
report_album_initializing, report_album_done, report_playlist_initializing, report_playlist_done
)
from deezspot.libutils.taggers import (
enhance_metadata_with_image, process_and_tag_track, process_and_tag_episode,
save_cover_image_for_track
)
from deezspot.libutils.logging_utils import logger, report_progress
from deezspot.libutils.cleanup_utils import (
register_active_download,
@@ -49,7 +60,6 @@ from deezspot.models.callback import (
IDs
)
from deezspot.spotloader.__spo_api__ import tracking, json_to_track_playlist_object
from datetime import datetime
# --- Global retry counter variables ---
GLOBAL_RETRY_COUNT = 0
@@ -58,103 +68,15 @@ GLOBAL_MAX_RETRIES = 100 # Adjust this value as needed
# --- Global tracking of active downloads ---
# Moved to deezspot.libutils.cleanup_utils
# Use unified metadata converter
def _track_object_to_dict(track_obj: trackObject) -> dict:
"""Converts a trackObject into a dictionary for legacy functions like taggers."""
if not track_obj:
return {}
tags = {}
# Track details
tags['music'] = track_obj.title
tags['tracknum'] = track_obj.track_number
tags['discnum'] = track_obj.disc_number
tags['duration'] = track_obj.duration_ms // 1000 if track_obj.duration_ms else 0
if track_obj.ids:
tags['ids'] = track_obj.ids.spotify
tags['isrc'] = track_obj.ids.isrc
tags['artist'] = "; ".join([artist.name for artist in track_obj.artists])
# Album details
if track_obj.album:
album = track_obj.album
tags['album'] = album.title
tags['ar_album'] = "; ".join([artist.name for artist in album.artists])
tags['nb_tracks'] = album.total_tracks
if album.release_date and 'year' in album.release_date:
try:
# Create a datetime object for the tagger
tags['year'] = datetime(
year=album.release_date['year'],
month=album.release_date.get('month', 1),
day=album.release_date.get('day', 1)
)
except (ValueError, TypeError):
tags['year'] = None # Handle invalid date parts
else:
tags['year'] = None
if album.ids:
tags['upc'] = album.ids.upc
tags['album_id'] = album.ids.spotify
if album.images:
tags['image'] = max(album.images, key=lambda i: i.get('height', 0) * i.get('width', 0)).get('url') if album.images else None
else:
tags['image'] = None
# These are not in the model, add them from album if they exist
tags['label'] = getattr(album, 'label', '')
tags['copyright'] = getattr(album, 'copyright', '')
# Default/Placeholder values
tags['bpm'] = tags.get('bpm', 'Unknown')
tags['gain'] = tags.get('gain', 'Unknown')
tags['lyric'] = tags.get('lyric', '')
tags['author'] = tags.get('author', '')
tags['composer'] = tags.get('composer', '')
tags['lyricist'] = tags.get('lyricist', '')
tags['version'] = tags.get('version', '')
return tags
return track_object_to_dict(track_obj, source_type='spotify')
# Use unified metadata converter
def _album_object_to_dict(album_obj: albumObject) -> dict:
"""Converts an albumObject into a dictionary for legacy functions."""
if not album_obj:
return {}
tags = {}
# Album details
tags['album'] = album_obj.title
tags['ar_album'] = "; ".join([artist.name for artist in album_obj.artists])
tags['nb_tracks'] = album_obj.total_tracks
if album_obj.release_date and 'year' in album_obj.release_date:
try:
tags['year'] = datetime(
year=album_obj.release_date['year'],
month=album_obj.release_date.get('month', 1),
day=album_obj.release_date.get('day', 1)
)
except (ValueError, TypeError):
tags['year'] = None
else:
tags['year'] = None
if album_obj.ids:
tags['upc'] = album_obj.ids.upc
tags['album_id'] = album_obj.ids.spotify
if album_obj.images:
tags['image'] = max(album_obj.images, key=lambda i: i.get('height', 0) * i.get('width', 0)).get('url') if album_obj.images else None
else:
tags['image'] = None
tags['label'] = getattr(album_obj, 'label', '')
tags['copyright'] = getattr(album_obj, 'copyright', '')
return tags
return album_object_to_dict(album_obj, source_type='spotify')
class Download_JOB:
session = None
@@ -377,11 +299,8 @@ class EASY_DW:
return self.__c_track
def easy_dw(self) -> Track:
# Request the image data
pic = self.__song_metadata_dict.get('image')
image = request(pic).content if pic else None
if image:
self.__song_metadata_dict['image'] = image
# Process image data using unified utility
self.__song_metadata_dict = enhance_metadata_with_image(self.__song_metadata_dict)
try:
# Initialize success to False, it will be set to True if download_try is successful
@@ -426,8 +345,13 @@ class EASY_DW:
# If we reach here, the track should be successful and not skipped.
if hasattr(self, '_EASY_DW__c_track') and self.__c_track and self.__c_track.success:
self.__c_track.tags = self.__song_metadata_dict
write_tags(self.__c_track)
# Apply tags using unified utility
process_and_tag_track(
track=self.__c_track,
metadata_dict=self.__song_metadata_dict,
source_type='spotify',
save_cover=getattr(self.__preferences, 'save_cover', False)
)
# Unregister the final successful file path after all operations are done.
# self.__c_track.song_path would have been updated by __convert_audio__ if conversion occurred.
@@ -478,28 +402,15 @@ class EASY_DW:
parent_obj = playlistTrackObject(
title=parent_info.get("name"),
owner=userObject(name=parent_info.get("owner"))
)
)
# Build status object
status_obj = skippedObject(
ids=self.__song_metadata.ids,
# Report track skipped status
report_track_skipped(
track_obj=track_obj,
reason=f"Track already exists at '{existing_file_path}'",
convert_to=self.__preferences.convert_to,
bitrate=self.__preferences.bitrate
)
# Build callback object
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=getattr(self.__preferences, 'track_number', None),
total_tracks=total_tracks_val,
parent=parent_obj
)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
preferences=self.__preferences,
parent_obj=parent_obj,
total_tracks=total_tracks_val
)
return self.__c_track
@@ -519,25 +430,12 @@ class EASY_DW:
owner=userObject(name=parent_info.get("owner"))
)
# Build status object
status_obj = initializingObject(
ids=self.__song_metadata.ids,
convert_to=self.__preferences.convert_to,
bitrate=self.__preferences.bitrate
)
# Build callback object
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=getattr(self.__preferences, 'track_number', None),
total_tracks=total_tracks_val,
parent=parent_obj
)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
# Report track initialization status
report_track_initializing(
track_obj=track_obj,
preferences=self.__preferences,
parent_obj=parent_obj,
total_tracks=total_tracks_val
)
# If track does not exist in the desired final format, proceed with download/conversion
@@ -593,27 +491,14 @@ class EASY_DW:
if current_percentage > self._last_reported_percentage:
self._last_reported_percentage = current_percentage
# Build status object
status_obj = realTimeObject(
ids=self.__song_metadata.ids,
# Report real-time progress
report_track_realtime_progress(
track_obj=track_obj,
time_elapsed=int((current_time - start_time) * 1000),
progress=current_percentage,
convert_to=self.__convert_to,
bitrate=self.__bitrate
)
# Build callback object
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=getattr(self.__preferences, 'track_number', None),
total_tracks=total_tracks_val,
parent=parent_obj
)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
preferences=self.__preferences,
parent_obj=parent_obj,
total_tracks=total_tracks_val
)
# Rate limiting (if needed)
@@ -669,28 +554,15 @@ class EASY_DW:
os.remove(self.__song_path)
unregister_active_download(self.__song_path)
# Build status object
status_obj = retryingObject(
ids=self.__song_metadata.ids,
# Report retry status
report_track_retrying(
track_obj=track_obj,
retry_count=retries,
seconds_left=retry_delay,
error=str(e),
convert_to=self.__convert_to,
bitrate=self.__bitrate
)
# Build callback object
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=getattr(self.__preferences, 'track_number', None),
total_tracks=total_tracks_val,
parent=parent_obj
)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
preferences=self.__preferences,
parent_obj=parent_obj,
total_tracks=total_tracks_val
)
if retries >= max_retries or GLOBAL_RETRY_COUNT >= GLOBAL_MAX_RETRIES:
@@ -710,15 +582,8 @@ class EASY_DW:
retry_delay += retry_delay_increase # Use the custom retry delay increase
# Save cover image if requested, after successful download and before conversion
if self.__preferences.save_cover and hasattr(self, '_EASY_DW__song_path') and self.__song_path and self.__song_metadata_dict.get('image'):
try:
track_directory = dirname(self.__song_path)
# Ensure the directory exists (it should, from os.makedirs earlier)
image_bytes = request(self.__song_metadata_dict['image']).content
save_cover_image(image_bytes, track_directory, "cover.jpg")
logger.info(f"Saved cover image for track in {track_directory}")
except Exception as e_img_save:
logger.warning(f"Failed to save cover image for track: {e_img_save}")
if self.__preferences.save_cover and hasattr(self, '_EASY_DW__song_path') and self.__song_path:
save_cover_image_for_track(self.__song_metadata_dict, self.__song_path, self.__preferences.save_cover)
try:
self.__convert_audio()
@@ -732,26 +597,13 @@ class EASY_DW:
else:
error_msg = f"Audio conversion failed: {original_error_str}"
# Build status object
status_obj = errorObject(
ids=self.__song_metadata.ids,
# Report error status
report_track_error(
track_obj=track_obj,
error=error_msg,
convert_to=self.__convert_to,
bitrate=self.__bitrate
)
# Build callback object
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=getattr(self.__preferences, 'track_number', None),
total_tracks=total_tracks_val,
parent=parent_obj
)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
preferences=self.__preferences,
parent_obj=parent_obj,
total_tracks=total_tracks_val
)
logger.error(f"Audio conversion error: {error_msg}")
@@ -768,26 +620,13 @@ class EASY_DW:
# If conversion fails twice, create a final error report
error_msg_2 = f"Audio conversion failed after retry for '{self.__song_metadata.title}'. Original error: {str(conv_e)}"
# Build status object
status_obj = errorObject(
ids=self.__song_metadata.ids,
# Report error status
report_track_error(
track_obj=track_obj,
error=error_msg_2,
convert_to=self.__convert_to,
bitrate=self.__bitrate
)
# Build callback object
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
current_track=getattr(self.__preferences, 'track_number', None),
total_tracks=total_tracks_val,
parent=parent_obj
)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
preferences=self.__preferences,
parent_obj=parent_obj,
total_tracks=total_tracks_val
)
logger.error(error_msg)
@@ -801,8 +640,12 @@ class EASY_DW:
if hasattr(self, '_EASY_DW__c_track') and self.__c_track:
self.__c_track.success = True
self.__c_track.tags = self.__song_metadata_dict
write_tags(self.__c_track)
# Apply tags using unified utility
process_and_tag_track(
track=self.__c_track,
metadata_dict=self.__song_metadata_dict,
source_type='spotify'
)
# Create done status report
parent_info, total_tracks_val = self._get_parent_info()
@@ -823,26 +666,14 @@ class EASY_DW:
total_failed=0
)
# Build status object
status_obj = doneObject(
ids=self.__song_metadata.ids,
# Report track done status
report_track_done(
track_obj=track_obj,
preferences=self.__preferences,
summary=summary_obj,
convert_to=self.__convert_to,
bitrate=self.__bitrate
)
# Build callback object
callback_obj = trackCallbackObject(
track=track_obj,
status_info=status_obj,
parent_obj=parent_obj,
current_track=current_track_val,
total_tracks=total_tracks_val,
parent=parent_obj
)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
total_tracks=total_tracks_val
)
if hasattr(self, '_EASY_DW__c_track') and self.__c_track and self.__c_track.success:
@@ -1053,8 +884,12 @@ class EASY_DW:
# If we reach here, download and any conversion were successful.
if hasattr(self, '_EASY_DW__c_episode') and self.__c_episode:
self.__c_episode.success = True
self.__c_episode.tags = self.__song_metadata_dict
write_tags(self.__c_episode)
# Apply tags using unified utility
process_and_tag_episode(
episode=self.__c_episode,
metadata_dict=self.__song_metadata_dict,
source_type='spotify'
)
# Unregister the final successful file path for episodes, as it's now complete.
# self.__c_episode.episode_path would have been updated by __convert_audio__ if conversion occurred.
unregister_active_download(self.__c_episode.episode_path)
@@ -1106,13 +941,7 @@ class DW_ALBUM:
def dw(self) -> Album:
# Report album initializing status
album_obj = self.__album_metadata
status_obj = initializingObject(ids=album_obj.ids)
callback_obj = albumCallbackObject(album=album_obj, status_info=status_obj)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
)
report_album_initializing(album_obj)
pic_url = max(album_obj.images, key=lambda i: i.get('height', 0) * i.get('width', 0)).get('url') if album_obj.images else None
image_bytes = request(pic_url).content if pic_url else None
@@ -1133,11 +962,14 @@ class DW_ALBUM:
pad_tracks=self.__preferences.pad_tracks
)
# Calculate total number of discs for proper metadata tagging
total_discs = max((track.disc_number for track in album_obj.tracks), default=1)
for a, track_in_album in enumerate(album_obj.tracks):
current_track = a + 1
c_preferences = deepcopy(self.__preferences)
c_preferences.track_number = current_track
# Use actual track position for progress tracking, not for metadata
c_preferences.track_number = a + 1 # For progress reporting only
try:
# Fetch full track object as album endpoint only provides simplified track objects
@@ -1210,13 +1042,7 @@ class DW_ALBUM:
total_failed=len(failed_tracks)
)
status_obj = doneObject(ids=album_obj.ids, summary=summary_obj)
callback_obj = albumCallbackObject(album=album_obj, status_info=status_obj)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
)
report_album_done(album_obj, summary_obj)
return album
@@ -1259,21 +1085,10 @@ class DW_PLAYLIST:
# --- End build playlistObject ---
# Report playlist initializing status
status_obj = initializingObject(ids=IDs(spotify=playlist_id))
callback_obj = playlistCallbackObject(playlist=playlist_obj_for_cb, status_info=status_obj)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj
)
report_playlist_initializing(playlist_obj_for_cb)
# --- Prepare the m3u playlist file ---
playlist_m3u_dir = os.path.join(self.__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")
if not os.path.exists(m3u_path):
with open(m3u_path, "w", encoding="utf-8") as m3u_file:
m3u_file.write("#EXTM3U\n")
m3u_path = create_m3u_file(self.__output_dir, playlist_name)
# -------------------------------------
playlist = Playlist()
@@ -1319,9 +1134,7 @@ class DW_PLAYLIST:
# --- Append the final track path to the m3u file using a relative path ---
if track and track.success and hasattr(track, 'song_path') and track.song_path:
relative_path = os.path.relpath(track.song_path, start=playlist_m3u_dir)
with open(m3u_path, "a", encoding="utf-8") as m3u_file:
m3u_file.write(f"{relative_path}\n")
append_track_to_m3u(m3u_path, track.song_path)
# ---------------------------------------------------------------------
if self.__make_zip:
@@ -1361,13 +1174,7 @@ class DW_PLAYLIST:
total_failed=len(failed_tracks_cb)
)
status_obj = doneObject(ids=playlist_obj_for_cb.ids, summary=summary_obj)
callback_obj = playlistCallbackObject(playlist=playlist_obj_for_cb, status_info=status_obj)
report_progress(
reporter=Download_JOB.progress_reporter,
callback_obj=callback_obj,
)
report_playlist_done(playlist_obj_for_cb, summary_obj)
return playlist