diff --git a/tests/parsers/test_thumbnails.py b/tests/parsers/test_thumbnails.py new file mode 100644 index 00000000..701b1871 --- /dev/null +++ b/tests/parsers/test_thumbnails.py @@ -0,0 +1,74 @@ +"""Parsers must tolerate items without thumbnail data (see #977).""" + +import pytest + +from ytmusicapi.parsers.browsing import ( + parse_album, + parse_related_artist, + parse_single, + parse_song, + parse_watch_playlist, +) +from ytmusicapi.parsers.explore import parse_chart_playlist +from ytmusicapi.parsers.library import parse_albums +from ytmusicapi.parsers.playlists import parse_playlist_header_meta +from ytmusicapi.parsers.podcasts import parse_base_header, parse_podcast +from ytmusicapi.parsers.watch import parse_watch_track + + +def _title(text: str = "Title", browse_id: str | None = None, video_id: str | None = None) -> dict: + run: dict = {"text": text} + if browse_id: + run["navigationEndpoint"] = {"browseEndpoint": {"browseId": browse_id}} + if video_id: + run["navigationEndpoint"] = {"watchEndpoint": {"videoId": video_id}} + return {"title": {"runs": [run]}} + + +def _subtitle(*texts: str) -> dict: + return {"subtitle": {"runs": [{"text": text} for text in texts]}} + + +@pytest.mark.parametrize( + ("parse_func", "item"), + [ + (parse_album, {**_title(browse_id="MPREb_1"), **_subtitle("Album", " • ", "2024")}), + (parse_single, {**_title(browse_id="MPREb_1"), **_subtitle("2024")}), + ( + parse_song, + { + **_title(), + **_subtitle("Artist"), + "navigationEndpoint": {"watchEndpoint": {"videoId": "videoId1"}}, + }, + ), + (parse_related_artist, {**_title(browse_id="UC_1"), **_subtitle("1M subscribers")}), + ( + parse_watch_playlist, + {**_title(), "navigationEndpoint": {"watchPlaylistEndpoint": {"playlistId": "RD1"}}}, + ), + (parse_chart_playlist, _title(browse_id="VLPL1")), + (parse_podcast, {**_title(browse_id="MPSP1"), **_subtitle("Channel")}), + (parse_base_header, {**_title(), "straplineTextOne": {}}), + (parse_playlist_header_meta, {**_title(), "secondSubtitle": {}}), + ], +) +def test_missing_thumbnails(parse_func, item): + assert parse_func(item)["thumbnails"] is None + + +def test_missing_thumbnails_library_albums(): + item = {"musicTwoRowItemRenderer": {**_title(browse_id="MPREb_1"), "subtitle": {}}} + + assert parse_albums([item])[0]["thumbnails"] is None + + +def test_missing_thumbnail_watch_track(): + track = { + "videoId": "videoId1", + **_title(), + "menu": {"menuRenderer": {"items": []}}, + "longBylineText": {"runs": [{"text": "Artist"}]}, + } + + assert parse_watch_track(track)["thumbnail"] is None diff --git a/ytmusicapi/mixins/podcasts.py b/ytmusicapi/mixins/podcasts.py index 0e8cf2f7..579a90d5 100644 --- a/ytmusicapi/mixins/podcasts.py +++ b/ytmusicapi/mixins/podcasts.py @@ -71,7 +71,7 @@ def get_channel(self, channelId: str) -> JsonDict: channel = { "title": nav(response, [*HEADER_MUSIC_VISUAL, *TITLE_TEXT]), - "thumbnails": nav(response, [*HEADER_MUSIC_VISUAL, *THUMBNAILS]), + "thumbnails": nav(response, [*HEADER_MUSIC_VISUAL, *THUMBNAILS], True), } results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) diff --git a/ytmusicapi/parsers/albums.py b/ytmusicapi/parsers/albums.py index de5e97be..58741447 100644 --- a/ytmusicapi/parsers/albums.py +++ b/ytmusicapi/parsers/albums.py @@ -11,7 +11,7 @@ def parse_album_header(response: JsonDict) -> JsonDict: album = { "title": nav(header, TITLE_TEXT), "type": nav(header, SUBTITLE), - "thumbnails": nav(header, THUMBNAIL_CROPPED), + "thumbnails": nav(header, THUMBNAIL_CROPPED, True), "isExplicit": nav(header, SUBTITLE_BADGE_LABEL, True) is not None, } @@ -45,7 +45,7 @@ def parse_album_header_2024(response: JsonDict) -> JsonDict: album = { "title": nav(header, TITLE_TEXT), "type": nav(header, SUBTITLE), - "thumbnails": nav(header, THUMBNAILS), + "thumbnails": nav(header, THUMBNAILS, True), "isExplicit": nav(header, SUBTITLE_BADGE_LABEL, True) is not None, } description, description_runs = parse_description_runs( diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index d171eb3d..88e9ba68 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -91,7 +91,7 @@ def parse_album(result: JsonDict) -> JsonDict: ], "browseId": nav(result, TITLE + NAVIGATION_BROWSE_ID), "audioPlaylistId": parse_album_playlistid_if_exists(nav(result, THUMBNAIL_OVERLAY_NAVIGATION, True)), - "thumbnails": nav(result, THUMBNAIL_RENDERER), + "thumbnails": nav(result, THUMBNAIL_RENDERER, True), "isExplicit": nav(result, SUBTITLE_BADGE_LABEL, True) is not None, } @@ -102,7 +102,7 @@ def parse_single(result: JsonDict) -> JsonDict: single = { "title": nav(result, TITLE_TEXT), "browseId": nav(result, TITLE + NAVIGATION_BROWSE_ID), - "thumbnails": nav(result, THUMBNAIL_RENDERER), + "thumbnails": nav(result, THUMBNAIL_RENDERER, True), } return _parse_album_single_subtitle(result, single) @@ -113,7 +113,7 @@ def parse_song(result: JsonDict) -> JsonDict: "title": nav(result, TITLE_TEXT), "videoId": nav(result, NAVIGATION_VIDEO_ID), "playlistId": nav(result, NAVIGATION_PLAYLIST_ID, True), - "thumbnails": nav(result, THUMBNAIL_RENDERER), + "thumbnails": nav(result, THUMBNAIL_RENDERER, True), } song.update(parse_song_runs(nav(result, SUBTITLE_RUNS), skip_type_spec=True)) return song @@ -125,7 +125,7 @@ def parse_song_flat(data: JsonDict, with_playlist_id: bool = False) -> JsonDict: "title": nav(columns[0], TEXT_RUN_TEXT), "videoId": nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True), "videoType": nav(data, [*PLAY_BUTTON, "playNavigationEndpoint", *NAVIGATION_VIDEO_TYPE], True), - "thumbnails": nav(data, THUMBNAILS), + "thumbnails": nav(data, THUMBNAILS, True), "isExplicit": nav(data, BADGE_LABEL, True) is not None, } @@ -208,7 +208,7 @@ def parse_related_artist(data: JsonDict) -> JsonDict: "title": nav(data, TITLE_TEXT), "browseId": nav(data, TITLE + NAVIGATION_BROWSE_ID), "subscribers": subscribers, - "thumbnails": nav(data, THUMBNAIL_RENDERER), + "thumbnails": nav(data, THUMBNAIL_RENDERER, True), } @@ -216,5 +216,5 @@ def parse_watch_playlist(data: JsonDict) -> JsonDict: return { "title": nav(data, TITLE_TEXT), "playlistId": nav(data, NAVIGATION_WATCH_PLAYLIST_ID), - "thumbnails": nav(data, THUMBNAIL_RENDERER), + "thumbnails": nav(data, THUMBNAIL_RENDERER, True), } diff --git a/ytmusicapi/parsers/explore.py b/ytmusicapi/parsers/explore.py index b0eaa314..d45a59da 100644 --- a/ytmusicapi/parsers/explore.py +++ b/ytmusicapi/parsers/explore.py @@ -26,7 +26,7 @@ def parse_chart_playlist(data: JsonDict) -> JsonDict: return { "title": nav(data, TITLE_TEXT), "playlistId": nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], - "thumbnails": nav(data, THUMBNAIL_RENDERER), + "thumbnails": nav(data, THUMBNAIL_RENDERER, True), } @@ -47,7 +47,7 @@ def parse_chart_artist(data: JsonDict) -> JsonDict: "title": nav(get_flex_column_item(data, 0), TEXT_RUN_TEXT), "browseId": nav(data, NAVIGATION_BROWSE_ID), "subscribers": subscribers, - "thumbnails": nav(data, THUMBNAILS), + "thumbnails": nav(data, THUMBNAILS, True), } parsed.update(parse_ranking(data, none_if_absent=True)) return parsed diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index 7ebe6a66..367453b5 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -57,7 +57,7 @@ def parse_albums(results: JsonList) -> JsonList: album["browseId"] = nav(data, TITLE + NAVIGATION_BROWSE_ID) album["playlistId"] = nav(data, MENU_PLAYLIST_ID, none_if_absent=True) album["title"] = nav(data, TITLE_TEXT) - album["thumbnails"] = nav(data, THUMBNAIL_RENDERER) + album["thumbnails"] = nav(data, THUMBNAIL_RENDERER, True) if "runs" in data["subtitle"]: album["type"] = nav(data, SUBTITLE) diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 68127124..a64f069f 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -39,7 +39,7 @@ def parse_playlist_header_meta(header: JsonDict) -> JsonDict: "duration": None, "trackCount": None, "title": "".join([run["text"] for run in header.get("title", {}).get("runs", [])]), - "thumbnails": nav(header, THUMBNAILS), + "thumbnails": nav(header, THUMBNAILS, True), } if "facepile" in header: avatar_renderer = nav(header, ["facepile", "avatarStackViewModel", "rendererContext"]) diff --git a/ytmusicapi/parsers/podcasts.py b/ytmusicapi/parsers/podcasts.py index 61da1f8a..a40a63f9 100644 --- a/ytmusicapi/parsers/podcasts.py +++ b/ytmusicapi/parsers/podcasts.py @@ -76,7 +76,7 @@ def parse_base_header(header: JsonDict) -> JsonDict: return { "author": author if author["name"] else None, "title": nav(header, TITLE_TEXT), - "thumbnails": nav(header, THUMBNAILS), + "thumbnails": nav(header, THUMBNAILS, True), } @@ -107,7 +107,7 @@ def parse_episode_header(header: JsonDict) -> JsonDict: def parse_episode(data: JsonDict) -> JsonDict: """Parses a single episode under "Episodes" on a channel page or on a podcast page""" - thumbnails = nav(data, THUMBNAILS) + thumbnails = nav(data, THUMBNAILS, True) date = nav(data, SUBTITLE, True) duration = nav(data, ["playbackProgress", *PROGRESS_RENDERER, *DURATION_TEXT], True) title = nav(data, TITLE_TEXT) @@ -138,7 +138,7 @@ def parse_episode_flat(data: JsonDict) -> JsonDict: "playlistId": nav(data, [*PLAY_BUTTON, "playNavigationEndpoint", *WATCH_PLAYLIST_ID]), "videoType": nav(data, [*PLAY_BUTTON, "playNavigationEndpoint", *NAVIGATION_VIDEO_TYPE]), "date": nav(get_flex_column_item(data, 2), TEXT_RUN_TEXT), - "thumbnails": nav(data, THUMBNAILS), + "thumbnails": nav(data, THUMBNAILS, True), } @@ -149,5 +149,5 @@ def parse_podcast(data: JsonDict) -> JsonDict: "channel": parse_id_name(nav(data, [*SUBTITLE_RUNS, 0], True)), "browseId": nav(data, TITLE + NAVIGATION_BROWSE_ID), "podcastId": nav(data, THUMBNAIL_OVERLAY, True), - "thumbnails": nav(data, THUMBNAIL_RENDERER), + "thumbnails": nav(data, THUMBNAIL_RENDERER, True), } diff --git a/ytmusicapi/parsers/uploads.py b/ytmusicapi/parsers/uploads.py index 11f2290d..a5d31da7 100644 --- a/ytmusicapi/parsers/uploads.py +++ b/ytmusicapi/parsers/uploads.py @@ -31,7 +31,7 @@ def parse_uploaded_items(results: JsonList) -> JsonList: title = get_item_text(data, 0) like = nav(data, MENU_LIKE_STATUS) - thumbnails = nav(data, THUMBNAILS) if "thumbnail" in data else None + thumbnails = nav(data, THUMBNAILS, True) duration = None if "fixedColumns" in data: duration = nav(get_fixed_column_item(data, 0), TEXT_RUN_TEXT) diff --git a/ytmusicapi/parsers/watch.py b/ytmusicapi/parsers/watch.py index 923b1afa..52554625 100644 --- a/ytmusicapi/parsers/watch.py +++ b/ytmusicapi/parsers/watch.py @@ -38,7 +38,7 @@ def parse_watch_track(data: JsonDict) -> JsonDict: "videoId": data["videoId"], "title": nav(data, TITLE_TEXT), "length": nav(data, ["lengthText", "runs", 0, "text"], True), - "thumbnail": nav(data, THUMBNAIL), + "thumbnail": nav(data, THUMBNAIL, True), "likeStatus": like_status, "videoType": nav(data, ["navigationEndpoint", *NAVIGATION_VIDEO_TYPE], True), }