diff --git a/README.md b/README.md index 96f68f0..649ec25 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ This package being used in production in one of my applications, I will do my be **Browsing**: * [x] search (including all filters) -* [ ] get artist information and releases (songs, videos, albums, singles) -* [ ] get user information (videos, playlists) -* [ ] get albums -* [ ] get song metadata -* [ ] get watch playlists (playlist that appears when you press play in YouTube Music) +* [x] get artist information and releases (songs, videos, albums, singles) +* [x] get user information (videos, playlists) +* [x] get albums +* [x] get song metadata +* [x] get watch playlists (playlist that appears when you press play in YouTube Music) ## Requirements @@ -49,9 +49,43 @@ php artisan vendor:publish --provider="MGKProd\YTMusic\YTMusicServiceProvider" - ``` php use MGKProd\YTMusic\Facades\YTMusic; -$results = YTMusic::search('daft punk'); -$artists = YTMusic::search('magenta', 'artists'); -$songs = YTMusic::search('blizzard oddscure', 'songs'); +// search in all +$results = YTMusic::browse()->search('daft punk'); + +// filtered search (albums, artists, playlists, songs or videos) +$artists = YTMusic::browse()->search('magenta', 'artists'); +$songs = YTMusic::browse()->search('blizzard oddscure', 'songs'); + +// artist +$artist = YTMusic::browse()->artist('MPLAUCmMUZbaYdNH0bEd1PAlAqsA'); + +// ... and his albums +$albums = YTMusic::browse()->artistAlbums( + $artist['albums']['browseId'], + $artist['albums']['params'] +); + +// ... or his singles +$singles = YTMusic::browse()->artistAlbums( + $artist['singles']['browseId'], + $artist['singles']['params'] +); + +// album +$album = YTMusic::browse()->album('MPREb_BQZvl3BFGay'); + +// song +$song = YTMusic::browse()->song('ZrOKjDZOtkA'); + +// user +$user = YTMusic::browse()->user('UCPVhZsC2od1xjGhgEc2NEPQ'); + +// ... and his playlists +$playlists = YTMusic::browse()->userPlaylists( + 'UCPVhZsC2od1xjGhgEc2NEPQ', + $user['playlists']['params'] +); + ``` The [tests](./tests/BrowsingTest.php) are also a great source of usage examples. diff --git a/src/Mixins/Browsing.php b/src/Mixins/Browsing.php index 9b430e2..13e585d 100644 --- a/src/Mixins/Browsing.php +++ b/src/Mixins/Browsing.php @@ -3,6 +3,8 @@ namespace MGKProd\YTMusic\Mixins; use Exception; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; use MGKProd\YTMusic\Parsers\Browsing as ParsersBrowsing; use MGKProd\YTMusic\Parsers\Paths; use MGKProd\YTMusic\Parsers\Utils; @@ -23,7 +25,7 @@ public function search($query, $filter = null, $limit = 20, $ignore_spelling = f $body = ['query' => $query]; $endpoint = 'search'; $search_results = []; - $filters = ['albums', 'artists', 'playlists', 'songs', 'videos', 'uploads']; + $filters = ['albums', 'artists', 'playlists', 'songs', 'videos']; if ($filter and ! in_array($filter, $filters)) { throw new Exception('Invalid filter provided. Please use one of the following filters or leave out the parameter: ' . ', ' . implode(', ', $filters)); @@ -38,25 +40,18 @@ public function search($query, $filter = null, $limit = 20, $ignore_spelling = f $param3 = 'MABCAggBagoQBBADEAkQBRAK'; } - if ($filter == 'uploads') { - $params = 'agIYAw%3D%3D'; + if ($filter == 'videos') { + $param2 = 'BABGAAgACgA'; + } elseif ($filter == 'albums') { + $param2 = 'BAAGAEgACgA'; + } elseif ($filter == 'artists') { + $param2 = 'BAAGAAgASgA'; + } elseif ($filter == 'playlists') { + $param2 = 'BAAGAAgACgB'; } else { - if ($filter == 'videos') { - $param2 = 'BABGAAgACgA'; - } elseif ($filter == 'albums') { - $param2 = 'BAAGAEgACgA'; - } elseif ($filter == 'artists') { - $param2 = 'BAAGAAgASgA'; - } elseif ($filter == 'playlists') { - $param2 = 'BAAGAAgACgB'; - } elseif ($filter == 'uploads') { - // self.__check_auth() - // $param2 = 'RABGAEgASgB'; - } else { - $param2 = 'RAAGAAgACgA'; - } - $params = $param1 . $param2 . $param3; + $param2 = 'RAAGAAgACgA'; } + $params = $param1 . $param2 . $param3; $body['params'] = $params; } elseif ($ignore_spelling) { @@ -109,4 +104,338 @@ public function search($query, $filter = null, $limit = 20, $ignore_spelling = f return $search_results; } + + public function artist($channelId) + { + if (Str::startsWith($channelId, 'MPLA')) { + $channelId = substr($channelId, 4); + } + + $body = Utils::prepareBrowseEndpoint('ARTIST', $channelId); + $endpoint = 'browse'; + + $response = $this->ytmusic->_send_request($endpoint, $body); + + $results = data_get($response, Paths::SINGLE_COLUMN_TAB . '.' . Paths::SECTION_LIST); + + $artist = [ + 'description' => null, + 'views' => null, + ]; + + $header = $response['header']['musicImmersiveHeaderRenderer']; + + $artist['name'] = data_get($header, Paths::TITLE_TEXT); + + $description_shelf = Utils::findObjectByKey($results, 'musicDescriptionShelfRenderer', null, true); + if ($description_shelf) { + $artist['description'] = $description_shelf['description']['runs'][0]['text']; + + if (! array_key_exists('subheader', $description_shelf)) { + $artist['views'] = null; + } else { + $artist['views'] = $description_shelf['subheader']['runs'][0]['text']; + } + } + + $subscription_button = $header['subscriptionButton']['subscribeButtonRenderer']; + $artist['channelId'] = $subscription_button['channelId']; + $artist['subscribers'] = data_get($subscription_button, 'subscriberCountText.runs.0.text'); + $artist['subscribed'] = $subscription_button['subscribed']; + $artist['thumbnails'] = data_get($header, Paths::THUMBNAILS); + $artist['songs'] = ['browseId' => null]; + + if (array_key_exists('musicShelfRenderer', $results[0])) { + // API sometimes does not return songs + + $musicShelf = data_get($results, Paths::MUSIC_SHELF); + + if (array_key_exists('navigationEndpoint', data_get($musicShelf, Paths::TITLE))) { + $artist['songs']['browseId'] = data_get($musicShelf, Paths::TITLE . '.' . Paths::NAVIGATION_BROWSE_ID); + } + + $artist['songs']['results'] = $this->parser->parsePlaylistItems($musicShelf['contents']); + } + + $artist = array_merge($artist, $this->parser->parseArtistContents($results)); + + return $artist; + } + + public function artistAlbums($channelId, $params) + { + $body = ['browseId' => $channelId, 'params' => $params]; + $endpoint = 'browse'; + + $response = $this->ytmusic->_send_request($endpoint, $body); + + $artist = data_get($response['header']['musicHeaderRenderer'], Paths::TITLE_TEXT); + $results = data_get($response, Paths::SINGLE_COLUMN_TAB . '.' . Paths::SECTION_LIST . '.' . Paths::MUSIC_SHELF); + + $albums = []; + $release_type = mb_strtolower(data_get($results, Paths::TITLE_TEXT)); + + foreach ($results['contents'] as $result) { + $data = $result['musicResponsiveListItemRenderer']; + $browseId = data_get($data, Paths::NAVIGATION_BROWSE_ID); + $title = Utils::getItemText($data, 0); + $thumbnails = data_get($data, Paths::THUMBNAILS); + + if ($release_type == 'albums') { + $album_type = Utils::getItemText($data, 1); + $year = Utils::getItemText($data, 1, 2); + } else { + $album_type = 'Single'; + $year = Utils::getItemText($data, 1, 0); + } + + $albums[] = [ + 'browseId' => $browseId, + 'artist' => $artist, + 'title' => $title, + 'thumbnails' => $thumbnails, + 'type' => $album_type, + 'year' => $year, + ]; + } + + return $albums; + } + + public function user($channelId) + { + $endpoint = 'browse'; + $body = ['browseId' => $channelId]; + $response = $this->ytmusic->_send_request($endpoint, $body); + + $user = ['name' => data_get($response, 'header.musicVisualHeaderRenderer.' . Paths::TITLE_TEXT)]; + $results = data_get($response, Paths::SINGLE_COLUMN_TAB . '.' . Paths::SECTION_LIST); + + $user = array_merge($user, $this->parser->parseArtistContents($results)); + + return $user; + } + + public function userPlaylists($channelId, $params) + { + $endpoint = 'browse'; + $body = ['browseId' => $channelId, 'params' => $params]; + $response = $this->ytmusic->_send_request($endpoint, $body); + + $data = data_get($response, Paths::SINGLE_COLUMN_TAB . '.' . Paths::SECTION_LIST . '.' . Paths::MUSIC_SHELF); + $user_playlists = []; + + foreach ($data['contents'] as $result) { + $data = $result['musicResponsiveListItemRenderer']; + $user_playlists[] = [ + 'browseId' => data_get($data, Paths::NAVIGATION_BROWSE_ID), + 'title' => Utils::getItemText($data, 0), + 'thumbnails' => data_get($data, Paths::THUMBNAILS), + ]; + } + + return $user_playlists; + } + + public function album($browseId) + { + $body = Utils::prepareBrowseEndpoint('ALBUM', $browseId); + $endpoint = 'browse'; + $response = $this->ytmusic->_send_request($endpoint, $body); + $data = data_get($response, Paths::FRAMEWORK_MUTATIONS); + + $album = []; + $album_data = Utils::findObjectByKey($data, 'musicAlbumRelease', 'payload', true); + $album['title'] = $album_data['title']; + $album['trackCount'] = $album_data['trackCount']; + $album['durationMs'] = $album_data['durationMs']; + $album['playlistId'] = $album_data['audioPlaylistId']; + $album['releaseDate'] = $album_data['releaseDate']; + $album['description'] = Utils::findObjectByKey($data, 'musicAlbumReleaseDetail', 'payload', true)['description']; + + $album['thumbnails'] = $album_data['thumbnailDetails']['thumbnails']; + + $album['artist'] = []; + $artists_data = Utils::findObjectsByKey($data, 'musicArtist', 'payload'); + foreach ($artists_data as $artist) { + $album['artist'][] = [ + 'name' => $artist['musicArtist']['name'], + 'id' => $artist['musicArtist']['externalChannelId'], + ]; + } + $album['tracks'] = []; + + foreach (array_slice($data, 3) as $item) { + if (array_key_exists('musicTrack', $item['payload'])) { + $track = []; + $track['index'] = $item['payload']['musicTrack']['albumTrackIndex']; + $track['title'] = $item['payload']['musicTrack']['title']; + $track['thumbnails'] = $item['payload']['musicTrack']['thumbnailDetails']['thumbnails']; + $track['artists'] = $item['payload']['musicTrack']['artistNames']; + + // in case the song is unavailable, there is no videoId + if (array_key_exists('videoId', $item['payload']['musicTrack'])) { + $track['videoId'] = $item['payload']['musicTrack']['videoId']; + } else { + $track['videoId'] = null; + } + + // very occasionally lengthMs is not returned + if (array_key_exists('lengthMs', $item['payload']['musicTrack'])) { + $track['lengthMs'] = $item['payload']['musicTrack']['videoId']; + } else { + $track['lengthMs'] = null; + } + + $album['tracks'][] = $track; + } + } + + return $album; + } + + public function song($videoId) + { + $endpoint = 'https://www.youtube.com/get_video_info'; + $params = ['video_id' => $videoId, 'hl' => 'fr', 'el' => 'detailpage']; + + $response = Http::asJson() + ->withHeaders($this->ytmusic->headers) + ->get($endpoint . '?' . http_build_query($params)); + + parse_str($response->body(), $text); + + if (! array_key_exists('player_response', $text)) { + return $text; + } + + $player_response = json_decode($text['player_response'], true); + + $song_meta = $player_response['videoDetails']; + $song_meta['category'] = $player_response['microformat']['playerMicroformatRenderer']['category']; + if (Str::endsWith($song_meta['shortDescription'], 'Auto-generated by YouTube.')) { + try { + $description = explode("\n\n", $song_meta['shortDescription']); + foreach ($description as $i => $detail) { + $description[$i] = utf8_decode($detail); + } + + $song_meta['provider'] = str_replace('Provided to YouTube by ', '', $description[0]); + // $song_meta['artists'] = [artist for artist in description[1].split(' ยท ')[1:]] + $song_meta['copyright'] = $description[3]; + // $song_meta['release'] = None if len(description) < 5 else description[4].replace('Released on: ', '') + // song_meta['production'] = None if len(description) < 6 else [ pub for pub in description[5].split('\n') ] + } catch (\Throwable $th) { + } + } + + return $song_meta; + } + + public function streamingData($videoId) + { + $endpoint = 'https://www.youtube.com/get_video_info'; + $params = [ + 'video_id' => $videoId, + 'hl' => 'fr', + 'el' => 'detailpage', + 'c' => 'WEB_REMIX', + 'cver' => '0.1', + ]; + + $response = Http::asJson() + ->withHeaders($this->ytmusic->headers) + ->get($endpoint . '?' . http_build_query($params)); + + parse_str($response->body(), $text); + + if (! array_key_exists('player_response', $text)) { + return $text; + } + + $player_response = json_decode($text['player_response'], true); + + if (! array_key_exists('streamingData', $player_response)) { + throw new Exception('This video is not playable.', 1); + } + + return $player_response['streamingData']; + } + + public function lyrics($browseId) + { + $lyrics = []; + $response = $this->ytmusic->_send_request('browse', ['browseId' => $browseId]); + + if (array_key_exists('sectionListRenderer', $response['contents'])) { + $lyrics['lyricsFound'] = true; + $lyrics['lyrics'] = $response['contents']['sectionListRenderer']['contents'][0]['musicDescriptionShelfRenderer']['description']['runs'][0]['text']; + $lyrics['source'] = $response['contents']['sectionListRenderer']['contents'][0]['musicDescriptionShelfRenderer']['footer']['runs'][0]['text']; + } else { + $lyrics['lyricsFound'] = false; + $lyrics['lyrics'] = $response['contents']['messageRenderer']['subtext']['messageSubtextRenderer']['text']['runs'][0]['text']; + $lyrics['source'] = $response['contents']['messageRenderer']['text']['runs'][0]['text']; + } + + return $lyrics; + } + + public function watchPlaylist($videoId = null, $playlistId = null, $limit = 25, $params = []) + { + $body = ['enablePersistentPlaylistPanel' => true, 'isAudioOnly' => true]; + + if ($videoId) { + $body['videoId'] = $videoId; + $body['watchEndpointMusicSupportedConfigs'] = [ + 'watchEndpointMusicConfig' => [ + 'hasPersistentPlaylistPanel' => true, + 'musicVideoType' => 'MUSIC_VIDEO_TYPE_OMV', + ], + ]; + } + + $is_playlist = false; + + if ($playlistId) { + $body['playlistId'] = $playlistId; + $is_playlist = Str::startsWith($playlistId, 'PL'); + } + + if ($params) { + $body['params'] = $params; + } + + $endpoint = 'next'; + $response = $this->ytmusic->_send_request($endpoint, $body); + + $watchNextRenderer = data_get($response, 'contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer'); + + $lyrics_browse_id = null; + if (count($watchNextRenderer['tabs']) > 1) { + $lyrics_browse_id = $watchNextRenderer['tabs'][1]['tabRenderer']['endpoint']['browseEndpoint']['browseId']; + } + + $results = data_get($watchNextRenderer, Paths::TAB_CONTENT . '.musicQueueRenderer.content.playlistPanelRenderer'); + $tracks = $this->parser->parseWatchPlaylist($results['contents']); + + if (array_key_exists('continuations', $results)) { + $continuations = Utils::getContinuations( + $results, + 'playlistPanelContinuation', + $limit - count($tracks), + fn ($additional_params) => $this->ytmusic->_send_request($endpoint, $body, $additional_params), + fn ($results) => $this->parser->parseWatchPlaylist($results), + $is_playlist ? '' : 'Radio' + ); + + $tracks = array_merge($tracks, $continuations); + } + + return ['tracks' => $tracks, 'lyrics' => $lyrics_browse_id]; + } + + public function watchPlaylistShuffle($playlistId, $limit = 50) + { + return $this->watchPlaylist(null, $playlistId, $limit, 'wAEB8gECGAE%3D'); + } } diff --git a/src/Parsers/Browsing.php b/src/Parsers/Browsing.php index ce4c21d..73be5eb 100644 --- a/src/Parsers/Browsing.php +++ b/src/Parsers/Browsing.php @@ -72,57 +72,10 @@ public function parseSearchResults($results, $result_type = null) ]; $search_result['duration'] = $runs[count($runs) - 1]['text'] ?? null; - - // if (array_key_exists('menu', $data)) { - // $toggle_menu = find_object_by_key(data_get($data, Path::MENU_ITEMS), 'toggleMenuServiceItemRenderer') - // if ($toggle_menu) { - // $search_result['feedbackTokens'] = parse_song_menu_tokens(toggle_menu) - // } - // } } } elseif (in_array($result_type, ['video'])) { $search_result['views'] = explode(' ', Utils::getItemText($data, 1, -3))[0]; $search_result['duration'] = Utils::getItemText($data, 1, count($runs) - 1); - } elseif (in_array($result_type, ['upload'])) { - $search_result['title'] = Utils::getItemText($data, 0); - - $browse_id = data_get($data, Paths::NAVIGATION_BROWSE_ID); - if (! $browse_id) { - // song result - // flex_items = [ - // data_get($Utils::getFlexColumnItem(data, i), ['text', 'runs'], True) - // for i in range(2) - // ] - // if flex_items[0]: - // search_result['videoId'] = data_get($flex_items[0][0], NAVIGATION_VIDEO_ID) - // search_result['playlistId'] = data_get($flex_items[0][0], NAVIGATION_PLAYLIST_ID) - // if flex_items[1]: - // search_result['artist'] = { - // 'name': flex_items[1][0]['text'], - // 'id': data_get($flex_items[1][0], NAVIGATION_BROWSE_ID) - // } - // search_result['album'] = { - // 'name': flex_items[1][2]['text'], - // 'id': data_get($flex_items[1][2], NAVIGATION_BROWSE_ID) - // } - // search_result['duration'] = flex_items[1][4]['text'] - // search_result['resultType'] = 'song' - } else { - // artist or album result - // $search_result['browseId'] = $browse_id; - // if 'artist' in search_result['browseId']: - // search_result['resultType'] = 'artist' - // else: - // flex_item2 = Utils::getFlexColumnItem(data, 1) - // runs = [ - // run['text'] for i, run in enumerate(flex_item2['text']['runs']) - // if i % 2 == 0 - // ] - // search_result['artist'] = runs[1] - // if len(runs) > 2: # date may be missing - // search_result['releaseDate'] = runs[2] - // search_result['resultType'] = 'album' - } } $search_result['thumbnails'] = data_get($data, Paths::THUMBNAILS); @@ -133,42 +86,84 @@ public function parseSearchResults($results, $result_type = null) return $search_results; } - protected function parseArtistContents($results) + public function parseWatchPlaylist($results) { - $categories = ['albums', 'singles', 'videos', 'playlists']; - $categories_local = [trans('ytmusicapi::types.albums'), trans('ytmusicapi::types.singles'), trans('ytmusicapi::types.videos'), trans('ytmusicapi::types.playlists')]; - $categories_parser = ['parseAlbum', 'parseSingle', 'parseVideo', 'parsePlaylist']; + $tracks = []; - $artist = []; + foreach ($results as $result) { + if (! array_key_exists('playlistPanelVideoRenderer', $result)) { + continue; + } - foreach ($categories as $category) { - $data = [ - // r['musicCarouselShelfRenderer'] for r in results - // if 'musicCarouselShelfRenderer' in r - // and data_get($r['musicCarouselShelfRenderer'], - // CAROUSEL_TITLE)['text'].lower() == categories_local[i] + $data = $result['playlistPanelVideoRenderer']; + + if (array_key_exists('unplayableText', $data)) { + continue; + } + + $track = [ + 'title' => data_get($data, Paths::TITLE_TEXT), + 'byline' => data_get($data, 'shortBylineText.runs.0.text'), + 'length' => data_get($data, 'lengthText.runs.0.text'), + 'videoId' => $data['videoId'], + 'playlistId' => data_get($data, Paths::NAVIGATION_PLAYLIST_ID), + 'thumbnail' => data_get($data, Paths::THUMBNAIL), ]; + $tracks[] = $track; + } + + return $tracks; + } + + public function parseArtistContents($results) + { + $categories = [ + trans('ytmusicapi::types.albums') => 'albums', + trans('ytmusicapi::types.singles') => 'singles', + trans('ytmusicapi::types.videos') => 'videos', + trans('ytmusicapi::types.playlists') => 'playlists', + ]; + + $categories_parser = [ + 'albums' => 'parseAlbum', + 'singles' => 'parseSingle', + 'videos' => 'parseVideo', + 'playlists' => 'parsePlaylist', + ]; + + $artist = []; + + foreach ($categories as $trans => $category) { + $data = []; + + foreach ($results as $result) { + if ( + array_key_exists('musicCarouselShelfRenderer', $result) + && mb_strtolower(data_get($result['musicCarouselShelfRenderer'], Paths::CAROUSEL_TITLE)['text']) == $trans + ) { + $data[] = $result['musicCarouselShelfRenderer']; + } + } + if (count($data)) { - // $artist[$category] = ['browseId' => 'None', 'results': []]; - // if 'navigationEndpoint' in data_get($data[0], CAROUSEL_TITLE) { - // artist[category]['browseId'] = data_get($data[0], - // CAROUSEL_TITLE + NAVIGATION_BROWSE_ID) - // if category in ['albums', 'singles', 'playlists']: - // artist[category]['params'] = data_get($ - // data[0], - // CAROUSEL_TITLE)['navigationEndpoint']['browseEndpoint']['params'] - - // artist[category]['results'] = parse_content_list(data[0]['contents'], - // categories_parser[i]) - // } + $artist[$category] = ['browseId' => null, 'results' => []]; + + if (array_key_exists('navigationEndpoint', data_get($data[0], Paths::CAROUSEL_TITLE))) { + $artist[$category]['browseId'] = data_get($data[0], Paths::CAROUSEL_TITLE . '.' . Paths::NAVIGATION_BROWSE_ID); + if (in_array($category, ['albums', 'singles', 'playlists'])) { + $artist[$category]['params'] = data_get($data[0], Paths::CAROUSEL_TITLE)['navigationEndpoint']['browseEndpoint']['params']; + } + } + + $artist[$category]['results'] = $this->parseContentList($data[0]['contents'], $categories_parser[$category]); } } return $artist; } - protected function parseContentList($results, $parse_func) + public function parseContentList($results, $parse_func) { $contents = []; @@ -179,27 +174,27 @@ protected function parseContentList($results, $parse_func) return $contents; } - protected function parseAlbum($result) + public function parseAlbum($result) { return [ 'title' => data_get($result, Paths::TITLE_TEXT), 'year' => data_get($result, Paths::SUBTITLE2), - 'browseId' => data_get($result, Paths::TITLE . Parse::NAVIGATION_BROWSE_ID), + 'browseId' => data_get($result, Paths::TITLE . '.' . Paths::NAVIGATION_BROWSE_ID), 'thumbnails' => data_get($result, Paths::THUMBNAIL_RENDERER), ]; } - protected function parseSingle($result) + public function parseSingle($result) { return [ 'title' => data_get($result, Paths::TITLE_TEXT), 'year' => data_get($result, Paths::SUBTITLE, true), - 'browseId' => data_get($result, Paths::TITLE . Parse::NAVIGATION_BROWSE_ID), + 'browseId' => data_get($result, Paths::TITLE . '.' . Paths::NAVIGATION_BROWSE_ID), 'thumbnails' => data_get($result, Paths::THUMBNAIL_RENDERER), ]; } - protected function parseVideo($result) + public function parseVideo($result) { $video = [ 'title' => data_get($result, Paths::TITLE_TEXT), @@ -215,11 +210,11 @@ protected function parseVideo($result) return $video; } - protected function parsePlaylist($data) + public function parsePlaylist($data) { $playlist = [ 'title' => data_get($data, Paths::TITLE_TEXT), - 'playlistId' => data_get($data, Paths::TITLE + Paths::NAVIGATION_BROWSE_ID)[2], + 'playlistId' => data_get($data, Paths::TITLE . '.' . Paths::NAVIGATION_BROWSE_ID)[2], 'thumbnails' => data_get($data, Paths::THUMBNAIL_RENDERER), ]; @@ -229,4 +224,107 @@ protected function parsePlaylist($data) return $playlist; } + + public function parsePlaylistItems($results, $menu_entries = null) + { + $songs = []; + $count = 1; + + foreach ($results as $result) { + $count++; + if (! array_key_exists('musicResponsiveListItemRenderer', $result)) { + continue; + } + + $data = $result['musicResponsiveListItemRenderer']; + + try { + $videoId = $setVideoId = null; + $like = null; + $feedback_tokens = null; + + // if the item has a menu, find its setVideoId + if (array_key_exists('menu', $data)) { + foreach (data_get($data, Paths::MENU_ITEMS, []) as $item) { + if (array_key_exists('menuServiceItemRenderer', $item)) { + $menu_service = data_get($item, Paths::MENU_SERVICE); + if (array_key_exists('playlistEditEndpoint', $menu_service)) { + $setVideoId = $menu_service['playlistEditEndpoint']['actions'][0]['setVideoId']; + $videoId = $menu_service['playlistEditEndpoint']['actions'][0]['removedVideoId']; + } + } + + if (array_key_exists('toggleMenuServiceItemRenderer', $item)) { + $feedback_tokens = Utils::parseSongMenuTokens($item); + } + } + } + + // if item is not playable, the videoId was retrieved above + if (array_key_exists('playNavigationEndpoint', data_get($data, Paths::PLAY_BUTTON))) { + $videoId = data_get($data, Paths::PLAY_BUTTON)['playNavigationEndpoint']['watchEndpoint']['videoId']; + + if (array_key_exists('menu', $data)) { + $like = data_get($data, Paths::MENU_LIKE_STATUS); + } + } + $title = Utils::getItemText($data, 0); + if ($title == 'Song deleted') { + continue; + } + + $artists = Utils::parseSongArtists($data, 1); + $album = Utils::parseSongAlbum($data, 2); + + $duration = null; + if (array_key_exists('fixedColumns', $data)) { + if (array_key_exists('simpleText', Utils::getFixedColumnItem($data, 0)['text'])) { + $duration = Utils::getFixedColumnItem($data, 0)['text']['simpleText']; + } else { + $duration = Utils::getFixedColumnItem($data, 0)['text']['runs'][0]['text']; + } + } + + $thumbnails = null; + if (array_key_exists('thumbnails', $data)) { + $thumbnails = data_get($data, Paths::THUMBNAILS); + } + + $isAvailable = true; + if (array_key_exists('musicItemRendererDisplayPolicy', $data)) { + $isAvailable = $data['musicItemRendererDisplayPolicy'] != 'MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT'; + } + + $song = [ + 'videoId' => $videoId, + 'title' => $title, + 'artists' => $artists, + 'album' => $album, + 'likeStatus' => $like, + 'thumbnails' => $thumbnails, + 'isAvailable' => $isAvailable, + ]; + + if ($duration) { + $song['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) + + $songs[] = $song; + } catch (\Throwable $th) { + throw $th; + } + } + + return $songs; + } } diff --git a/src/Parsers/Utils.php b/src/Parsers/Utils.php index 39b9c74..1c3cae5 100644 --- a/src/Parsers/Utils.php +++ b/src/Parsers/Utils.php @@ -33,6 +33,65 @@ public static function getItemText($item, $index, $run_index = 0, $null_if_absen return $column['text']['runs'][$run_index]['text'] ?? null; } + public static function getFixedColumnItem($item, $index) + { + if ( + ! array_key_exists('text', $item['fixedColumns'][$index]['musicResponsiveListItemFixedColumnRenderer']) + && ! array_key_exists('runs', $item['fixedColumns'][$index]['musicResponsiveListItemFixedColumnRenderer']['text']) + ) { + return null; + } + + return $item['fixedColumns'][$index]['musicResponsiveListItemFixedColumnRenderer']; + } + + public static function getBrowseId($item, $index) + { + if ( + ! array_key_exists('navigationEndpoint', $item['text']['runs'][$index]) + ) { + return null; + } + + return data_get($item['text']['runs'][$index], Paths::NAVIGATION_BROWSE_ID); + } + + public static function parseSongAlbum($data, $index) + { + $column = self::getFlexColumnItem($data, $index); + + if (! $column) { + return null; + } + + return [ + 'name' => self::getItemText($data, $index), + 'id' => self::getBrowseId($column, 0), + ]; + } + + public static function parseSongMenuTokens($item) + { + $library_add_token = $library_remove_token = null; + $toggle_menu = $item['toggleMenuServiceItemRenderer']; + $service_type = $toggle_menu['defaultIcon']['iconType']; + + if ($service_type == 'LIBRARY_ADD') { + $library_add_token = data_get($toggle_menu, 'defaultServiceEndpoint' . '.' . Paths::FEEDBACK_TOKEN); + $library_remove_token = data_get($toggle_menu, 'toggledServiceEndpoint' . '.' . Paths::FEEDBACK_TOKEN); + } elseif ($service_type == 'LIBRARY_REMOVE') { + // swap if already in library + $old_library_add_token = $library_add_token; + $library_add_token = $library_remove_token; + $library_remove_token = $old_library_add_token; + } + + return [ + 'add' => $library_add_token, + 'remove' => $library_remove_token, + ]; + } + public static function parseSongArtists($data, $index) { $flex_item = self::getFlexColumnItem($data, $index); @@ -152,4 +211,51 @@ public static function validateResponse($response, $per_page, $limit, $current_c // # response is invalid, if it has less items then minimal expected count // return len(response['parsed']) >= expected_items_count } + + public static function prepareBrowseEndpoint($type, $browseId) + { + return [ + 'browseEndpointContextSupportedConfigs' => [ + 'browseEndpointContextMusicConfig' => [ + 'pageType' => 'MUSIC_PAGE_TYPE_' . $type, + ], + ], + 'browseId' => $browseId, + ]; + } + + public static function findObjectByKey($object_list, $key, $nested = null, $is_key = false) + { + foreach ($object_list as $item) { + if ($nested) { + $item = $item[$nested]; + } + + if (array_key_exists($key, $item)) { + if ($is_key) { + return $item[$key]; + } else { + return $item; + } + } + } + + return null; + } + + public static function findObjectsByKey($object_list, $key, $nested = null) + { + $objects = []; + + foreach ($object_list as $item) { + if ($nested) { + $item = $item[$nested]; + } + if (array_key_exists($key, $item)) { + $objects[] = $item; + } + } + + return $objects; + } } diff --git a/src/YTMusic.php b/src/YTMusic.php index 3966bfd..24bb2e6 100644 --- a/src/YTMusic.php +++ b/src/YTMusic.php @@ -12,7 +12,7 @@ class YTMusic const PARAMS = '?alt=json&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30'; const BASE_URL = 'https://music.youtube.com/youtubei/v1/'; - protected $headers; + public $headers; protected $context; public function __construct() @@ -21,9 +21,9 @@ public function __construct() $this->context = json_decode(File::get(__DIR__ . '/context.json'), true); } - public function search($query, $filter = null, $limit = 20, $ignore_spelling = false) + public function browse() { - return (new Browsing($this))->search($query, $filter, $limit, $ignore_spelling); + return new Browsing($this); } public function _send_request($endpoint, $body, $additional_params = null) diff --git a/tests/BrowsingTest.php b/tests/BrowsingTest.php index ceb2645..a53ee50 100644 --- a/tests/BrowsingTest.php +++ b/tests/BrowsingTest.php @@ -12,14 +12,14 @@ public function test_search_fails() $query = 'The Weekend'; $this->expectException(Exception::class); - YTMusic::search($query, 'song'); + YTMusic::browse()->search($query, 'song'); } public function test_search() { $query = 'Daft Punk'; - $results = YTMusic::search($query); + $results = YTMusic::browse()->search($query); $this->assertGreaterThan(10, count($results)); } @@ -27,19 +27,19 @@ public function test_search_filters() { $query = 'Oasis Wonderwall'; - $results = YTMusic::search($query, 'songs'); + $results = YTMusic::browse()->search($query, 'songs'); $this->assertGreaterThan(10, count($results)); - $results = YTMusic::search($query, 'videos'); + $results = YTMusic::browse()->search($query, 'videos'); $this->assertGreaterThan(5, count($results)); - $results = YTMusic::search($query, 'albums', 40); + $results = YTMusic::browse()->search($query, 'albums', 40); $this->assertGreaterThan(20, count($results)); - $results = YTMusic::search($query, 'artists'); + $results = YTMusic::browse()->search($query, 'artists'); $this->assertGreaterThan(0, count($results)); - $results = YTMusic::search($query, 'playlists'); + $results = YTMusic::browse()->search($query, 'playlists'); $this->assertGreaterThan(5, count($results)); } @@ -47,83 +47,98 @@ public function test_search_ignore_spelling() { $query = 'Magenta - Boum Bap'; - $results = YTMusic::search($query, null, 20, true); + $results = YTMusic::browse()->search($query, null, 20, true); $this->assertGreaterThan(0, count($results)); } public function test_get_artist() { - // $results = youtube.get_artist("MPLAUCmMUZbaYdNH0bEd1PAlAqsA"); - // $this->assertEqual(len(results), 11); - // $results = youtube.get_artist("UCLZ7tlKC06ResyDmEStSrOw") # no album year; - // $this->assertGreaterEqual(len(results), 9); - // $results = youtube.get_artist(; - // "UCDAPd3S5CBIEKXn-tvy57Lg") # no thumbnail, albums, subscribe count - // $this->assertGreaterEqual(len(results), 9); + $artist = YTMusic::browse()->artist('MPLAUCmMUZbaYdNH0bEd1PAlAqsA'); + $this->assertEquals(11, count($artist)); + + $artist = YTMusic::browse()->artist('UCLZ7tlKC06ResyDmEStSrOw'); // no album year; + $this->assertGreaterThanOrEqual(9, count($artist)); + + $artist = YTMusic::browse()->artist('UCDAPd3S5CBIEKXn-tvy57Lg'); // no thumbnail, albums, subscribe count + $this->assertGreaterThanOrEqual(9, count($artist)); } public function test_get_artist_albums() { - // artist = youtube.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") - // $results = youtube.get_artist_albums(artist['albums']['browseId'],; - // artist['albums']['params']) - // $this->assertGreater(len(results), 0); + $artist = YTMusic::browse()->artist('UCAeLFBCQS7FvI8PvBrWvSBg'); + + $results = YTMusic::browse()->artistAlbums( + $artist['albums']['browseId'], + $artist['albums']['params'] + ); + + $this->assertGreaterThan(0, count($results)); } public function test_get_artist_singles() { - // artist = youtube_auth.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") - // $results = youtube_auth.get_artist_albums(artist['singles']['browseId'],; - // artist['singles']['params']) - // $this->assertGreater(len(results), 0); + $artist = YTMusic::browse()->artist('UCAeLFBCQS7FvI8PvBrWvSBg'); + $results = YTMusic::browse()->artistAlbums( + $artist['singles']['browseId'], + $artist['singles']['params'] + ); + + $this->assertGreaterThan(0, count($results)); } public function test_get_user() { - // $results = youtube.get_user("UC44hbeRoCZVVMVg5z0FfIww"); - // $this->assertEqual(len(results), 3); + $user = YTMusic::browse()->user('UC44hbeRoCZVVMVg5z0FfIww'); + + $this->assertEquals(3, count($user)); } public function test_get_user_playlists() { - // $results = youtube.get_user("UCPVhZsC2od1xjGhgEc2NEPQ"); - // $results = youtube.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ",; - // results['playlists']['params']) - // $this->assertGreater(len(results), 200); + $user = YTMusic::browse()->user('UCPVhZsC2od1xjGhgEc2NEPQ'); + $results = YTMusic::browse()->userPlaylists( + 'UCPVhZsC2od1xjGhgEc2NEPQ', + $user['playlists']['params'] + ); + + $this->assertGreaterThan(150, count($results)); } public function test_get_album() { - // $results = youtube_auth.get_album("MPREb_BQZvl3BFGay"); - // $this->assertEqual(len(results), 9); - // $this->assertIn('feedbackTokens', results['tracks'][0]); - // $results = youtube.get_album("MPREb_BQZvl3BFGay"); - // $this->assertEqual(len(results['tracks']), 7); + $results = YTMusic::browse()->album('MPREb_BQZvl3BFGay'); + $this->assertEquals(9, count($results)); + + $results = YTMusic::browse()->album('MPREb_BQZvl3BFGay'); + $this->assertEquals(7, count($results['tracks'])); } public function test_get_song() { - // song = youtube.get_song("ZrOKjDZOtkA") - // $this->assertGreaterEqual(len(song), 16); + $song = YTMusic::browse()->song('ZrOKjDZOtkA'); + $this->assertGreaterThanOrEqual(16, count($song)); } public function test_get_streaming_data() { - // streaming_data = youtube_auth.get_streaming_data("ZrOKjDZOtkA") - // $this->assertEqual(len(streaming_data), 3); + $streaming_data = YTMusic::browse()->streamingData('ZrOKjDZOtkA'); + $this->assertEquals(3, count($streaming_data)); } public function test_get_lyrics() { - // playlist = youtube.get_watch_playlist("ZrOKjDZOtkA") - // lyrics_song = youtube.get_lyrics(playlist["lyrics"]) - // $this->assertTrue(lyrics_song["lyricsFound"]); - // $this->assertIsNotNone(lyrics_song["lyrics"]); - // $this->assertIsNotNone(lyrics_song["source"]); - // playlist = youtube.get_watch_playlist("9TnpB8WgW4s") - // no_lyrics_song = youtube.get_lyrics(playlist["lyrics"]) - // $this->assertFalse(no_lyrics_song["lyricsFound"]); - // $this->assertIsNotNone(no_lyrics_song["lyrics"]); - // $this->assertIsNotNone(no_lyrics_song["source"]); + $playlist = YTMusic::browse()->watchPlaylist('ZrOKjDZOtkA'); + $lyrics_song = YTMusic::browse()->lyrics($playlist['lyrics']); + + $this->assertTrue($lyrics_song['lyricsFound']); + $this->assertNotNull($lyrics_song['lyrics']); + $this->assertNotNull($lyrics_song['source']); + + $playlist = YTMusic::browse()->watchPlaylist('9TnpB8WgW4s'); + $no_lyrics_song = YTMusic::browse()->lyrics($playlist['lyrics']); + + $this->assertFalse($no_lyrics_song['lyricsFound']); + $this->assertNotNull($no_lyrics_song['lyrics']); + $this->assertNotNull($no_lyrics_song['source']); } }