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
48 changes: 48 additions & 0 deletions streamrip/client/qobuz.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ async def get_metadata(self, item: str, media_type: str):
if media_type == "label":
return await self.get_label(item)

if media_type == "playlist":
return await self.get_playlist(item)

c = self.config.session.qobuz
params = {
"app_id": str(c.app_id),
Expand Down Expand Up @@ -249,6 +252,51 @@ async def get_metadata(self, item: str, media_type: str):

return resp

async def get_playlist(self, playlist_id: str) -> dict:
c = self.config.session.qobuz
page_limit = 500
params = {
"app_id": str(c.app_id),
"playlist_id": playlist_id,
"limit": page_limit,
"offset": 0,
"extra": "tracks",
}
epoint = "playlist/get"
status, playlist_resp = await self._api_request(epoint, params)
assert status == 200

# Get the total number of tracks in the playlist
tracks_count = playlist_resp["tracks_count"]
logger.debug(f"Playlist has {tracks_count} tracks total")

if tracks_count <= page_limit:
return playlist_resp

# Need to fetch additional pages
requests = [
self._api_request(
epoint,
{
"app_id": str(c.app_id),
"playlist_id": playlist_id,
"limit": page_limit,
"offset": offset,
"extra": "tracks",
},
)
for offset in range(page_limit, tracks_count, page_limit)
]

results = await asyncio.gather(*requests)
items = playlist_resp["tracks"]["items"]
for status, resp in results:
assert status == 200
items.extend(resp["tracks"]["items"])

logger.debug(f"Successfully fetched all {len(items)} tracks from playlist")
return playlist_resp

async def get_label(self, label_id: str) -> dict:
c = self.config.session.qobuz
page_limit = 500
Expand Down
38 changes: 36 additions & 2 deletions streamrip/media/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,25 @@ async def postprocess(self):

async def download(self):
track_resolve_chunk_size = 20
success_count = 0
failed_count = 0
total_tracks = len(self.tracks)

logger.info(f"Starting download of {total_tracks} tracks from playlist '{self.name}'")

async def _resolve_download(item: PendingPlaylistTrack):
nonlocal success_count, failed_count
try:
track = await item.resolve()
if track is None:
logger.debug(f"Track {item.id} skipped (already downloaded or unavailable)")
failed_count += 1
return
await track.rip()
success_count += 1
except Exception as e:
logger.error(f"Error downloading track: {e}")
logger.error(f"Error downloading track {item.id}: {e}")
failed_count += 1

batches = self.batch(
[_resolve_download(track) for track in self.tracks],
Expand All @@ -140,6 +150,17 @@ async def _resolve_download(item: PendingPlaylistTrack):
for result in results:
if isinstance(result, Exception):
logger.error(f"Batch processing error: {result}")
failed_count += 1

# Log summary of download results
if success_count > 0:
logger.info(f"Successfully downloaded {success_count} out of {total_tracks} tracks from playlist '{self.name}'")
if failed_count > 0:
logger.warning(f"Failed to download {failed_count} out of {total_tracks} tracks from playlist '{self.name}'")
if success_count == 0 and total_tracks > 0:
logger.error(f"Failed to download any tracks from playlist '{self.name}'")
elif success_count == total_tracks:
logger.info(f"Successfully downloaded all tracks from playlist '{self.name}'")

@staticmethod
def batch(iterable, n=1):
Expand Down Expand Up @@ -169,9 +190,16 @@ async def resolve(self) -> Playlist | None:
except Exception as e:
logger.error(f"Error creating playlist: {e}")
return None

name = meta.name
parent = self.config.session.downloads.folder
folder = os.path.join(parent, clean_filepath(name))

track_ids = meta.ids()
if not track_ids:
logger.warning(f"No available tracks to download in playlist '{name}'")
return None

tracks = [
PendingPlaylistTrack(
id,
Expand All @@ -182,8 +210,14 @@ async def resolve(self) -> Playlist | None:
position + 1,
self.db,
)
for position, id in enumerate(meta.ids())
for position, id in enumerate(track_ids)
]

if not tracks:
logger.warning(f"No tracks to download in playlist '{name}'")
return None

logger.info(f"Preparing to download {len(tracks)} tracks from playlist '{name}'")
return Playlist(name, self.config, self.client, tracks)


Expand Down
28 changes: 21 additions & 7 deletions streamrip/metadata/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,30 @@ def from_qobuz(cls, resp: dict):
logger.debug(resp)
name = typed(resp["name"], str)
tracks = []
unavailable_count = 0
total_tracks = len(resp["tracks"]["items"])

for i, track in enumerate(resp["tracks"]["items"]):
meta = TrackMetadata.from_qobuz(
AlbumMetadata.from_qobuz(track["album"]),
track,
)
if meta is None:
logger.error(f"Track {i+1} in playlist {name} not available for stream")
try:
meta = TrackMetadata.from_qobuz(
AlbumMetadata.from_qobuz(track["album"]),
track,
)
if meta is None:
logger.error(f"Track {i+1} in playlist {name} not available for stream")
unavailable_count += 1
continue
tracks.append(meta)
except Exception as e:
logger.error(f"Error processing track {i+1} in playlist {name}: {e}")
unavailable_count += 1
continue
tracks.append(meta)

if unavailable_count > 0:
logger.warning(f"{unavailable_count} out of {total_tracks} tracks in playlist {name} are not available for streaming")

if not tracks:
logger.warning(f"No available tracks found in playlist {name}")

return cls(name, tracks)

Expand Down
123 changes: 123 additions & 0 deletions tests/test_qobuz_playlist_pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from streamrip.client.qobuz import QobuzClient
from streamrip.config import Config


@pytest.fixture
def mock_config():
"""Fixture that provides a mocked Config."""
config = MagicMock()
# Create nested mock objects
session = MagicMock()
qobuz = MagicMock()
downloads = MagicMock()

# Set up the structure
config.session = session
session.qobuz = qobuz
session.downloads = downloads

# Set the values
qobuz.app_id = "12345"
qobuz.email_or_userid = "[email protected]"
qobuz.password_or_token = "test_token"
qobuz.use_auth_token = True
qobuz.secrets = ["secret1", "secret2"]
downloads.verify_ssl = True
downloads.requests_per_minute = 100

return config


@pytest.fixture
def mock_qobuz_client(mock_config):
"""Fixture that provides a mocked QobuzClient."""
with patch.object(QobuzClient, "login", AsyncMock(return_value=None)):
with patch.object(QobuzClient, "get_session", AsyncMock()):
client = QobuzClient(mock_config)
client.session = MagicMock()
client.logged_in = True
client.secret = "test_secret"
yield client


@pytest.mark.asyncio
async def test_get_playlist_pagination(mock_qobuz_client):
"""Test that get_playlist correctly paginates results for large playlists."""
# Mock the _api_request method to return different responses for different offsets

# First page response (offset 0)
first_page_response = {
"tracks_count": 1200, # Total tracks in the playlist
"tracks": {
"items": [{"id": f"track_{i}"} for i in range(500)] # 500 tracks
}
}

# Second page response (offset 500)
second_page_response = {
"tracks": {
"items": [{"id": f"track_{i}"} for i in range(500, 1000)] # 500 more tracks
}
}

# Third page response (offset 1000)
third_page_response = {
"tracks": {
"items": [{"id": f"track_{i}"} for i in range(1000, 1200)] # 200 more tracks
}
}

# Mock the _api_request method to return different responses based on offset
async def mock_api_request(endpoint, params):
if params.get("offset") == 0:
return 200, first_page_response
elif params.get("offset") == 500:
return 200, second_page_response
elif params.get("offset") == 1000:
return 200, third_page_response
else:
return 404, {"message": "Not found"}

mock_qobuz_client._api_request = AsyncMock(side_effect=mock_api_request)

# Call the get_playlist method
result = await mock_qobuz_client.get_playlist("test_playlist_id")

# Verify that the _api_request method was called with the correct parameters
assert mock_qobuz_client._api_request.call_count == 3

# Verify that the result contains all tracks from all pages
assert len(result["tracks"]["items"]) == 1200

# Verify that the tracks are in the correct order
for i in range(1200):
assert result["tracks"]["items"][i]["id"] == f"track_{i}"


@pytest.mark.asyncio
async def test_get_playlist_small(mock_qobuz_client):
"""Test that get_playlist works correctly for small playlists (no pagination needed)."""
# Mock response for a small playlist
small_playlist_response = {
"tracks_count": 100, # Total tracks in the playlist
"tracks": {
"items": [{"id": f"track_{i}"} for i in range(100)] # 100 tracks
}
}

# Mock the _api_request method to return the small playlist response
mock_qobuz_client._api_request = AsyncMock(return_value=(200, small_playlist_response))

# Call the get_playlist method
result = await mock_qobuz_client.get_playlist("test_small_playlist_id")

# Verify that the _api_request method was called only once (no pagination needed)
assert mock_qobuz_client._api_request.call_count == 1

# Verify that the result contains all tracks
assert len(result["tracks"]["items"]) == 100