Skip to content

Commit

Permalink
get_episodes_playlist (#561)
Browse files Browse the repository at this point in the history
* get_episodes_playlist

* fix minor issues
  • Loading branch information
sigma67 authored Mar 8, 2024
1 parent 90d172b commit f6932c2
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 137 deletions.
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Features
* get podcasts
* get episodes
* get channels
* get episodes playlists

| **Uploads**:
Expand Down
1 change: 1 addition & 0 deletions docs/source/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Podcasts
.. automethod:: YTMusic.get_channel_episodes
.. automethod:: YTMusic.get_podcast
.. automethod:: YTMusic.get_episode
.. automethod:: YTMusic.get_episodes_playlist

Uploads
-------
Expand Down
4 changes: 4 additions & 0 deletions tests/mixins/test_podcasts.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,7 @@ def test_many_episodes(self, yt):
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
44 changes: 5 additions & 39 deletions ytmusicapi/mixins/playlists.py
Original file line number Diff line number Diff line change
@@ -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 *
Expand Down Expand Up @@ -108,44 +108,9 @@ def get_playlist(
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)

Expand All @@ -154,6 +119,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):
parse_func = lambda results: parse_playlist_items(results)
suggested = request_func(additionalParams)
Expand Down
21 changes: 21 additions & 0 deletions ytmusicapi/mixins/podcasts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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 *
Expand Down Expand Up @@ -228,3 +229,23 @@ def get_episode(self, videoId: str) -> Dict:
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
238 changes: 141 additions & 97 deletions ytmusicapi/parsers/playlists.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
2 changes: 1 addition & 1 deletion ytmusicapi/parsers/podcasts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit f6932c2

Please sign in to comment.