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(