Skip to content
Merged
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <nathanthomas707@gmail.com>"]
license = "GPL-3.0-only"
Expand Down
2 changes: 1 addition & 1 deletion streamrip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
from .config import Config

__all__ = ["Config", "converter", "db", "exceptions", "media", "metadata"]
__version__ = "2.1.0"
__version__ = "2.2.0"
28 changes: 24 additions & 4 deletions streamrip/client/deezer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion streamrip/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion streamrip/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
),
Expand Down
4 changes: 3 additions & 1 deletion tests/test_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
145 changes: 145 additions & 0 deletions tests/test_deezer.py
Original file line number Diff line number Diff line change
@@ -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"
1 change: 0 additions & 1 deletion tests/test_parse_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,3 @@ async def run_test():

if __name__ == "__main__":
unittest.main()