From 5b8dd5c77ddaf0560d97944e5cde34f78cb98221 Mon Sep 17 00:00:00 2001 From: Sebastien Date: Mon, 2 Nov 2026 21:42:04 +0100 Subject: [PATCH] deezer: fall back to MP3 on geolocation errors When the Deezer media endpoint rejects a track URL request with error code 2002 (WrongGeolocation), the restriction usually only applies to the lossless (FLAC) master; the MP3_320/MP3_128 formats of the same track are often still available in the user's country. Previously, get_downloadable only retried with the FALLBACK track id at the same quality and otherwise raised NonStreamableError, so tracks were skipped even though an MP3 version could be downloaded. Now, on WrongGeolocation the same track is retried at one quality lower (FLAC -> MP3_320 -> MP3_128) when lower_quality_if_not_available is enabled (the default). The FALLBACK track is only tried once even MP3_128 is geoblocked, and NonStreamableError is raised only when nothing is available. Fixes #806 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- streamrip/client/deezer.py | 18 +++++++ tests/test_deezer.py | 99 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index 056463f9..91d2d9c2 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -195,6 +195,24 @@ async def get_downloadable( "quality allowed is 1.", ) except deezer.WrongGeolocation: + # A geolocation error usually only affects the lossless master: + # the MP3 formats of the same track are often still available in + # the user's country. Retry at a lower quality before giving up. + if quality > 0 and self.config.lower_quality_if_not_available: + logger.warning( + "Track %s is not available in your country at quality %s (%s). " + "Falling back to quality %s (%s).", + item_id, + quality, + format_str, + quality - 1, + quality_map[quality - 1][1], + ) + return await self.get_downloadable( + item_id, + quality - 1, + is_retry=is_retry, + ) if not is_retry and fallback_id: return await self.get_downloadable(fallback_id, quality, is_retry=True) raise NonStreamableError( diff --git a/tests/test_deezer.py b/tests/test_deezer.py index 5b7b7d77..c1f33407 100644 --- a/tests/test_deezer.py +++ b/tests/test_deezer.py @@ -1,4 +1,5 @@ import os +import deezer import pytest from unittest.mock import Mock, patch from util import arun @@ -124,6 +125,104 @@ def test_deezer_no_fallback_when_disabled(mock_deezer_client): with pytest.raises(NonStreamableError, match="The requested quality 2 is not available and fallback is disabled"): arun(mock_deezer_client.get_downloadable("123", quality=2)) +# ===== GEOLOCATION FALLBACK TESTS ===== + +def _geo_url_side_effect(blocked_formats): + """Return a get_track_url replacement that raises WrongGeolocation for + blocked formats and a fake URL otherwise.""" + def _get_track_url(token, format_str): + if format_str in blocked_formats: + raise deezer.WrongGeolocation("ES") + return f"https://cdn.example.com/{format_str}/file" + return _get_track_url + +def _full_track_info(token="test_token", **extra): + # All formats have a non-zero size: the server reports the files exist, + # so only the media endpoint's geolocation check can reject them. + return { + "FILESIZE_FLAC": 25000000, + "FILESIZE_MP3_320": 5000000, + "FILESIZE_MP3_128": 2000000, + "TRACK_TOKEN": token, + **extra, + } + +def test_deezer_geolocation_falls_back_to_mp3(mock_deezer_client): + """Geo error on FLAC -> retry the same track as MP3_320""" + mock_deezer_client.client.gw.get_track.return_value = _full_track_info() + mock_deezer_client.client.get_track_url.side_effect = _geo_url_side_effect({"FLAC"}) + + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + + assert downloadable.quality == 1 + assert downloadable.extension == "mp3" + formats_requested = [ + c.args[1] for c in mock_deezer_client.client.get_track_url.call_args_list + ] + assert formats_requested == ["FLAC", "MP3_320"] + +def test_deezer_geolocation_falls_back_to_mp3_128(mock_deezer_client): + """Geo error on FLAC and MP3_320 -> retry the same track as MP3_128""" + mock_deezer_client.client.gw.get_track.return_value = _full_track_info() + mock_deezer_client.client.get_track_url.side_effect = _geo_url_side_effect( + {"FLAC", "MP3_320"} + ) + + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + + assert downloadable.quality == 0 + assert downloadable.extension == "mp3" + formats_requested = [ + c.args[1] for c in mock_deezer_client.client.get_track_url.call_args_list + ] + assert formats_requested == ["FLAC", "MP3_320", "MP3_128"] + +def test_deezer_geolocation_all_qualities_blocked_raises(mock_deezer_client): + """Geo error on every format and no fallback track -> NonStreamableError""" + mock_deezer_client.client.gw.get_track.return_value = _full_track_info() + mock_deezer_client.client.get_track_url.side_effect = _geo_url_side_effect( + {"FLAC", "MP3_320", "MP3_128"} + ) + + with pytest.raises(NonStreamableError, match="country/location"): + arun(mock_deezer_client.get_downloadable("123", quality=2)) + +def test_deezer_geolocation_uses_fallback_track_when_mp3_blocked(mock_deezer_client): + """Geo error on every format of the track -> try the FALLBACK track id""" + def get_track(item_id): + if item_id == "123": + return _full_track_info(token="token123", FALLBACK={"SNG_ID": "456"}) + return _full_track_info(token="token456") + + mock_deezer_client.client.gw.get_track.side_effect = get_track + # Original track is geo-blocked at every quality; fallback track works as MP3_128 + mock_deezer_client.client.get_track_url.side_effect = lambda token, fmt: ( + _geo_url_side_effect({"FLAC", "MP3_320", "MP3_128"})(token, fmt) + if token == "token123" + else f"https://cdn.example.com/fallback/{fmt}" + ) + + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + + assert downloadable.id == "456" + assert downloadable.quality == 0 + assert downloadable.extension == "mp3" + +def test_deezer_geolocation_no_quality_fallback_when_disabled(mock_deezer_client): + """lower_quality_if_not_available=False -> geo error raises immediately""" + mock_deezer_client.config.lower_quality_if_not_available = False + mock_deezer_client.client.gw.get_track.return_value = _full_track_info() + mock_deezer_client.client.get_track_url.side_effect = _geo_url_side_effect({"FLAC"}) + + with pytest.raises(NonStreamableError, match="country/location"): + arun(mock_deezer_client.get_downloadable("123", quality=2)) + + # MP3 formats must not be requested when fallback is disabled + formats_requested = [ + c.args[1] for c in mock_deezer_client.client.get_track_url.call_args_list + ] + assert formats_requested == ["FLAC"] + # ===== INTEGRATION TEST ===== @pytest.mark.skipif(