diff --git a/pyproject.toml b/pyproject.toml index 3f63e8b2..307cf5a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "streamrip" -version = "2.1.0" +version = "2.2.0" description = "A fast, all-in-one music downloader for Qobuz, Deezer, Tidal, and SoundCloud" authors = ["nathom "] license = "GPL-3.0-only" diff --git a/streamrip/__init__.py b/streamrip/__init__.py index c88eefa0..08d9db88 100644 --- a/streamrip/__init__.py +++ b/streamrip/__init__.py @@ -2,4 +2,4 @@ from .config import Config __all__ = ["Config", "converter", "db", "exceptions", "media", "metadata"] -__version__ = "2.1.0" +__version__ = "2.2.0" diff --git a/streamrip/client/deezer.py b/streamrip/client/deezer.py index d2642d96..056463f9 100644 --- a/streamrip/client/deezer.py +++ b/streamrip/client/deezer.py @@ -157,12 +157,32 @@ async def get_downloadable( (3, "MP3_320"), # quality 1 (1, "FLAC"), # quality 2 ] - - _, format_str = quality_map[quality] - - dl_info["quality_to_size"] = [ + size_map = [ int(track_info.get(f"FILESIZE_{format}", 0)) for _, format in quality_map ] + dl_info["quality_to_size"] = size_map + + # Check if requested quality is available + if size_map[quality] == 0: + if self.config.lower_quality_if_not_available: + # Fallback to lower quality + while size_map[quality] == 0 and quality > 0: + logger.warning( + "The requested quality %s is not available. Falling back to quality %s", + quality, + quality - 1, + ) + quality -= 1 + else: + # No fallback - raise error + raise NonStreamableError( + f"The requested quality {quality} is not available and fallback is disabled." + ) + + # Update the quality in dl_info to reflect the final quality used + dl_info["quality"] = quality + + _, format_str = quality_map[quality] token = track_info["TRACK_TOKEN"] try: diff --git a/streamrip/config.py b/streamrip/config.py index d43a155d..bafa5687 100644 --- a/streamrip/config.py +++ b/streamrip/config.py @@ -17,7 +17,7 @@ APP_DIR = click.get_app_dir("streamrip") os.makedirs(APP_DIR, exist_ok=True) DEFAULT_CONFIG_PATH = os.path.join(APP_DIR, "config.toml") -CURRENT_CONFIG_VERSION = "2.0.6" +CURRENT_CONFIG_VERSION = "2.2.0" class OutdatedConfigError(Exception): @@ -66,6 +66,8 @@ class DeezerConfig: # This only applies to paid Deezer subscriptions. Those using deezloader # are automatically limited to quality = 1 quality: int + # If the target quality is not available, fallback to best quality available + lower_quality_if_not_available: bool # This allows for free 320kbps MP3 downloads from Deezer # If an arl is provided, deezloader is never used use_deezloader: bool diff --git a/streamrip/config.toml b/streamrip/config.toml index eb109b8e..dadf7219 100644 --- a/streamrip/config.toml +++ b/streamrip/config.toml @@ -59,6 +59,9 @@ token_expiry = "" # This only applies to paid Deezer subscriptions. Those using deezloader # are automatically limited to quality = 1 quality = 2 +# If the target quality is not available, fallback to best quality available +# 0 = MP3_128, 1 = MP3_320, 2 = FLAC +lower_quality_if_not_available = true # An authentication cookie that allows streamrip to use your Deezer account # See https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie # for instructions on how to find this @@ -190,6 +193,6 @@ max_search_results = 100 [misc] # Metadata to identify this config file. Do not change. -version = "2.0.6" +version = "2.2.0" # Print a message if a new version of streamrip is available check_for_updates = true diff --git a/tests/test_config.py b/tests/test_config.py index 0bbc79cc..44742b95 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -136,7 +136,7 @@ def test_config_file_update(): assert toml["cli"]["text_output"] is True # type: ignore assert toml["cli"]["progress_bars"] is True # type: ignore assert toml["cli"]["max_search_results"] == 100 # type: ignore - assert toml["misc"]["version"] == "2.0.6" # type: ignore + assert toml["misc"]["version"] == "2.2.0" # type: ignore assert "YouTubeVideos" in str(toml["youtube"]["video_downloads_folder"]) # type: ignore os.remove("tests/test_config_old2.toml") @@ -186,6 +186,7 @@ def test_sample_config_data_fields(sample_config_data): deezer=DeezerConfig( arl="testarl", quality=2, + lower_quality_if_not_available=True, use_deezloader=True, deezloader_warnings=True, ), diff --git a/tests/test_config.toml b/tests/test_config.toml index ada6596b..a33a1864 100644 --- a/tests/test_config.toml +++ b/tests/test_config.toml @@ -59,6 +59,8 @@ token_expiry = "tokenexpiry" # This only applies to paid Deezer subscriptions. Those using deezloader # are automatically limited to quality = 1 quality = 2 +# If the target quality is not available, fallback to best quality available +lower_quality_if_not_available = true # An authentication cookie that allows streamrip to use your Deezer account # See https://github.com/nathom/streamrip/wiki/Finding-Your-Deezer-ARL-Cookie # for instructions on how to find this @@ -189,5 +191,5 @@ max_search_results = 100 [misc] # Metadata to identify this config file. Do not change. -version = "2.0.6" +version = "2.2.0" check_for_updates = true diff --git a/tests/test_deezer.py b/tests/test_deezer.py new file mode 100644 index 00000000..5b7b7d77 --- /dev/null +++ b/tests/test_deezer.py @@ -0,0 +1,145 @@ +import os +import pytest +from unittest.mock import Mock, patch +from util import arun + +from streamrip.client.downloadable import DeezerDownloadable +from streamrip.client.deezer import DeezerClient +from streamrip.config import Config +from streamrip.exceptions import NonStreamableError + +@pytest.fixture(scope="session") +def deezer_client(): + """Integration test fixture - requires DEEZER_ARL environment variable""" + config = Config.defaults() + config.session.deezer.arl = os.environ.get("DEEZER_ARL", "") + config.session.deezer.quality = 2 # FLAC + config.session.deezer.lower_quality_if_not_available = True + client = DeezerClient(config) + arun(client.login()) + + yield client + + arun(client.session.close()) + +@pytest.fixture +def mock_deezer_client(): + """Unit test fixture - mocked client for fast testing""" + config = Config.defaults() + config.session.deezer.arl = "test_arl" + config.session.deezer.quality = 2 + config.session.deezer.lower_quality_if_not_available = True + + client = DeezerClient(config) + client.client = Mock() + client.client.gw = Mock() + client.session = Mock() + + return client + +# ===== UNIT TESTS ===== + +def test_deezer_fallback_logic_with_mock_data(mock_deezer_client): + """Unit test: fallback logic works with mocked track data""" + # Mock track info where FLAC is unavailable but MP3_320 is available + # quality_map: [(9, "MP3_128"), (3, "MP3_320"), (1, "FLAC")] + # So FILESIZE_MP3_128 = quality 0, FILESIZE_MP3_320 = quality 1, FILESIZE_FLAC = quality 2 + mock_track_info = { + "FILESIZE_FLAC": 0, # FLAC unavailable (quality 2) + "FILESIZE_MP3_320": 5000000, # MP3_320 available (quality 1) + "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) + "TRACK_TOKEN": "test_token" + } + + # Mock the client methods + mock_deezer_client.client.gw.get_track.return_value = mock_track_info + mock_deezer_client.client.get_track_url.return_value = "https://test.mp3" + + # Test fallback behavior + with patch.object(mock_deezer_client, 'get_session'): + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + + # Should have fallen back to quality 1 (MP3_320) since FLAC is unavailable + assert downloadable.quality == 1 + +def test_deezer_no_fallback_when_quality_available(mock_deezer_client): + """Unit test: no fallback when requested quality is available""" + # Mock track info where FLAC is available + # quality_map: [(9, "MP3_128"), (3, "MP3_320"), (1, "FLAC")] + mock_track_info = { + "FILESIZE_FLAC": 25000000, # FLAC available (quality 2) + "FILESIZE_MP3_320": 5000000, # MP3_320 available (quality 1) + "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) + "TRACK_TOKEN": "test_token" + } + + mock_deezer_client.client.gw.get_track.return_value = mock_track_info + mock_deezer_client.client.get_track_url.return_value = "https://test.flac" + + with patch.object(mock_deezer_client, 'get_session'): + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + + # Should use requested quality 2 (FLAC) + assert downloadable.quality == 2 + +def test_deezer_fallback_to_lowest_available_quality(mock_deezer_client): + """Unit test: fallback walks down quality list until finding available quality""" + # Mock track info where only MP3_128 is available + # quality_map: [(9, "MP3_128"), (3, "MP3_320"), (1, "FLAC")] + mock_track_info = { + "FILESIZE_FLAC": 0, # FLAC unavailable (quality 2) + "FILESIZE_MP3_320": 0, # MP3_320 unavailable (quality 1) + "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) + "TRACK_TOKEN": "test_token" + } + + mock_deezer_client.client.gw.get_track.return_value = mock_track_info + mock_deezer_client.client.get_track_url.return_value = "https://test.mp3" + + with patch.object(mock_deezer_client, 'get_session'): + downloadable = arun(mock_deezer_client.get_downloadable("123", quality=2)) + + # Should have fallen back to quality 0 (MP3_128) since higher qualities unavailable + assert downloadable.quality == 0 + +def test_deezer_no_fallback_when_disabled(mock_deezer_client): + """Unit test: no fallback when lower_quality_if_not_available is False""" + # Disable fallback + mock_deezer_client.config.lower_quality_if_not_available = False + + # Mock track info where FLAC is unavailable + # quality_map: [(9, "MP3_128"), (3, "MP3_320"), (1, "FLAC")] + mock_track_info = { + "FILESIZE_FLAC": 0, # FLAC unavailable (quality 2) + "FILESIZE_MP3_320": 5000000, # MP3_320 available (quality 1) + "FILESIZE_MP3_128": 2000000, # MP3_128 available (quality 0) + "TRACK_TOKEN": "test_url" + } + + mock_deezer_client.client.gw.get_track.return_value = mock_track_info + mock_deezer_client.client.get_track_url.return_value = "https://test.mp3" + + # Should raise an error when requested quality is unavailable and fallback is disabled + with patch.object(mock_deezer_client, 'get_session'): + 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)) + +# ===== INTEGRATION TEST ===== + +@pytest.mark.skipif( + "DEEZER_ARL" not in os.environ, reason="Deezer ARL not found in env." +) +def test_deezer_fallback_actually_occurred(deezer_client): + """Integration test: verify fallback works with real track 77874822""" + # We know track 77874822 doesn't have FLAC available, so test fallback scenario + downloadable = arun(deezer_client.get_downloadable("77874822", quality=2)) + + # Since we requested FLAC (quality=2) but it's not available, + # we should have fallen back to the next available quality (1 = MP3_320) + assert downloadable.quality == 1, "Should have fallen back to MP3_320 when FLAC unavailable" + print("Fallback occurred: FLAC unavailable, fell back to MP3_320") + + # Verify the URL is actually accessible and working + assert downloadable.url.startswith("https://") + assert downloadable._size > 0, "Downloadable should have a valid file size" + assert downloadable.extension == "mp3", "MP3_320 should have .mp3 extension" diff --git a/tests/test_parse_url.py b/tests/test_parse_url.py index 88bea94d..9048cd64 100644 --- a/tests/test_parse_url.py +++ b/tests/test_parse_url.py @@ -152,4 +152,3 @@ async def run_test(): if __name__ == "__main__": unittest.main() -