Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 152 additions & 7 deletions streamrip/client/deezer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging

import deezer
from deezer.errors import PermissionException
from Cryptodome.Cipher import AES

from ..config import Config
Expand Down Expand Up @@ -98,13 +99,157 @@ async def get_album(self, item_id: str) -> dict:
return album_metadata

async def get_playlist(self, item_id: str) -> dict:
pl_metadata, pl_tracks = await asyncio.gather(
asyncio.to_thread(self.client.api.get_playlist, item_id),
asyncio.to_thread(self.client.api.get_playlist_tracks, item_id),
)
pl_metadata["tracks"] = pl_tracks["data"]
pl_metadata["track_total"] = len(pl_tracks["data"])
return pl_metadata
try:
# Try public API first
pl_metadata, pl_tracks = await asyncio.gather(
asyncio.to_thread(self.client.api.get_playlist, item_id),
asyncio.to_thread(self.client.api.get_playlist_tracks, item_id),
)
pl_metadata["tracks"] = pl_tracks["data"]
pl_metadata["track_total"] = len(pl_tracks["data"])
return pl_metadata
except PermissionException as e:
# Public API failed - likely a private/user playlist
# Try GW (gateway) API which works with private playlists when using ARL
logger.debug(f"Public API failed for playlist {item_id}, trying GW API: {e}")

try:
# GW API works with ARL for private playlists
logger.info(f"Attempting to fetch private playlist {item_id} using GW API with ARL authentication")

# Verify client is logged in
if not self.logged_in:
raise ValueError("Client not logged in - GW API requires authentication")

# Fetch both playlist metadata and ALL tracks via GW API
# Note: get_playlist_page() only returns first 10 tracks (paginated)
# So we use get_playlist_tracks() to get ALL tracks
def get_gw_playlist_data():
try:
# Get metadata (contains playlist info but only first page of tracks)
metadata = self.client.gw.get_playlist_page(item_id)
# Get ALL tracks (returns complete list, not paginated)
all_tracks = self.client.gw.get_playlist_tracks(item_id)

logger.debug(f"GW API metadata: {len(metadata.get('SONGS', {}).get('data', []))} tracks (page)")
logger.debug(f"GW API all tracks: {len(all_tracks)} tracks (complete)")

# Replace paginated tracks with complete track list
if 'SONGS' in metadata and isinstance(metadata['SONGS'], dict):
metadata['SONGS']['data'] = all_tracks

return metadata
except Exception as inner_e:
logger.error(f"GW API call failed inside thread: {type(inner_e).__name__}: {inner_e}")
raise

gw_response = await asyncio.to_thread(get_gw_playlist_data)

logger.debug(f"GW API response type: {type(gw_response)}, keys: {list(gw_response.keys()) if isinstance(gw_response, dict) else 'N/A'}")

if not isinstance(gw_response, dict):
raise ValueError(f"Unexpected GW API response type: {type(gw_response)}, value: {gw_response}")

# Convert GW format to regular API format
logger.info(f"Successfully fetched private playlist {item_id} via GW API with {len(gw_response.get('SONGS', {}).get('data', []))} tracks, converting to standard format")
return self._convert_gw_playlist_to_api_format(gw_response)

except Exception as gw_error:
# Both APIs failed - provide helpful error message
logger.error(
f"Failed to access playlist {item_id}. "
f"This appears to be a private playlist. "
f"Public API error: {e}"
)
logger.error(
f"GW API also failed: {type(gw_error).__name__}: {gw_error}",
exc_info=True # Show full traceback
)
logger.error(
f"Possible causes:\n"
f" 1. Your ARL token may be invalid or expired\n"
f" 2. The playlist owner has restricted sharing\n"
f" 3. The playlist doesn't exist or was deleted\n\n"
f"Workaround: Make the playlist public temporarily in Deezer, "
f"download it, then make it private again."
)
raise NonStreamableError(
f"Cannot access private playlist {item_id}. "
f"Verify your ARL token is valid, or make the playlist public temporarily."
)

def _convert_gw_playlist_to_api_format(self, gw_response: dict) -> dict:
"""Convert GW API playlist format to regular API format.

GW API uses uppercase keys (DATA, SONGS) while regular API uses lowercase (data, tracks).
This method transforms the response to match the expected format.
"""
logger.debug(f"Converting GW playlist, top-level keys: {list(gw_response.keys())}")

data = gw_response.get("DATA", {})
logger.debug(f"DATA type: {type(data)}, is dict: {isinstance(data, dict)}")

songs_container = gw_response.get("SONGS", {})
logger.debug(f"SONGS type: {type(songs_container)}, value: {songs_container if not isinstance(songs_container, dict) else 'dict'}")

# Handle different SONGS formats
if isinstance(songs_container, list):
songs = songs_container
elif isinstance(songs_container, dict):
songs = songs_container.get("data", [])
else:
logger.warning(f"Unexpected SONGS format: {type(songs_container)}, using empty list")
songs = []

# Get creator info - CURATOR is dict for public playlists, bool for private
curator = gw_response.get("CURATOR")
if isinstance(curator, dict):
creator_id = curator.get("USER_ID")
creator_name = curator.get("USER_NAME")
else:
# Private playlist - use PARENT_USER info from DATA
creator_id = data.get("PARENT_USER_ID")
creator_name = data.get("PARENT_USERNAME")

# Transform to regular API format
playlist = {
"id": data.get("PLAYLIST_ID"),
"title": data.get("TITLE"),
"description": data.get("DESCRIPTION"),
"duration": data.get("DURATION"),
"public": data.get("STATUS") == 1, # 1 = public, 0 = private
"nb_tracks": data.get("NB_SONG", len(songs)),
"track_total": len(songs),
"picture": data.get("PLAYLIST_PICTURE"),
"picture_small": f"https://e-cdns-images.dzcdn.net/images/cover/{data.get('PLAYLIST_PICTURE')}/250x250-000000-80-0-0.jpg" if data.get("PLAYLIST_PICTURE") else None,
"picture_medium": f"https://e-cdns-images.dzcdn.net/images/cover/{data.get('PLAYLIST_PICTURE')}/500x500-000000-80-0-0.jpg" if data.get("PLAYLIST_PICTURE") else None,
"picture_big": f"https://e-cdns-images.dzcdn.net/images/cover/{data.get('PLAYLIST_PICTURE')}/1000x1000-000000-80-0-0.jpg" if data.get("PLAYLIST_PICTURE") else None,
"picture_xl": f"https://e-cdns-images.dzcdn.net/images/cover/{data.get('PLAYLIST_PICTURE')}/1400x1400-000000-80-0-0.jpg" if data.get("PLAYLIST_PICTURE") else None,
"checksum": data.get("CHECKSUM"),
"creator": {
"id": creator_id,
"name": creator_name,
},
"tracks": [
{
"id": track.get("SNG_ID"),
"title": track.get("SNG_TITLE"),
"duration": track.get("DURATION"),
"artist": {
"id": track.get("ART_ID"),
"name": track.get("ART_NAME"),
},
"album": {
"id": track.get("ALB_ID"),
"title": track.get("ALB_TITLE"),
},
}
for track in songs
],
}

logger.debug(f"Converted GW playlist to API format: {playlist['title']} ({len(playlist['tracks'])} tracks)")
return playlist

async def get_artist(self, item_id: str) -> dict:
artist, albums = await asyncio.gather(
Expand Down