From e4e2e9a9e9a596f3216c0b57ea193e927f827142 Mon Sep 17 00:00:00 2001 From: Ryan Hartman Date: Sun, 14 Dec 2025 09:21:21 -0700 Subject: [PATCH] feat(browse): Add support for browsing new releases Adds 'New releases' to the browse playlists directory, allowing users to browse Spotify's new album releases via spotify:playlists:new-releases. - Add 'New releases' entry to _PLAYLISTS_DIR_CONTENTS - Extend _browse_playlists() to handle 'new-releases' variant - Add Google-style docstring documenting the function - Add logging for unknown playlist variants - Add comprehensive tests with API documentation references Closes #241 --- src/mopidy_spotify/browse.py | 51 +++++++++--- tests/test_browse.py | 157 ++++++++++++++++++++++++++++++++++- 2 files changed, 194 insertions(+), 14 deletions(-) diff --git a/src/mopidy_spotify/browse.py b/src/mopidy_spotify/browse.py index abe102f2..6f524cb7 100644 --- a/src/mopidy_spotify/browse.py +++ b/src/mopidy_spotify/browse.py @@ -32,6 +32,7 @@ _PLAYLISTS_DIR_CONTENTS = [ models.Ref.directory(uri="spotify:playlists:featured", name="Featured"), + models.Ref.directory(uri="spotify:playlists:new-releases", name="New releases"), ] @@ -183,20 +184,44 @@ def _browse_your_music(web_client, variant): def _browse_playlists(web_client, variant): + """Browse playlist-related directories (featured playlists, new releases). + + Args: + web_client: The Spotify OAuth client for API requests. + variant: The playlist variant to browse ('featured' or 'new-releases'). + + Returns: + A list of Mopidy Ref objects (playlists for 'featured', albums for + 'new-releases'). + """ if not web_client.logged_in: return [] - if variant != "featured": - return [] + if variant == "featured": + items = flatten( + [ + page.get("playlists", {}).get("items", []) + for page in web_client.get_all( + "browse/featured-playlists", + params={"limit": 50}, + ) + if page + ] + ) + return list(translator.to_playlist_refs(items)) - items = flatten( - [ - page.get("playlists", {}).get("items", []) - for page in web_client.get_all( - "browse/featured-playlists", - params={"limit": 50}, - ) - if page - ] - ) - return list(translator.to_playlist_refs(items)) + if variant == "new-releases": + items = flatten( + [ + page.get("albums", {}).get("items", []) + for page in web_client.get_all( + "browse/new-releases", + params={"limit": 50}, + ) + if page + ] + ) + return list(translator.web_to_album_refs(items)) + + logger.info(f"Failed to browse 'spotify:playlists:{variant}': Unknown URI type") + return [] diff --git a/tests/test_browse.py b/tests/test_browse.py index 65e3b21c..a21aa18f 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -65,13 +65,18 @@ def test_browse_your_music_directory(provider): def test_browse_playlists_directory(provider): + """Test browsing the playlists directory returns all playlist categories.""" results = provider.browse("spotify:playlists") - assert len(results) == 1 + assert len(results) == 2 assert ( models.Ref.directory(uri="spotify:playlists:featured", name="Featured") in results ) + assert ( + models.Ref.directory(uri="spotify:playlists:new-releases", name="New releases") + in results + ) def test_browse_playlist(web_client_mock, web_playlist_mock, provider): @@ -305,3 +310,153 @@ def test_browse_playlists_featured(web_client_mock, web_playlist_mock, provider) assert len(results) == 2 assert results[0].name == "Foo" assert results[0].uri == "spotify:user:alice:playlist:foo" + + +def test_browse_new_releases(web_client_mock, web_album_mock_base, provider): + """Test browsing new releases returns album refs. + + Verifies successful response handling per Spotify Web API reference: + https://developer.spotify.com/documentation/web-api/reference/get-new-releases + + Response structure: { "albums": { "items": [SimplifiedAlbumObject, ...] } } + """ + web_client_mock.get_all.return_value = [ + {"albums": {"items": [web_album_mock_base]}}, + {"albums": {"items": [web_album_mock_base]}}, + ] + + results = provider.browse("spotify:playlists:new-releases") + + web_client_mock.get_all.assert_called_once_with( + "browse/new-releases", params={"limit": 50} + ) + assert len(results) == 2 + assert results[0] == models.Ref.album( + uri="spotify:album:def", name="ABBA - DEF 456" + ) + + +def test_browse_new_releases_empty(web_client_mock, provider): + """Test browsing new releases when API returns empty results. + + Per Spotify API docs, albums.items may be an empty array when no new + releases are available for the market. + https://developer.spotify.com/documentation/web-api/reference/get-new-releases + """ + web_client_mock.get_all.return_value = [{}] + + results = provider.browse("spotify:playlists:new-releases") + + web_client_mock.get_all.assert_called_once_with( + "browse/new-releases", params={"limit": 50} + ) + assert len(results) == 0 + + +def test_browse_new_releases_when_offline(web_client_mock, provider): + """Test browsing new releases when not logged in. + + Spotify API requires valid OAuth token. Error 401 (bad/expired token) and + 403 (bad OAuth request) are handled by web_client before reaching browse. + https://developer.spotify.com/documentation/web-api/reference/get-new-releases + """ + web_client_mock.logged_in = False + + results = provider.browse("spotify:playlists:new-releases") + + web_client_mock.get_all.assert_not_called() + assert len(results) == 0 + + +def test_browse_playlists_unknown_variant(web_client_mock, provider, caplog): + """Test browsing unknown playlist variant logs warning and returns empty.""" + results = provider.browse("spotify:playlists:unknown") + + web_client_mock.get_all.assert_not_called() + assert len(results) == 0 + assert "Unknown URI type" in caplog.text + + +# Defensive programming tests - verifying robust handling of edge cases +# +# These tests verify graceful handling of malformed or unexpected API responses. +# Per Spotify Web API reference, the expected response structure is: +# { "albums": { "href": str, "limit": int, "next": str|null, "offset": int, +# "previous": str|null, "total": int, "items": [SimplifiedAlbumObject] } } +# https://developer.spotify.com/documentation/web-api/reference/get-new-releases +# +# However, pagination and network issues may produce partial/malformed responses. +# The translator.valid_web_data() validates each album object has type="album" and uri. + + +def test_browse_new_releases_handles_none_pages(web_client_mock, provider): + """Test that None pages in the response are safely filtered out. + + Pagination via web_client.get_all() may yield None for failed page fetches + (e.g., network timeouts, rate limiting via 429 status). + """ + web_client_mock.get_all.return_value = [ + None, + {"albums": {"items": []}}, + None, + ] + + results = provider.browse("spotify:playlists:new-releases") + + assert len(results) == 0 + + +def test_browse_new_releases_handles_missing_albums_key(web_client_mock, provider): + """Test that pages missing the 'albums' key are handled gracefully. + + While the API spec defines 'albums' as required, defensive coding handles + unexpected response shapes that may occur during API changes or errors. + """ + web_client_mock.get_all.return_value = [ + {"unexpected_key": {"items": []}}, + {}, + ] + + results = provider.browse("spotify:playlists:new-releases") + + assert len(results) == 0 + + +def test_browse_new_releases_handles_missing_items_key( + web_client_mock, web_album_mock_base, provider +): + """Test that pages with 'albums' but missing 'items' are handled gracefully. + + The API spec shows 'items' as required within 'albums', but we use .get() + with empty list default to handle partial responses defensively. + """ + web_client_mock.get_all.return_value = [ + {"albums": {}}, + {"albums": {"items": [web_album_mock_base]}}, + ] + + results = provider.browse("spotify:playlists:new-releases") + + assert len(results) == 1 + + +def test_browse_new_releases_handles_mixed_valid_invalid_pages( + web_client_mock, web_album_mock_base, provider +): + """Test that valid data is extracted even when mixed with invalid pages. + + Real-world pagination may encounter intermittent failures. This verifies + the implementation extracts all valid albums while gracefully skipping + malformed pages, ensuring maximum data availability. + """ + web_client_mock.get_all.return_value = [ + None, + {"albums": {"items": [web_album_mock_base]}}, + {}, + {"albums": {}}, + {"albums": {"items": [web_album_mock_base, web_album_mock_base]}}, + ] + + results = provider.browse("spotify:playlists:new-releases") + + assert len(results) == 3