Skip to content

Commit

Permalink
fix #562 (#573)
Browse files Browse the repository at this point in the history
* fix #562

* fix test

* use correct public test data

* fix up for new format

* fix lint
  • Loading branch information
sigma67 authored May 4, 2024
1 parent 0ba103e commit f53074e
Show file tree
Hide file tree
Showing 6 changed files with 166 additions and 19 deletions.
1 change: 1 addition & 0 deletions tests/data/2024_03_get_playlist.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions tests/data/2024_03_get_playlist_public.json

Large diffs are not rendered by default.

39 changes: 38 additions & 1 deletion tests/mixins/test_playlists.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import json
import time
from pathlib import Path
from unittest import mock

import pytest


class TestPlaylists:
@pytest.mark.parametrize(
("test_file", "owned"),
[
("2024_03_get_playlist.json", True),
("2024_03_get_playlist_public.json", False),
],
)
def test_get_playlist_2024(self, yt, test_file, owned):
with open(Path(__file__).parent.parent / "data" / test_file, encoding="utf8") as f:
mock_response = json.load(f)
with mock.patch("ytmusicapi.YTMusic._send_request", return_value=mock_response):
playlist = yt.get_playlist("MPREabc")
assert playlist["year"] == "2024"
assert playlist["owned"] == owned
assert "hours" in playlist["duration"]
assert playlist["id"]
assert isinstance(playlist["description"], str)

def test_get_playlist_foreign(self, yt, yt_auth, yt_oauth):
with pytest.raises(Exception):
yt.get_playlist("PLABC")
Expand All @@ -20,6 +41,22 @@ def test_get_playlist_foreign(self, yt, yt_auth, yt_oauth):
assert len(playlist["tracks"]) > 200
assert len(playlist["related"]) == 0

def test_get_playlist_foreign_new_format(self, yt_empty):
with pytest.raises(Exception):
yt_empty.get_playlist("PLABC")
playlist = yt_empty.get_playlist("PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", limit=300, suggestions_limit=7)
assert len(playlist["duration"]) > 5
assert len(playlist["tracks"]) > 200
assert "suggestions" not in playlist
assert playlist["owned"] is False

playlist = yt_empty.get_playlist("RDATgXd-")
assert len(playlist["tracks"]) >= 100

playlist = yt_empty.get_playlist("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", limit=None, related=True)
assert len(playlist["tracks"]) > 200
assert len(playlist["related"]) == 0

def test_get_playlist_owned(self, config, yt_brand):
playlist = yt_brand.get_playlist(config["playlists"]["own"], related=True, suggestions_limit=21)
assert len(playlist["tracks"]) < 100
Expand Down Expand Up @@ -52,7 +89,7 @@ def test_edit_playlist(self, config, yt_brand):
)
assert response == "STATUS_SUCCEEDED", "Playlist edit failed"

def test_end2end(self, config, yt_brand, sample_video):
def test_end2end(self, yt_brand, sample_video):
playlist_id = yt_brand.create_playlist(
"test",
"test description",
Expand Down
111 changes: 108 additions & 3 deletions ytmusicapi/mixins/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,12 @@ def get_playlist(
body = {"browseId": browseId}
endpoint = "browse"
response = self._send_request(endpoint, body)
results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + ["musicPlaylistShelfRenderer"])
results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + ["musicPlaylistShelfRenderer"], True)
if not results:
return self._parse_new_playlist_format(
response, endpoint, body, suggestions_limit, related, limit
)

playlist = {"id": results["playlistId"]}
playlist.update(parse_playlist_header(response))
if playlist["trackCount"] is None:
Expand All @@ -119,8 +124,7 @@ def get_playlist(
playlist["related"] = []
if "continuations" in section_list:
additionalParams = get_continuation_params(section_list)
own_playlist = "musicEditablePlaylistDetailHeaderRenderer" in response["header"]
if own_playlist and (suggestions_limit > 0 or related):
if playlist["owned"] and (suggestions_limit > 0 or related):
parse_func = lambda results: parse_playlist_items(results)
suggested = request_func(additionalParams)
continuation = nav(suggested, SECTION_LIST_CONTINUATION)
Expand Down Expand Up @@ -164,6 +168,107 @@ def get_playlist(
playlist["duration_seconds"] = sum_total_duration(playlist)
return playlist

def _parse_new_playlist_format(
self, response: Dict, endpoint, body, suggestions_limit, related, limit
) -> Dict: # pragma: no cover
"""temporary function to avoid too many ifs in get_playlist during a/b test"""

header_data = nav(response, [*TWO_COLUMN_RENDERER, *TAB_CONTENT, *SECTION_LIST_ITEM])
section_list = nav(response, [*TWO_COLUMN_RENDERER, "secondaryContents", *SECTION])
playlist: Dict = {}
playlist["owned"] = EDITABLE_PLAYLIST_DETAIL_HEADER[0] in header_data
if not playlist["owned"]:
header = nav(header_data, RESPONSIVE_HEADER)
playlist["id"] = nav(
header,
["buttons", 1, "musicPlayButtonRenderer", "playNavigationEndpoint", *WATCH_PLAYLIST_ID],
True,
)
playlist["privacy"] = "PUBLIC"
else:
playlist["id"] = nav(header_data, [*EDITABLE_PLAYLIST_DETAIL_HEADER, *PLAYLIST_ID])
header = nav(header_data, [*EDITABLE_PLAYLIST_DETAIL_HEADER, *HEADER, *RESPONSIVE_HEADER])
playlist["privacy"] = header_data[EDITABLE_PLAYLIST_DETAIL_HEADER[0]]["editHeader"][
"musicPlaylistEditHeaderRenderer"
]["privacy"]

description_shelf = nav(header, ["description", *DESCRIPTION_SHELF], True)
playlist["description"] = (
"".join([run["text"] for run in description_shelf["description"]["runs"]])
if description_shelf
else None
)
playlist["title"] = nav(header, TITLE_TEXT)
playlist.update(parse_song_runs(nav(header, SUBTITLE_RUNS)[2 + playlist["owned"] * 2 :]))

playlist["views"] = None
playlist["duration"] = None
if "runs" in header["secondSubtitle"]:
second_subtitle_runs = header["secondSubtitle"]["runs"]
has_views = (len(second_subtitle_runs) > 3) * 2
playlist["views"] = None if not has_views else to_int(second_subtitle_runs[0]["text"])
has_duration = (len(second_subtitle_runs) > 1) * 2
playlist["duration"] = (
None if not has_duration else second_subtitle_runs[has_views + has_duration]["text"]
)
song_count = second_subtitle_runs[has_views + 0]["text"].split(" ")
song_count = to_int(song_count[0]) if len(song_count) > 1 else 0
else:
song_count = len(section_list["contents"])

playlist["trackCount"] = song_count

request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams)

# suggestions and related are missing e.g. on liked songs
playlist["related"] = []
if "continuations" in section_list:
additionalParams = get_continuation_params(section_list)
if playlist["owned"] and (suggestions_limit > 0 or related):
parse_func = lambda results: parse_playlist_items(results)
suggested = request_func(additionalParams)
continuation = nav(suggested, SECTION_LIST_CONTINUATION)
additionalParams = get_continuation_params(continuation)
suggestions_shelf = nav(continuation, CONTENT + MUSIC_SHELF)
playlist["suggestions"] = get_continuation_contents(suggestions_shelf, parse_func)

parse_func = lambda results: parse_playlist_items(results)
playlist["suggestions"].extend(
get_continuations(
suggestions_shelf,
"musicShelfContinuation",
suggestions_limit - len(playlist["suggestions"]),
request_func,
parse_func,
reloadable=True,
)
)

if related:
response = request_func(additionalParams)
continuation = nav(response, SECTION_LIST_CONTINUATION, True)
if continuation:
parse_func = lambda results: parse_content_list(results, parse_playlist)
playlist["related"] = get_continuation_contents(
nav(continuation, CONTENT + CAROUSEL), parse_func
)

playlist["tracks"] = []
content_data = nav(section_list, [*CONTENT, "musicPlaylistShelfRenderer"])
if "contents" in content_data:
playlist["tracks"] = parse_playlist_items(content_data["contents"])

parse_func = lambda contents: parse_playlist_items(contents)
if "continuations" in content_data:
playlist["tracks"].extend(
get_continuations(
content_data, "musicPlaylistShelfContinuation", limit, request_func, parse_func
)
)

playlist["duration_seconds"] = sum_total_duration(playlist)
return playlist

def get_liked_songs(self, limit: int = 100) -> Dict:
"""
Gets playlist items for the 'Liked Songs' playlist
Expand Down
18 changes: 11 additions & 7 deletions ytmusicapi/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,12 @@
NAVIGATION_BROWSE_ID = [*NAVIGATION_BROWSE, "browseId"]
PAGE_TYPE = ["browseEndpointContextSupportedConfigs", "browseEndpointContextMusicConfig", "pageType"]
WATCH_VIDEO_ID = ["watchEndpoint", "videoId"]
WATCH_PLAYLIST_ID = ["watchEndpoint", "playlistId"]
PLAYLIST_ID = ["playlistId"]
WATCH_PLAYLIST_ID = ["watchEndpoint", *PLAYLIST_ID]
NAVIGATION_VIDEO_ID = ["navigationEndpoint", *WATCH_VIDEO_ID]
QUEUE_VIDEO_ID = ["queueAddEndpoint", "queueTarget", "videoId"]
NAVIGATION_PLAYLIST_ID = ["navigationEndpoint", *WATCH_PLAYLIST_ID]
WATCH_PID = ["watchPlaylistEndpoint", "playlistId"]
WATCH_PID = ["watchPlaylistEndpoint", *PLAYLIST_ID]
NAVIGATION_WATCH_PLAYLIST_ID = ["navigationEndpoint", *WATCH_PID]
NAVIGATION_VIDEO_TYPE = [
"watchEndpoint",
Expand Down Expand Up @@ -72,16 +73,19 @@
SECTION_LIST_CONTINUATION = ["continuationContents", "sectionListContinuation"]
MENU_PLAYLIST_ID = [*MENU_ITEMS, 0, MNIR, *NAVIGATION_WATCH_PLAYLIST_ID]
MULTI_SELECT = ["musicMultiSelectMenuItemRenderer"]
HEADER_DETAIL = ["header", "musicDetailHeaderRenderer"]
HEADER_SIDE = ["header", "musicSideAlignedItemRenderer"]
HEADER_MUSIC_VISUAL = ["header", "musicVisualHeaderRenderer"]
HEADER = ["header"]
HEADER_DETAIL = [*HEADER, "musicDetailHeaderRenderer"]
EDITABLE_PLAYLIST_DETAIL_HEADER = ["musicEditablePlaylistDetailHeaderRenderer"]
HEADER_EDITABLE_DETAIL = [*HEADER, *EDITABLE_PLAYLIST_DETAIL_HEADER]
HEADER_SIDE = [*HEADER, "musicSideAlignedItemRenderer"]
HEADER_MUSIC_VISUAL = [*HEADER, "musicVisualHeaderRenderer"]
DESCRIPTION_SHELF = ["musicDescriptionShelfRenderer"]
DESCRIPTION = ["description", *RUN_TEXT]
CAROUSEL = ["musicCarouselShelfRenderer"]
IMMERSIVE_CAROUSEL = ["musicImmersiveCarouselShelfRenderer"]
CAROUSEL_CONTENTS = [*CAROUSEL, "contents"]
CAROUSEL_TITLE = ["header", "musicCarouselShelfBasicHeaderRenderer", *TITLE]
CARD_SHELF_TITLE = ["header", "musicCardShelfHeaderBasicRenderer", *TITLE_TEXT]
CAROUSEL_TITLE = [*HEADER, "musicCarouselShelfBasicHeaderRenderer", *TITLE]
CARD_SHELF_TITLE = [*HEADER, "musicCardShelfHeaderBasicRenderer", *TITLE_TEXT]
FRAMEWORK_MUTATIONS = ["frameworkUpdates", "entityBatchUpdate", "mutations"]


Expand Down
15 changes: 7 additions & 8 deletions ytmusicapi/parsers/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@

def parse_playlist_header(response: Dict) -> Dict[str, Any]:
playlist: Dict[str, Any] = {}
own_playlist = "musicEditablePlaylistDetailHeaderRenderer" in response["header"]
if not own_playlist:
header = response["header"]["musicDetailHeaderRenderer"]
playlist["privacy"] = "PUBLIC"
editable_header = nav(response, [*HEADER, *EDITABLE_PLAYLIST_DETAIL_HEADER], True)
playlist["owned"] = editable_header is not None
playlist["privacy"] = "PUBLIC"
if editable_header is not None: # owned playlist
header = nav(editable_header, HEADER_DETAIL)
playlist["privacy"] = editable_header["editHeader"]["musicPlaylistEditHeaderRenderer"]["privacy"]
else:
header = response["header"]["musicEditablePlaylistDetailHeaderRenderer"]
playlist["privacy"] = header["editHeader"]["musicPlaylistEditHeaderRenderer"]["privacy"]
header = header["header"]["musicDetailHeaderRenderer"]
playlist["owned"] = own_playlist
header = nav(response, HEADER_DETAIL, True)

playlist["title"] = nav(header, TITLE_TEXT)
playlist["thumbnails"] = nav(header, THUMBNAIL_CROPPED)
Expand Down

0 comments on commit f53074e

Please sign in to comment.