Skip to content
Open
Show file tree
Hide file tree
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
71 changes: 31 additions & 40 deletions streamrip/client/deezer.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import asyncio
import binascii
import hashlib
import logging

import deezer
from Cryptodome.Cipher import AES

from ..config import Config
from ..exceptions import (
Expand Down Expand Up @@ -152,6 +149,12 @@ async def get_downloadable(

fallback_id = track_info.get("FALLBACK", {}).get("SNG_ID")

# Preserved across the FILESIZE-based downgrade below. A fallback track
# is a different release with its own metadata, so it should be asked
# for at the quality the caller wanted -- not at one lowered by the
# zeroed FILESIZEs of the track being replaced.
requested_quality = quality

quality_map = [
(9, "MP3_128"), # quality 0
(3, "MP3_320"), # quality 1
Expand Down Expand Up @@ -202,45 +205,33 @@ async def get_downloadable(
)

if url is None:
url = self._get_encrypted_file_url(
item_id,
track_info["MD5_ORIGIN"],
track_info["MEDIA_VERSION"],
# No URL at any quality is the signature of a delisted old-catalog
# track: it has been superseded by another release, and Deezer
# names that release in FALLBACK.SNG_ID. Follow it, exactly as the
# geoblock branch above does -- there the API says "not here", here
# it says nothing at all. Recursing passes the fallback id as
# item_id, so dl_info["id"] follows the track actually served.
if not is_retry and fallback_id:
logger.debug(
"No download URL for track %s; retrying with fallback ID %s",
item_id,
fallback_id,
)
return await self.get_downloadable(
fallback_id, requested_quality, is_retry=True
)

# This used to fall back to the legacy AES-ECB CDN at
# e-cdns-proxy-<c>.dzcdn.net. Deezer has retired those hosts and
# none of the sixteen resolve any more, so the generated URL could
# only ever fail at download time with a DNS error pointing at the
# wrong culprit. Fail here instead, while we can still say why.
raise NonStreamableError(
"Deezer returned no download URL for this track at any quality, "
"and it has no fallback track (delisted?). The legacy CDN that "
"used to serve as a fallback has been retired.",
)

dl_info["url"] = url
logger.debug("dz track info: %s", track_info)
return DeezerDownloadable(self.session, dl_info)

def _get_encrypted_file_url(
self,
meta_id: str,
track_hash: str,
media_version: str,
):
logger.debug("Unable to fetch URL. Trying encryption method.")
format_number = 1

url_bytes = b"\xa4".join(
(
track_hash.encode(),
str(format_number).encode(),
str(meta_id).encode(),
str(media_version).encode(),
),
)
url_hash = hashlib.md5(url_bytes).hexdigest()
info_bytes = bytearray(url_hash.encode())
info_bytes.extend(b"\xa4")
info_bytes.extend(url_bytes)
info_bytes.extend(b"\xa4")
# Pad the bytes so that len(info_bytes) % 16 == 0
padding_len = 16 - (len(info_bytes) % 16)
info_bytes.extend(b"." * padding_len)

path = binascii.hexlify(
AES.new(b"jo6aey6haid2Teih", AES.MODE_ECB).encrypt(info_bytes),
).decode("utf-8")
url = f"https://e-cdns-proxy-{track_hash[0]}.dzcdn.net/mobile/1/{path}"
logger.debug("Encrypted file path %s", url)
return url
103 changes: 103 additions & 0 deletions tests/test_deezer.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,109 @@ def test_deezer_fallback_to_lowest_available_quality(mock_deezer_client):
# Should have fallen back to quality 0 (MP3_128) since higher qualities unavailable
assert downloadable.quality == 0

def test_deezer_no_url_raises_instead_of_building_legacy_cdn_url(mock_deezer_client):
"""Unit test: a missing download URL fails immediately.

get_track_url returning None used to fall back to a generated
e-cdns-proxy-<c>.dzcdn.net URL. Deezer retired that CDN, so the URL was
guaranteed to fail at download time.
"""
mock_track_info = {
"FILESIZE_FLAC": 25000000,
"FILESIZE_MP3_320": 5000000,
"FILESIZE_MP3_128": 2000000,
"TRACK_TOKEN": "test_token",
"MD5_ORIGIN": "abc123def456abc123def456abc12345",
"MEDIA_VERSION": "1",
}

mock_deezer_client.client.gw.get_track.return_value = mock_track_info
mock_deezer_client.client.get_track_url.return_value = None

with patch.object(mock_deezer_client, 'get_session'):
with pytest.raises(NonStreamableError, match="legacy CDN"):
arun(mock_deezer_client.get_downloadable("123", quality=2))

def test_deezer_no_url_error_does_not_leak_a_cdn_url(mock_deezer_client):
"""Unit test: no code path may still produce an e-cdns-proxy URL."""
mock_track_info = {
"FILESIZE_FLAC": 25000000,
"FILESIZE_MP3_320": 5000000,
"FILESIZE_MP3_128": 2000000,
"TRACK_TOKEN": "test_token",
"MD5_ORIGIN": "abc123def456abc123def456abc12345",
"MEDIA_VERSION": "1",
}

mock_deezer_client.client.gw.get_track.return_value = mock_track_info
mock_deezer_client.client.get_track_url.return_value = None

with patch.object(mock_deezer_client, 'get_session'):
with pytest.raises(NonStreamableError) as excinfo:
arun(mock_deezer_client.get_downloadable("123", quality=2))

assert "e-cdns-proxy" not in str(excinfo.value)

def test_deezer_no_url_follows_fallback_track(mock_deezer_client):
"""Unit test: a delisted track is recovered through FALLBACK.SNG_ID.

Old-catalog tracks report FILESIZE_* = 0 for every tier and get no URL at
any quality -- they have been superseded rather than made unavailable, and
Deezer names the superseding release in FALLBACK. These are exactly the
tracks that used to reach the retired CDN.
"""
def gw_get_track(track_id):
if track_id == "123":
return {
"FILESIZE_FLAC": 0,
"FILESIZE_MP3_320": 0,
"FILESIZE_MP3_128": 0,
"TRACK_TOKEN": "token_123",
"FALLBACK": {"SNG_ID": "456"},
}
return {
"FILESIZE_FLAC": 25000000,
"FILESIZE_MP3_320": 5000000,
"FILESIZE_MP3_128": 2000000,
"TRACK_TOKEN": "token_456",
}

mock_deezer_client.client.gw.get_track.side_effect = gw_get_track
mock_deezer_client.client.get_track_url.side_effect = (
lambda token, fmt: None if token == "token_123" else "https://test.flac"
)

with patch.object(mock_deezer_client, 'get_session'):
downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2))

# The bytes are the fallback track's, so the id -- from which the Blowfish
# key is derived -- must follow it, not the originally requested track.
assert downloadable.id == "456"
# The fallback is asked for at the requested quality, not at the one the
# zeroed FILESIZEs of the delisted track downgraded to.
assert downloadable.quality == 2

def test_deezer_fallback_is_not_followed_twice(mock_deezer_client):
"""Unit test: a fallback that itself resolves nowhere raises, not recurses.

Deezer's FALLBACK chains can point at another dead entry.
"""
mock_deezer_client.client.gw.get_track.return_value = {
"FILESIZE_FLAC": 25000000,
"FILESIZE_MP3_320": 5000000,
"FILESIZE_MP3_128": 2000000,
"TRACK_TOKEN": "test_token",
"FALLBACK": {"SNG_ID": "456"},
}
mock_deezer_client.client.get_track_url.return_value = None

with patch.object(mock_deezer_client, 'get_session'):
with pytest.raises(NonStreamableError, match="delisted"):
arun(mock_deezer_client.get_downloadable("123", quality=2))

# Once for the requested track, once for the fallback - never a third time.
assert mock_deezer_client.client.gw.get_track.call_count == 2

def test_deezer_no_fallback_when_disabled(mock_deezer_client):
"""Unit test: no fallback when lower_quality_if_not_available is False"""
# Disable fallback
Expand Down