Repository: sigma67/ytmusicapi (python)

Issue:
get_episodes_playlist
Hello, first of all, thank you for your work on this API. It's very much appreciated!

I am running into an issue that I'm not sure is PIBKAC, a bug, or a use case that just isn't valid. My oauth.json looks valid. I followed the steps in the documentation to generate it.

Here is my code, via a Python 3.11 interpreter, which is fairly simple:

```python
from ytmusicapi import YTMusic
ytmusic = YTMusic("oauth.json")

# Get latest podcasts from playlist
podcasts = ytmusic.get_playlist(playlistId='RDPN', limit=100, related=False, suggestions_limit=0)

# List each podcast
for podcast in podcasts['tracks']:
    print(podcast['title'])
```

I retrieved the playlistId in the following steps: 
- Log into YouTube Music
- Navigate to https://music.youtube.com/library/podcasts
- Click on "New Episodes"
- Get redirected to https://music.youtube.com/playlist?list=RDPN
- Copy the playlist ID of RDPN.

The error I'm receiving when I execute the script:

```error
Traceback (most recent call last):
  File "D:\code-projects\ytmusic-grabber\main.py", line 5, in <module>
    podcasts = ytmusic.get_playlist(playlistId='RDPN', limit=100, related=False, suggestions_limit=0)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\code-projects\ytmusic-grabber\.venv\Lib\site-packages\ytmusicapi\mixins\playlists.py", line 109, in get_playlist
    results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + ["musicPlaylistShelfRenderer"])
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "D:\code-projects\ytmusic-grabber\.venv\Lib\site-packages\ytmusicapi\navigation.py", line 103, in nav
    raise err
  File "D:\code-projects\ytmusic-grabber\.venv\Lib\site-packages\ytmusicapi\navigation.py", line 97, in nav
    root = root[k]
```

I've checked the documentation and as far as I can tell, this should work. Am I missing something that is obvious?



Pull request description:
get_episodes_playlist
closes #531 

Patch:
diff --git a/README.rst b/README.rst
index 42e534b..2b2efc3 100644
--- a/README.rst
+++ b/README.rst
@@ -63,6 +63,7 @@ Features
 * get podcasts
 * get episodes
 * get channels
+* get episodes playlists
 
 | **Uploads**:
 
diff --git a/docs/source/reference.rst b/docs/source/reference.rst
index 056eef6..d568663 100644
--- a/docs/source/reference.rst
+++ b/docs/source/reference.rst
@@ -81,6 +81,7 @@ Podcasts
 .. automethod:: YTMusic.get_channel_episodes
 .. automethod:: YTMusic.get_podcast
 .. automethod:: YTMusic.get_episode
+.. automethod:: YTMusic.get_episodes_playlist
 
 Uploads
 -------
diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py
index b59b46f..8dc7d16 100644
--- a/ytmusicapi/mixins/playlists.py
+++ b/ytmusicapi/mixins/playlists.py
@@ -1,7 +1,7 @@
 from typing import Any, Dict, List, Optional, Tuple, Union
 
 from ytmusicapi.continuations import *
-from ytmusicapi.helpers import sum_total_duration, to_int
+from ytmusicapi.helpers import sum_total_duration
 from ytmusicapi.navigation import *
 from ytmusicapi.parsers.browsing import parse_content_list, parse_playlist
 from ytmusicapi.parsers.playlists import *
@@ -108,44 +108,9 @@ class PlaylistsMixin(MixinProtocol):
         response = self._send_request(endpoint, body)
         results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + ["musicPlaylistShelfRenderer"])
         playlist = {"id": results["playlistId"]}
-        own_playlist = "musicEditablePlaylistDetailHeaderRenderer" in response["header"]
-        if not own_playlist:
-            header = response["header"]["musicDetailHeaderRenderer"]
-            playlist["privacy"] = "PUBLIC"
-        else:
-            header = response["header"]["musicEditablePlaylistDetailHeaderRenderer"]
-            playlist["privacy"] = header["editHeader"]["musicPlaylistEditHeaderRenderer"]["privacy"]
-            header = header["header"]["musicDetailHeaderRenderer"]
-        playlist["owned"] = own_playlist
-
-        playlist["title"] = nav(header, TITLE_TEXT)
-        playlist["thumbnails"] = nav(header, THUMBNAIL_CROPPED)
-        playlist["description"] = nav(header, DESCRIPTION, True)
-        run_count = len(nav(header, SUBTITLE_RUNS))
-        if run_count > 1:
-            playlist["author"] = {
-                "name": nav(header, SUBTITLE2),
-                "id": nav(header, [*SUBTITLE_RUNS, 2, *NAVIGATION_BROWSE_ID], True),
-            }
-            if run_count == 5:
-                playlist["year"] = nav(header, SUBTITLE3)
-
-        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(results["contents"])
-
-        playlist["trackCount"] = song_count
+        playlist.update(parse_playlist_header(response))
+        if playlist["trackCount"] is None:
+            playlist["trackCount"] = len(results["contents"])
 
         request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams)
 
@@ -154,6 +119,7 @@ class PlaylistsMixin(MixinProtocol):
         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):
                 parse_func = lambda results: parse_playlist_items(results)
                 suggested = request_func(additionalParams)
diff --git a/ytmusicapi/mixins/podcasts.py b/ytmusicapi/mixins/podcasts.py
index 012a9fd..ddff56e 100644
--- a/ytmusicapi/mixins/podcasts.py
+++ b/ytmusicapi/mixins/podcasts.py
@@ -4,6 +4,7 @@ from ytmusicapi.continuations import *
 from ytmusicapi.mixins._protocol import MixinProtocol
 from ytmusicapi.navigation import *
 from ytmusicapi.parsers.browsing import parse_content_list
+from ytmusicapi.parsers.playlists import parse_playlist_header
 from ytmusicapi.parsers.podcasts import *
 
 from ._utils import *
@@ -228,3 +229,23 @@ class PodcastsMixin(MixinProtocol):
         episode["description"] = Description.from_runs(description_runs)
 
         return episode
+
+    def get_episodes_playlist(self, playlist_id: str = "RDPN") -> Dict:
+        """
+        Get all episodes in an episodes playlist. Currently the only known playlist is the
+        "New Episodes" auto-generated playlist
+
+        :param playlist_id: Playlist ID, defaults to "RDPN", the id of the New Episodes playlist
+        :return: Dictionary in the format of :py:func:`get_podcast`
+        """
+        browseId = "VL" + playlist_id if not playlist_id.startswith("VL") else playlist_id
+        body = {"browseId": browseId}
+        endpoint = "browse"
+        response = self._send_request(endpoint, body)
+        playlist = parse_playlist_header(response)
+
+        results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF)
+        parse_func = lambda contents: parse_content_list(contents, parse_episode, MMRIR)
+        playlist["episodes"] = parse_func(results["contents"])
+
+        return playlist
diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py
index ebbb7b9..3e31af6 100644
--- a/ytmusicapi/parsers/playlists.py
+++ b/ytmusicapi/parsers/playlists.py
@@ -1,114 +1,158 @@
 from typing import List, Optional
 
+from ..helpers import to_int
 from .songs import *
 
 
+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"
+    else:
+        header = response["header"]["musicEditablePlaylistDetailHeaderRenderer"]
+        playlist["privacy"] = header["editHeader"]["musicPlaylistEditHeaderRenderer"]["privacy"]
+        header = header["header"]["musicDetailHeaderRenderer"]
+    playlist["owned"] = own_playlist
+
+    playlist["title"] = nav(header, TITLE_TEXT)
+    playlist["thumbnails"] = nav(header, THUMBNAIL_CROPPED)
+    playlist["description"] = nav(header, DESCRIPTION, True)
+    run_count = len(nav(header, SUBTITLE_RUNS))
+    if run_count > 1:
+        playlist["author"] = {
+            "name": nav(header, SUBTITLE2),
+            "id": nav(header, [*SUBTITLE_RUNS, 2, *NAVIGATION_BROWSE_ID], True),
+        }
+        if run_count == 5:
+            playlist["year"] = nav(header, SUBTITLE3)
+
+    playlist["views"] = None
+    playlist["duration"] = None
+    playlist["trackCount"] = 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
+        playlist["trackCount"] = song_count
+
+    return playlist
+
+
 def parse_playlist_items(results, menu_entries: Optional[List[List]] = None, is_album=False):
     songs = []
     for result in results:
         if MRLIR not in result:
             continue
         data = result[MRLIR]
+        song = parse_playlist_item(data, menu_entries, is_album)
+        if song:
+            songs.append(song)
 
-        videoId = setVideoId = None
-        like = None
-        feedback_tokens = None
-        library_status = None
-
-        # if the item has a menu, find its setVideoId
-        if "menu" in data:
-            for item in nav(data, MENU_ITEMS):
-                if "menuServiceItemRenderer" in item:
-                    menu_service = nav(item, MENU_SERVICE)
-                    if "playlistEditEndpoint" in menu_service:
-                        setVideoId = nav(
-                            menu_service, ["playlistEditEndpoint", "actions", 0, "setVideoId"], True
-                        )
-                        videoId = nav(
-                            menu_service, ["playlistEditEndpoint", "actions", 0, "removedVideoId"], True
-                        )
-
-                if TOGGLE_MENU in item:
-                    feedback_tokens = parse_song_menu_tokens(item)
-                    library_status = parse_song_library_status(item)
-
-        # if item is not playable, the videoId was retrieved above
-        if nav(data, PLAY_BUTTON, none_if_absent=True) is not None:
-            if "playNavigationEndpoint" in nav(data, PLAY_BUTTON):
-                videoId = nav(data, PLAY_BUTTON)["playNavigationEndpoint"]["watchEndpoint"]["videoId"]
-
-                if "menu" in data:
-                    like = nav(data, MENU_LIKE_STATUS, True)
-
-        title = get_item_text(data, 0)
-        if title == "Song deleted":
-            continue
-
-        flex_column_count = len(data["flexColumns"])
-
-        artists = parse_song_artists(data, 1)
-
-        album = parse_song_album(data, flex_column_count - 1) if not is_album else None
-
-        views = get_item_text(data, 2) if flex_column_count == 4 or is_album else None
-
-        duration = None
-        if "fixedColumns" in data:
-            if "simpleText" in get_fixed_column_item(data, 0)["text"]:
-                duration = get_fixed_column_item(data, 0)["text"]["simpleText"]
-            else:
-                duration = get_fixed_column_item(data, 0)["text"]["runs"][0]["text"]
-
-        thumbnails = None
-        if "thumbnail" in data:
-            thumbnails = nav(data, THUMBNAILS)
-
-        isAvailable = True
-        if "musicItemRendererDisplayPolicy" in data:
-            isAvailable = (
-                data["musicItemRendererDisplayPolicy"] != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT"
-            )
-
-        isExplicit = nav(data, BADGE_LABEL, True) is not None
-
-        videoType = nav(
-            data,
-            [*MENU_ITEMS, 0, MNIR, "navigationEndpoint", *NAVIGATION_VIDEO_TYPE],
-            True,
-        )
-
-        song = {
-            "videoId": videoId,
-            "title": title,
-            "artists": artists,
-            "album": album,
-            "likeStatus": like,
-            "inLibrary": library_status,
-            "thumbnails": thumbnails,
-            "isAvailable": isAvailable,
-            "isExplicit": isExplicit,
-            "videoType": videoType,
-            "views": views,
-        }
-
-        if is_album:
-            song["trackNumber"] = int(nav(data, ["index", "runs", 0, "text"])) if isAvailable else None
-
-        if duration:
-            song["duration"] = duration
-            song["duration_seconds"] = parse_duration(duration)
-        if setVideoId:
-            song["setVideoId"] = setVideoId
-        if feedback_tokens:
-            song["feedbackTokens"] = feedback_tokens
-
-        if menu_entries:
-            for menu_entry in menu_entries:
-                song[menu_entry[-1]] = nav(data, MENU_ITEMS + menu_entry)
+    return songs
 
-        songs.append(song)
 
-    return songs
+def parse_playlist_item(
+    data: Dict, menu_entries: Optional[List[List]] = None, is_album=False
+) -> Optional[Dict]:
+    videoId = setVideoId = None
+    like = None
+    feedback_tokens = None
+    library_status = None
+
+    # if the item has a menu, find its setVideoId
+    if "menu" in data:
+        for item in nav(data, MENU_ITEMS):
+            if "menuServiceItemRenderer" in item:
+                menu_service = nav(item, MENU_SERVICE)
+                if "playlistEditEndpoint" in menu_service:
+                    setVideoId = nav(menu_service, ["playlistEditEndpoint", "actions", 0, "setVideoId"], True)
+                    videoId = nav(
+                        menu_service, ["playlistEditEndpoint", "actions", 0, "removedVideoId"], True
+                    )
+
+            if TOGGLE_MENU in item:
+                feedback_tokens = parse_song_menu_tokens(item)
+                library_status = parse_song_library_status(item)
+
+    # if item is not playable, the videoId was retrieved above
+    if nav(data, PLAY_BUTTON, none_if_absent=True) is not None:
+        if "playNavigationEndpoint" in nav(data, PLAY_BUTTON):
+            videoId = nav(data, PLAY_BUTTON)["playNavigationEndpoint"]["watchEndpoint"]["videoId"]
+
+            if "menu" in data:
+                like = nav(data, MENU_LIKE_STATUS, True)
+
+    title = get_item_text(data, 0)
+    if title == "Song deleted":
+        return None
+
+    flex_column_count = len(data["flexColumns"])
+
+    artists = parse_song_artists(data, 1)
+
+    album = parse_song_album(data, flex_column_count - 1) if not is_album else None
+
+    views = get_item_text(data, 2) if flex_column_count == 4 or is_album else None
+
+    duration = None
+    if "fixedColumns" in data:
+        if "simpleText" in get_fixed_column_item(data, 0)["text"]:
+            duration = get_fixed_column_item(data, 0)["text"]["simpleText"]
+        else:
+            duration = get_fixed_column_item(data, 0)["text"]["runs"][0]["text"]
+
+    thumbnails = nav(data, THUMBNAILS, True)
+
+    isAvailable = True
+    if "musicItemRendererDisplayPolicy" in data:
+        isAvailable = data["musicItemRendererDisplayPolicy"] != "MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT"
+
+    isExplicit = nav(data, BADGE_LABEL, True) is not None
+
+    videoType = nav(
+        data,
+        [*MENU_ITEMS, 0, MNIR, "navigationEndpoint", *NAVIGATION_VIDEO_TYPE],
+        True,
+    )
+
+    song = {
+        "videoId": videoId,
+        "title": title,
+        "artists": artists,
+        "album": album,
+        "likeStatus": like,
+        "inLibrary": library_status,
+        "thumbnails": thumbnails,
+        "isAvailable": isAvailable,
+        "isExplicit": isExplicit,
+        "videoType": videoType,
+        "views": views,
+    }
+
+    if is_album:
+        song["trackNumber"] = int(nav(data, ["index", "runs", 0, "text"])) if isAvailable else None
+
+    if duration:
+        song["duration"] = duration
+        song["duration_seconds"] = parse_duration(duration)
+    if setVideoId:
+        song["setVideoId"] = setVideoId
+    if feedback_tokens:
+        song["feedbackTokens"] = feedback_tokens
+
+    if menu_entries:
+        for menu_entry in menu_entries:
+            song[menu_entry[-1]] = nav(data, MENU_ITEMS + menu_entry)
+
+    return song
 
 
 def validate_playlist_id(playlistId: str) -> str:
diff --git a/ytmusicapi/parsers/podcasts.py b/ytmusicapi/parsers/podcasts.py
index 20836ff..031975c 100644
--- a/ytmusicapi/parsers/podcasts.py
+++ b/ytmusicapi/parsers/podcasts.py
@@ -111,7 +111,7 @@ def parse_episode(data):
     videoId = nav(data, ["onTap", *WATCH_VIDEO_ID], True)
     browseId = nav(data, [*TITLE, *NAVIGATION_BROWSE_ID], True)
     videoType = nav(data, ["onTap", *NAVIGATION_VIDEO_TYPE], True)
-    index = nav(data, ["onTap", "watchEndpoint", "index"])
+    index = nav(data, ["onTap", "watchEndpoint", "index"], True)
     return {
         "index": index,
         "title": title,


Test patch:
diff --git a/tests/mixins/test_podcasts.py b/tests/mixins/test_podcasts.py
index 63935fc..9abb7cd 100644
--- a/tests/mixins/test_podcasts.py
+++ b/tests/mixins/test_podcasts.py
@@ -43,3 +43,7 @@ class TestPodcasts:
         for result in results:
             result = yt.get_episode(result["videoId"])
             assert len(result["description"].text) > 0
+
+    def test_get_episodes_playlist(self, yt_brand):
+        playlist = yt_brand.get_episodes_playlist()
+        assert len(playlist["episodes"]) > 90