diff --git a/streamrip/client/qobuz.py b/streamrip/client/qobuz.py index 734e2b82..a2c650d5 100644 --- a/streamrip/client/qobuz.py +++ b/streamrip/client/qobuz.py @@ -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), @@ -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 diff --git a/streamrip/media/playlist.py b/streamrip/media/playlist.py index 383f2465..80af68a1 100644 --- a/streamrip/media/playlist.py +++ b/streamrip/media/playlist.py @@ -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], @@ -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): @@ -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, @@ -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) diff --git a/streamrip/metadata/playlist.py b/streamrip/metadata/playlist.py index 58a563c6..9c351724 100644 --- a/streamrip/metadata/playlist.py +++ b/streamrip/metadata/playlist.py @@ -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) diff --git a/tests/test_qobuz_playlist_pagination.py b/tests/test_qobuz_playlist_pagination.py new file mode 100644 index 00000000..2ab1542e --- /dev/null +++ b/tests/test_qobuz_playlist_pagination.py @@ -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 = "test@example.com" + 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