feat: Ditch spotipy in favor of librespot internal api
This commit is contained in:
@@ -47,6 +47,7 @@ from deezspot.libutils.others_settings import (
|
|||||||
)
|
)
|
||||||
from deezspot.libutils.logging_utils import ProgressReporter, logger, report_progress
|
from deezspot.libutils.logging_utils import ProgressReporter, logger, report_progress
|
||||||
import requests
|
import requests
|
||||||
|
from librespot.core import Session
|
||||||
|
|
||||||
from deezspot.models.callback.callbacks import (
|
from deezspot.models.callback.callbacks import (
|
||||||
trackCallbackObject,
|
trackCallbackObject,
|
||||||
@@ -97,6 +98,8 @@ class DeeLogin:
|
|||||||
# Store Spotify credentials
|
# Store Spotify credentials
|
||||||
self.spotify_client_id = spotify_client_id
|
self.spotify_client_id = spotify_client_id
|
||||||
self.spotify_client_secret = spotify_client_secret
|
self.spotify_client_secret = spotify_client_secret
|
||||||
|
# Optional path to Spotify credentials.json (env override or CWD default)
|
||||||
|
self.spotify_credentials_path = os.environ.get("SPOTIFY_CREDENTIALS_PATH") or os.path.join(os.getcwd(), "credentials.json")
|
||||||
|
|
||||||
# Initialize Spotify API if credentials are provided
|
# Initialize Spotify API if credentials are provided
|
||||||
if spotify_client_id and spotify_client_secret:
|
if spotify_client_id and spotify_client_secret:
|
||||||
@@ -120,6 +123,27 @@ class DeeLogin:
|
|||||||
# Set the progress reporter for Download_JOB
|
# Set the progress reporter for Download_JOB
|
||||||
Download_JOB.set_progress_reporter(self.progress_reporter)
|
Download_JOB.set_progress_reporter(self.progress_reporter)
|
||||||
|
|
||||||
|
def _ensure_spotify_session(self) -> None:
|
||||||
|
"""Ensure Spo has an attached librespot Session. Used only by spo->dee flows."""
|
||||||
|
try:
|
||||||
|
# Check if Spo already has a session (accessing private attr is ok internally)
|
||||||
|
has_session = getattr(Spo, f"_{Spo.__name__}__session", None) is not None
|
||||||
|
if has_session:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cred_path = self.spotify_credentials_path
|
||||||
|
if not os.path.isfile(cred_path):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Spotify session not initialized. Missing credentials.json at '{cred_path}'. "
|
||||||
|
"Set SPOTIFY_CREDENTIALS_PATH or place credentials.json in the working directory."
|
||||||
|
)
|
||||||
|
builder = Session.Builder()
|
||||||
|
builder.conf.stored_credentials_file = cred_path
|
||||||
|
session = builder.stored_file().create()
|
||||||
|
Spo.set_session(session)
|
||||||
|
|
||||||
def download_trackdee(
|
def download_trackdee(
|
||||||
self, link_track,
|
self, link_track,
|
||||||
output_dir=stock_output,
|
output_dir=stock_output,
|
||||||
@@ -424,6 +448,8 @@ class DeeLogin:
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
def convert_spoty_to_dee_link_track(self, link_track):
|
def convert_spoty_to_dee_link_track(self, link_track):
|
||||||
|
# Ensure Spotify session only when using spo->dee conversion
|
||||||
|
self._ensure_spotify_session()
|
||||||
link_is_valid(link_track)
|
link_is_valid(link_track)
|
||||||
ids = get_ids(link_track)
|
ids = get_ids(link_track)
|
||||||
|
|
||||||
@@ -516,6 +542,8 @@ class DeeLogin:
|
|||||||
return track_link_dee
|
return track_link_dee
|
||||||
|
|
||||||
def convert_spoty_to_dee_link_album(self, link_album):
|
def convert_spoty_to_dee_link_album(self, link_album):
|
||||||
|
# Ensure Spotify session only when using spo->dee conversion
|
||||||
|
self._ensure_spotify_session()
|
||||||
link_is_valid(link_album)
|
link_is_valid(link_album)
|
||||||
ids = get_ids(link_album)
|
ids = get_ids(link_album)
|
||||||
|
|
||||||
@@ -656,6 +684,8 @@ class DeeLogin:
|
|||||||
|
|
||||||
spotify_album_obj = None
|
spotify_album_obj = None
|
||||||
if spotify_metadata:
|
if spotify_metadata:
|
||||||
|
# Only initialize Spotify session when we actually need Spotify metadata
|
||||||
|
self._ensure_spotify_session()
|
||||||
try:
|
try:
|
||||||
# Fetch full Spotify album with tracks once and convert to albumObject
|
# Fetch full Spotify album with tracks once and convert to albumObject
|
||||||
from deezspot.spotloader.__spo_api__ import tracking_album as spo_tracking_album
|
from deezspot.spotloader.__spo_api__ import tracking_album as spo_tracking_album
|
||||||
@@ -716,7 +746,34 @@ class DeeLogin:
|
|||||||
link_is_valid(link_playlist)
|
link_is_valid(link_playlist)
|
||||||
ids = get_ids(link_playlist)
|
ids = get_ids(link_playlist)
|
||||||
|
|
||||||
|
# Ensure Spotify session for fetching playlist and tracks
|
||||||
|
self._ensure_spotify_session()
|
||||||
|
|
||||||
playlist_json = Spo.get_playlist(ids)
|
playlist_json = Spo.get_playlist(ids)
|
||||||
|
# Ensure we keep the playlist ID for callbacks
|
||||||
|
if 'id' not in playlist_json:
|
||||||
|
playlist_json['id'] = ids
|
||||||
|
|
||||||
|
# Enrich items with full track objects so downstream expects Web API shape
|
||||||
|
try:
|
||||||
|
items = playlist_json.get('tracks', {}).get('items', []) or []
|
||||||
|
track_ids = [it.get('track', {}).get('id') for it in items if it.get('track') and it['track'].get('id')]
|
||||||
|
full = Spo.get_tracks(track_ids) if track_ids else {'tracks': []}
|
||||||
|
full_list = full.get('tracks') or []
|
||||||
|
full_by_id = {t.get('id'): t for t in full_list if t and t.get('id')}
|
||||||
|
new_items = []
|
||||||
|
for it in items:
|
||||||
|
tid = (it.get('track') or {}).get('id')
|
||||||
|
full_track = full_by_id.get(tid)
|
||||||
|
if full_track:
|
||||||
|
new_items.append({'track': full_track})
|
||||||
|
else:
|
||||||
|
new_items.append(it)
|
||||||
|
playlist_json['tracks']['items'] = new_items
|
||||||
|
except Exception:
|
||||||
|
# If enrichment fails, continue with minimal ids
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Extract track metadata for playlist callback object
|
# Extract track metadata for playlist callback object
|
||||||
playlist_tracks_for_callback = []
|
playlist_tracks_for_callback = []
|
||||||
@@ -793,7 +850,7 @@ class DeeLogin:
|
|||||||
playlist_obj = playlistCbObject(
|
playlist_obj = playlistCbObject(
|
||||||
title=playlist_json['name'],
|
title=playlist_json['name'],
|
||||||
owner=userObject(name=playlist_json.get('owner', {}).get('display_name', 'Unknown Owner')),
|
owner=userObject(name=playlist_json.get('owner', {}).get('display_name', 'Unknown Owner')),
|
||||||
ids=IDs(spotify=playlist_json['id']),
|
ids=IDs(spotify=playlist_json.get('id', ids)),
|
||||||
tracks=playlist_tracks_for_callback # Populate tracks array with track objects
|
tracks=playlist_tracks_for_callback # Populate tracks array with track objects
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -833,6 +890,10 @@ class DeeLogin:
|
|||||||
track_name = track_info.get('name', 'Unknown Track')
|
track_name = track_info.get('name', 'Unknown Track')
|
||||||
artist_name = track_info['artists'][0]['name'] if track_info.get('artists') else 'Unknown Artist'
|
artist_name = track_info['artists'][0]['name'] if track_info.get('artists') else 'Unknown Artist'
|
||||||
link_track = track_info.get('external_urls', {}).get('spotify')
|
link_track = track_info.get('external_urls', {}).get('spotify')
|
||||||
|
if not link_track:
|
||||||
|
tid = track_info.get('id')
|
||||||
|
if tid:
|
||||||
|
link_track = f"https://open.spotify.com/track/{tid}"
|
||||||
|
|
||||||
if not link_track:
|
if not link_track:
|
||||||
logger.warning(f"The track \"{track_name}\" is not available on Spotify :(")
|
logger.warning(f"The track \"{track_name}\" is not available on Spotify :(")
|
||||||
|
|||||||
@@ -1,342 +1,551 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
from spotipy import Spotify
|
from librespot.core import Session, SearchManager
|
||||||
|
from librespot.metadata import TrackId, AlbumId, ArtistId, EpisodeId, ShowId, PlaylistId
|
||||||
from deezspot.exceptions import InvalidLink
|
from deezspot.exceptions import InvalidLink
|
||||||
from spotipy.exceptions import SpotifyException
|
from typing import Any, Dict, List, Optional
|
||||||
from spotipy.oauth2 import SpotifyClientCredentials
|
|
||||||
import os
|
# Note: We intentionally avoid importing spotipy. This module is now a
|
||||||
|
# thin shim over librespot's internal API, returning Web-API-shaped dicts
|
||||||
|
# consumed by spotloader's converters.
|
||||||
|
|
||||||
class Spo:
|
class Spo:
|
||||||
__error_codes = [404, 400]
|
__error_codes = [404, 400]
|
||||||
|
|
||||||
# Class-level API instance and credentials
|
# Class-level references
|
||||||
__api = None
|
__session: Optional[Session] = None
|
||||||
__client_id = None
|
|
||||||
__client_secret = None
|
|
||||||
__initialized = False
|
__initialized = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __init__(cls, client_id, client_secret):
|
def set_session(cls, session: Session):
|
||||||
"""
|
"""Attach an active librespot Session for metadata/search operations."""
|
||||||
Initialize the Spotify API client.
|
cls.__session = session
|
||||||
|
cls.__initialized = True
|
||||||
|
|
||||||
Args:
|
@classmethod
|
||||||
client_id (str): Spotify API client ID.
|
def __init__(cls, client_id=None, client_secret=None):
|
||||||
client_secret (str): Spotify API client secret.
|
"""Kept for compatibility; no longer used (librespot session is used)."""
|
||||||
"""
|
|
||||||
if not client_id or not client_secret:
|
|
||||||
raise ValueError("Spotify API credentials required. Provide client_id and client_secret.")
|
|
||||||
|
|
||||||
client_credentials_manager = SpotifyClientCredentials(
|
|
||||||
client_id=client_id,
|
|
||||||
client_secret=client_secret
|
|
||||||
)
|
|
||||||
|
|
||||||
# Store the credentials and API instance
|
|
||||||
cls.__client_id = client_id
|
|
||||||
cls.__client_secret = client_secret
|
|
||||||
cls.__api = Spotify(
|
|
||||||
auth_manager=client_credentials_manager
|
|
||||||
)
|
|
||||||
cls.__initialized = True
|
cls.__initialized = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __check_initialized(cls):
|
def __check_initialized(cls):
|
||||||
"""Check if the class has been initialized with credentials"""
|
if not cls.__initialized or cls.__session is None:
|
||||||
if not cls.__initialized:
|
raise ValueError("Spotify session not initialized. Ensure SpoLogin created a librespot Session and called Spo.set_session(session).")
|
||||||
raise ValueError("Spotify API not initialized. Call Spo.__init__(client_id, client_secret) first.")
|
|
||||||
|
|
||||||
@classmethod
|
# ------------------------- helpers -------------------------
|
||||||
def __get_api(cls, client_id=None, client_secret=None):
|
@staticmethod
|
||||||
"""
|
def __base62_from_gid(gid_bytes: bytes, kind: str) -> Optional[str]:
|
||||||
Get a Spotify API instance with the provided credentials or use stored credentials.
|
if not gid_bytes:
|
||||||
|
return None
|
||||||
Args:
|
hex_id = gid_bytes.hex()
|
||||||
client_id (str, optional): Spotify API client ID
|
try:
|
||||||
client_secret (str, optional): Spotify API client secret
|
if kind == 'track':
|
||||||
|
obj = TrackId.from_hex(hex_id)
|
||||||
Returns:
|
elif kind == 'album':
|
||||||
A Spotify API instance
|
obj = AlbumId.from_hex(hex_id)
|
||||||
"""
|
elif kind == 'artist':
|
||||||
# If new credentials are provided, create a new API instance
|
obj = ArtistId.from_hex(hex_id)
|
||||||
if client_id and client_secret:
|
elif kind == 'episode':
|
||||||
client_credentials_manager = SpotifyClientCredentials(
|
obj = EpisodeId.from_hex(hex_id)
|
||||||
client_id=client_id,
|
elif kind == 'show':
|
||||||
client_secret=client_secret
|
obj = ShowId.from_hex(hex_id)
|
||||||
)
|
elif kind == 'playlist':
|
||||||
return Spotify(auth_manager=client_credentials_manager)
|
# PlaylistId typically not hex-backed in same way, avoid for playlists here
|
||||||
|
return None
|
||||||
# Otherwise, use the existing class-level API
|
|
||||||
cls.__check_initialized()
|
|
||||||
return cls.__api
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def __lazy(cls, results, api=None):
|
|
||||||
"""Process paginated results and extend the initial page's items in-place."""
|
|
||||||
api = api or cls.__api
|
|
||||||
if not results or 'items' not in results:
|
|
||||||
return results
|
|
||||||
items_ref = results['items']
|
|
||||||
|
|
||||||
while results.get('next'):
|
|
||||||
results = api.next(results)
|
|
||||||
if results and 'items' in results:
|
|
||||||
items_ref.extend(results['items'])
|
|
||||||
else:
|
else:
|
||||||
break
|
return None
|
||||||
|
uri = obj.to_spotify_uri()
|
||||||
|
return uri.split(":")[-1]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
return results
|
@staticmethod
|
||||||
|
def __external_ids_to_dict(external_ids) -> Dict[str, str]:
|
||||||
|
# Map repeated ExternalId { type, id } to a simple dict
|
||||||
|
result: Dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
for ext in external_ids or []:
|
||||||
|
t = getattr(ext, 'type', None)
|
||||||
|
v = getattr(ext, 'id', None)
|
||||||
|
if t and v:
|
||||||
|
result[t.lower()] = v
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __images_from_group(img_group) -> List[Dict[str, Any]]:
|
||||||
|
images: List[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
for im in getattr(img_group, 'image', []) or []:
|
||||||
|
fid = getattr(im, 'file_id', None)
|
||||||
|
if fid:
|
||||||
|
hex_id = fid.hex()
|
||||||
|
images.append({
|
||||||
|
'url': f"https://i.scdn.co/image/{hex_id}",
|
||||||
|
'width': getattr(im, 'width', 0),
|
||||||
|
'height': getattr(im, 'height', 0)
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return images
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __images_from_repeated(imgs) -> List[Dict[str, Any]]:
|
||||||
|
images: List[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
for im in imgs or []:
|
||||||
|
fid = getattr(im, 'file_id', None)
|
||||||
|
if fid:
|
||||||
|
hex_id = fid.hex()
|
||||||
|
images.append({
|
||||||
|
'url': f"https://i.scdn.co/image/{hex_id}",
|
||||||
|
'width': getattr(im, 'width', 0),
|
||||||
|
'height': getattr(im, 'height', 0)
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return images
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def __fetch_all_album_tracks(cls, album_id: str, api: Spotify) -> dict:
|
def __artist_proto_to_dict(cls, a_proto) -> Dict[str, Any]:
|
||||||
"""
|
gid = getattr(a_proto, 'gid', None)
|
||||||
Fetch all tracks for an album using album_tracks pagination.
|
|
||||||
Returns a dict shaped like Spotify's 'tracks' object with all items merged.
|
|
||||||
"""
|
|
||||||
all_items = []
|
|
||||||
limit = 50
|
|
||||||
offset = 0
|
|
||||||
first_page = None
|
|
||||||
while True:
|
|
||||||
page = api.album_tracks(album_id, limit=limit, offset=offset)
|
|
||||||
if first_page is None:
|
|
||||||
first_page = dict(page) if page is not None else None
|
|
||||||
items = page.get('items', []) if page else []
|
|
||||||
if not items:
|
|
||||||
break
|
|
||||||
all_items.extend(items)
|
|
||||||
offset += len(items)
|
|
||||||
if page.get('next') is None:
|
|
||||||
break
|
|
||||||
if first_page is None:
|
|
||||||
return {'items': [], 'total': 0, 'limit': limit, 'offset': 0}
|
|
||||||
# Build a consolidated tracks object
|
|
||||||
total_val = first_page.get('total', len(all_items))
|
|
||||||
return {
|
return {
|
||||||
'items': all_items,
|
'id': cls.__base62_from_gid(gid, 'artist'),
|
||||||
'total': total_val,
|
'name': getattr(a_proto, 'name', '')
|
||||||
'limit': limit,
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __track_proto_to_web_dict(cls, t_proto, parent_album: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
if t_proto is None:
|
||||||
|
return {}
|
||||||
|
gid = getattr(t_proto, 'gid', None)
|
||||||
|
artists = [cls.__artist_proto_to_dict(a) for a in getattr(t_proto, 'artist', [])]
|
||||||
|
external_ids_map = cls.__external_ids_to_dict(getattr(t_proto, 'external_id', []))
|
||||||
|
# Album for a track inside Album.disc.track is often simplified in proto
|
||||||
|
album_dict = parent_album or {}
|
||||||
|
return {
|
||||||
|
'id': cls.__base62_from_gid(gid, 'track'),
|
||||||
|
'name': getattr(t_proto, 'name', ''),
|
||||||
|
'duration_ms': getattr(t_proto, 'duration', 0),
|
||||||
|
'explicit': getattr(t_proto, 'explicit', False),
|
||||||
|
'track_number': getattr(t_proto, 'number', 1),
|
||||||
|
'disc_number': getattr(t_proto, 'disc_number', 1),
|
||||||
|
'artists': artists,
|
||||||
|
'external_ids': external_ids_map,
|
||||||
|
'available_markets': None, # Not derived; market check code handles None by warning and continuing
|
||||||
|
'album': album_dict
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __album_proto_to_web_dict(cls, a_proto) -> Dict[str, Any]:
|
||||||
|
if a_proto is None:
|
||||||
|
return {}
|
||||||
|
gid = getattr(a_proto, 'gid', None)
|
||||||
|
# Album basic fields
|
||||||
|
title = getattr(a_proto, 'name', '')
|
||||||
|
album_type = None
|
||||||
|
try:
|
||||||
|
# Map enum to typical Web API strings when possible
|
||||||
|
t_val = getattr(a_proto, 'type', None)
|
||||||
|
if t_val is not None:
|
||||||
|
# Common mapping heuristic
|
||||||
|
# 1 = ALBUM, 2 = SINGLE, 3 = COMPILATION (values may differ by proto)
|
||||||
|
mapping = {1: 'album', 2: 'single', 3: 'compilation'}
|
||||||
|
album_type = mapping.get(int(t_val), None)
|
||||||
|
except Exception:
|
||||||
|
album_type = None
|
||||||
|
|
||||||
|
# Date
|
||||||
|
release_date_str = ''
|
||||||
|
release_date_precision = 'day'
|
||||||
|
try:
|
||||||
|
date = getattr(a_proto, 'date', None)
|
||||||
|
year = getattr(date, 'year', 0) if date else 0
|
||||||
|
month = getattr(date, 'month', 0) if date else 0
|
||||||
|
day = getattr(date, 'day', 0) if date else 0
|
||||||
|
if year and month and day:
|
||||||
|
release_date_str = f"{year:04d}-{month:02d}-{day:02d}"
|
||||||
|
release_date_precision = 'day'
|
||||||
|
elif year and month:
|
||||||
|
release_date_str = f"{year:04d}-{month:02d}"
|
||||||
|
release_date_precision = 'month'
|
||||||
|
elif year:
|
||||||
|
release_date_str = f"{year:04d}"
|
||||||
|
release_date_precision = 'year'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Artists
|
||||||
|
artists = [cls.__artist_proto_to_dict(a) for a in getattr(a_proto, 'artist', [])]
|
||||||
|
|
||||||
|
# Genres
|
||||||
|
genres = list(getattr(a_proto, 'genre', []) or [])
|
||||||
|
|
||||||
|
# External IDs (e.g., upc)
|
||||||
|
external_ids_map = cls.__external_ids_to_dict(getattr(a_proto, 'external_id', []))
|
||||||
|
|
||||||
|
# Images
|
||||||
|
images: List[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
cg = getattr(a_proto, 'cover_group', None)
|
||||||
|
if cg:
|
||||||
|
images = cls.__images_from_group(cg)
|
||||||
|
if not images:
|
||||||
|
images = cls.__images_from_repeated(getattr(a_proto, 'cover', []) or [])
|
||||||
|
except Exception:
|
||||||
|
images = []
|
||||||
|
|
||||||
|
# Tracks from discs
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
total_tracks = 0
|
||||||
|
try:
|
||||||
|
for disc in getattr(a_proto, 'disc', []) or []:
|
||||||
|
disc_number = getattr(disc, 'number', 1)
|
||||||
|
for t in getattr(disc, 'track', []) or []:
|
||||||
|
total_tracks += 1
|
||||||
|
# Album context passed minimally for track mapper
|
||||||
|
parent_album_min = {
|
||||||
|
'id': cls.__base62_from_gid(gid, 'album'),
|
||||||
|
'name': title,
|
||||||
|
'album_type': album_type,
|
||||||
|
'release_date': release_date_str,
|
||||||
|
'release_date_precision': release_date_precision,
|
||||||
|
'total_tracks': None,
|
||||||
|
'images': images,
|
||||||
|
'genres': genres,
|
||||||
|
'artists': artists,
|
||||||
|
'external_ids': external_ids_map,
|
||||||
|
'available_markets': None
|
||||||
|
}
|
||||||
|
# Ensure numbering aligns with album context
|
||||||
|
setattr(t, 'disc_number', disc_number)
|
||||||
|
item = cls.__track_proto_to_web_dict(t, parent_album=parent_album_min)
|
||||||
|
# Override with correct numbering if proto uses different fields
|
||||||
|
item['disc_number'] = disc_number
|
||||||
|
if 'track_number' not in item or not item['track_number']:
|
||||||
|
item['track_number'] = getattr(t, 'number', 1)
|
||||||
|
items.append(item)
|
||||||
|
except Exception:
|
||||||
|
items = []
|
||||||
|
|
||||||
|
album_dict: Dict[str, Any] = {
|
||||||
|
'id': cls.__base62_from_gid(gid, 'album'),
|
||||||
|
'name': title,
|
||||||
|
'album_type': album_type,
|
||||||
|
'release_date': release_date_str,
|
||||||
|
'release_date_precision': release_date_precision,
|
||||||
|
'total_tracks': total_tracks or getattr(a_proto, 'num_tracks', 0),
|
||||||
|
'genres': genres,
|
||||||
|
'images': images, # Web API-like images with i.scdn.co URLs
|
||||||
|
'copyrights': [],
|
||||||
|
'available_markets': None,
|
||||||
|
'external_ids': external_ids_map,
|
||||||
|
'artists': artists,
|
||||||
|
'tracks': {
|
||||||
|
'items': items,
|
||||||
|
'total': len(items),
|
||||||
|
'limit': len(items),
|
||||||
'offset': 0,
|
'offset': 0,
|
||||||
'next': None,
|
'next': None,
|
||||||
'previous': None
|
'previous': None
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return album_dict
|
||||||
|
|
||||||
|
# ------------------------- public API -------------------------
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_track(cls, ids, client_id=None, client_secret=None):
|
def get_track(cls, ids, client_id=None, client_secret=None):
|
||||||
"""
|
cls.__check_initialized()
|
||||||
Get track information by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ids (str): Spotify track ID
|
|
||||||
client_id (str, optional): Optional custom Spotify client ID
|
|
||||||
client_secret (str, optional): Optional custom Spotify client secret
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Track information
|
|
||||||
"""
|
|
||||||
api = cls.__get_api(client_id, client_secret)
|
|
||||||
try:
|
try:
|
||||||
track_json = api.track(ids)
|
t_id = TrackId.from_base62(ids)
|
||||||
except SpotifyException as error:
|
t_proto = cls.__session.api().get_metadata_4_track(t_id)
|
||||||
if error.http_status in cls.__error_codes:
|
if not t_proto:
|
||||||
|
raise InvalidLink(ids)
|
||||||
|
# Build minimal album context from nested album proto if present
|
||||||
|
album_proto = getattr(t_proto, 'album', None)
|
||||||
|
album_ctx = None
|
||||||
|
try:
|
||||||
|
if album_proto is not None:
|
||||||
|
agid = getattr(album_proto, 'gid', None)
|
||||||
|
# Images for embedded album
|
||||||
|
images: List[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
cg = getattr(album_proto, 'cover_group', None)
|
||||||
|
if cg:
|
||||||
|
images = cls.__images_from_group(cg)
|
||||||
|
if not images:
|
||||||
|
images = cls.__images_from_repeated(getattr(album_proto, 'cover', []) or [])
|
||||||
|
except Exception:
|
||||||
|
images = []
|
||||||
|
album_ctx = {
|
||||||
|
'id': cls.__base62_from_gid(agid, 'album'),
|
||||||
|
'name': getattr(album_proto, 'name', ''),
|
||||||
|
'images': images,
|
||||||
|
'genres': [],
|
||||||
|
'available_markets': None
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
album_ctx = None
|
||||||
|
return cls.__track_proto_to_web_dict(t_proto, parent_album=album_ctx)
|
||||||
|
except InvalidLink:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
raise InvalidLink(ids)
|
raise InvalidLink(ids)
|
||||||
|
|
||||||
return track_json
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_tracks(cls, ids: list, market: str = None, client_id=None, client_secret=None):
|
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.
|
|
||||||
Handles chunking by 50 IDs per request and merges results while preserving order.
|
|
||||||
|
|
||||||
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 under key 'tracks'.
|
|
||||||
"""
|
|
||||||
if not ids:
|
if not ids:
|
||||||
return {'tracks': []}
|
return {'tracks': []}
|
||||||
|
cls.__check_initialized()
|
||||||
api = cls.__get_api(client_id, client_secret)
|
tracks: List[Dict[str, Any]] = []
|
||||||
all_tracks = []
|
for tid in ids:
|
||||||
chunk_size = 50
|
|
||||||
try:
|
try:
|
||||||
for i in range(0, len(ids), chunk_size):
|
tracks.append(cls.get_track(tid))
|
||||||
chunk = ids[i:i + chunk_size]
|
except Exception:
|
||||||
resp = api.tracks(chunk, market=market) if market else api.tracks(chunk)
|
# Preserve order with None entries similar to Web API behavior on bad IDs
|
||||||
# Spotify returns {'tracks': [...]} for each chunk
|
tracks.append(None)
|
||||||
chunk_tracks = resp.get('tracks', []) if resp else []
|
return {'tracks': tracks}
|
||||||
all_tracks.extend(chunk_tracks)
|
|
||||||
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}]")
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
return {'tracks': all_tracks}
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_album(cls, ids, client_id=None, client_secret=None):
|
def get_album(cls, ids, client_id=None, client_secret=None):
|
||||||
"""
|
cls.__check_initialized()
|
||||||
Get album information by ID and include all tracks (paged if needed).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ids (str): Spotify album ID
|
|
||||||
client_id (str, optional): Optional custom Spotify client ID
|
|
||||||
client_secret (str, optional): Optional custom Spotify client secret
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Album information with full 'tracks.items'
|
|
||||||
"""
|
|
||||||
api = cls.__get_api(client_id, client_secret)
|
|
||||||
try:
|
try:
|
||||||
album_json = api.album(ids)
|
a_id = AlbumId.from_base62(ids)
|
||||||
except SpotifyException as error:
|
a_proto = cls.__session.api().get_metadata_4_album(a_id)
|
||||||
if error.http_status in cls.__error_codes:
|
if not a_proto:
|
||||||
raise InvalidLink(ids)
|
raise InvalidLink(ids)
|
||||||
else:
|
return cls.__album_proto_to_web_dict(a_proto)
|
||||||
|
except InvalidLink:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# Replace/ensure tracks contains all items via dedicated pagination endpoint
|
|
||||||
try:
|
|
||||||
full_tracks_obj = cls.__fetch_all_album_tracks(ids, api)
|
|
||||||
if isinstance(album_json, dict):
|
|
||||||
album_json['tracks'] = full_tracks_obj
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback to lazy-paging over embedded 'tracks' if available
|
raise InvalidLink(ids)
|
||||||
try:
|
|
||||||
tracks = album_json.get('tracks') if isinstance(album_json, dict) else None
|
|
||||||
if tracks:
|
|
||||||
cls.__lazy(tracks, api)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return album_json
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_playlist(cls, ids, client_id=None, client_secret=None):
|
def get_playlist(cls, ids, client_id=None, client_secret=None):
|
||||||
"""
|
cls.__check_initialized()
|
||||||
Get playlist information by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ids (str): Spotify playlist ID
|
|
||||||
client_id (str, optional): Optional custom Spotify client ID
|
|
||||||
client_secret (str, optional): Optional custom Spotify client secret
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Playlist information
|
|
||||||
"""
|
|
||||||
api = cls.__get_api(client_id, client_secret)
|
|
||||||
try:
|
try:
|
||||||
playlist_json = api.playlist(ids)
|
# PlaylistId accepts base62-ish/id string directly
|
||||||
except SpotifyException as error:
|
p_id = PlaylistId(ids)
|
||||||
if error.http_status in cls.__error_codes:
|
p_proto = cls.__session.api().get_playlist(p_id)
|
||||||
|
if not p_proto:
|
||||||
raise InvalidLink(ids)
|
raise InvalidLink(ids)
|
||||||
|
# Minimal mapping sufficient for current playlist flow
|
||||||
|
name = None
|
||||||
|
try:
|
||||||
|
attrs = getattr(p_proto, 'attributes', None)
|
||||||
|
name = getattr(attrs, 'name', None) if attrs else None
|
||||||
|
except Exception:
|
||||||
|
name = None
|
||||||
|
owner_name = getattr(p_proto, 'owner_username', None) or 'Unknown Owner'
|
||||||
|
items = []
|
||||||
|
try:
|
||||||
|
contents = getattr(p_proto, 'contents', None)
|
||||||
|
for it in getattr(contents, 'items', []) or []:
|
||||||
|
# Attempt to obtain a track gid/id from multiple potential locations
|
||||||
|
tref = getattr(it, 'track', None)
|
||||||
|
gid = getattr(tref, 'gid', None) if tref else None
|
||||||
|
base62 = cls.__base62_from_gid(gid, 'track') if gid else None
|
||||||
|
|
||||||
tracks = playlist_json['tracks']
|
# Some playlists can reference an "original_track" field
|
||||||
cls.__lazy(tracks, api)
|
if not base62:
|
||||||
|
orig = getattr(it, 'original_track', None)
|
||||||
|
ogid = getattr(orig, 'gid', None) if orig else None
|
||||||
|
base62 = cls.__base62_from_gid(ogid, 'track') if ogid else None
|
||||||
|
|
||||||
return playlist_json
|
# As an additional fallback, try to parse a spotify:track: URI if exposed on the nested track
|
||||||
|
if not base62:
|
||||||
|
uri = getattr(tref, 'uri', None) if tref else None
|
||||||
|
if isinstance(uri, str) and uri.startswith("spotify:track:"):
|
||||||
|
try:
|
||||||
|
parts = uri.split(":")
|
||||||
|
maybe_id = parts[-1] if parts else None
|
||||||
|
if maybe_id and len(maybe_id) == 22:
|
||||||
|
base62 = maybe_id
|
||||||
|
elif maybe_id and len(maybe_id) in (32, 40):
|
||||||
|
from librespot.metadata import TrackId
|
||||||
|
tid = TrackId.from_hex(maybe_id)
|
||||||
|
base62 = tid.to_spotify_uri().split(":")[-1]
|
||||||
|
except Exception:
|
||||||
|
base62 = None
|
||||||
|
|
||||||
|
# Fallback: some implementations expose the URI at the item level
|
||||||
|
if not base62:
|
||||||
|
item_uri = getattr(it, 'uri', None)
|
||||||
|
if isinstance(item_uri, str) and item_uri.startswith("spotify:track:"):
|
||||||
|
try:
|
||||||
|
parts = item_uri.split(":")
|
||||||
|
maybe_id = parts[-1] if parts else None
|
||||||
|
if maybe_id and len(maybe_id) == 22:
|
||||||
|
base62 = maybe_id
|
||||||
|
elif maybe_id and len(maybe_id) in (32, 40):
|
||||||
|
from librespot.metadata import TrackId
|
||||||
|
tid = TrackId.from_hex(maybe_id)
|
||||||
|
base62 = tid.to_spotify_uri().split(":")[-1]
|
||||||
|
except Exception:
|
||||||
|
base62 = None
|
||||||
|
|
||||||
|
# Fallback: check original_track uri if provided
|
||||||
|
if not base62:
|
||||||
|
orig = getattr(it, 'original_track', None)
|
||||||
|
o_uri = getattr(orig, 'uri', None) if orig else None
|
||||||
|
if isinstance(o_uri, str) and o_uri.startswith("spotify:track:"):
|
||||||
|
try:
|
||||||
|
parts = o_uri.split(":")
|
||||||
|
maybe_id = parts[-1] if parts else None
|
||||||
|
if maybe_id and len(maybe_id) == 22:
|
||||||
|
base62 = maybe_id
|
||||||
|
elif maybe_id and len(maybe_id) in (32, 40):
|
||||||
|
from librespot.metadata import TrackId
|
||||||
|
tid = TrackId.from_hex(maybe_id)
|
||||||
|
base62 = tid.to_spotify_uri().split(":")[-1]
|
||||||
|
except Exception:
|
||||||
|
base62 = None
|
||||||
|
|
||||||
|
if base62:
|
||||||
|
items.append({'track': {'id': base62}})
|
||||||
|
except Exception:
|
||||||
|
items = []
|
||||||
|
return {
|
||||||
|
'name': name or 'Unknown Playlist',
|
||||||
|
'owner': {'display_name': owner_name},
|
||||||
|
'images': [],
|
||||||
|
'tracks': {'items': items, 'total': len(items)}
|
||||||
|
}
|
||||||
|
except InvalidLink:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise InvalidLink(ids)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_episode(cls, ids, client_id=None, client_secret=None):
|
def get_episode(cls, ids, client_id=None, client_secret=None):
|
||||||
"""
|
cls.__check_initialized()
|
||||||
Get episode information by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ids (str): Spotify episode ID
|
|
||||||
client_id (str, optional): Optional custom Spotify client ID
|
|
||||||
client_secret (str, optional): Optional custom Spotify client secret
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Episode information
|
|
||||||
"""
|
|
||||||
api = cls.__get_api(client_id, client_secret)
|
|
||||||
try:
|
try:
|
||||||
episode_json = api.episode(ids)
|
e_id = EpisodeId.from_base62(ids)
|
||||||
except SpotifyException as error:
|
e_proto = cls.__session.api().get_metadata_4_episode(e_id)
|
||||||
if error.http_status in cls.__error_codes:
|
if not e_proto:
|
||||||
raise InvalidLink(ids)
|
raise InvalidLink(ids)
|
||||||
|
# Map show info
|
||||||
return episode_json
|
show_proto = getattr(e_proto, 'show', None)
|
||||||
|
show_id = None
|
||||||
@classmethod
|
show_name = ''
|
||||||
def search(cls, query, search_type='track', limit=10, client_id=None, client_secret=None):
|
publisher = ''
|
||||||
"""
|
|
||||||
Search for tracks, albums, artists, or playlists.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query (str): Search query
|
|
||||||
search_type (str, optional): Type of search ('track', 'album', 'artist', 'playlist')
|
|
||||||
limit (int, optional): Maximum number of results to return
|
|
||||||
client_id (str, optional): Optional custom Spotify client ID
|
|
||||||
client_secret (str, optional): Optional custom Spotify client secret
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Search results
|
|
||||||
"""
|
|
||||||
api = cls.__get_api(client_id, client_secret)
|
|
||||||
search = api.search(q=query, type=search_type, limit=limit)
|
|
||||||
return search
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_artist(cls, ids, client_id=None, client_secret=None):
|
|
||||||
"""
|
|
||||||
Get artist information by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ids (str): Spotify artist ID
|
|
||||||
client_id (str, optional): Optional custom Spotify client ID
|
|
||||||
client_secret (str, optional): Optional custom Spotify client secret
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Artist information
|
|
||||||
"""
|
|
||||||
api = cls.__get_api(client_id, client_secret)
|
|
||||||
try:
|
try:
|
||||||
artist_json = api.artist(ids)
|
sgid = getattr(show_proto, 'gid', None) if show_proto else None
|
||||||
except SpotifyException as error:
|
show_id = cls.__base62_from_gid(sgid, 'show') if sgid else None
|
||||||
if error.http_status in cls.__error_codes:
|
show_name = getattr(show_proto, 'name', '') if show_proto else ''
|
||||||
raise InvalidLink(ids)
|
publisher = getattr(show_proto, 'publisher', '') if show_proto else ''
|
||||||
|
except Exception:
|
||||||
return artist_json
|
pass
|
||||||
|
# Images for episode (cover_image ImageGroup)
|
||||||
@classmethod
|
images: List[Dict[str, Any]] = []
|
||||||
def get_artist_discography(cls, ids, album_type='album,single,compilation,appears_on', limit=50, offset=0, client_id=None, client_secret=None):
|
|
||||||
"""
|
|
||||||
Get artist information and discography by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ids (str): Spotify artist ID
|
|
||||||
album_type (str, optional): Types of albums to include
|
|
||||||
limit (int, optional): Maximum number of results
|
|
||||||
client_id (str, optional): Optional custom Spotify client ID
|
|
||||||
client_secret (str, optional): Optional custom Spotify client secret
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Artist discography
|
|
||||||
"""
|
|
||||||
api = cls.__get_api(client_id, client_secret)
|
|
||||||
try:
|
try:
|
||||||
# Request all types of releases by the artist.
|
images = cls.__images_from_group(getattr(e_proto, 'cover_image', None))
|
||||||
discography = api.artist_albums(
|
except Exception:
|
||||||
ids,
|
images = []
|
||||||
album_type=album_type,
|
return {
|
||||||
limit=limit,
|
'id': cls.__base62_from_gid(getattr(e_proto, 'gid', None), 'episode'),
|
||||||
offset=offset
|
'name': getattr(e_proto, 'name', ''),
|
||||||
)
|
'duration_ms': getattr(e_proto, 'duration', 0),
|
||||||
except SpotifyException as error:
|
'explicit': getattr(e_proto, 'explicit', False),
|
||||||
if error.http_status in cls.__error_codes:
|
'images': images,
|
||||||
raise InvalidLink(ids)
|
'available_markets': None,
|
||||||
else:
|
'show': {
|
||||||
|
'id': show_id,
|
||||||
|
'name': show_name,
|
||||||
|
'publisher': publisher
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except InvalidLink:
|
||||||
raise
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise InvalidLink(ids)
|
||||||
|
|
||||||
# Ensure that all pages of results are fetched.
|
@classmethod
|
||||||
cls.__lazy(discography, api)
|
def get_artist(cls, ids, album_type='album,single,compilation,appears_on', limit: int = 50, client_id=None, client_secret=None):
|
||||||
return discography
|
"""Return a dict with artist name and an 'items' list of albums matching album_type.
|
||||||
|
Each item contains an external_urls.spotify link, minimally enough for download_artist."""
|
||||||
|
cls.__check_initialized()
|
||||||
|
try:
|
||||||
|
ar_id = ArtistId.from_base62(ids)
|
||||||
|
ar_proto = cls.__session.api().get_metadata_4_artist(ar_id)
|
||||||
|
if not ar_proto:
|
||||||
|
raise InvalidLink(ids)
|
||||||
|
# Parse requested groups
|
||||||
|
requested = [s.strip().lower() for s in str(album_type).split(',') if s.strip()]
|
||||||
|
order = ['album', 'single', 'compilation', 'appears_on']
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
for group_name in order:
|
||||||
|
if requested and group_name not in requested:
|
||||||
|
continue
|
||||||
|
attr = f"{group_name}_group"
|
||||||
|
grp = getattr(ar_proto, attr, None)
|
||||||
|
if not grp:
|
||||||
|
continue
|
||||||
|
# grp is repeated AlbumGroup; each has 'album' repeated Album
|
||||||
|
try:
|
||||||
|
for ag in grp:
|
||||||
|
albums = getattr(ag, 'album', []) or []
|
||||||
|
for a in albums:
|
||||||
|
gid = getattr(a, 'gid', None)
|
||||||
|
base62 = cls.__base62_from_gid(gid, 'album') if gid else None
|
||||||
|
name = getattr(a, 'name', '')
|
||||||
|
if base62:
|
||||||
|
items.append({
|
||||||
|
'name': name,
|
||||||
|
'external_urls': {'spotify': f"https://open.spotify.com/album/{base62}"}
|
||||||
|
})
|
||||||
|
if limit and len(items) >= int(limit):
|
||||||
|
break
|
||||||
|
if limit and len(items) >= int(limit):
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if limit and len(items) >= int(limit):
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
'id': cls.__base62_from_gid(getattr(ar_proto, 'gid', None), 'artist'),
|
||||||
|
'name': getattr(ar_proto, 'name', ''),
|
||||||
|
'items': items
|
||||||
|
}
|
||||||
|
except InvalidLink:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise InvalidLink(ids)
|
||||||
|
|
||||||
|
# ------------------------- search (optional) -------------------------
|
||||||
|
@classmethod
|
||||||
|
def __get_session_country_code(cls) -> str:
|
||||||
|
try:
|
||||||
|
if cls.__session is None:
|
||||||
|
return ""
|
||||||
|
cc = getattr(cls.__session, "_Session__country_code", None)
|
||||||
|
if isinstance(cc, str) and len(cc) == 2:
|
||||||
|
return cc
|
||||||
|
cc2 = getattr(cls.__session, "country_code", None)
|
||||||
|
if isinstance(cc2, str) and len(cc2) == 2:
|
||||||
|
return cc2
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def search(cls, query, search_type='track', limit=10, country: Optional[str] = None, locale: Optional[str] = None, catalogue: Optional[str] = None, image_size: Optional[str] = None, client_id=None, client_secret=None):
|
||||||
|
cls.__check_initialized()
|
||||||
|
# Map simple type value; librespot returns a combined JSON-like response
|
||||||
|
req = SearchManager.SearchRequest(query).set_limit(limit)
|
||||||
|
# Country precedence: explicit country > session country
|
||||||
|
if country:
|
||||||
|
req.set_country(country)
|
||||||
|
else:
|
||||||
|
cc = cls.__get_session_country_code()
|
||||||
|
if cc:
|
||||||
|
req.set_country(cc)
|
||||||
|
if locale:
|
||||||
|
req.set_locale(locale)
|
||||||
|
if catalogue:
|
||||||
|
req.set_catalogue(catalogue)
|
||||||
|
if image_size:
|
||||||
|
req.set_image_size(image_size)
|
||||||
|
res = cls.__session.search().request(req)
|
||||||
|
return res
|
||||||
|
|||||||
@@ -53,10 +53,10 @@ class SpoLogin:
|
|||||||
self.spotify_client_id = spotify_client_id
|
self.spotify_client_id = spotify_client_id
|
||||||
self.spotify_client_secret = spotify_client_secret
|
self.spotify_client_secret = spotify_client_secret
|
||||||
|
|
||||||
# Initialize Spotify API with credentials if provided
|
# Initialize Spotify API with credentials if provided (kept no-op for compatibility)
|
||||||
if spotify_client_id and spotify_client_secret:
|
if spotify_client_id and spotify_client_secret:
|
||||||
Spo.__init__(client_id=spotify_client_id, client_secret=spotify_client_secret)
|
Spo.__init__(client_id=spotify_client_id, client_secret=spotify_client_secret)
|
||||||
logger.info("Initialized Spotify API with provided credentials")
|
logger.info("Initialized Spotify API compatibility shim (librespot-backed)")
|
||||||
|
|
||||||
# Configure progress reporting
|
# Configure progress reporting
|
||||||
self.progress_reporter = ProgressReporter(callback=progress_callback, silent=silent)
|
self.progress_reporter = ProgressReporter(callback=progress_callback, silent=silent)
|
||||||
@@ -77,6 +77,8 @@ class SpoLogin:
|
|||||||
|
|
||||||
Download_JOB(session)
|
Download_JOB(session)
|
||||||
Download_JOB.set_progress_reporter(self.progress_reporter)
|
Download_JOB.set_progress_reporter(self.progress_reporter)
|
||||||
|
# Wire the session into Spo shim for metadata/search
|
||||||
|
Spo.set_session(session)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to initialize Spotify session: {str(e)}")
|
logger.error(f"Failed to initialize Spotify session: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|||||||
Reference in New Issue
Block a user