From ee41ab250b97b8bdf40cd42f82b02a690c7e7b0c Mon Sep 17 00:00:00 2001 From: Seppe Gadeyne Date: Fri, 31 Jul 2026 19:33:31 +0200 Subject: [PATCH 1/4] Expose ownership for library playlists --- tests/parsers/test_browsing.py | 52 ++++++++++++++++++++++++++++++++++ ytmusicapi/mixins/library.py | 7 +++-- ytmusicapi/parsers/browsing.py | 18 +++++++++++- 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 tests/parsers/test_browsing.py diff --git a/tests/parsers/test_browsing.py b/tests/parsers/test_browsing.py new file mode 100644 index 00000000..3cd95d59 --- /dev/null +++ b/tests/parsers/test_browsing.py @@ -0,0 +1,52 @@ +from copy import deepcopy + +from ytmusicapi.parsers.browsing import parse_playlist + +OWNED_PLAYLIST = { + "thumbnailRenderer": {"musicThumbnailRenderer": {"thumbnail": {"thumbnails": []}}}, + "title": { + "runs": [ + { + "text": "Family playlist", + "navigationEndpoint": {"browseEndpoint": {"browseId": "VLPL_family"}}, + } + ] + }, + "subtitle": { + "runs": [ + { + "text": "Owner", + "navigationEndpoint": {"browseEndpoint": {"browseId": "UC_owner"}}, + }, + {"text": " • "}, + {"text": "1 track"}, + ] + }, + "menu": { + "menuRenderer": { + "items": [ + { + "menuNavigationItemRenderer": { + "text": {"runs": [{"text": "Edit playlist"}]}, + "navigationEndpoint": {"playlistEditorEndpoint": {"playlistId": "PL_family"}}, + } + } + ] + } + }, +} + + +def test_parse_playlist_marks_playlist_with_editor_endpoint_as_owned(): + playlist = parse_playlist(OWNED_PLAYLIST) + + assert playlist["owned"] is True + + +def test_parse_playlist_marks_playlist_without_editor_endpoint_as_not_owned(): + saved_playlist = deepcopy(OWNED_PLAYLIST) + del saved_playlist["menu"] + + playlist = parse_playlist(saved_playlist) + + assert playlist["owned"] is False diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index b102f8a7..8bae1e6c 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -23,15 +23,16 @@ def get_library_playlists(self, limit: int | None = 25) -> JsonList: Retrieves the playlists in the user's library. :param limit: Number of playlists to retrieve. ``None`` retrieves them all. - :return: List of owned playlists. + :return: List of playlists in the user's library. Each item is in the following format:: { 'playlistId': 'PLQwVIlKxHM6rz0fDJVv_0UlXGEWf-bFys', 'title': 'Playlist title', - 'thumbnails: [...], - 'count': 5 + 'thumbnails': [...], + 'count': 5, + 'owned': True } """ self._check_auth() diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 305acb31..a37247b8 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -160,14 +160,30 @@ def parse_video(result: JsonDict) -> JsonDict: def parse_playlist(data: JsonDict) -> JsonDict: + playlist_id = nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:] + menu_items = nav(data, ["menu", "menuRenderer", "items"], True) or [] playlist = { "title": nav( data, TITLE_TEXT, none_if_absent=True, # rare but possible for playlist title to be missing ), - "playlistId": nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], + "playlistId": playlist_id, "thumbnails": nav(data, THUMBNAIL_RENDERER), + "owned": any( + nav( + item, + [ + "menuNavigationItemRenderer", + "navigationEndpoint", + "playlistEditorEndpoint", + "playlistId", + ], + True, + ) + == playlist_id + for item in menu_items + ), } subtitle = data["subtitle"] if "runs" in subtitle: From bf8b3ff65a1bef28e53daebdc613dde2f776b808 Mon Sep 17 00:00:00 2001 From: Seppe Gadeyne Date: Mon, 3 Aug 2026 10:25:53 +0200 Subject: [PATCH 2/4] Test saved playlist menus without edit endpoints --- tests/parsers/test_browsing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/parsers/test_browsing.py b/tests/parsers/test_browsing.py index 3cd95d59..d0759f93 100644 --- a/tests/parsers/test_browsing.py +++ b/tests/parsers/test_browsing.py @@ -45,7 +45,9 @@ def test_parse_playlist_marks_playlist_with_editor_endpoint_as_owned(): def test_parse_playlist_marks_playlist_without_editor_endpoint_as_not_owned(): saved_playlist = deepcopy(OWNED_PLAYLIST) - del saved_playlist["menu"] + saved_playlist["menu"]["menuRenderer"]["items"][0]["menuNavigationItemRenderer"]["navigationEndpoint"] = { + "watchPlaylistEndpoint": {"playlistId": "PL_family"} + } playlist = parse_playlist(saved_playlist) From e95d0201159dd7869d920d7a7f58860b8015408f Mon Sep 17 00:00:00 2001 From: Seppe Gadeyne Date: Mon, 3 Aug 2026 10:33:11 +0200 Subject: [PATCH 3/4] Document playlist count as a string --- ytmusicapi/mixins/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 8bae1e6c..9c837604 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -31,7 +31,7 @@ def get_library_playlists(self, limit: int | None = 25) -> JsonList: 'playlistId': 'PLQwVIlKxHM6rz0fDJVv_0UlXGEWf-bFys', 'title': 'Playlist title', 'thumbnails': [...], - 'count': 5, + 'count': '5', 'owned': True } """ From ba60df9cff6d99352c2f24c3e6b32303ddb25b85 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 8 Aug 2026 19:36:23 +0200 Subject: [PATCH 4/4] test: move OWNED_PLAYLIST fixture to tests/parsers/data.py --- tests/parsers/data.py | 33 ++++++++++++++++++++++++++++++++ tests/parsers/test_browsing.py | 35 +--------------------------------- 2 files changed, 34 insertions(+), 34 deletions(-) create mode 100644 tests/parsers/data.py diff --git a/tests/parsers/data.py b/tests/parsers/data.py new file mode 100644 index 00000000..caf98a77 --- /dev/null +++ b/tests/parsers/data.py @@ -0,0 +1,33 @@ +OWNED_PLAYLIST = { + "thumbnailRenderer": {"musicThumbnailRenderer": {"thumbnail": {"thumbnails": []}}}, + "title": { + "runs": [ + { + "text": "Family playlist", + "navigationEndpoint": {"browseEndpoint": {"browseId": "VLPL_family"}}, + } + ] + }, + "subtitle": { + "runs": [ + { + "text": "Owner", + "navigationEndpoint": {"browseEndpoint": {"browseId": "UC_owner"}}, + }, + {"text": " • "}, + {"text": "1 track"}, + ] + }, + "menu": { + "menuRenderer": { + "items": [ + { + "menuNavigationItemRenderer": { + "text": {"runs": [{"text": "Edit playlist"}]}, + "navigationEndpoint": {"playlistEditorEndpoint": {"playlistId": "PL_family"}}, + } + } + ] + } + }, +} diff --git a/tests/parsers/test_browsing.py b/tests/parsers/test_browsing.py index 62b94b17..2ad988ab 100644 --- a/tests/parsers/test_browsing.py +++ b/tests/parsers/test_browsing.py @@ -2,42 +2,9 @@ import pytest +from tests.parsers.data import OWNED_PLAYLIST from ytmusicapi.parsers.browsing import parse_playlist -OWNED_PLAYLIST = { - "thumbnailRenderer": {"musicThumbnailRenderer": {"thumbnail": {"thumbnails": []}}}, - "title": { - "runs": [ - { - "text": "Family playlist", - "navigationEndpoint": {"browseEndpoint": {"browseId": "VLPL_family"}}, - } - ] - }, - "subtitle": { - "runs": [ - { - "text": "Owner", - "navigationEndpoint": {"browseEndpoint": {"browseId": "UC_owner"}}, - }, - {"text": " • "}, - {"text": "1 track"}, - ] - }, - "menu": { - "menuRenderer": { - "items": [ - { - "menuNavigationItemRenderer": { - "text": {"runs": [{"text": "Edit playlist"}]}, - "navigationEndpoint": {"playlistEditorEndpoint": {"playlistId": "PL_family"}}, - } - } - ] - } - }, -} - def test_parse_playlist_marks_playlist_with_editor_endpoint_as_owned(): playlist = parse_playlist(OWNED_PLAYLIST)