implemented typechecking in spotloader

This commit is contained in:
Xoconoch
2025-06-10 21:10:13 -06:00
parent cc947fe374
commit ed8f41d45f
12 changed files with 759 additions and 928 deletions

View File

@@ -104,6 +104,31 @@ class Spo:
return track_json
@classmethod
def get_tracks(cls, ids: list, market: str = None, client_id=None, client_secret=None):
"""
Get information for multiple tracks by a list of IDs.
Args:
ids (list): A list of Spotify track IDs.
market (str, optional): An ISO 3166-1 alpha-2 country code.
client_id (str, optional): Optional custom Spotify client ID.
client_secret (str, optional): Optional custom Spotify client secret.
Returns:
dict: A dictionary containing a list of track information.
"""
api = cls.__get_api(client_id, client_secret)
try:
tracks_json = api.tracks(ids, market=market)
except SpotifyException as error:
if error.http_status in cls.__error_codes:
# Create a string of the first few IDs for the error message
ids_preview = ', '.join(ids[:3]) + ('...' if len(ids) > 3 else '')
raise InvalidLink(f"one or more IDs in the list: [{ids_preview}]")
return tracks_json
@classmethod
def get_album(cls, ids, client_id=None, client_secret=None):
"""

View File

@@ -4,6 +4,7 @@ import logging
import sys
from typing import Optional, Callable, Dict, Any, Union
import json
from dataclasses import asdict
from deezspot.models.callback.callbacks import (
BaseStatusObject,
@@ -165,7 +166,8 @@ def report_progress(
raise TypeError(f"callback_obj must be of type trackCallbackObject, albumCallbackObject, or playlistCallbackObject, got {type(callback_obj)}")
# Convert the callback object to a dictionary and report it
report_dict = asdict(callback_obj)
if reporter:
reporter.report(callback_obj.__dict__)
reporter.report(report_dict)
else:
logger.info(json.dumps(callback_obj.__dict__))
logger.info(json.dumps(report_dict))

View File

@@ -6,7 +6,7 @@ Callback data models for the music metadata schema.
from .common import IDs, ReleaseDate
from .artist import artistObject, albumArtistObject
from .album import albumObject, trackAlbumObject
from .album import albumObject, trackAlbumObject, artistAlbumObject
from .track import trackObject, artistTrackObject, albumTrackObject, playlistTrackObject
from .playlist import playlistObject, trackPlaylistObject, albumTrackPlaylistObject, artistTrackPlaylistObject
from .callbacks import (
@@ -22,4 +22,5 @@ from .callbacks import (
trackCallbackObject,
albumCallbackObject,
playlistCallbackObject
)
)
from .user import userObject

View File

@@ -29,6 +29,7 @@ class trackAlbumObject:
disc_number: int = 1
track_number: int = 1
duration_ms: int = 0
explicit: bool = False
genres: List[str] = field(default_factory=list)
ids: IDs = field(default_factory=IDs)
artists: List[artistTrackAlbumObject] = field(default_factory=list)
@@ -43,6 +44,8 @@ class albumObject:
release_date: Dict[str, Any] = field(default_factory=dict)
total_tracks: int = 0
genres: List[str] = field(default_factory=list)
images: List[Dict[str, Any]] = field(default_factory=list)
copyrights: List[Dict[str, str]] = field(default_factory=list)
ids: IDs = field(default_factory=IDs)
tracks: List[trackAlbumObject] = field(default_factory=list)
artists: List[artistAlbumObject] = field(default_factory=list)

View File

@@ -1,7 +1,7 @@
#!/usr/bin/python3
from dataclasses import dataclass, field
from typing import List, Optional
from typing import List, Optional, Dict, Any
from .common import IDs
@@ -23,5 +23,6 @@ class artistObject:
type: str = "artist"
name: str = ""
genres: List[str] = field(default_factory=list)
images: List[Dict[str, Any]] = field(default_factory=list)
ids: IDs = field(default_factory=IDs)
albums: List[albumArtistObject] = field(default_factory=list)

View File

@@ -10,6 +10,7 @@ class IDs:
spotify: Optional[str] = None
deezer: Optional[str] = None
isrc: Optional[str] = None
upc: Optional[str] = None
@dataclass

View File

@@ -20,6 +20,8 @@ class albumTrackPlaylistObject:
album_type: str = "" # "album" | "single" | "compilation"
title: str = ""
release_date: Dict[str, Any] = field(default_factory=dict) # ReleaseDate as dict
total_tracks: int = 0
images: List[Dict[str, Any]] = field(default_factory=list)
ids: IDs = field(default_factory=IDs)
artists: List[artistAlbumTrackPlaylistObject] = field(default_factory=list)
@@ -54,4 +56,5 @@ class playlistObject:
description: Optional[str] = None
owner: userObject = field(default_factory=userObject)
tracks: List[trackPlaylistObject] = field(default_factory=list)
images: List[Dict[str, Any]] = field(default_factory=list)
ids: IDs = field(default_factory=IDs)

View File

@@ -32,6 +32,7 @@ class albumTrackObject:
release_date: Dict[str, Any] = field(default_factory=dict) # ReleaseDate as dict
total_tracks: int = 0
genres: List[str] = field(default_factory=list)
images: List[Dict[str, Any]] = field(default_factory=list)
ids: IDs = field(default_factory=IDs)
artists: List[artistAlbumTrackObject] = field(default_factory=list)
@@ -52,6 +53,7 @@ class trackObject:
disc_number: int = 1
track_number: int = 1
duration_ms: int = 0 # mandatory
explicit: bool = False
genres: List[str] = field(default_factory=list)
album: albumTrackObject = field(default_factory=albumTrackObject)
artists: List[artistTrackObject] = field(default_factory=list)

View File

@@ -8,6 +8,7 @@ class Preferences:
self.output_dir = None
self.ids = None
self.json_data = None
self.playlist_tracks_json = None
self.recursive_quality = None
self.recursive_download = None
self.not_interface = None

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ from deezspot.models.download import (
Smart,
Episode
)
from deezspot.models.callback import trackCallbackObject, errorObject
from deezspot.spotloader.__download__ import (
DW_TRACK,
DW_ALBUM,
@@ -98,6 +99,7 @@ class SpoLogin:
save_cover=stock_save_cover,
market: list[str] | None = stock_market
) -> Track:
song_metadata = None
try:
link_is_valid(link_track)
ids = get_ids(link_track)
@@ -106,7 +108,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.get('music', 'Unknown')} - {song_metadata.get('artist', 'Unknown')}")
logger.info(f"Starting download for track: {song_metadata.title} - {'; '.join([a.name for a in song_metadata.artists])}")
preferences = Preferences()
preferences.real_time_dl = real_time_dl
@@ -140,60 +142,22 @@ class SpoLogin:
except MarketAvailabilityError as e:
logger.error(f"Track download failed due to market availability: {str(e)}")
if song_metadata:
track_info = {
"name": song_metadata.get("music", "Unknown Track"),
"artist": song_metadata.get("artist", "Unknown Artist"),
}
summary = {
"successful_tracks": [],
"skipped_tracks": [],
"failed_tracks": [{
"track": f"{track_info['name']} - {track_info['artist']}",
"reason": str(e)
}],
"total_successful": 0,
"total_skipped": 0,
"total_failed": 1
}
status_obj = errorObject(ids=song_metadata.ids, error=str(e))
callback_obj = trackCallbackObject(track=song_metadata, status_info=status_obj)
report_progress(
reporter=self.progress_reporter,
report_type="track",
song=track_info['name'],
artist=track_info['artist'],
status="error",
url=link_track,
error=str(e),
summary=summary
callback_obj=callback_obj
)
raise
except Exception as e:
logger.error(f"Failed to download track: {str(e)}")
traceback.print_exc()
if song_metadata:
track_info = {
"name": song_metadata.get("music", "Unknown Track"),
"artist": song_metadata.get("artist", "Unknown Artist"),
}
summary = {
"successful_tracks": [],
"skipped_tracks": [],
"failed_tracks": [{
"track": f"{track_info['name']} - {track_info['artist']}",
"reason": str(e)
}],
"total_successful": 0,
"total_skipped": 0,
"total_failed": 1
}
status_obj = errorObject(ids=song_metadata.ids, error=str(e))
callback_obj = trackCallbackObject(track=song_metadata, status_info=status_obj)
report_progress(
reporter=self.progress_reporter,
report_type="track",
song=track_info['name'],
artist=track_info['artist'],
status="error",
url=link_track,
error=str(e),
summary=summary
callback_obj=callback_obj
)
raise e
@@ -228,7 +192,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.get('album', 'Unknown')} - {song_metadata.get('ar_album', 'Unknown')}")
logger.info(f"Starting download for album: {song_metadata.title} - {'; '.join([a.name for a in song_metadata.artists])}")
preferences = Preferences()
preferences.real_time_dl = real_time_dl
@@ -300,90 +264,50 @@ class SpoLogin:
logger.info(f"Starting download for playlist: {playlist_json.get('name', 'Unknown')}")
for track_item_wrapper in playlist_json['tracks']['items']:
track_info = track_item_wrapper.get('track')
c_song_metadata = None # Initialize for each item
if not track_info:
logger.warning(f"Skipping an item in playlist {playlist_json.get('name', 'Unknown Playlist')} as it does not appear to be a valid track object.")
# Create a placeholder for this unidentifiable item
c_song_metadata = {
'name': 'Unknown Skipped Item',
'ids': None,
'error_type': 'InvalidItemStructure',
'error_message': 'Playlist item was not a valid track object.'
}
song_metadata.append(c_song_metadata)
playlist_tracks_data = playlist_json.get('tracks', {}).get('items', [])
if not playlist_tracks_data:
logger.warning(f"Playlist {link_playlist} has no tracks or could not be fetched.")
# We can still proceed to create an empty playlist object for consistency
song_metadata_list = []
for item in playlist_tracks_data:
if not item or 'track' not in item or not item['track']:
# Log a warning for items that are not valid tracks (e.g., local files, etc.)
logger.warning(f"Skipping an item in playlist {link_playlist} as it does not appear to be a valid track object.")
song_metadata_list.append({'error_type': 'invalid_track_object', 'error_message': 'Playlist item was not a valid track object.', 'name': 'Unknown Skipped Item', 'ids': None})
continue
track_data = item['track']
track_id = track_data.get('id')
if not track_id:
logger.warning(f"Skipping an item in playlist {link_playlist} because it has no track ID.")
song_metadata_list.append({'error_type': 'missing_track_id', 'error_message': 'Playlist item is missing a track ID.', 'name': track_data.get('name', 'Unknown Track without ID'), 'ids': None})
continue
track_name_for_logs = track_info.get('name', 'Unknown Track')
track_id_for_logs = track_info.get('id', 'Unknown ID') # Track's own ID if available
external_urls = track_info.get('external_urls')
if not external_urls or not external_urls.get('spotify'):
logger.warning(f"Track \"{track_name_for_logs}\" (ID: {track_id_for_logs}) in playlist {playlist_json.get('name', 'Unknown Playlist')} is not available on Spotify or has no URL.")
c_song_metadata = {
'name': track_name_for_logs,
'ids': track_id_for_logs, # Use track's own ID if available, otherwise will be None
'error_type': 'MissingTrackURL',
'error_message': f"Track \"{track_name_for_logs}\" is not available on Spotify or has no URL."
}
else:
track_spotify_url = external_urls['spotify']
track_ids_from_url = get_ids(track_spotify_url) # This is the ID used for fetching with 'tracking'
try:
# Market check for each track is done within tracking()
# Pass market. tracking() will raise MarketAvailabilityError if unavailable.
fetched_metadata = tracking(track_ids_from_url, market=market)
if fetched_metadata:
c_song_metadata = fetched_metadata
else:
# tracking() returned None, but didn't raise MarketAvailabilityError. General fetch error.
logger.warning(f"Could not retrieve full metadata for track {track_name_for_logs} (ID: {track_ids_from_url}, URL: {track_spotify_url}) in playlist {playlist_json.get('name', 'Unknown Playlist')}. API error or other issue.")
c_song_metadata = {
'name': track_name_for_logs,
'ids': track_ids_from_url,
'error_type': 'MetadataFetchError',
'error_message': f"Failed to fetch full metadata for track {track_name_for_logs}."
}
except MarketAvailabilityError as e:
logger.warning(f"Track {track_name_for_logs} (ID: {track_ids_from_url}, URL: {track_spotify_url}) in playlist {playlist_json.get('name', 'Unknown Playlist')} is not available in the specified market(s). Skipping. Error: {str(e)}")
c_song_metadata = {
'name': track_name_for_logs,
'ids': track_ids_from_url,
'error_type': 'MarketAvailabilityError',
'error_message': str(e)
}
except Exception as e_tracking: # Catch any other unexpected error from tracking()
logger.error(f"Unexpected error fetching metadata for track {track_name_for_logs} (ID: {track_ids_from_url}, URL: {track_spotify_url}): {str(e_tracking)}")
c_song_metadata = {
'name': track_name_for_logs,
'ids': track_ids_from_url,
'error_type': 'UnexpectedTrackingError',
'error_message': f"Unexpected error fetching metadata: {str(e_tracking)}"
}
if c_song_metadata: # Ensure something is appended
song_metadata.append(c_song_metadata)
else:
# This case should ideally not be reached if logic above is complete
logger.error(f"Logic error: c_song_metadata remained None for track {track_name_for_logs} in playlist {playlist_json.get('name', 'Unknown Playlist')}")
song_metadata.append({
'name': track_name_for_logs,
'ids': track_id_for_logs or track_ids_from_url,
'error_type': 'InternalLogicError',
'error_message': 'Internal error processing playlist track metadata.'
})
try:
song_metadata = tracking(track_id, market=market)
if song_metadata:
song_metadata_list.append(song_metadata)
else:
# Create a placeholder for tracks that fail metadata fetching
failed_track_info = {'error_type': 'metadata_fetch_failed', 'error_message': f"Failed to fetch metadata for track ID: {track_id}", 'name': track_data.get('name', f'Track ID {track_id}'), 'ids': track_id}
song_metadata_list.append(failed_track_info)
logger.warning(f"Could not retrieve metadata for track {track_id} in playlist {link_playlist}.")
except MarketAvailabilityError as e:
failed_track_info = {'error_type': 'market_availability_error', 'error_message': str(e), 'name': track_data.get('name', f'Track ID {track_id}'), 'ids': track_id}
song_metadata_list.append(failed_track_info)
logger.warning(str(e))
preferences = Preferences()
preferences.real_time_dl = real_time_dl
preferences.link = link_playlist
preferences.song_metadata = song_metadata
preferences.song_metadata = song_metadata_list
preferences.quality_download = quality_download
preferences.output_dir = output_dir
preferences.ids = ids
preferences.json_data = playlist_json
preferences.playlist_tracks_json = playlist_tracks_data
preferences.recursive_quality = recursive_quality
preferences.recursive_download = recursive_download
preferences.not_interface = not_interface
@@ -403,7 +327,7 @@ class SpoLogin:
preferences.bitrate = bitrate
preferences.save_cover = save_cover
preferences.market = market
playlist = DW_PLAYLIST(preferences).dw()
return playlist
@@ -445,7 +369,7 @@ class SpoLogin:
if episode_metadata is None:
raise Exception(f"Could not process episode metadata for {link_episode}. It might not be available in the specified market(s) or an API error occurred.")
logger.info(f"Starting download for episode: {episode_metadata.get('name', 'Unknown')} - {episode_metadata.get('show', 'Unknown')}")
logger.info(f"Starting download for episode: {episode_metadata.title} - {episode_metadata.album.title}")
preferences = Preferences()
preferences.real_time_dl = real_time_dl

View File

@@ -1,11 +1,18 @@
#!/usr/bin/python3
from deezspot.easy_spoty import Spo
from datetime import datetime
from deezspot.libutils.utils import convert_to_date
import traceback
from deezspot.libutils.logging_utils import logger
from deezspot.exceptions import MarketAvailabilityError
from typing import List, Optional, Dict, Any
from deezspot.models.callback.album import albumObject, artistAlbumObject, trackAlbumObject as CbTrackAlbumObject, artistTrackAlbumObject
from deezspot.models.callback.artist import artistObject
from deezspot.models.callback.common import IDs
from deezspot.models.callback.playlist import playlistObject, trackPlaylistObject, albumTrackPlaylistObject, artistTrackPlaylistObject, artistAlbumTrackPlaylistObject
from deezspot.models.callback.track import trackObject, artistTrackObject, albumTrackObject, artistAlbumTrackObject
from deezspot.models.callback.user import userObject
def _check_market_availability(item_name: str, item_type: str, api_available_markets: list[str] | None, user_markets: list[str] | None):
"""Checks if an item is available in any of the user-specified markets."""
@@ -15,338 +22,256 @@ def _check_market_availability(item_name: str, item_type: str, api_available_mar
markets_str = ", ".join(user_markets)
raise MarketAvailabilityError(f"{item_type} '{item_name}' not available in provided market(s): {markets_str}")
elif user_markets and api_available_markets is None:
# Log a warning if user specified markets, but API response doesn't include 'available_markets'
# This might indicate the item is available in all markets or API doesn't provide this info for this item type.
# For now, we proceed without raising an error, as we cannot confirm it's "not available".
logger.warning(
f"Market availability check for {item_type} '{item_name}' skipped: "
"API response did not include 'available_markets' field. Assuming availability."
)
def _get_best_image_urls(images_list):
urls = {'image': '', 'image2': '', 'image3': ''}
if not images_list or not isinstance(images_list, list):
return urls
def _parse_release_date(date_str: Optional[str], precision: Optional[str]) -> Dict[str, Any]:
if not date_str:
return {}
parts = date_str.split('-')
data = {}
if len(parts) >= 1 and parts[0]:
data['year'] = int(parts[0])
if precision in ['month', 'day'] and len(parts) >= 2 and parts[1]:
data['month'] = int(parts[1])
if precision == 'day' and len(parts) >= 3 and parts[2]:
data['day'] = int(parts[2])
return data
# Sort images by area (height * width) in descending order
# Handle cases where height or width might be missing
sorted_images = sorted(
images_list,
key=lambda img: img.get('height', 0) * img.get('width', 0),
reverse=True
def _json_to_ids(item_json: dict) -> IDs:
external_ids = item_json.get('external_ids', {})
return IDs(
spotify=item_json.get('id'),
isrc=external_ids.get('isrc'),
upc=external_ids.get('upc')
)
if len(sorted_images) > 0:
urls['image'] = sorted_images[0].get('url', '')
if len(sorted_images) > 1:
urls['image2'] = sorted_images[1].get('url', '') # Second largest or same if only one size
if len(sorted_images) > 2:
urls['image3'] = sorted_images[2].get('url', '') # Third largest
return urls
def _json_to_artist_track_object(artist_json: dict) -> artistTrackObject:
return artistTrackObject(
name=artist_json.get('name', ''),
ids=_json_to_ids(artist_json)
)
def tracking(ids, album_data_for_track=None, market: list[str] | None = None):
datas = {}
def _json_to_artist_album_track_object(artist_json: dict) -> artistAlbumTrackObject:
return artistAlbumTrackObject(
name=artist_json.get('name', ''),
ids=_json_to_ids(artist_json)
)
def _json_to_album_track_object(album_json: dict) -> albumTrackObject:
return albumTrackObject(
album_type=album_json.get('album_type', 'album'),
title=album_json.get('name', ''),
release_date=_parse_release_date(album_json.get('release_date'), album_json.get('release_date_precision')),
total_tracks=album_json.get('total_tracks', 0),
genres=album_json.get('genres', []),
images=album_json.get('images', []),
ids=_json_to_ids(album_json),
artists=[_json_to_artist_album_track_object(artist) for artist in album_json.get('artists', [])]
)
def tracking(ids, album_data_for_track=None, market: list[str] | None = None) -> Optional[trackObject]:
try:
json_track = Spo.get_track(ids)
if not json_track:
logger.error(f"Failed to get track details for ID: {ids} from Spotify API.")
return None
# Perform market availability check for the track
track_name_for_check = json_track.get('name', f'Track ID {ids}')
api_track_markets = json_track.get('available_markets')
_check_market_availability(track_name_for_check, "Track", api_track_markets, market)
# Album details section
# Use provided album_data_for_track if available (from tracking_album context)
# Otherwise, fetch from track's album info or make a new API call for more details
album_to_process = None
fetch_full_album_details = False
if album_data_for_track:
album_to_process = album_data_for_track
elif json_track.get('album'):
album_to_process = json_track.get('album')
# We might want fuller album details (like label, genres, upc, copyrights)
# not present in track's nested album object.
fetch_full_album_details = True
album_id = json_track.get('album', {}).get('id')
if album_id:
album_to_process = Spo.get_album(album_id)
if not album_to_process:
album_to_process = json_track.get('album')
album_for_track = _json_to_album_track_object(album_to_process) if album_to_process else albumTrackObject()
if fetch_full_album_details and album_to_process and album_to_process.get('id'):
full_album_json = Spo.get_album(album_to_process.get('id'))
if full_album_json:
album_to_process = full_album_json # Prioritize full album details
if album_to_process:
image_urls = _get_best_image_urls(album_to_process.get('images', []))
datas.update(image_urls)
datas['genre'] = "; ".join(album_to_process.get('genres', []))
album_artists_data = album_to_process.get('artists', [])
ar_album_names = [artist.get('name', '') for artist in album_artists_data if artist.get('name')]
datas['ar_album'] = "; ".join(filter(None, ar_album_names)) or 'Unknown Artist'
datas['album'] = album_to_process.get('name', 'Unknown Album')
datas['label'] = album_to_process.get('label', '') # Often in full album, not track's album obj
datas['album_type'] = album_to_process.get('album_type', 'unknown')
copyrights_data = album_to_process.get('copyrights', [])
datas['copyright'] = copyrights_data[0].get('text', '') if copyrights_data else ''
album_external_ids = album_to_process.get('external_ids', {})
datas['upc'] = album_external_ids.get('upc', '')
datas['nb_tracks'] = album_to_process.get('total_tracks', 0)
# Release date from album_to_process is likely more definitive
datas['year'] = convert_to_date(album_to_process.get('release_date', ''))
datas['release_date_precision'] = album_to_process.get('release_date_precision', 'unknown')
else: # Fallback if no album_to_process
datas.update(_get_best_image_urls([]))
datas['genre'] = ''
datas['ar_album'] = 'Unknown Artist'
datas['album'] = json_track.get('album', {}).get('name', 'Unknown Album') # Basic fallback
datas['label'] = ''
datas['album_type'] = json_track.get('album', {}).get('album_type', 'unknown')
datas['copyright'] = ''
datas['upc'] = ''
datas['nb_tracks'] = json_track.get('album', {}).get('total_tracks', 0)
datas['year'] = convert_to_date(json_track.get('album', {}).get('release_date', ''))
datas['release_date_precision'] = json_track.get('album', {}).get('release_date_precision', 'unknown')
# Track specific details
datas['music'] = json_track.get('name', 'Unknown Track')
track_artists_data = json_track.get('artists', [])
track_artist_names = [artist.get('name', '') for artist in track_artists_data if artist.get('name')]
datas['artist'] = "; ".join(filter(None, track_artist_names)) or 'Unknown Artist'
datas['tracknum'] = json_track.get('track_number', 0)
datas['discnum'] = json_track.get('disc_number', 0)
# If year details were not set from a more complete album object, use track's album info
if not datas.get('year') and json_track.get('album'):
datas['year'] = convert_to_date(json_track.get('album', {}).get('release_date', ''))
datas['release_date_precision'] = json_track.get('album', {}).get('release_date_precision', 'unknown')
datas['duration'] = json_track.get('duration_ms', 0) // 1000
track_external_ids = json_track.get('external_ids', {})
datas['isrc'] = track_external_ids.get('isrc', '')
datas['explicit'] = json_track.get('explicit', False)
datas['popularity'] = json_track.get('popularity', 0)
# Placeholder for tags not directly from this API response but might be expected by tagger
datas['bpm'] = datas.get('bpm', 'Unknown') # Not available here
datas['gain'] = datas.get('gain', 'Unknown') # Not available here
datas['lyric'] = datas.get('lyric', '') # Not available here
datas['author'] = datas.get('author', '') # Not available here (lyricist)
datas['composer'] = datas.get('composer', '') # Not available here
# copyright is handled by album section
datas['lyricist'] = datas.get('lyricist', '') # Same as author, not here
datas['version'] = datas.get('version', '') # Not typically here
datas['ids'] = ids
track_obj = trackObject(
title=json_track.get('name', ''),
disc_number=json_track.get('disc_number', 1),
track_number=json_track.get('track_number', 1),
duration_ms=json_track.get('duration_ms', 0),
explicit=json_track.get('explicit', False),
genres=album_for_track.genres,
album=album_for_track,
artists=[_json_to_artist_track_object(artist) for artist in json_track.get('artists', [])],
ids=_json_to_ids(json_track)
)
logger.debug(f"Successfully tracked metadata for track {ids}")
return track_obj
except MarketAvailabilityError: # Re-raise to be caught by the calling download method
except MarketAvailabilityError:
raise
except Exception as e:
logger.error(f"Failed to track metadata for track {ids}: {str(e)}")
logger.debug(traceback.format_exc())
return None
return datas
def _json_to_artist_album_object(artist_json: dict) -> artistAlbumObject:
return artistAlbumObject(
name=artist_json.get('name', ''),
ids=_json_to_ids(artist_json)
)
def tracking_album(album_json, market: list[str] | None = None):
def _json_to_track_album_object(track_json: dict) -> CbTrackAlbumObject:
return CbTrackAlbumObject(
title=track_json.get('name', ''),
disc_number=track_json.get('disc_number', 1),
track_number=track_json.get('track_number', 1),
duration_ms=track_json.get('duration_ms', 0),
explicit=track_json.get('explicit', False),
ids=_json_to_ids(track_json),
artists=[artistTrackAlbumObject(name=a.get('name'), ids=_json_to_ids(a)) for a in track_json.get('artists', [])]
)
def tracking_album(album_json, market: list[str] | None = None) -> Optional[albumObject]:
if not album_json:
logger.error("tracking_album received None or empty album_json.")
return None
song_metadata = {}
try:
# Perform market availability check for the album itself
album_name_for_check = album_json.get('name', f"Album ID {album_json.get('id', 'Unknown')}")
api_album_markets = album_json.get('available_markets')
_check_market_availability(album_name_for_check, "Album", api_album_markets, market)
initial_list_fields = {
"music": [], "artist": [], "tracknum": [], "discnum": [],
"duration": [], "isrc": [], "ids": [], "explicit_list": [], "popularity_list": []
# "bpm": [], "gain": [] are usually unknown from this endpoint for tracks
}
song_metadata.update(initial_list_fields)
image_urls = _get_best_image_urls(album_json.get('images', []))
song_metadata.update(image_urls)
song_metadata['album'] = album_json.get('name', 'Unknown Album')
song_metadata['label'] = album_json.get('label', '')
song_metadata['year'] = convert_to_date(album_json.get('release_date', ''))
song_metadata['release_date_precision'] = album_json.get('release_date_precision', 'unknown')
song_metadata['nb_tracks'] = album_json.get('total_tracks', 0)
song_metadata['genre'] = "; ".join(album_json.get('genres', []))
song_metadata['album_type'] = album_json.get('album_type', 'unknown')
song_metadata['popularity'] = album_json.get('popularity', 0)
album_artists_data = album_json.get('artists', [])
ar_album_names = [artist.get('name', '') for artist in album_artists_data if artist.get('name')]
song_metadata['ar_album'] = "; ".join(filter(None, ar_album_names)) or 'Unknown Artist'
album_external_ids = album_json.get('external_ids', {})
song_metadata['upc'] = album_external_ids.get('upc', '')
copyrights_data = album_json.get('copyrights', [])
song_metadata['copyright'] = copyrights_data[0].get('text', '') if copyrights_data else ''
album_artists = [_json_to_artist_album_object(a) for a in album_json.get('artists', [])]
# Add other common flat metadata keys with defaults if not directly from album_json
song_metadata['bpm'] = 'Unknown'
song_metadata['gain'] = 'Unknown'
song_metadata['lyric'] = ''
song_metadata['author'] = ''
song_metadata['composer'] = ''
song_metadata['lyricist'] = ''
song_metadata['version'] = ''
album_tracks = []
simplified_tracks = album_json.get('tracks', {}).get('items', [])
track_ids = [t['id'] for t in simplified_tracks if t and t.get('id')]
full_tracks_data = []
if track_ids:
# Batch fetch full track objects. The get_tracks method should handle chunking if necessary.
full_tracks_data = Spo.get_tracks(track_ids, market=','.join(market) if market else None)
track_items_to_process = []
if full_tracks_data and full_tracks_data.get('tracks'):
track_items_to_process = full_tracks_data['tracks']
else: # Fallback to simplified if batch fetch fails
track_items_to_process = simplified_tracks
tracks_data = album_json.get('tracks', {}).get('items', [])
for track_item in tracks_data:
if not track_item: continue # Skip if track_item is None
c_ids = track_item.get('id')
if not c_ids: # If track has no ID, try to get some basic info directly
song_metadata['music'].append(track_item.get('name', 'Unknown Track'))
track_artists_data = track_item.get('artists', [])
track_artist_names = [artist.get('name', '') for artist in track_artists_data if artist.get('name')]
song_metadata['artist'].append("; ".join(filter(None, track_artist_names)) or 'Unknown Artist')
song_metadata['tracknum'].append(track_item.get('track_number', 0))
song_metadata['discnum'].append(track_item.get('disc_number', 0))
song_metadata['duration'].append(track_item.get('duration_ms', 0) // 1000)
song_metadata['isrc'].append(track_item.get('external_ids', {}).get('isrc', ''))
song_metadata['ids'].append('N/A')
song_metadata['explicit_list'].append(track_item.get('explicit', False))
song_metadata['popularity_list'].append(track_item.get('popularity', 0))
for track_item in track_items_to_process:
if not track_item or not track_item.get('id'):
continue
# Pass the main album_json as album_data_for_track to avoid refetching it in tracking()
# Also pass the market parameter
track_details = tracking(c_ids, album_data_for_track=album_json, market=market)
if track_details:
song_metadata['music'].append(track_details.get('music', 'Unknown Track'))
song_metadata['artist'].append(track_details.get('artist', 'Unknown Artist'))
song_metadata['tracknum'].append(track_details.get('tracknum', 0))
song_metadata['discnum'].append(track_details.get('discnum', 0))
# BPM and Gain are generally not per-track from this endpoint
# song_metadata['bpm'].append(track_details.get('bpm', 'Unknown'))
song_metadata['duration'].append(track_details.get('duration', 0))
song_metadata['isrc'].append(track_details.get('isrc', ''))
song_metadata['ids'].append(c_ids)
song_metadata['explicit_list'].append(track_details.get('explicit', False))
# popularity_list for track specific popularity if needed, or use album popularity
# song_metadata['popularity_list'].append(track_details.get('popularity',0))
else: # Fallback if tracking(c_ids) failed
logger.warning(f"Could not retrieve full metadata for track ID {c_ids} in album {album_json.get('id', 'N/A')}. Using minimal data.")
song_metadata['music'].append(track_item.get('name', 'Unknown Track'))
track_artists_data = track_item.get('artists', [])
track_artist_names = [artist.get('name', '') for artist in track_artists_data if artist.get('name')]
song_metadata['artist'].append("; ".join(filter(None, track_artist_names)) or 'Unknown Artist')
song_metadata['tracknum'].append(track_item.get('track_number', 0))
song_metadata['discnum'].append(track_item.get('disc_number', 0))
song_metadata['duration'].append(track_item.get('duration_ms', 0) // 1000)
song_metadata['isrc'].append(track_item.get('external_ids', {}).get('isrc', ''))
song_metadata['ids'].append(c_ids)
song_metadata['explicit_list'].append(track_item.get('explicit', False))
# song_metadata['popularity_list'].append(track_item.get('popularity',0))
# Simplified track object from album endpoint is enough for trackAlbumObject
album_tracks.append(_json_to_track_album_object(track_item))
album_obj = albumObject(
album_type=album_json.get('album_type'),
title=album_json.get('name'),
release_date=_parse_release_date(album_json.get('release_date'), album_json.get('release_date_precision')),
total_tracks=album_json.get('total_tracks'),
genres=album_json.get('genres', []),
images=album_json.get('images', []),
copyrights=album_json.get('copyrights', []),
ids=_json_to_ids(album_json),
tracks=album_tracks,
artists=album_artists
)
logger.debug(f"Successfully tracked metadata for album {album_json.get('id', 'N/A')}")
return album_obj
except MarketAvailabilityError: # Re-raise
except MarketAvailabilityError:
raise
except Exception as e:
logger.error(f"Failed to track album metadata for album ID {album_json.get('id', 'N/A') if album_json else 'N/A'}: {str(e)}")
logger.debug(traceback.format_exc())
return None
return song_metadata
def tracking_episode(ids, market: list[str] | None = None):
datas = {}
def tracking_episode(ids, market: list[str] | None = None) -> Optional[trackObject]:
try:
json_episode = Spo.get_episode(ids)
if not json_episode:
logger.error(f"Failed to get episode details for ID: {ids} from Spotify API.")
return None
# Perform market availability check for the episode
episode_name_for_check = json_episode.get('name', f'Episode ID {ids}')
api_episode_markets = json_episode.get('available_markets')
_check_market_availability(episode_name_for_check, "Episode", api_episode_markets, market)
image_urls = _get_best_image_urls(json_episode.get('images', []))
datas.update(image_urls)
datas['audio_preview_url'] = json_episode.get('audio_preview_url', '')
datas['description'] = json_episode.get('description', '')
datas['duration'] = json_episode.get('duration_ms', 0) // 1000
datas['explicit'] = json_episode.get('explicit', False)
datas['external_urls_spotify'] = json_episode.get('external_urls', {}).get('spotify', '')
datas['href'] = json_episode.get('href', '')
datas['html_description'] = json_episode.get('html_description', '')
datas['id'] = json_episode.get('id', '') # Episode's own ID
datas['is_externally_hosted'] = json_episode.get('is_externally_hosted', False)
datas['is_playable'] = json_episode.get('is_playable', False)
datas['language'] = json_episode.get('language', '') # Deprecated, use languages
datas['languages'] = "; ".join(json_episode.get('languages', []))
datas['music'] = json_episode.get('name', 'Unknown Episode') # Use 'music' for consistency with track naming
datas['name'] = json_episode.get('name', 'Unknown Episode') # Keep 'name' as well if needed by other parts
datas['release_date'] = convert_to_date(json_episode.get('release_date', ''))
datas['release_date_precision'] = json_episode.get('release_date_precision', 'unknown')
show_data = json_episode.get('show', {})
datas['show_name'] = show_data.get('name', 'Unknown Show')
datas['publisher'] = show_data.get('publisher', 'Unknown Publisher')
datas['show_description'] = show_data.get('description', '')
datas['show_explicit'] = show_data.get('explicit', False)
datas['show_total_episodes'] = show_data.get('total_episodes', 0)
datas['show_media_type'] = show_data.get('media_type', 'unknown') # e.g. 'audio'
# For tagger compatibility, map some show data to common track/album fields
datas['artist'] = datas['publisher'] # Publisher as artist for episodes
datas['album'] = datas['show_name'] # Show name as album for episodes
datas['genre'] = "; ".join(show_data.get('genres', [])) # If shows have genres
datas['copyright'] = copyrights_data[0].get('text', '') if (copyrights_data := show_data.get('copyrights', [])) else ''
# Placeholder for tags not directly from this API response but might be expected by tagger
datas['tracknum'] = 1 # Default for single episode
datas['discnum'] = 1 # Default for single episode
datas['ar_album'] = datas['publisher']
datas['label'] = datas['publisher']
datas['bpm'] = 'Unknown'
datas['gain'] = 'Unknown'
datas['isrc'] = ''
datas['upc'] = ''
datas['lyric'] = ''
datas['author'] = ''
datas['composer'] = ''
datas['lyricist'] = ''
datas['version'] = ''
datas['ids'] = ids # The episode's own ID passed to the function
album_for_episode = albumTrackObject(
album_type='show',
title=show_data.get('name', 'Unknown Show'),
total_tracks=show_data.get('total_episodes', 0),
genres=show_data.get('genres', []),
images=json_episode.get('images', []),
ids=IDs(spotify=show_data.get('id')),
artists=[artistTrackAlbumObject(name=show_data.get('publisher', ''))]
)
episode_as_track = trackObject(
title=json_episode.get('name', 'Unknown Episode'),
duration_ms=json_episode.get('duration_ms', 0),
explicit=json_episode.get('explicit', False),
album=album_for_episode,
artists=[artistTrackObject(name=show_data.get('publisher', ''))],
ids=_json_to_ids(json_episode)
)
logger.debug(f"Successfully tracked metadata for episode {ids}")
return episode_as_track
except MarketAvailabilityError: # Re-raise
except MarketAvailabilityError:
raise
except Exception as e:
logger.error(f"Failed to track episode metadata for ID {ids}: {str(e)}")
logger.debug(traceback.format_exc())
return None
return datas
def json_to_artist_album_track_playlist_object(artist_json: dict) -> artistAlbumTrackPlaylistObject:
"""Converts a JSON dict to an artistAlbumTrackPlaylistObject."""
return artistAlbumTrackPlaylistObject(
name=artist_json.get('name', ''),
ids=_json_to_ids(artist_json)
)
def json_to_artist_track_playlist_object(artist_json: dict) -> artistTrackPlaylistObject:
"""Converts a JSON dict to an artistTrackPlaylistObject."""
return artistTrackPlaylistObject(
name=artist_json.get('name', ''),
ids=_json_to_ids(artist_json)
)
def json_to_album_track_playlist_object(album_json: dict) -> albumTrackPlaylistObject:
"""Converts a JSON dict to an albumTrackPlaylistObject."""
return albumTrackPlaylistObject(
album_type=album_json.get('album_type', ''),
title=album_json.get('name', ''),
total_tracks=album_json.get('total_tracks', 0),
release_date=_parse_release_date(album_json.get('release_date'), album_json.get('release_date_precision')),
images=album_json.get('images', []),
ids=_json_to_ids(album_json),
artists=[json_to_artist_album_track_playlist_object(a) for a in album_json.get('artists', [])]
)
def json_to_track_playlist_object(track_json: dict) -> Optional[trackPlaylistObject]:
"""Converts a JSON dict from a playlist item to a trackPlaylistObject."""
if not track_json:
return None
album_data = track_json.get('album', {})
return trackPlaylistObject(
title=track_json.get('name', ''),
disc_number=track_json.get('disc_number', 1),
track_number=track_json.get('track_number', 1),
duration_ms=track_json.get('duration_ms', 0),
ids=_json_to_ids(track_json),
album=json_to_album_track_playlist_object(album_data),
artists=[json_to_artist_track_playlist_object(a) for a in track_json.get('artists', [])]
)